From b7e802bdec917c948b6b4302676e2266ec01bc1d Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Fri, 23 Oct 2026 00:32:29 -0500 Subject: [PATCH 01/59] fix(claude): route approval feedback to the requesting agent (#23657) --- .../features/threads/ThreadDetailScreen.tsx | 54 +++++-- .../src/state/thread-task-permissions.test.ts | 38 +++++ .../src/state/use-selected-thread-requests.ts | 3 +- .../src/state/use-thread-composer-state.ts | 15 +- .../Adapters/ClaudeAdapterV2.test.ts | 66 ++++++++- .../Adapters/ClaudeAdapterV2.ts | 29 ++-- .../src/orchestration-v2/EffectOutbox.ts | 1 + .../src/orchestration-v2/EffectWorker.ts | 3 + .../Orchestrator.control-reads.test.ts | 136 +++++++++++++++++ .../src/orchestration-v2/Orchestrator.ts | 78 ++++++++++ .../RuntimeRequestService.test.ts | 138 ++++++++++-------- .../orchestration-v2/RuntimeRequestService.ts | 2 + apps/web/src/components/chat/ChatComposer.tsx | 4 +- .../src/components/chat/useComposerSend.ts | 47 +++++- .../chat/usePendingThreadRequests.ts | 2 + docs/user/permission-modes.md | 2 + .../client-runtime/src/operations/commands.ts | 2 + .../src/state/threadRequests.test.ts | 43 ++++++ .../src/state/threadRequests.ts | 16 ++ packages/contracts/src/orchestrationV2.ts | 8 +- .../src/server/ProviderAdapter.ts | 1 + 21 files changed, 596 insertions(+), 92 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index e8f99bf4e642..57f671957a88 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -55,6 +55,7 @@ import { } from "@t3tools/shared/codexArtifactTemplates"; import { declinePendingApprovals, + sendApprovalFeedback, type ApprovalResponder, type ThreadUserInputQuestion, } from "@t3tools/client-runtime/state/thread-requests"; @@ -258,7 +259,10 @@ export interface ThreadDetailScreenProps { readonly onResumeQueue: (() => Promise) | null; readonly onSendMessage: ( followUp?: ActiveTurnComposerAction, - beforeDispatch?: () => Promise, + beforeDispatch?: ( + text: string, + hasAttachmentsOrContext: boolean, + ) => Promise, ) => Promise; readonly onReconnectEnvironment: () => void; readonly canSwitchThreadProvider: boolean; @@ -1199,18 +1203,40 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai const approvalsToDecline = props.activePendingApprovals; const declineBeforeDispatch = approvalsToDecline.length > 0 && props.activePendingUserInput === null - ? async () => - props.respondingApprovalId === null && - (await declinePendingApprovals(approvalsToDecline, props.onRespondToApproval)) && - selectedThreadKeyRef.current === targetThreadKey && - // A draft edited or pasted into during the decline isn't what the user sent. - stillSendable?.() !== false && - // An approval that arrived during the decline would block the redirect; the draft - // stays so the next send declines it too. - !activePendingApprovalsRef.current.some( - (approval) => - !approvalsToDecline.some((declined) => declined.requestId === approval.requestId), + ? async (text: string, hasAttachmentsOrContext: boolean) => { + if ( + selectedThreadKeyRef.current !== targetThreadKey || + stillSendable?.() === false || + props.respondingApprovalId !== null ) + return false; + const feedbackTarget = approvalsToDecline[0]; + if (feedbackTarget?.supportsFeedback) { + if (hasAttachmentsOrContext) { + Dialog.alert( + "Approval feedback", + "Send approval feedback without attachments or added context.", + ); + return false; + } + return (await sendApprovalFeedback(feedbackTarget, text, props.onRespondToApproval)) + ? ("responded" as const) + : false; + } + return ( + props.respondingApprovalId === null && + (await declinePendingApprovals(approvalsToDecline, props.onRespondToApproval)) && + selectedThreadKeyRef.current === targetThreadKey && + // A draft edited or pasted into during the decline isn't what the user sent. + stillSendable?.() !== false && + // An approval that arrived during the decline would block the redirect; the draft + // stays so the next send declines it too. + !activePendingApprovalsRef.current.some( + (approval) => + !approvalsToDecline.some((declined) => declined.requestId === approval.requestId), + ) + ); + } : undefined; const messageId = await props.onSendMessage(followUp, declineBeforeDispatch); if (messageId === null || selectedThreadKeyRef.current !== targetThreadKey) { @@ -1642,7 +1668,9 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai editorRef={composerEditorRef} placeholder={ props.activePendingApproval - ? "Send a message to decline and redirect the agent" + ? props.activePendingApproval.supportsFeedback + ? "Send feedback to decline this request" + : "Send a message to decline and redirect the agent" : "Ask the repo agent, or run a command…" } contentMaxWidth={contentMaxWidth} diff --git a/apps/mobile/src/state/thread-task-permissions.test.ts b/apps/mobile/src/state/thread-task-permissions.test.ts index eac2e40e9835..4c962cf5be65 100644 --- a/apps/mobile/src/state/thread-task-permissions.test.ts +++ b/apps/mobile/src/state/thread-task-permissions.test.ts @@ -18,6 +18,7 @@ import { } from "../../../../packages/client-runtime/src/state/orchestrationV2TestFixtures"; import type { DraftComposerImageAttachment } from "../lib/composerImages"; import type { PendingThreadRequests } from "@t3tools/client-runtime/state/thread-requests"; +import { sendApprovalFeedback } from "@t3tools/client-runtime/state/thread-requests"; import { AsyncResult, type Atom } from "effect/reactivity"; import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; @@ -202,6 +203,43 @@ beforeEach(() => { }); describe("mobile task permissions", () => { + it("consumes an approval reply without queueing a parent message", async () => { + state.grantedEnvironments.add("secondary"); + const composer = useThreadComposerState(); + const requests = useSelectedThreadRequests(); + const approval = { ...state.pendingRequests.approvals[0]!, supportsFeedback: true }; + const messageId = await composer.onSendMessage(undefined, async (text) => + (await sendApprovalFeedback(approval, text, requests.onRespondToApproval)) + ? "responded" + : false, + ); + expect(messageId).toBeNull(); + expect(state.approve).toHaveBeenCalledWith({ + environmentId: "secondary", + input: { + threadId: "thread", + requestId: "approval", + decision: "decline", + feedback: "Keep my draft", + }, + }); + expect(state.enqueue).not.toHaveBeenCalled(); + expect(state.clearDraft).toHaveBeenCalledOnce(); + }); + + it("preserves a draft changed during an approval reply and never sends it to the parent", async () => { + state.grantedEnvironments.add("secondary"); + const composer = useThreadComposerState(); + const beforeDispatch = async () => { + state.draft = { ...state.draft, text: "New feedback" }; + return "responded" as const; + }; + expect(await composer.onSendMessage(undefined, beforeDispatch)).toBeNull(); + expect(state.enqueue).not.toHaveBeenCalled(); + expect(state.clearDraft).not.toHaveBeenCalled(); + expect(state.draft.text).toBe("New feedback"); + }); + it("keeps a connected read-only task's draft instead of queueing it with another environment's grant", async () => { const composer = useThreadComposerState(); expect(await composer.onSendMessage()).toBeNull(); diff --git a/apps/mobile/src/state/use-selected-thread-requests.ts b/apps/mobile/src/state/use-selected-thread-requests.ts index 98bec9753325..1371b5206040 100644 --- a/apps/mobile/src/state/use-selected-thread-requests.ts +++ b/apps/mobile/src/state/use-selected-thread-requests.ts @@ -236,7 +236,7 @@ export function useSelectedThreadRequests() { ); const onRespondToApproval = useCallback( - async (requestId: RuntimeRequestId, decision: ProviderApprovalDecision) => { + async (requestId: RuntimeRequestId, decision: ProviderApprovalDecision, feedback?: string) => { if ( !selectedThreadShell || !readEnvironmentScope(selectedThreadShell.environmentId, AuthOrchestrationOperateScope) @@ -257,6 +257,7 @@ export function useSelectedThreadRequests() { threadId: selectedThreadShell.id, requestId, decision, + ...(feedback === undefined ? {} : { feedback }), }, }); setRespondingApprovalId((current) => (current === requestId ? null : current)); diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index afd952d43abd..ec71677013b4 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -526,7 +526,10 @@ export function useThreadComposerState() { const onSendMessage = async ( followUpOverride?: ActiveTurnComposerAction, - beforeDispatch?: () => Promise, + beforeDispatch?: ( + text: string, + hasAttachmentsOrContext: boolean, + ) => Promise, ) => { if ( selectedThreadShell && @@ -645,7 +648,11 @@ export function useThreadComposerState() { } if (beforeDispatch !== undefined) { - if (!(await beforeDispatch())) return null; + const response = await beforeDispatch( + text, + attachments.length > 0 || draft.context !== undefined, + ); + if (!response) return null; // A context paste still landing, or any draft or settings change while approvals were // declined, means this snapshot is no longer what the user sent. if (appAtomRegistry.get(composerContextImportsAtom)[threadKey]) return null; @@ -660,6 +667,10 @@ export function useThreadComposerState() { current.attachments.some((attachment, index) => attachment.id !== attachments[index]?.id) ) return null; + if (response === "responded") { + clearComposerDraftContent(threadKey); + return null; + } } // Resolved here rather than at drain time: the outbox can deliver minutes diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index b231fbfca9d9..0c8b2ad588ac 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -967,7 +967,10 @@ describe("ClaudeAdapterV2 append-system-prompt-file launch argument", () => { describe("ClaudeAdapterV2 approval cancellation", () => { it.effect("observes an approval signal that was already aborted", () => Effect.gen(function* () { - const decision = yield* Deferred.make(); + const decision = yield* Deferred.make<{ + decision: ProviderApprovalDecision; + feedback?: string; + }>(); const controller = new AbortController(); controller.abort(); @@ -976,7 +979,7 @@ describe("ClaudeAdapterV2 approval cancellation", () => { controller.signal, ); - assert.equal(result, "cancel"); + assert.deepEqual(result, { decision: "cancel" }); }), ); }); @@ -2873,12 +2876,14 @@ describe("ClaudeAdapterV2 background wake turns", () => { turn: Effect.Success>, toolName: string, toolInput: Record, + options?: { toolUseID: string; agentID?: string }, ) { const decision = yield* Effect.promise(() => turn.canUseTool(toolName, toolInput, { signal: new AbortController().signal, - toolUseID: "tool-plan-write", + toolUseID: options?.toolUseID ?? "tool-plan-write", requestId: "request-plan-write", + ...(options?.agentID === undefined ? {} : { agentID: options.agentID }), }), ).pipe(Effect.forkScoped); const first = yield* Effect.raceFirst( @@ -2954,6 +2959,61 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + it.effect( + "returns approval feedback to the requesting tool without sending a parent prompt", + () => + Effect.gen(function* () { + const turn = yield* openPlanTurn("full-access"); + const parentPermission = yield* requestWrite( + turn, + "Bash", + { command: "git status" }, + { toolUseID: "parent-command" }, + ); + assert.equal(parentPermission.first.type, "request"); + if (parentPermission.first.type !== "request") return; + const permission = yield* requestWrite( + turn, + "Bash", + { command: "rm -f $W/*/*.obj" }, + { toolUseID: "child-command", agentID: "child-worker" }, + ); + assert.equal(permission.first.type, "request"); + if (permission.first.type !== "request") return; + const request = permission.first.event.runtimeRequest; + assert.equal(request.responseCapability.type, "live"); + assert.isTrue( + request.responseCapability.type === "live" && + request.responseCapability.supportsFeedback, + ); + yield* turn.runtime.respondToRuntimeRequest({ + requestId: request.id, + decision: "decline", + feedback: "Run it in a safer manner.", + }); + const result = yield* Fiber.join(permission.decision); + assert.equal(result?.behavior, "deny"); + if (result?.behavior === "deny") { + assert.include(result.message, "Run it in a safer manner."); + assert.notProperty(result, "interrupt"); + } + assert.equal(turn.offeredMessages.length, 1); + yield* turn.runtime.respondToRuntimeRequest({ + requestId: parentPermission.first.event.runtimeRequest.id, + decision: "accept", + }); + const parentResult = yield* Fiber.join(parentPermission.decision); + assert.equal(parentResult?.behavior, "allow"); + if (parentResult?.behavior === "allow") + assert.equal(parentResult.toolUseID, "parent-command"); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ); + it.effect("still captures ExitPlanMode when Plan approvals are never allowed", () => Effect.gen(function* () { const turn = yield* openPlanTurn("full-access", "never"); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 49b344961b3f..91c08f565a29 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -2892,6 +2892,7 @@ export function permissionResultFromDecision(input: { readonly toolInput: Record; readonly toolUseID: string; readonly suggestions?: Parameters[2]["suggestions"]; + readonly feedback?: string; }): PermissionResult { if (input.decision === "accept" || input.decision === "acceptForSession") { return { @@ -2911,9 +2912,10 @@ export function permissionResultFromDecision(input: { return { behavior: "deny", message: - input.decision === "cancel" + input.feedback?.trim() || + (input.decision === "cancel" ? "User cancelled tool execution." - : "User declined tool execution.", + : "User declined tool execution."), toolUseID: input.toolUseID, decisionClassification: "user_reject", ...(input.decision === "cancel" ? { interrupt: true } : {}), @@ -2942,11 +2944,11 @@ function toSessionPermissionUpdates( } export const awaitClaudeApprovalDecision = Effect.fn("awaitClaudeApprovalDecision")(function* ( - decision: Deferred.Deferred, + decision: Deferred.Deferred, signal: AbortSignal, ) { - const cancellation = Effect.callback((resume) => { - const abort = () => resume(Effect.succeed("cancel")); + const cancellation = Effect.callback((resume) => { + const abort = () => resume(Effect.succeed({ decision: "cancel" })); signal.addEventListener("abort", abort, { once: true }); if (signal.aborted) { abort(); @@ -3625,12 +3627,17 @@ function rememberClaudeSubagentLaunch( } } +type ClaudeApprovalResponse = { + readonly decision: ProviderApprovalDecision; + readonly feedback?: string; +}; + type PendingClaudeRuntimeRequest = | { readonly type: "approval"; readonly requestId: OrchestrationV2RuntimeRequest["id"]; readonly requestKind: ProviderRequestKind; - readonly decision: Deferred.Deferred; + readonly decision: Deferred.Deferred; } | { readonly type: "user_input"; @@ -6467,6 +6474,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( responseCapability: { type: "live", providerSessionId, + ...(input.questions === undefined ? { supportsFeedback: true } : {}), }, createdAt, resolvedAt: null, @@ -8986,7 +8994,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( callbackOptions, }), }); - const decision = yield* Deferred.make(); + const decision = yield* Deferred.make(); yield* Ref.update(pendingRuntimeRequests, (current) => { const updated = new Map(current); updated.set(String(artifacts.request.id), { @@ -9033,7 +9041,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( return permissionResultFromDecision({ toolName, - decision: resolvedDecision, + ...resolvedDecision, toolInput, toolUseID: callbackOptions.toolUseID, ...(callbackOptions.suggestions === undefined @@ -10283,7 +10291,10 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }), }); } - yield* Deferred.succeed(pending.decision, requestInput.decision); + yield* Deferred.succeed(pending.decision, { + decision: requestInput.decision, + ...(requestInput.feedback === undefined ? {} : { feedback: requestInput.feedback }), + }); }, (effect, requestInput) => effect.pipe( diff --git a/apps/server/src/orchestration-v2/EffectOutbox.ts b/apps/server/src/orchestration-v2/EffectOutbox.ts index c89beae951e7..6818ba361df3 100644 --- a/apps/server/src/orchestration-v2/EffectOutbox.ts +++ b/apps/server/src/orchestration-v2/EffectOutbox.ts @@ -80,6 +80,7 @@ export const OrchestrationEffectRequestV2 = Schema.Union([ providerSessionId: ProviderSessionId, requestId: RuntimeRequestId, decision: Schema.optional(ProviderApprovalDecision), + feedback: Schema.optional(Schema.String), answers: Schema.optional(ProviderUserInputAnswers), }), Schema.Struct({ diff --git a/apps/server/src/orchestration-v2/EffectWorker.ts b/apps/server/src/orchestration-v2/EffectWorker.ts index ebcb50debd11..42128b883711 100644 --- a/apps/server/src/orchestration-v2/EffectWorker.ts +++ b/apps/server/src/orchestration-v2/EffectWorker.ts @@ -384,6 +384,9 @@ export const layerExecutor: Layer.Layer< ...(effect.request.decision === undefined ? {} : { decision: effect.request.decision }), + ...(effect.request.feedback === undefined + ? {} + : { feedback: effect.request.feedback }), ...(effect.request.answers === undefined ? {} : { answers: effect.request.answers }), diff --git a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts index eda76857a9ef..6259fd78db62 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts @@ -57,6 +57,142 @@ const layerTest = Layer.mergeAll( ), ); +it.effect( + "records approval feedback and targets only its runtime request without dispatching a parent turn", + () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const outbox = yield* EffectOutbox.EffectOutboxV2; + const threadId = ThreadId.make("thread:approval-feedback"); + const sessionId = ProviderSessionId.make("session:approval-feedback"); + const now = yield* DateTime.now; + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("create-approval-feedback"), + threadId, + projectId: ProjectId.make("project:approval-feedback"), + title: "Parent", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdBy: "user", + creationSource: "web", + }); + yield* projections.apply({ + id: EventId.make("session:approval-feedback"), + type: "provider-session.attached", + threadId, + occurredAt: now, + payload: { + id: sessionId, + driver: adapter.driver, + providerInstanceId: instanceId, + status: "ready", + cwd: "/repo", + model: modelSelection.model, + capabilities: CodexProviderCapabilitiesV2, + createdAt: now, + updatedAt: now, + lastError: null, + }, + }); + for (const name of ["child", "other"]) { + yield* projections.apply({ + id: EventId.make(`request:${name}`), + type: "runtime-request.updated", + threadId, + occurredAt: now, + payload: { + id: RuntimeRequestId.make(name), + nodeId: NodeId.make(`node:${name}`), + providerTurnId: null, + nativeRequestRef: null, + kind: "command", + status: "pending", + responseCapability: { + type: "live", + providerSessionId: sessionId, + supportsFeedback: name === "child", + }, + createdAt: now, + resolvedAt: null, + }, + }); + } + const commandId = CommandId.make("respond:approval-feedback"); + const feedback = "Run it in a safer manner."; + for (const [index, invalid] of [ + { requestId: RuntimeRequestId.make("other"), decision: "decline" as const, feedback }, + { requestId: RuntimeRequestId.make("child"), decision: "accept" as const, feedback }, + { requestId: RuntimeRequestId.make("child"), decision: "decline" as const, feedback: " " }, + ].entries()) { + const rejectedId = CommandId.make(`invalid-feedback:${index}`); + const rejected = yield* orchestrator + .dispatch({ + type: "runtime-request.respond", + commandId: rejectedId, + threadId, + ...invalid, + }) + .pipe(Effect.result); + assert.isTrue(Result.isFailure(rejected)); + assert.deepEqual(yield* outbox.listByCommandId(rejectedId), []); + } + yield* orchestrator.dispatch({ + type: "runtime-request.respond", + commandId, + threadId, + requestId: RuntimeRequestId.make("child"), + decision: "decline", + feedback, + }); + const projection = yield* projections.getThreadProjection(threadId); + assert.equal( + projection.runtimeRequests.find((request) => request.id === "child")?.feedback, + feedback, + ); + assert.equal( + projection.runtimeRequests.find((request) => request.id === "other")?.status, + "pending", + ); + assert.equal(projection.messages.at(-1)?.text, feedback); + const feedbackMessageId = MessageId.make("approval-feedback:child"); + assert.ok( + projection.visibleTurnItems.some( + ({ item }) => + item.type === "user_message" && + item.messageId === feedbackMessageId && + item.text === feedback, + ), + ); + const history = yield* projections.getTimelinePage(threadId, { limit: 20, view: "messages" }); + assert.ok( + history.items.some( + ({ item }) => + item.type === "user_message" && + item.messageId === feedbackMessageId && + item.text === feedback, + ), + ); + assert.equal(projection.runs.length, 0); + assert.deepEqual( + (yield* outbox.listByCommandId(commandId)).map((effect) => effect.request), + [ + { + type: "runtime-request.respond", + providerSessionId: sessionId, + requestId: RuntimeRequestId.make("child"), + decision: "decline", + feedback, + }, + ], + ); + }).pipe(Effect.provide(layerTest)), +); + it.effect("marks a never-visited thread's first completion unread", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index f9cb1bf9dd9d..5dd48ba548d2 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -7673,6 +7673,20 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio cause: runtimeRequest.responseCapability.reason, }); } + if ( + command.feedback !== undefined && + (runtimeRequest.responseCapability.type !== "live" || + !runtimeRequest.responseCapability.supportsFeedback || + runtimeRequest.kind === "user_input" || + command.decision !== "decline" || + !command.feedback.trim()) + ) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: "Feedback requires a live approval that supports it and a decline decision.", + }); + } const providerSessionId = runtimeRequest.responseCapability.type === "live" ? runtimeRequest.responseCapability.providerSessionId @@ -7702,10 +7716,73 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio status: "resolved" as const, resolvedAt: now, ...(command.decision === undefined ? {} : { decision: command.decision }), + ...(command.feedback === undefined ? {} : { feedback: command.feedback }), ...(answers === undefined ? {} : { answers }), }; const emitEvent = emit(events, command); const requestNode = context.node; + if (command.feedback !== undefined) { + const messageId = MessageId.make(`approval-feedback:${command.requestId}`); + // Record the reply for every client without dispatching a prompt to the parent agent. + yield* emitEvent({ + type: "message.updated", + threadId: command.threadId, + ...(requestNode?.runId == null ? {} : { runId: requestNode.runId }), + nodeId: runtimeRequest.nodeId, + occurredAt: now, + payload: { + id: messageId, + threadId: command.threadId, + runId: requestNode?.runId ?? null, + nodeId: runtimeRequest.nodeId, + role: "user", + text: command.feedback, + attachments: [], + streaming: false, + createdBy: "user", + creationSource: "server", + createdAt: now, + updatedAt: now, + }, + }); + yield* emitEvent({ + type: "turn-item.updated", + threadId: command.threadId, + ...(requestNode?.runId == null ? {} : { runId: requestNode.runId }), + nodeId: runtimeRequest.nodeId, + occurredAt: now, + payload: { + id: idAllocator.derive.userTurnItem({ messageId }), + threadId: command.threadId, + runId: requestNode?.runId ?? null, + nodeId: runtimeRequest.nodeId, + providerThreadId: + context.item?.providerThreadId ?? requestNode?.providerThreadId ?? null, + providerTurnId: runtimeRequest.providerTurnId, + nativeItemRef: null, + parentItemId: null, + ordinal: yield* projectionStore + .getNextTurnItemOrdinal(command.threadId) + .pipe( + Effect.mapError( + (cause) => new OrchestratorProjectionError({ threadId: command.threadId, cause }), + ), + ), + status: "completed", + title: null, + startedAt: now, + completedAt: now, + updatedAt: now, + type: "user_message", + messageId, + inputIntent: "steer", + text: command.feedback, + attachments: [], + createdBy: "user", + creationSource: "server", + }, + }); + } const resolvedNodeStatus = command.decision === "decline" || command.decision === "cancel" ? ("cancelled" as const) @@ -7872,6 +7949,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio providerSessionId, requestId: command.requestId, ...(command.decision === undefined ? {} : { decision: command.decision }), + ...(command.feedback === undefined ? {} : { feedback: command.feedback }), ...(answers === undefined ? {} : { answers }), }, } satisfies PendingOrchestrationEffectV2, diff --git a/apps/server/src/orchestration-v2/RuntimeRequestService.test.ts b/apps/server/src/orchestration-v2/RuntimeRequestService.test.ts index 70ddb5e84ef4..0c6aa9d97b16 100644 --- a/apps/server/src/orchestration-v2/RuntimeRequestService.test.ts +++ b/apps/server/src/orchestration-v2/RuntimeRequestService.test.ts @@ -16,70 +16,88 @@ import * as ProjectionStore from "./ProjectionStore.ts"; import * as ProviderSessionManager from "./ProviderSessionManager.ts"; import * as RuntimeRequestService from "./RuntimeRequestService.ts"; -it.effect("rejects expired runtime requests before invoking the live adapter", () => { - const threadId = ThreadId.make("thread-runtime-request-expired"); - const providerSessionId = ProviderSessionId.make("provider-session-runtime-request-expired"); - const requestId = RuntimeRequestId.make("request-expired"); - const respondToRuntimeRequest = vi.fn( - (_input: ProviderAdapter.ProviderAdapterV2RuntimeRequestResponseInput) => Effect.void, - ); - const getSession = vi.fn(() => - Effect.succeed( - Option.some({ - respondToRuntimeRequest, - } as never), - ), - ); - const projection = { - runtimeRequests: [ - { - id: requestId, - nodeId: NodeId.make("node-runtime-request-expired"), - providerTurnId: null, - nativeRequestRef: null, - kind: "command", - status: "expired", - responseCapability: { - type: "live", - providerSessionId, +it.effect.each(["expired", "resolved"] as const)( + "responds only to resolved requests and preserves feedback (%s)", + (status) => { + const threadId = ThreadId.make("thread-runtime-request-expired"); + const providerSessionId = ProviderSessionId.make("provider-session-runtime-request-expired"); + const requestId = RuntimeRequestId.make("request-expired"); + const respondToRuntimeRequest = vi.fn( + (_input: ProviderAdapter.ProviderAdapterV2RuntimeRequestResponseInput) => Effect.void, + ); + const getSession = vi.fn(() => + Effect.succeed( + Option.some({ + respondToRuntimeRequest, + } as never), + ), + ); + const projection = { + runtimeRequests: [ + { + id: requestId, + nodeId: NodeId.make("node-runtime-request-expired"), + providerTurnId: null, + nativeRequestRef: null, + kind: "command", + status, + responseCapability: { + type: "live", + providerSessionId, + }, + createdAt: DateTime.makeUnsafe("2026-07-29T00:00:00.000Z"), + resolvedAt: DateTime.makeUnsafe("2026-07-29T00:00:01.000Z"), }, - createdAt: DateTime.makeUnsafe("2026-07-29T00:00:00.000Z"), - resolvedAt: DateTime.makeUnsafe("2026-07-29T00:00:01.000Z"), - }, - ], - } as unknown as OrchestrationV2ThreadProjection; - const layerTest = RuntimeRequestService.layer.pipe( - Layer.provide( - Layer.mergeAll( - Layer.mock(ProjectionStore.ProjectionStoreV2)({ - getRuntimeRequest: (_threadId, requestId) => - Effect.succeed(projection.runtimeRequests.find((request) => request.id === requestId)), - }), - Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({ - get: getSession, - }), + ], + } as unknown as OrchestrationV2ThreadProjection; + const layerTest = RuntimeRequestService.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.mock(ProjectionStore.ProjectionStoreV2)({ + getRuntimeRequest: (_threadId, requestId) => + Effect.succeed( + projection.runtimeRequests.find((request) => request.id === requestId), + ), + }), + Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({ + get: getSession, + }), + ), ), - ), - ); + ); - return Effect.gen(function* () { - const service = yield* RuntimeRequestService.RuntimeRequestServiceV2; - const error = yield* service - .respond({ + return Effect.gen(function* () { + const service = yield* RuntimeRequestService.RuntimeRequestServiceV2; + const response = service.respond({ threadId, providerSessionId, requestId, - decision: "accept", - }) - .pipe(Effect.flip); + decision: "decline", + feedback: "Use an explicit path.", + }); + if (status === "resolved") { + yield* response; + assert.deepEqual(respondToRuntimeRequest.mock.calls, [ + [ + { + requestId, + decision: "decline", + feedback: "Use an explicit path.", + }, + ], + ]); + return; + } + const error = yield* response.pipe(Effect.flip); - assert.equal(error.reason, "request-not-ready"); - assert.equal( - error.message, - `Runtime request ${requestId} on thread ${threadId} is not ready for response execution.`, - ); - assert.isUndefined(error.cause); - assert.equal(getSession.mock.calls.length, 0); - assert.equal(respondToRuntimeRequest.mock.calls.length, 0); - }).pipe(Effect.provide(layerTest)); -}); + assert.equal(error.reason, "request-not-ready"); + assert.equal( + error.message, + `Runtime request ${requestId} on thread ${threadId} is not ready for response execution.`, + ); + assert.isUndefined(error.cause); + assert.equal(getSession.mock.calls.length, 0); + assert.equal(respondToRuntimeRequest.mock.calls.length, 0); + }).pipe(Effect.provide(layerTest)); + }, +); diff --git a/apps/server/src/orchestration-v2/RuntimeRequestService.ts b/apps/server/src/orchestration-v2/RuntimeRequestService.ts index 9c96ea5d6b40..e58b7211ea31 100644 --- a/apps/server/src/orchestration-v2/RuntimeRequestService.ts +++ b/apps/server/src/orchestration-v2/RuntimeRequestService.ts @@ -54,6 +54,7 @@ export interface RuntimeRequestServiceV2Shape { readonly providerSessionId: ProviderSessionId; readonly requestId: RuntimeRequestId; readonly decision?: ProviderApprovalDecision; + readonly feedback?: string; readonly answers?: ProviderUserInputAnswers; }) => Effect.Effect; } @@ -118,6 +119,7 @@ export const layer: Layer.Layer< yield* session.value.respondToRuntimeRequest({ requestId: input.requestId, ...(input.decision === undefined ? {} : { decision: input.decision }), + ...(input.feedback === undefined ? {} : { feedback: input.feedback }), ...(input.answers === undefined ? {} : { answers: input.answers }), }); }, diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index b74e691ddd13..162069163f9b 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -3801,7 +3801,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onPaste={onComposerPasteStable} placeholder={ isComposerApprovalState - ? "Send a message to decline and redirect the agent" + ? activePendingApproval?.supportsFeedback + ? "Send feedback to decline this request" + : "Send a message to decline and redirect the agent" : activePendingProgress ? isChoiceOnlyPendingQuestion ? "Choose an option above" diff --git a/apps/web/src/components/chat/useComposerSend.ts b/apps/web/src/components/chat/useComposerSend.ts index d3bcbdf47020..ed7def98c88c 100644 --- a/apps/web/src/components/chat/useComposerSend.ts +++ b/apps/web/src/components/chat/useComposerSend.ts @@ -28,7 +28,10 @@ import { } from "@t3tools/contracts"; import { deriveThreadTitleSeed } from "@t3tools/client-runtime/operations"; import { wasBootstrapThreadDeleted } from "@t3tools/client-runtime/errors"; -import { declinePendingApprovals } from "@t3tools/client-runtime/state/thread-requests"; +import { + declinePendingApprovals, + sendApprovalFeedback, +} from "@t3tools/client-runtime/state/thread-requests"; import { parseCodexFeedbackCommand, submitCodexFeedback, @@ -475,6 +478,10 @@ export function useComposerSend(input: ComposerSendInput) { return; } const multipleModelSelections = sendCtx.multipleModelSelections; + const approvalFeedbackTarget = pendingApprovalsRef.current.find( + (approval) => + approval.supportsFeedback && approval.requestId === sendCtx.shownPendingApprovalIds[0], + ); const multipleModelBlock = multipleModelSelections === null ? null @@ -945,7 +952,7 @@ export function useComposerSend(input: ComposerSendInput) { sendCtx?.shownPendingApprovalIds.includes(approval.requestId), ) : []; - if (approvalsToDecline.length > 0) { + if (approvalsToDecline.length > 0 || approvalFeedbackTarget !== undefined) { // A draft edited since send, or a paste still landing in it, is no longer the message // the user sent. const attachmentIdsAtSend = composerAttachmentsSnapshot.map((attachment) => attachment.id); @@ -981,6 +988,42 @@ export function useComposerSend(input: ComposerSendInput) { const approvalResponding = approvalsToDecline.some((approval) => respondingRequestIdsRef.current.includes(approval.requestId), ); + if (approvalFeedbackTarget !== undefined) { + if (composerAttachmentsSnapshot.length > 0 || outgoingMessageContext !== undefined) { + setThreadError( + threadIdForSend, + "Send approval feedback without attachments or added context.", + ); + sendInFlightRef.current = false; + return; + } + if ( + changedBeforeDecline || + approvalResponding || + currentRouteThreadKeyRef.current !== routeThreadKey + ) { + sendInFlightRef.current = false; + return; + } + const sent = await sendApprovalFeedback( + approvalFeedbackTarget, + messageTextForSend, + onRespondToApprovalStable, + ).catch(() => false); + sendInFlightRef.current = false; + if ( + sent && + !draftChangedSinceSend() && + currentRouteThreadKeyRef.current === routeThreadKey && + useComposerDraftStore.getState().getComposerDraft(composerDraftTarget) === + draftBeforeDecline + ) { + promptRef.current = ""; + clearComposerDraftContent(composerDraftTarget); + composerRef.current?.resetCursorState(); + } + return; + } const declined = !changedBeforeDecline && !approvalResponding && diff --git a/apps/web/src/components/chat/usePendingThreadRequests.ts b/apps/web/src/components/chat/usePendingThreadRequests.ts index 3b1e73e59389..0f29e61e5033 100644 --- a/apps/web/src/components/chat/usePendingThreadRequests.ts +++ b/apps/web/src/components/chat/usePendingThreadRequests.ts @@ -300,6 +300,7 @@ export function usePendingThreadRequests(input: { const onRespondToApproval = async ( requestId: RuntimeRequestId, decision: ProviderApprovalDecision, + feedback?: string, ) => { if (!activeThreadId || !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) return; @@ -318,6 +319,7 @@ export function usePendingThreadRequests(input: { threadId: activeThreadId, requestId, decision, + ...(feedback === undefined ? {} : { feedback }), }, }); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { diff --git a/docs/user/permission-modes.md b/docs/user/permission-modes.md index 150c857db944..a15850973fb2 100644 --- a/docs/user/permission-modes.md +++ b/docs/user/permission-modes.md @@ -37,6 +37,8 @@ by the permission mode. See [ACP Registry permissions](./providers-acp.md#permis Claude can still ask in **Full access** when its settings disable bypass permissions or require approval for an action. +While a Claude approval is pending, type a text reply to decline that request and give feedback +directly to the agent that asked, including a subagent. Other pending requests stay unanswered. Antigravity can still send native approval requests in **Full access**. It only offers remembered approvals for actions that support them. diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index db343d2d0f8f..8663b433d2c2 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -185,6 +185,7 @@ export interface InterruptThreadTurnInput extends ThreadCommandInput { export interface RespondToThreadApprovalInput extends ThreadCommandInput { readonly requestId: RuntimeRequestId; readonly decision: ProviderApprovalDecision; + readonly feedback?: string; } export interface RespondToThreadUserInputInput extends ThreadCommandInput { @@ -873,6 +874,7 @@ export const respondToThreadApproval = Effect.fn("EnvironmentCommands.respondToT threadId: input.threadId, requestId: input.requestId, decision: input.decision, + ...(input.feedback === undefined ? {} : { feedback: input.feedback }), }); }, ); diff --git a/packages/client-runtime/src/state/threadRequests.test.ts b/packages/client-runtime/src/state/threadRequests.test.ts index 1d3db9ce27d4..b4a6447c2fce 100644 --- a/packages/client-runtime/src/state/threadRequests.test.ts +++ b/packages/client-runtime/src/state/threadRequests.test.ts @@ -13,6 +13,7 @@ import { createQuestionHistoryProjector, derivePendingThreadRequests, declinePendingApprovals, + sendApprovalFeedback, seedUserInputDraftAnswers, type ApprovalResponder, type ThreadPendingApproval, @@ -72,6 +73,48 @@ describe("declining approvals before sending", () => { ).toBe(true); expect(respond).not.toHaveBeenCalled(); }); + + it("sends feedback only to the displayed request, leaving other approvals pending", async () => { + const respond = vi.fn(async () => AsyncResult.success(undefined)); + const pending = derivePendingThreadRequests({ + runtimeRequests: [ + { + ...projection.runtimeRequests[0]!, + kind: "command", + responseCapability: { + type: "live", + providerSessionId: ProviderSessionId.make("claude-session"), + supportsFeedback: true, + }, + }, + { ...projection.runtimeRequests[0]!, id: second.requestId, kind: "command" }, + ], + turnItems: [], + }); + expect(await sendApprovalFeedback(pending.approvals[0]!, " Safer please. ", respond)).toBe( + true, + ); + expect(respond.mock.calls).toEqual([[approval.requestId, "decline", "Safer please."]]); + }); + + it("keeps feedback unsent when unsupported, stale, empty or rejected", async () => { + const respond = vi.fn(async () => AsyncResult.failure(Cause.fail("failed"))); + expect(await sendApprovalFeedback(approval, "Safer please.", respond)).toBe(false); + expect( + await sendApprovalFeedback( + { ...approval, supportsFeedback: true, responseCapability: "not_resumable" }, + "Safer please.", + respond, + ), + ).toBe(false); + expect(await sendApprovalFeedback({ ...approval, supportsFeedback: true }, " ", respond)).toBe( + false, + ); + expect(respond).not.toHaveBeenCalled(); + expect( + await sendApprovalFeedback({ ...approval, supportsFeedback: true }, "Safer please.", respond), + ).toBe(false); + }); }); describe("initial user input drafts", () => { diff --git a/packages/client-runtime/src/state/threadRequests.ts b/packages/client-runtime/src/state/threadRequests.ts index 5a9289fe396e..e14f54715712 100644 --- a/packages/client-runtime/src/state/threadRequests.ts +++ b/packages/client-runtime/src/state/threadRequests.ts @@ -15,8 +15,20 @@ import type { ThreadStructure, ThreadStructureVisibleTurnItem } from "./models.t export type ApprovalResponder = ( requestId: RuntimeRequestId, decision: ProviderApprovalDecision, + feedback?: string, ) => Promise | undefined>; +/** Feedback belongs to the displayed request, which may be a native child's tool call. */ +export async function sendApprovalFeedback( + approval: ThreadPendingApproval, + feedback: string, + respond: ApprovalResponder, +): Promise { + if (!approval.supportsFeedback || approval.responseCapability !== "live" || !feedback.trim()) + return false; + return (await respond(approval.requestId, "decline", feedback.trim()))?._tag === "Success"; +} + /** Declines every approval a redirect replaces; any one still pending would block the agent. */ export async function declinePendingApprovals( approvals: ReadonlyArray, @@ -40,6 +52,7 @@ export interface ThreadPendingApproval { readonly appName?: string; readonly options?: ReadonlyArray; readonly responseCapability: "live" | "not_resumable"; + readonly supportsFeedback?: boolean; } export const FULL_ACCESS_OVERRIDE_QUESTION = "Why am I being asked this?"; @@ -142,6 +155,9 @@ export function derivePendingThreadRequests( ? { options: item.options } : {}), responseCapability: responseCapability === "live" ? "live" : "not_resumable", + ...(request.responseCapability.type === "live" && request.responseCapability.supportsFeedback + ? { supportsFeedback: true } + : {}), }); } diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 881a316f59ae..cb748cc5005e 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -1020,13 +1020,18 @@ export const OrchestrationV2RuntimeRequest = Schema.Struct({ ]), status: Schema.Literals(["pending", "resolved", "expired", "cancelled"]), responseCapability: Schema.Union([ - Schema.Struct({ type: Schema.Literal("live"), providerSessionId: ProviderSessionId }), + Schema.Struct({ + type: Schema.Literal("live"), + providerSessionId: ProviderSessionId, + supportsFeedback: Schema.optional(Schema.Boolean), + }), Schema.Struct({ type: Schema.Literal("message") }), Schema.Struct({ type: Schema.Literal("not_resumable"), reason: Schema.String }), ]), createdAt: Schema.DateTimeUtc, resolvedAt: Schema.NullOr(Schema.DateTimeUtc), decision: Schema.optional(ProviderApprovalDecision), + feedback: Schema.optional(Schema.String), answers: Schema.optional(ProviderUserInputAnswers), }); export type OrchestrationV2RuntimeRequest = typeof OrchestrationV2RuntimeRequest.Type; @@ -2921,6 +2926,7 @@ export const OrchestrationV2Command = Schema.Union([ threadId: ThreadId, requestId: RuntimeRequestId, decision: Schema.optional(ProviderApprovalDecision), + feedback: Schema.optional(Schema.String), answers: Schema.optional(ProviderUserInputAnswers), attachmentsByQuestionId: Schema.optional(UserInputAttachments), }), diff --git a/packages/provider-core/src/server/ProviderAdapter.ts b/packages/provider-core/src/server/ProviderAdapter.ts index db5a18f77b46..314705eca3af 100644 --- a/packages/provider-core/src/server/ProviderAdapter.ts +++ b/packages/provider-core/src/server/ProviderAdapter.ts @@ -455,6 +455,7 @@ export interface ProviderAdapterV2InterruptInput { export interface ProviderAdapterV2RuntimeRequestResponseInput { readonly requestId: RuntimeRequestId; readonly decision?: ProviderApprovalDecision; + readonly feedback?: string; readonly answers?: ProviderUserInputAnswers; readonly response?: unknown; } From 0c25c2974ace440cc1f94dddf395977ca2e5c604 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Fri, 23 Oct 2026 00:32:53 -0500 Subject: [PATCH 02/59] fix(web): align pull request file counts with diff stats (#23905) --- .../src/components/pullRequest/PullRequestDetailLayout.tsx | 6 +++--- .../src/components/pullRequest/PullRequestDetailPanel.tsx | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestDetailLayout.tsx b/apps/web/src/components/pullRequest/PullRequestDetailLayout.tsx index 92bd0ab324d5..c9bcfc1a378f 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailLayout.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailLayout.tsx @@ -44,9 +44,9 @@ export function PullRequestDetailHeaderBody({ /> {head} - - - + + + {files} {diffStat} diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 01ff0a961ba8..0e3372b81a1a 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -1440,14 +1440,14 @@ export function PullRequestDetailPanel({ {detail.headBranch} - + - + {detail.changedFiles.toLocaleString()} Date: Fri, 23 Oct 2026 01:33:15 -0400 Subject: [PATCH 03/59] fix(web): task durations over an hour no longer wrap in the tasks panel (#13472) Co-authored-by: Claude Opus 5.5 (1M context) Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Co-authored-by: Julius Marminge --- apps/web/src/components/chat/ComposerTasksBadge.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/ComposerTasksBadge.tsx b/apps/web/src/components/chat/ComposerTasksBadge.tsx index 704bfc38e4a1..008370aa10b4 100644 --- a/apps/web/src/components/chat/ComposerTasksBadge.tsx +++ b/apps/web/src/components/chat/ComposerTasksBadge.tsx @@ -201,7 +201,7 @@ export const ComposerTasksContent = memo(function ComposerTasksContent({ {step.durationMs !== undefined From 53245cce537af0e7dc722c2850749127a4c90681 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B4=81=E6=9C=88=E6=98=9F?= Date: Fri, 23 Oct 2026 13:33:51 +0800 Subject: [PATCH 04/59] fix(server): preserve Codex sign-in guidance in usage notices (#17055) Co-authored-by: Codex (gpt-6-astra) Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> --- apps/server/src/provider/CodexProvider.ts | 1 + apps/server/src/provider/ProviderRegistry.test.ts | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/apps/server/src/provider/CodexProvider.ts b/apps/server/src/provider/CodexProvider.ts index 4a60f12891ee..81d6842692e2 100644 --- a/apps/server/src/provider/CodexProvider.ts +++ b/apps/server/src/provider/CodexProvider.ts @@ -796,6 +796,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu ? makeUnavailableUsageLimits({ checkedAt, reason: "probeFailed", + ...(accountStatus.message ? { message: accountStatus.message } : {}), ...(snapshot.rateLimits ? { message: snapshot.rateLimits.failure } : {}), }) : codexRateLimitsToLimits({ diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index 8bcde5f9420c..65ceac5012ab 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -400,6 +400,10 @@ it.layer( status.message, "Codex CLI is not authenticated. Run `codex login` and try again.", ); + assert.deepStrictEqual(status.usageLimits?.unavailable, { + reason: "probeFailed", + message: "Codex CLI is not authenticated. Run `codex login` and try again.", + }); }), ); From 7e52513a13d972c6c3f9685a263e09fcefa2769e Mon Sep 17 00:00:00 2001 From: Nand Date: Fri, 23 Oct 2026 07:35:11 +0200 Subject: [PATCH 05/59] fix(server): restore Claude subscription usage through proxies (#12069) Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> --- apps/server/src/usage/cliproxyApi.test.ts | 5 +++-- apps/server/src/usage/cliproxyApi.ts | 7 ++++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/server/src/usage/cliproxyApi.test.ts b/apps/server/src/usage/cliproxyApi.test.ts index ba7f5de41ace..bca612a044f4 100644 --- a/apps/server/src/usage/cliproxyApi.test.ts +++ b/apps/server/src/usage/cliproxyApi.test.ts @@ -317,8 +317,9 @@ describe("CLIProxyAPI built-in management API", () => { Effect.gen(function* () { const test = fixture({ accounts: [{ ...accounts[0]!, provider: "claude" }], - upstream: () => ({ - status: 200, + // The usage endpoint answers 429 to requests without a Claude Code user agent. + upstream: (request) => ({ + status: request.header?.["User-Agent"]?.startsWith("claude-code/") ? 200 : 429, body: { five_hour: { utilization: 10, resets_at: null }, seven_day: { utilization: 50, resets_at: "2099-01-01T00:00:00Z" }, diff --git a/apps/server/src/usage/cliproxyApi.ts b/apps/server/src/usage/cliproxyApi.ts index f097aa7a358c..4c981b96dfb6 100644 --- a/apps/server/src/usage/cliproxyApi.ts +++ b/apps/server/src/usage/cliproxyApi.ts @@ -214,7 +214,12 @@ export const makeCliproxyApi = Effect.gen(function* () { ? { "Chatgpt-Account-Id": account.id_token.chatgpt_account_id } : {}), } - : { Authorization: "Bearer $TOKEN$", "anthropic-beta": "oauth-2025-04-20" }; + : { + Authorization: "Bearer $TOKEN$", + "anthropic-beta": "oauth-2025-04-20", + // The OAuth usage endpoint can return 429 without a Claude Code user agent. + "User-Agent": "claude-code/2.1.273", + }; const raw = yield* management(config, "api-call", { auth_index: account.auth_index, method: data === undefined ? "GET" : "POST", From 71a283aed253f23be1860a31d4ea48f1c06e0b1c Mon Sep 17 00:00:00 2001 From: Igor Makowski <56691628+Mnigos@users.noreply.github.com> Date: Fri, 23 Oct 2026 07:41:49 +0200 Subject: [PATCH 06/59] fix(server): Forgejo skipped jobs no longer keep a pull request watch pending (#16668) Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 --- .../ForgejoSourceControlProvider.test.ts | 61 +++++++++++++++++++ .../src/server/forgejoPullRequestJson.ts | 27 ++++++-- 2 files changed, 82 insertions(+), 6 deletions(-) diff --git a/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts b/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts index dfef1329efc5..337f9c515e6a 100644 --- a/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts +++ b/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts @@ -278,6 +278,67 @@ it.effect.each([ }).pipe(Effect.provide(requiredChecksLayer(Effect.fail(cause)))); }); +it.effect("reads Forgejo jobs that did not run as finished checks", () => + Effect.gen(function* () { + const provider = yield* ForgejoPullRequestProvider.make; + const read = provider.getChangeRequestChecks; + if (read === undefined) return yield* Effect.die("checks read missing"); + const result = yield* read({ + cwd: "/repo", + repository: "acme/web", + host: "forgejo.test", + number: 1, + }); + assert.deepStrictEqual( + result.checks.map((check) => [check.name, check.status]), + [ + ["deploy", "skipped"], + ["audit", "neutral"], + ["docs", "pending"], + ], + ); + }).pipe( + Effect.provide( + Layer.mock(ForgejoCli.ForgejoCli)({ + api: (input) => { + const status = (context: string, state: string) => ({ + context, + status: state, + description: null, + target_url: null, + updated_at: "2026-10-11T00:00:00Z", + }); + const body = input.path.endsWith("pulls/1") + ? { + number: 1, + title: "Checks", + body: "", + html_url: "https://forgejo.test/acme/web/pulls/1", + user: null, + state: "open", + merged: false, + head: { ref: "feature", sha: "head", repo: null }, + base: { ref: "main", sha: "base", repo: null }, + created_at: "2026-10-11T00:00:00Z", + updated_at: "2026-10-11T00:00:00Z", + closed_at: null, + merged_at: null, + labels: [], + } + : input.path.endsWith("page=1") + ? [ + status("deploy", "skipped"), + status("audit", "warning"), + status("docs", "future-state"), + ] + : []; + return encodeJsonEffect(body).pipe(Effect.orDie, Effect.map(processOutput)); + }, + }), + ), + ), +); + it.effect("opens Forgejo Actions check links on the pull request's server", () => Effect.gen(function* () { const provider = yield* ForgejoPullRequestProvider.make; diff --git a/packages/source-control-forgejo/src/server/forgejoPullRequestJson.ts b/packages/source-control-forgejo/src/server/forgejoPullRequestJson.ts index 00244c17d999..a0984bc8736f 100644 --- a/packages/source-control-forgejo/src/server/forgejoPullRequestJson.ts +++ b/packages/source-control-forgejo/src/server/forgejoPullRequestJson.ts @@ -4,6 +4,7 @@ import * as Option from "effect/Option"; import type { PullRequestActor, PullRequestCheck, + PullRequestCheckStatus, PullRequestComment, PullRequestCommit, PullRequestReaction, @@ -213,6 +214,25 @@ export function forgejoCommit(commit: typeof ForgejoCommit.Type): PullRequestCom }; } +// Forgejo Actions reports a job that did not run as `skipped` and a cancelled one as `failure`, +// and `warning` is finished without being a pass. Only `pending` and states we do not know may +// hold a watch open. +function forgejoCheckStatus(state: string): PullRequestCheckStatus { + switch (state) { + case "success": + return "success"; + case "failure": + case "error": + return "failure"; + case "skipped": + return "skipped"; + case "warning": + return "neutral"; + default: + return "pending"; + } +} + export function forgejoChecks( statuses: ReadonlyArray, pullRequestUrl: string, @@ -227,12 +247,7 @@ export function forgejoChecks( url: status.target_url ? (URL.parse(status.target_url, pullRequestUrl)?.href ?? null) : null, - status: - status.status === "success" - ? "success" - : status.status === "failure" || status.status === "error" - ? "failure" - : "pending", + status: forgejoCheckStatus(status.status), }, })), ); From a68e6e97c501d265e1edccb47b96f8365c8e38f4 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 22 Oct 2026 23:17:23 -0700 Subject: [PATCH 07/59] fix(mobile): move Android undo toasts above bottom navigation (#23928) --- .../home/ThreadUndoNotice.android.tsx | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 apps/mobile/src/features/home/ThreadUndoNotice.android.tsx diff --git a/apps/mobile/src/features/home/ThreadUndoNotice.android.tsx b/apps/mobile/src/features/home/ThreadUndoNotice.android.tsx new file mode 100644 index 000000000000..d1caf4e664b7 --- /dev/null +++ b/apps/mobile/src/features/home/ThreadUndoNotice.android.tsx @@ -0,0 +1,128 @@ +import { useLayoutEffect, useState } from "react"; +import { StyleSheet, View } from "react-native"; +import { GestureDetector, usePanGesture } from "react-native-gesture-handler"; +import Animated, { + cancelAnimation, + Easing, + FadeOut, + ReduceMotion, + runOnJS, + useAnimatedStyle, + useSharedValue, + withTiming, + type EntryAnimationsValues, +} from "react-native-reanimated"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Toast } from "../../components/ui/Toast"; +import { MOBILE_MOTION } from "../../lib/designTokens"; +import { + dismissThreadUndoNotice, + readThreadUndoNotice, + useThreadUndoNotice, +} from "./thread-undo-notice"; + +const SWIPE_TIMING = { duration: 160, reduceMotion: ReduceMotion.System }; +const ENTER_TIMING = { + duration: MOBILE_MOTION.enter, + easing: Easing.out(Easing.cubic), + reduceMotion: ReduceMotion.System, +}; +const NOTICE_EXITING = FadeOut.duration(MOBILE_MOTION.exit).reduceMotion(ReduceMotion.System); +const noticeEntering = (values: EntryAnimationsValues) => { + "worklet"; + return { + initialValues: { originY: values.targetOriginY + values.targetHeight + 12 }, + animations: { originY: withTiming(values.targetOriginY, ENTER_TIMING) }, + }; +}; + +export function ThreadUndoNotice(props: { readonly visible: boolean }) { + const notice = useThreadUndoNotice(); + // Replacing a notice must also cancel the native gesture tracking the old one. + const [instance, setInstance] = useState({ notice, key: 0 }); + if (instance.notice !== notice) setInstance({ notice, key: instance.key + 1 }); + if (!props.visible || notice === null) return null; + return ; +} + +function SwipeableUndoNotice(props: { + readonly notice: NonNullable>; +}) { + const { notice } = props; + const insets = useSafeAreaInsets(); + const translation = useSharedValue(0); + const width = useSharedValue(0); + + useLayoutEffect( + () => () => { + cancelAnimation(translation); + }, + [translation], + ); + + const dismiss = () => { + // A newer action can replace the notice while the swipe is animating out. + if (readThreadUndoNotice() === notice) dismissThreadUndoNotice(); + }; + const gesture = usePanGesture({ + activeOffsetX: [-10, 10], + failOffsetY: [-12, 12], + onUpdate: (event) => { + translation.set(event.translationX); + }, + onDeactivate: (event) => { + if ( + !event.canceled && + (Math.abs(event.translationX) > width.value * 0.25 || + (Math.abs(event.velocityX) > 800 && event.translationX * event.velocityX > 0)) + ) { + const direction = Math.sign(event.translationX || event.velocityX); + translation.set( + withTiming(direction * (width.value + 24), SWIPE_TIMING, (finished) => { + if (finished) runOnJS(dismiss)(); + }), + ); + } else { + translation.set(withTiming(0, SWIPE_TIMING)); + } + }, + }); + const animatedStyle = useAnimatedStyle(() => ({ + transform: [{ translateX: translation.value }], + })); + + // Clip the entrance above navigation; the notice intentionally covers the anchored FAB. + return ( + { + width.set(event.nativeEvent.layout.width); + }} + > + + + {/* Keep the dismiss target separate so TalkBack can also focus Undo. */} + { + if (nativeEvent.actionName === "dismiss") dismiss(); + }} + pointerEvents="none" + style={StyleSheet.absoluteFill} + /> + void notice.undo() }} + /> + + + + ); +} From 9a6380263b0304494151d297a53210c10b1d07ef Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 22 Oct 2026 23:29:40 -0700 Subject: [PATCH 08/59] perf(server): skip unused Git output line parsing (#23953) Co-authored-by: Jet Co-authored-by: Claude Fable 5.1 --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 73 ++++++++++++++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 14 ++-- 2 files changed, 82 insertions(+), 5 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 84861d6dd4ae..6bd0137154da 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -87,6 +87,79 @@ const makeFailedHandle = (stderr: string) => stderr: Stream.encodeText(Stream.make(stderr)), }); +describe("Git output collection", () => { + // Git writes in arbitrary chunk sizes; one byte per chunk splits every + // multibyte character across reads. + const byteChunks = (text: string) => + Stream.fromIterable(Array.from(new TextEncoder().encode(text), (byte) => Uint8Array.of(byte))); + const makeDriver = (output: { stdout?: string; stderr?: string }) => + makeGitVcsDriverCore().pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed( + ChildProcessSpawner.makeHandle({ + ...makeSuccessfulHandle(""), + stdout: byteChunks(output.stdout ?? ""), + stderr: byteChunks(output.stderr ?? ""), + }), + ), + ), + ), + ); + + it.effect("returns the same split UTF-8 and NUL output with and without line callbacks", () => + Effect.gen(function* () { + const stdout = "a\\b\0雪\nlast "; + const stderr = "Receiving é\r\nUpdating 雪\rdone"; + const driver = yield* makeDriver({ stdout, stderr }); + const input = { operation: "test.chunkedGitOutput", cwd: "/repo", args: ["status"] }; + const expected = { + exitCode: 0, + stdout, + stderr, + stdoutTruncated: false, + stderrTruncated: false, + }; + + assert.deepEqual(yield* driver.execute(input), expected); + + const stdoutLines: string[] = []; + const stderrLines: string[] = []; + const withCallbacks = yield* driver.execute({ + ...input, + progress: { + onStdoutLine: (line) => Effect.sync(() => void stdoutLines.push(line)), + onStderrLine: (line) => Effect.sync(() => void stderrLines.push(line)), + }, + }); + assert.deepEqual(withCallbacks, expected); + assert.deepEqual(stdoutLines, ["a\\b\0雪", "last "]); + assert.deepEqual(stderrLines, ["Receiving é", "Updating 雪", "done"]); + }).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))), + ); + + it.effect("caps output without line callbacks", () => + Effect.gen(function* () { + const driver = yield* makeDriver({ stderr: "é雪\rdone" }); + const input = { + operation: "test.cappedGitOutput", + cwd: "/repo", + args: ["status"], + maxOutputBytes: 3, + }; + + const truncated = yield* driver.execute({ ...input, appendTruncationMarker: true }); + assert.equal(truncated.stderr, "é\uFFFD"); + assert.isTrue(truncated.stderrTruncated); + + const error = yield* driver.execute(input).pipe(Effect.flip); + assert.equal(error.outputLength, 4); + assert.include(error.detail, "exceeded 3 bytes"); + }).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))), + ); +}); + const makeTmpDir = ( prefix = "git-vcs-driver-test-", ): Effect.Effect => diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 62798374d2aa..b5a523876a58 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -900,8 +900,10 @@ const collectOutput = Effect.fnUntraced(function* ( const decoded = decoder.decode(chunkToDecode, { stream: !truncated }); text += decoded; - lineBuffer += lineDecoder ? lineDecoder.decode(chunk, { stream: true }) : decoded; - yield* emitCompleteLines(false); + if (onLine) { + lineBuffer += lineDecoder ? lineDecoder.decode(chunk, { stream: true }) : decoded; + yield* emitCompleteLines(false); + } }); yield* Stream.runForEach(stream, processChunk).pipe( @@ -917,9 +919,11 @@ const collectOutput = Effect.fnUntraced(function* ( const remainder = truncated ? "" : decoder.decode(); text += remainder; - lineBuffer += remainder; - if (lineDecoder) lineBuffer += lineDecoder.decode(); - yield* emitCompleteLines(true); + if (onLine) { + lineBuffer += remainder; + if (lineDecoder) lineBuffer += lineDecoder.decode(); + yield* emitCompleteLines(true); + } return { text, truncated, From e64a6d4ca84e8f7adab8066173d432e76ebf1996 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 22 Oct 2026 23:44:50 -0700 Subject: [PATCH 09/59] fix(server): abort interrupted ACP MCP fetches (#23955) Co-authored-by: kiiwee <6160452+kiiwee@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 --- apps/server/src/mcp/AcpMcpStdioBridge.test.ts | 49 ++++++++++++++++++- apps/server/src/mcp/AcpMcpStdioBridge.ts | 6 ++- 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/apps/server/src/mcp/AcpMcpStdioBridge.test.ts b/apps/server/src/mcp/AcpMcpStdioBridge.test.ts index 0368c8971420..0f1bedc17c63 100644 --- a/apps/server/src/mcp/AcpMcpStdioBridge.test.ts +++ b/apps/server/src/mcp/AcpMcpStdioBridge.test.ts @@ -2,10 +2,11 @@ import * as NodeStream from "node:stream"; import { describe, expect, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; -import { runAcpMcpStdioBridge } from "./AcpMcpStdioBridge.ts"; +import { mcpToolSession, runAcpMcpStdioBridge } from "./AcpMcpStdioBridge.ts"; function makeHarness(responder: (request: Request) => Promise | Response) { const input = new NodeStream.PassThrough(); @@ -29,6 +30,13 @@ function makeHarness(responder: (request: Request) => Promise | Respon return { input, written, requests, bridge }; } +function pendingFetch(started: Deferred.Deferred) { + return (_url: string, init?: RequestInit): Promise => { + Deferred.doneUnsafe(started, Effect.succeed(init?.signal ?? undefined)); + return new Promise(() => {}); + }; +} + describe("AcpMcpStdioBridge", () => { it.effect("forwards requests and replays the session id and protocol version", () => Effect.gen(function* () { @@ -282,4 +290,43 @@ describe("AcpMcpStdioBridge", () => { expect(JSON.parse(written[0]!)).toMatchObject({ id: 7, result: {} }); }), ); + + it.effect("aborts the pending fetch when a tool call is interrupted", () => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const fiber = yield* mcpToolSession({ + endpoint: "http://127.0.0.1:1/mcp", + authorization: "Bearer bridge-test", + clientName: "t3-code-acp-cli", + fetchImplementation: pendingFetch(started), + }).pipe( + Effect.flatMap((session) => session.call("orchestrator_capabilities", {})), + Effect.scoped, + Effect.forkChild, + ); + + const signal = yield* Deferred.await(started); + yield* Fiber.interrupt(fiber); + expect(signal?.aborted).toBe(true); + }), + ); + + it.effect("aborts the pending fetch when the stdio bridge is interrupted", () => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const input = new NodeStream.PassThrough(); + const fiber = yield* runAcpMcpStdioBridge({ + endpoint: "http://127.0.0.1:1/mcp", + authorization: "Bearer bridge-test", + input, + output: { write: () => undefined }, + fetchImplementation: pendingFetch(started), + }).pipe(Effect.forkChild); + + input.write(`${JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" })}\n`); + const signal = yield* Deferred.await(started); + yield* Fiber.interrupt(fiber); + expect(signal?.aborted).toBe(true); + }), + ); }); diff --git a/apps/server/src/mcp/AcpMcpStdioBridge.ts b/apps/server/src/mcp/AcpMcpStdioBridge.ts index 2db36ce5abc2..ea9a4c93b00f 100644 --- a/apps/server/src/mcp/AcpMcpStdioBridge.ts +++ b/apps/server/src/mcp/AcpMcpStdioBridge.ts @@ -92,11 +92,12 @@ export const mcpToolSession = ( message: unknown, ): Effect.fn.Return, AcpMcpBridgeError> { const response = yield* Effect.tryPromise({ - try: () => + try: (signal) => fetchImplementation(options.endpoint, { method: "POST", headers: { "content-type": "application/json", ...headers() }, body: JSON.stringify(message), + signal, }), catch: bridgeError, }); @@ -212,7 +213,7 @@ export function runAcpMcpStdioBridge(options: AcpMcpStdioBridgeOptions): Effect. )( function* (line: string, envelope: JsonRpcEnvelope) { const response = yield* Effect.tryPromise({ - try: () => + try: (signal) => fetchImplementation(options.endpoint, { method: "POST", headers: { @@ -223,6 +224,7 @@ export function runAcpMcpStdioBridge(options: AcpMcpStdioBridgeOptions): Effect. ...(protocolVersion === null ? {} : { "mcp-protocol-version": protocolVersion }), }, body: line, + signal, }), catch: bridgeError, }); From 56c365cc29de58746d9888567aed1a71f07d9370 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 22 Oct 2026 23:53:50 -0700 Subject: [PATCH 10/59] fix(web): new threads no longer hang when a project's machine is offline (#23967) Co-authored-by: Claude Opus 5.5 --- apps/web/src/hooks/useHandleNewThread.test.ts | 37 +++++++++++++++++-- apps/web/src/hooks/useHandleNewThread.ts | 9 ++++- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/apps/web/src/hooks/useHandleNewThread.test.ts b/apps/web/src/hooks/useHandleNewThread.test.ts index 5abfd0768849..678d72b038da 100644 --- a/apps/web/src/hooks/useHandleNewThread.test.ts +++ b/apps/web/src/hooks/useHandleNewThread.test.ts @@ -5,8 +5,9 @@ const testState = vi.hoisted(() => { let completeProjectFileRead: (value: null) => void = () => undefined; let projectFileRead = Promise.resolve(null); let projectRemoved = false; + let connectedEnvironmentIds = ["environment-primary", "environment-ssh"]; let targetSettings = { - defaultThreadEnvMode: "local" as "local" | "worktree", + defaultThreadEnvMode: "local" as "local" | "worktree" | null, newWorktreesStartFromOrigin: false, defaultModelSelection: null, defaultRuntimeMode: "full-access" as RuntimeMode, @@ -46,6 +47,12 @@ const testState = vi.hoisted(() => { get projectRemoved() { return projectRemoved; }, + get connectedEnvironmentIds() { + return connectedEnvironmentIds; + }, + disconnect(environmentId: string) { + connectedEnvironmentIds = connectedEnvironmentIds.filter((id) => id !== environmentId); + }, removeProject() { projectRemoved = true; }, @@ -55,12 +62,13 @@ const testState = vi.hoisted(() => { reset( nextStoredDraft: typeof storedDraft, workspaceDefaults = { - envMode: "local" as "local" | "worktree", + envMode: "local" as "local" | "worktree" | null, startFromOrigin: false, }, ) { storedDraft = nextStoredDraft; projectRemoved = false; + connectedEnvironmentIds = ["environment-primary", "environment-ssh"]; targetSettings = { defaultThreadEnvMode: workspaceDefaults.envMode, newWorktreesStartFromOrigin: workspaceDefaults.startFromOrigin, @@ -94,7 +102,12 @@ const testServerConfigs = () => ]); vi.mock("../rpc/atomRegistry", () => ({ - appAtomRegistry: { get: () => testServerConfigs() }, + appAtomRegistry: { + get: (atom: unknown) => + atom === "connected-environment-ids" + ? testState.connectedEnvironmentIds + : testServerConfigs(), + }, })); vi.mock("@effect/atom-react", () => ({ @@ -187,6 +200,9 @@ vi.mock("../state/entities", () => { useThread: () => null, }; }); +vi.mock("../state/presentation", () => ({ + environmentSummaries: { connectedEnvironmentIdsAtom: "connected-environment-ids" }, +})); vi.mock("../state/server", () => ({ environmentServerConfigsAtom: {}, primaryServerSettingsAtom: "primary-settings", @@ -244,3 +260,18 @@ describe.each([ expect(testState.draftStore.setLogicalProjectDraftThreadId).not.toHaveBeenCalled(); }); }); + +describe("useNewThreadHandler", () => { + it("opens the draft without waiting on a project file from an offline machine", async () => { + testState.reset(null, { envMode: null, startFromOrigin: false }); + testState.disconnect("environment-ssh"); + + const opened = await useNewThreadHandler()({ + environmentId: "environment-ssh", + projectId: "project-remote", + } as never); + + expect(opened).toEqual({ draftId: "draft-delayed", threadId: "thread-delayed" }); + expect(testState.router.navigate).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index cfb98fc83bd0..f079e65c8129 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -36,6 +36,7 @@ import { } from "../lib/chatThreadActions"; import { readT3ProjectFile } from "../lib/t3ProjectFileDefaults"; import { appAtomRegistry } from "../rpc/atomRegistry"; +import { environmentSummaries } from "../state/presentation"; import { environmentServerConfigsAtom } from "../state/server"; import { resolveThreadRouteTarget } from "../threadRoutes"; import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; @@ -139,8 +140,14 @@ export function useNewThreadHandler() { destinationDraftId, }); const resolveDefaultEnvMode = async (): Promise => { + // Environment queries wait for a connection, so reading the project file on an + // offline machine would never settle and the draft would never open. const consultProjectFile = - project !== undefined && projectSettings.settings.defaultThreadEnvMode === null; + project !== undefined && + projectSettings.settings.defaultThreadEnvMode === null && + appAtomRegistry + .get(environmentSummaries.connectedEnvironmentIdsAtom) + .includes(project.environmentId); const projectFile = consultProjectFile ? await readT3ProjectFile(project.environmentId, project.workspaceRoot) : null; From 6d9ec7bdf9a633da430a47149f271e1e36ef3c79 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 23 Oct 2026 02:09:55 -0500 Subject: [PATCH 11/59] fix(pi): stop blank input placeholders from failing the run (#23235) Co-authored-by: Claude Opus 5.5 --- .../provider-pi/src/server/adapter.test.ts | 34 +++++++++++++++++++ packages/provider-pi/src/server/adapter.ts | 6 +++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/packages/provider-pi/src/server/adapter.test.ts b/packages/provider-pi/src/server/adapter.test.ts index 9c81bab0abb5..be502f953373 100644 --- a/packages/provider-pi/src/server/adapter.test.ts +++ b/packages/provider-pi/src/server/adapter.test.ts @@ -3126,6 +3126,40 @@ describe("PiAdapterV2", () => { }).pipe(Effect.scoped, Effect.provide(layerTest)), ); + it.effect("asks an input dialog's title when its placeholder is blank", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent } = yield* openRuntime(fake); + yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + const title = "Which database?\n1. Postgres\n2. Type something.\nType your answer:"; + yield* fake.emit({ + type: "extension_ui_request", + id: "ui-input", + method: "input", + title, + placeholder: "", + }); + const event = yield* takeEvent( + (event) => + event.type === "turn_item.updated" && event.turnItem.type === "user_input_request", + ); + if (event.type !== "turn_item.updated" || event.turnItem.type !== "user_input_request") { + return assert.fail("expected a user input request"); + } + assert.equal(decodeUserInputQuestions(event.turnItem.questions)[0]?.question, title); + yield* runtime.respondToRuntimeRequest({ + requestId: event.turnItem.requestId, + answers: { "ui-input": "SQLite" }, + }); + const response = yield* fake.takeRequest("extension_ui_response"); + assert.equal(response["value"], "SQLite"); + }).pipe(Effect.scoped, Effect.provide(layerTest)), + ); + it.effect("keeps the complete editor prefill editable and returns exact edited text", () => Effect.gen(function* () { const fake = yield* makeFakePi; diff --git a/packages/provider-pi/src/server/adapter.ts b/packages/provider-pi/src/server/adapter.ts index a2a3caf9267e..6cac78ea9451 100644 --- a/packages/provider-pi/src/server/adapter.ts +++ b/packages/provider-pi/src/server/adapter.ts @@ -3322,7 +3322,11 @@ function piQuestion( value: "", }, ]; - const question = recordString(event, "message") ?? recordString(event, "placeholder") ?? title; + // Extensions may send a blank placeholder, which is not a valid question. + const question = + [recordString(event, "message"), recordString(event, "placeholder")].find( + (text) => text !== undefined && text.trim().length > 0, + ) ?? title; return { id: questionId, header: title, From 656f3f40945654f540a98b75ec11afff399e1baa Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 23 Oct 2026 02:10:23 -0500 Subject: [PATCH 12/59] fix(cursor): fail turns that end in a transport error after commentary (#23273) Co-authored-by: Claude Opus 5.5 --- .../src/server/adapter.test.ts | 35 +++++++++++++++---- .../provider-cursor/src/server/adapter.ts | 7 +++- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/packages/provider-cursor/src/server/adapter.test.ts b/packages/provider-cursor/src/server/adapter.test.ts index a297af469cf4..0686b0c831dd 100644 --- a/packages/provider-cursor/src/server/adapter.test.ts +++ b/packages/provider-cursor/src/server/adapter.test.ts @@ -256,7 +256,23 @@ describe("CursorAdapterV2", () => { "Cursor Agent SDK run.wait failed: Too many requests (resource_exhausted, HTTP 429)", }, }, - ])("fails the turn with $reason", ({ wait, failure }) => + { + reason: "a transport dump sent after earlier commentary", + updates: [ + { type: "text-delta", text: "Checking the build first." }, + { type: "step-completed", stepId: 1, stepDurationMs: 1 }, + { type: "text-delta", text: "Error: RetriableError: WritableIterable is closed" }, + ] satisfies ReadonlyArray, + wait: Effect.succeed({ + id: "native-cursor-run", + requestId: "native-request", + status: "finished" as const, + model: { id: "claude-fable-5" }, + durationMs: 1, + }), + failure: { class: "transport_error", message: "Provider turn failed." }, + }, + ])("fails the turn with $reason", ({ updates = [], wait, failure }) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const workspace = yield* fileSystem.makeTempDirectoryScoped({ prefix: "cursor-v2-failure-" }); @@ -280,12 +296,17 @@ describe("CursorAdapterV2", () => { agentId: "native-cursor-failure", listMessages: Effect.succeed([]), close: Effect.void, - send: () => - Effect.succeed({ - agentId: "native-cursor-failure", - runId: "native-cursor-run", - wait, - cancel: Effect.void, + send: (input) => + Effect.gen(function* () { + for (const update of updates) { + yield* input.onDelta!(update).pipe(Effect.orDie); + } + return { + agentId: "native-cursor-failure", + runId: "native-cursor-run", + wait, + cancel: Effect.void, + }; }), }), }), diff --git a/packages/provider-cursor/src/server/adapter.ts b/packages/provider-cursor/src/server/adapter.ts index 9ff5ba75f466..825596c04e6d 100644 --- a/packages/provider-cursor/src/server/adapter.ts +++ b/packages/provider-cursor/src/server/adapter.ts @@ -867,7 +867,7 @@ interface ActiveCursorTurn { readonly tools: Map; readonly subagents: Map; readonly assistant: ActiveCursorTextStream; - readonly assistantReply: CursorTransportFailure; + assistantReply: CursorTransportFailure; readonly reasoning: ActiveCursorTextStream; interrupted: boolean; finalized: boolean; @@ -1916,6 +1916,11 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( } switch (update.type) { case "text-delta": + // Classify each assistant message on its own, so earlier + // commentary does not hide a later standalone transport dump. + if (context.assistant.current === null) { + context.assistantReply = new CursorTransportFailure(); + } context.assistantReply.push(update.text); yield* completeReasoning(context); yield* appendTextSegment({ From 49597c77db579062bb70a26e416df7efbb14db71 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 23 Oct 2026 00:11:26 -0700 Subject: [PATCH 13/59] test(forgejo): answer the base branch read in the skipped-checks test (#23952) Co-authored-by: Claude Opus 5.5 --- .../ForgejoSourceControlProvider.test.ts | 50 ++++++++++--------- 1 file changed, 26 insertions(+), 24 deletions(-) diff --git a/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts b/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts index 337f9c515e6a..ac037da3f9c3 100644 --- a/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts +++ b/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts @@ -308,30 +308,32 @@ it.effect("reads Forgejo jobs that did not run as finished checks", () => target_url: null, updated_at: "2026-10-11T00:00:00Z", }); - const body = input.path.endsWith("pulls/1") - ? { - number: 1, - title: "Checks", - body: "", - html_url: "https://forgejo.test/acme/web/pulls/1", - user: null, - state: "open", - merged: false, - head: { ref: "feature", sha: "head", repo: null }, - base: { ref: "main", sha: "base", repo: null }, - created_at: "2026-10-11T00:00:00Z", - updated_at: "2026-10-11T00:00:00Z", - closed_at: null, - merged_at: null, - labels: [], - } - : input.path.endsWith("page=1") - ? [ - status("deploy", "skipped"), - status("audit", "warning"), - status("docs", "future-state"), - ] - : []; + const body = input.path.endsWith("branches/main") + ? {} + : input.path.endsWith("pulls/1") + ? { + number: 1, + title: "Checks", + body: "", + html_url: "https://forgejo.test/acme/web/pulls/1", + user: null, + state: "open", + merged: false, + head: { ref: "feature", sha: "head", repo: null }, + base: { ref: "main", sha: "base", repo: null }, + created_at: "2026-10-11T00:00:00Z", + updated_at: "2026-10-11T00:00:00Z", + closed_at: null, + merged_at: null, + labels: [], + } + : input.path.endsWith("page=1") + ? [ + status("deploy", "skipped"), + status("audit", "warning"), + status("docs", "future-state"), + ] + : []; return encodeJsonEffect(body).pipe(Effect.orDie, Effect.map(processOutput)); }, }), From 807319c276c6831b2c219640e9da5b38019a8f1d Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Fri, 23 Oct 2026 02:13:41 -0500 Subject: [PATCH 14/59] fix(opencode): retry failed model catalogs until they recover (#23939) --- .../src/server/managedProvider.test.ts | 75 ++++++++ .../src/server/managedProvider.ts | 16 +- .../src/server/driver.test.ts | 170 ++++++++++++++++++ .../provider-opencode/src/server/driver.ts | 11 +- .../src/server/status.test.ts | 148 +++++++++++++++ .../provider-opencode/src/server/status.ts | 92 ++++++++-- .../ForgejoSourceControlProvider.test.ts | 3 + 7 files changed, 491 insertions(+), 24 deletions(-) diff --git a/packages/provider-core/src/server/managedProvider.test.ts b/packages/provider-core/src/server/managedProvider.test.ts index 393372e46fe0..668502f93e43 100644 --- a/packages/provider-core/src/server/managedProvider.test.ts +++ b/packages/provider-core/src/server/managedProvider.test.ts @@ -13,6 +13,7 @@ import * as Layer from "effect/Layer"; import * as PubSub from "effect/PubSub"; import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import * as ProviderHost from "./ProviderHost.ts"; import { makeManagedServerProvider } from "./managedProvider.ts"; @@ -179,6 +180,80 @@ describe("makeManagedServerProvider", () => { ).pipe(Effect.provide(layerAlwaysRunTest)), ); + it.effect("skips periodic provider refreshes without foreground provider-status demand", () => + Effect.scoped( + Effect.gen(function* () { + const checkCalls = yield* Ref.make(0); + const initialCheckDone = yield* Deferred.make(); + yield* makeManagedServerProvider({ + resolveMaintenance: () => Effect.succeed(maintenanceCapabilities), + getSettings: Effect.succeed({ enabled: true }), + streamSettings: Stream.empty, + haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, + refreshOnInterval: () => true, + initialSnapshot: () => Effect.succeed(initialSnapshot), + checkProvider: Ref.updateAndGet(checkCalls, (count) => count + 1).pipe( + Effect.tap((count) => + count === 1 + ? Deferred.succeed(initialCheckDone, undefined).pipe(Effect.ignore) + : Effect.void, + ), + Effect.as(refreshedSnapshot), + ), + refreshInterval: "1 second", + }); + + yield* Deferred.await(initialCheckDone); + yield* TestClock.adjust("1 second"); + yield* Effect.yieldNow; + + assert.strictEqual(yield* Ref.get(checkCalls), 1); + }), + ).pipe( + Effect.provide( + Layer.mergeAll(layerProviderHost({ runBackgroundWork: false }), TestClock.layer()), + ), + ), + ); + + it.effect("stops periodic recovery checks once the snapshot is ready", () => + Effect.scoped( + Effect.gen(function* () { + const checkCalls = yield* Ref.make(0); + const releaseInitialCheck = yield* Deferred.make(); + const failedSnapshot = { ...refreshedSnapshot, status: "error" as const }; + const provider = yield* makeManagedServerProvider({ + resolveMaintenance: () => Effect.succeed(maintenanceCapabilities), + getSettings: Effect.succeed({ enabled: true }), + streamSettings: Stream.empty, + haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, + refreshOnInterval: (snapshot) => snapshot.status === "error", + initialSnapshot: () => Effect.succeed(initialSnapshot), + checkProvider: Deferred.await(releaseInitialCheck).pipe( + Effect.andThen(Ref.updateAndGet(checkCalls, (count) => count + 1)), + Effect.map((count) => (count === 1 ? failedSnapshot : refreshedSnapshot)), + ), + refreshInterval: "1 second", + }); + const updates = yield* provider.streamChanges.pipe( + Stream.take(2), + Stream.runCollect, + Effect.forkChild, + ); + yield* Effect.yieldNow; + yield* Deferred.succeed(releaseInitialCheck, undefined); + yield* TestClock.adjust("1 second"); + + assert.deepStrictEqual(Array.from(yield* Fiber.join(updates)), [ + failedSnapshot, + refreshedSnapshot, + ]); + yield* TestClock.adjust("3 seconds"); + assert.strictEqual(yield* Ref.get(checkCalls), 2); + }), + ).pipe(Effect.provide(Layer.mergeAll(layerAlwaysRunTest, TestClock.layer()))), + ); + it.effect("reruns the provider check when streamed settings change", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/provider-core/src/server/managedProvider.ts b/packages/provider-core/src/server/managedProvider.ts index e069bf3af004..12a4523884c8 100644 --- a/packages/provider-core/src/server/managedProvider.ts +++ b/packages/provider-core/src/server/managedProvider.ts @@ -51,7 +51,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect; }) => Effect.Effect; readonly refreshInterval?: Duration.Input; - readonly refreshOnInterval?: boolean; + readonly refreshOnInterval?: boolean | ((snapshot: ServerProvider) => boolean); readonly checkProviderOnSettingsChange?: (previous: Settings, next: Settings) => boolean; }): Effect.fn.Return< ManagedServerProvider, @@ -218,11 +218,15 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( return yield* applySnapshot(nextSettings, { forceRefresh: true }); }); - const hasProviderStatusDemand = Effect.gen(function* () { + const shouldRefreshOnInterval = Effect.gen(function* () { const state = yield* Ref.get(snapshotStateRef); // A disabled instance has nothing to probe; a periodic check would only // republish a snapshot that differs in checkedAt. - if (!state.snapshot.enabled) { + if ( + !state.snapshot.enabled || + input.refreshOnInterval === false || + (typeof input.refreshOnInterval === "function" && !input.refreshOnInterval(state.snapshot)) + ) { return false; } const instanceId = state.snapshot.instanceId; @@ -276,10 +280,8 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( Queue.take(refreshIntervalChanges).pipe(Effect.as(false)), ).pipe( Effect.flatMap((intervalElapsed) => - input.refreshOnInterval !== false && - intervalElapsed && - Duration.toMillis(Duration.fromInputUnsafe(refreshInterval)) > 0 - ? hasProviderStatusDemand.pipe( + intervalElapsed && Duration.toMillis(Duration.fromInputUnsafe(refreshInterval)) > 0 + ? shouldRefreshOnInterval.pipe( Effect.flatMap((shouldRefresh) => shouldRefresh ? refreshSnapshot().pipe(Effect.asVoid) : Effect.void, ), diff --git a/packages/provider-opencode/src/server/driver.test.ts b/packages/provider-opencode/src/server/driver.test.ts index 20afe88d8104..6b529dfd9dcc 100644 --- a/packages/provider-opencode/src/server/driver.test.ts +++ b/packages/provider-opencode/src/server/driver.test.ts @@ -8,11 +8,16 @@ import { assert, it } from "@effect/vitest"; import { ProviderInstanceId } from "@t3tools/contracts"; import type { OpenCodeSettings } from "../settings.ts"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; +import * as References from "effect/References"; +import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import * as HttpClient from "effect/http/HttpClient"; +import * as HttpClientResponse from "effect/http/HttpClientResponse"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; @@ -21,6 +26,7 @@ import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderS import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; import * as ProviderMaintenance from "@t3tools/provider-core/server/maintenanceResolver"; import * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; +import { OPENCODE_2_RESPONSES, replayOpenCodeServer } from "./probeResponses.fixture.ts"; import { OpenCodeDriver, openCodeUpdateFor } from "./driver.ts"; import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost"; @@ -94,6 +100,170 @@ it.layer(layer)("OpenCodeDriver runtime selection", (it) => { yield* Fiber.join(capabilities); }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), ); + + it.effect.each(["probe", "error", "empty"] as const)( + "recovers discovery without a manual refresh after %s", + (initialCatalog) => + Effect.gen(function* () { + const releaseCatalog = yield* Deferred.make(); + const discoveryLogs: Array> = []; + const logger = Logger.make(({ message, fiber }) => { + if (Array.isArray(message) && message.includes("OpenCode discovery failed")) { + discoveryLogs.push({ ...fiber.getRef(References.CurrentLogAnnotations) }); + } + }); + let catalogAvailable = false; + let modelReads = 0; + const replies = { + ...OPENCODE_2_RESPONSES, + "/api/provider": { + status: 200, + contentType: "application/json", + body: '{"location":{"directory":"/work"},"data":[]}', + }, + "/api/model": { + status: 200, + contentType: "application/json", + body: JSON.stringify({ + location: { directory: "/work" }, + data: [ + { + id: "big-pickle", + modelID: "big-pickle", + providerID: "opencode", + name: "Big Pickle", + package: "@opencode/ai/providers/openai-compatible", + settings: {}, + compatibility: {}, + capabilities: { tools: true, input: ["text"], output: ["text"] }, + variants: [], + time: { released: 1789516800000 }, + cost: [{ input: 0, output: 0, cache: { read: 0, write: 0 } }], + status: "active", + enabled: true, + limit: { context: 1000000, output: 65536 }, + }, + ], + }), + }, + }; + const replay = replayOpenCodeServer(replies, "secret"); + const server = HttpClient.make((request) => + Effect.gen(function* () { + if (initialCatalog === "probe" && !catalogAvailable) { + yield* Deferred.await(releaseCatalog); + return HttpClientResponse.fromWeb( + request, + new Response('{"_tag":"InternalServerError","message":"server starting"}', { + status: 503, + headers: { "content-type": "application/json" }, + }), + ); + } + if (new URL(request.url).pathname === "/api/model") { + modelReads += 1; + yield* Deferred.await(releaseCatalog); + if (!catalogAvailable) { + return HttpClientResponse.fromWeb( + request, + new Response( + initialCatalog === "error" + ? '{"_tag":"InternalServerError","message":"catalog unavailable"}' + : '{"location":{"directory":"/work"},"data":[]}', + { + status: initialCatalog === "error" ? 500 : 200, + headers: { "content-type": "application/json" }, + }, + ), + ); + } + } + return yield* replay.execute(request); + }), + ); + const instance = yield* create( + { serverUrl: "http://127.0.0.1:4096", serverPassword: "secret" }, + server, + ).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false }))); + const initial = yield* instance.snapshot.streamChanges.pipe( + Stream.filter( + (snapshot) => + snapshot.installed && + snapshot.status === (initialCatalog === "empty" ? "warning" : "error"), + ), + Stream.take(1), + Stream.runCollect, + Effect.forkChild, + ); + yield* Effect.yieldNow; + yield* Deferred.succeed(releaseCatalog, undefined); + yield* TestClock.adjust("5 seconds"); + const [failed] = Array.from(yield* Fiber.join(initial)); + assert.strictEqual(failed?.auth.status, "unknown"); + assert.deepStrictEqual(failed?.models, []); + if (initialCatalog === "probe") assert.strictEqual(failed?.version, null); + assert.strictEqual(discoveryLogs.length, 1); + assert.strictEqual(discoveryLogs[0]?.providerInstanceId, "opencode-test"); + assert.strictEqual(discoveryLogs[0]?.serverMode, "external"); + assert.strictEqual( + discoveryLogs[0]?.phase, + initialCatalog === "probe" ? "version" : "models", + ); + + catalogAvailable = true; + const recovered = yield* instance.snapshot.streamChanges.pipe( + Stream.filter((snapshot) => snapshot.status === "ready"), + Stream.take(1), + Stream.runCollect, + Effect.forkChild, + ); + yield* TestClock.adjust("5 minutes"); + assert.strictEqual((yield* instance.snapshot.getSnapshot).status, "ready"); + const [ready] = Array.from(yield* Fiber.join(recovered)); + assert.strictEqual(ready?.auth.status, "authenticated"); + assert.deepStrictEqual( + ready?.models.map((model) => model.slug), + ["opencode/big-pickle"], + ); + + const readsAfterRecovery = modelReads; + yield* TestClock.adjust("15 minutes"); + assert.strictEqual(modelReads, readsAfterRecovery); + assert.strictEqual(discoveryLogs.length, 1); + }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), + ); + + it.effect("does not retry a rejected server password in the background", () => + Effect.gen(function* () { + const releaseResponse = yield* Deferred.make(); + const requested: Array = []; + const server = replayOpenCodeServer(OPENCODE_2_RESPONSES, "secret", requested); + const instance = yield* create( + { serverUrl: "http://127.0.0.1:4096", serverPassword: "wrong" }, + HttpClient.make((request) => + Deferred.await(releaseResponse).pipe(Effect.andThen(server.execute(request))), + ), + ); + // Wait for startup and its maintenance enrichment, both of which probe. + const rejected = yield* instance.snapshot.streamChanges.pipe( + Stream.filter( + (snapshot) => snapshot.status === "error" && snapshot.versionAdvisory !== undefined, + ), + Stream.take(1), + Stream.runCollect, + Effect.forkChild, + ); + yield* Effect.yieldNow; + yield* Deferred.succeed(releaseResponse, undefined); + const [snapshot] = Array.from(yield* Fiber.join(rejected)); + assert.strictEqual(snapshot?.version, null); + assert.strictEqual(snapshot?.auth.status, "unauthenticated"); + const readsAfterRejection = requested.length; + + yield* TestClock.adjust("15 minutes"); + assert.strictEqual(requested.length, readsAfterRejection); + }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), + ); }); // npm links `/bin/opencode` into the package that installed it: diff --git a/packages/provider-opencode/src/server/driver.ts b/packages/provider-opencode/src/server/driver.ts index 5348e66ddda0..14de2ce4942f 100644 --- a/packages/provider-opencode/src/server/driver.ts +++ b/packages/provider-opencode/src/server/driver.ts @@ -350,6 +350,9 @@ export const OpenCodeDriver: ProviderDriver< host.paths.cwd, runtimeProbe.refresh, loadOpenCode2Models, + ).pipe( + Effect.annotateLogs({ providerInstanceId: instanceId }), + Effect.annotateSpans({ providerInstanceId: instanceId }), ), usageLimits: readOpenCodeGoUsageLimits({ enabled: effectiveConfig.enabled, @@ -440,7 +443,13 @@ export const OpenCodeDriver: ProviderDriver< streamSettings: snapshotSettings.streamSettings, haveSettingsChanged: haveProviderSnapshotSettingsChanged, checkProviderOnSettingsChange: () => false, - refreshOnInterval: false, + // A failed or still-loading catalog must recover without a manual + // refresh. Healthy instances need no recurring server starts. + refreshOnInterval: (snapshot) => + snapshot.enabled && + snapshot.installed && + snapshot.status !== "ready" && + snapshot.auth.status === "unknown", initialSnapshot: (settings) => makePendingOpenCodeProvider(settings.provider).pipe(Effect.map(stampIdentity)), checkProvider, diff --git a/packages/provider-opencode/src/server/status.test.ts b/packages/provider-opencode/src/server/status.test.ts index f55e07d2fa02..e2ba4277e450 100644 --- a/packages/provider-opencode/src/server/status.test.ts +++ b/packages/provider-opencode/src/server/status.test.ts @@ -8,7 +8,9 @@ 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 Logger from "effect/Logger"; import * as Schema from "effect/Schema"; +import * as Tracer from "effect/Tracer"; import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; import { beforeEach } from "vite-plus/test"; @@ -462,6 +464,110 @@ const checkProvider = Effect.fn("checkProvider")(function* ( }); it.layer(layerTest)("checkOpenCodeProviderStatus", (it) => { + it.effect.each(["version", "inventory", "models", "empty-v1", "empty-v2"] as const)( + "records %s discovery failures in the existing trace and logger", + (failure) => + Effect.gen(function* () { + const cause = new Error("discovery connection reset"); + const v2 = failure === "models" || failure === "empty-v2"; + runtimeMock.state.versionStdout = v2 ? "opencode v2.0.18\n" : DEFAULT_VERSION_STDOUT; + if (failure === "version") runtimeMock.state.runVersionError = cause; + if (failure === "inventory") runtimeMock.state.inventoryError = cause; + if (failure === "empty-v1") { + runtimeMock.state.inventory = { + providerList: { + connected: ["openai"], + all: [{ id: "openai", name: "OpenAI", models: {} }], + default: {}, + }, + agents: [], + skills: [], + }; + } + const spans: Array = []; + yield* checkProvider( + makeOpenCodeSettings({ serverPassword: "private-password" }), + process.cwd(), + undefined, + undefined, + failure === "models" + ? Effect.fail( + new OpenCodeRuntime.OpenCodeRuntimeError({ + operation: "model.list", + detail: "model list failed", + cause, + }), + ) + : Effect.succeed([]), + ).pipe( + Effect.provideService( + Tracer.Tracer, + Tracer.make({ + span: (options) => { + const span = new Tracer.NativeSpan(options); + spans.push(span); + return span; + }, + }), + ), + Effect.provide(Logger.layer([Logger.tracerLogger], { mergeWithExisting: false })), + ); + + const diagnostics = spans.flatMap((span) => + span.events + .filter(([name]) => name === "OpenCode discovery failed") + .map((event) => ({ span, event })), + ); + NodeAssert.equal(diagnostics.length, 1); + const { + span, + event: [, timestamp, attributes], + } = diagnostics[0]!; + NodeAssert.equal(attributes.provider, "opencode"); + NodeAssert.equal( + attributes.phase, + failure.startsWith("empty") ? (v2 ? "models" : "inventory") : failure, + ); + NodeAssert.equal(attributes.outcome, failure.startsWith("empty") ? "empty" : "failed"); + NodeAssert.equal(attributes.serverMode, "managed"); + NodeAssert.equal(attributes["effect.logLevel"], "WARN"); + NodeAssert.ok(timestamp >= 0n); + NodeAssert.ok(span.traceId.length > 0); + NodeAssert.equal(span.attributes.get("discovery.outcome"), attributes.outcome); + if (!failure.startsWith("empty")) { + NodeAssert.match(String(attributes["effect.cause"]), /discovery connection reset/); + } + NodeAssert.ok(!JSON.stringify(attributes).includes("private-password")); + }), + ); + + it.effect("keeps an empty legacy catalog retryable until connected providers list models", () => + Effect.gen(function* () { + const upstream = { id: "openai", name: "OpenAI", models: {} }; + runtimeMock.state.inventory = { + providerList: { connected: ["openai"], all: [upstream], default: {} }, + agents: [], + skills: [], + }; + const settings = makeOpenCodeSettings(); + const empty = yield* checkProvider(settings); + NodeAssert.equal(empty.status, "warning"); + NodeAssert.equal(empty.auth.status, "unknown"); + NodeAssert.deepEqual(empty.models, []); + + upstream.models = { + "gpt-5.4": { id: "gpt-5.4", name: "GPT-5.4", variants: {} }, + }; + const ready = yield* checkProvider(settings); + NodeAssert.equal(ready.status, "ready"); + NodeAssert.equal(ready.auth.status, "authenticated"); + NodeAssert.deepEqual( + ready.models.map((model) => model.slug), + ["openai/gpt-5.4"], + ); + }), + ); + it.effect("emits OpenCode variant defaults so trait picker can resolve a visible selection", () => Effect.gen(function* () { runtimeMock.state.inventory = { @@ -776,6 +882,48 @@ it.layer(layerTest)("checkOpenCodeProviderStatus", (it) => { }); it.layer(layerTest)("checkOpenCodeProviderStatus with configured server URL", (it) => { + it.effect.each([ + "401 Unauthorized", + "403 Forbidden", + "status=401 body={}", + "status=403 body={}", + "HTTP 401", + "HTTP/1.1 403", + "Response status code: 401", + "Response status code: 403", + "Request failed with status code 401", + "Request failed with status code 403", + ])("keeps explicit authentication rejection actionable: %s", (detail) => + Effect.gen(function* () { + runtimeMock.state.connectionError = new Error(detail); + const snapshot = yield* checkProvider( + makeOpenCodeSettings({ serverUrl: "http://127.0.0.1:9999" }), + ); + + NodeAssert.equal(snapshot.status, "error"); + NodeAssert.equal(snapshot.auth.status, "unauthenticated"); + NodeAssert.equal( + snapshot.message, + "OpenCode server rejected authentication. Check the server URL and password.", + ); + }), + ); + + it.effect.each([401, 403, 4010, 4030])("keeps connection failures on port %s retryable", (port) => + Effect.gen(function* () { + const serverUrl = `http://127.0.0.1:${port}`; + runtimeMock.state.connectionError = new Error(`connect ECONNREFUSED 127.0.0.1:${port}`); + const snapshot = yield* checkProvider(makeOpenCodeSettings({ serverUrl })); + + NodeAssert.equal(snapshot.status, "error"); + NodeAssert.equal(snapshot.auth.status, "unknown"); + NodeAssert.equal( + snapshot.message, + `Couldn't reach the configured OpenCode server at ${serverUrl}. Check that the server is running and the URL is correct.`, + ); + }), + ); + it.effect("routes a configured OpenCode 2 server to the 2.x check, never via 1.x", () => Effect.gen(function* () { const settings = makeOpenCodeSettings({ diff --git a/packages/provider-opencode/src/server/status.ts b/packages/provider-opencode/src/server/status.ts index a3d4697e6bcd..c5dcc057735d 100644 --- a/packages/provider-opencode/src/server/status.ts +++ b/packages/provider-opencode/src/server/status.ts @@ -66,19 +66,25 @@ function formatOpenCodeProbeError(input: { readonly isExternalServer: boolean; readonly phase: "version" | "inventory"; readonly serverUrl: string; -}): { readonly installed: boolean; readonly message: string } { +}): { + readonly installed: boolean; + readonly message: string; + readonly authStatus?: "unauthenticated"; +} { const detail = normalizedErrorMessage(input.cause); const lower = detail?.toLowerCase() ?? ""; if (input.isExternalServer) { + // Bare auth-code digits can be part of the endpoint's port or path. if ( - lower.includes("401") || - lower.includes("403") || - lower.includes("unauthorized") || - lower.includes("forbidden") + /(?:^|\s)(?:status(?:\s+code)?(?:\s*[:=]\s*|\s+)|http(?:\/\d(?:\.\d)?)?\s+)(?:401|403)\b/.test( + lower, + ) || + /\b(?:unauthorized|forbidden)\b/.test(lower) ) { return { installed: true, + authStatus: "unauthenticated", message: "OpenCode server rejected authentication. Check the server URL and password.", }; } @@ -137,6 +143,32 @@ function formatOpenCodeProbeError(input: { }; } +// The status check owns the span; keep handled failures visible without adding another span. +const logDiscoveryFailure = Effect.fnUntraced(function* (input: { + readonly phase: "version" | "inventory" | "models"; + readonly isExternalServer: boolean; + readonly version?: string; + readonly cause?: Cause.Cause; +}) { + const outcome = input.cause ? "failed" : "empty"; + const attributes = { + provider: "opencode", + phase: input.phase, + outcome, + serverMode: input.isExternalServer ? "external" : "managed", + ...(input.version === undefined ? {} : { version: input.version }), + }; + yield* Effect.annotateCurrentSpan({ + "discovery.phase": input.phase, + "discovery.outcome": outcome, + }); + yield* ( + input.cause + ? Effect.logWarning("OpenCode discovery failed", input.cause) + : Effect.logWarning("OpenCode discovery failed") + ).pipe(Effect.annotateLogs(attributes)); +}); + function titleCaseSlug(value: string): string { const segments: Array = []; for (const segment of value.split(/[-_/]+/)) { @@ -592,7 +624,12 @@ const checkOpenCode2 = Effect.fn("checkOpenCode2")(function* ( message, }); if (result._tag === "Failure") { - yield* Effect.logWarning("OpenCode 2 model list failed", result.cause); + yield* logDiscoveryFailure({ + phase: "models", + isExternalServer: settings.serverUrl.trim().length > 0, + version, + cause: result.cause, + }); return buildServerProvider({ presentation: OPENCODE_2_PRESENTATION, enabled: true, @@ -606,6 +643,13 @@ const checkOpenCode2 = Effect.fn("checkOpenCode2")(function* ( probe: probe("error", "OpenCode could not load its model list."), }); } + if (result.value.length === 0) { + yield* logDiscoveryFailure({ + phase: "models", + isExternalServer: settings.serverUrl.trim().length > 0, + version, + }); + } const models = providerModelsFromSettings( markOpenCodeLegacy( result.value.map((model) => { @@ -680,7 +724,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu installed: failure.installed, version, status: "error", - auth: { status: "unknown" }, + auth: { status: failure.authStatus ?? "unknown" }, message: failure.message, }, }); @@ -715,7 +759,10 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu ), ), ); - if (probedExit._tag === "Failure") return fallback(Cause.squash(probedExit.cause)); + if (probedExit._tag === "Failure") { + yield* logDiscoveryFailure({ phase: "version", isExternalServer, cause: probedExit.cause }); + return fallback(Cause.squash(probedExit.cause)); + } const probed = probedExit.value; if (probed.generation === "v2") { return yield* checkOpenCode2(openCodeSettings, probed.version, checkedAt, loadOpenCode2Models); @@ -777,19 +824,30 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu ), ); if (inventoryExit._tag === "Failure") { + yield* logDiscoveryFailure({ + phase: "inventory", + isExternalServer, + version, + cause: inventoryExit.cause, + }); return { ...fallback(Cause.squash(inventoryExit.cause), version, "inventory"), ...installedVersion, }; } + const inventoryModels = flattenOpenCodeModels(inventoryExit.value); const models = providerModelsFromSettings( - flattenOpenCodeModels(inventoryExit.value), + inventoryModels, customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES, ); const skills = openCodeSkillsToServerProviderSkills(inventoryExit.value.skills); const connectedCount = inventoryExit.value.providerList.connected.length; + const catalogReady = connectedCount > 0 && inventoryModels.length > 0; + if (connectedCount > 0 && inventoryModels.length === 0) { + yield* logDiscoveryFailure({ phase: "inventory", isExternalServer, version }); + } return { ...buildServerProvider({ presentation: OPENCODE_PRESENTATION, @@ -801,17 +859,19 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu probe: { installed: true, version, - status: connectedCount > 0 ? "ready" : "warning", + status: catalogReady ? "ready" : "warning", auth: { - status: connectedCount > 0 ? "authenticated" : "unknown", + status: catalogReady ? "authenticated" : "unknown", type: "opencode", }, message: - connectedCount > 0 - ? `${connectedCount} upstream provider${connectedCount === 1 ? "" : "s"} connected through ${isExternalServer ? "the configured OpenCode server" : "OpenCode"}.` - : isExternalServer - ? "Connected to the configured OpenCode server, but it did not report any connected upstream providers." - : "OpenCode is available, but it did not report any connected upstream providers.", + connectedCount > 0 && !catalogReady + ? "OpenCode upstream providers are connected, but it did not list any models yet." + : connectedCount > 0 + ? `${connectedCount} upstream provider${connectedCount === 1 ? "" : "s"} connected through ${isExternalServer ? "the configured OpenCode server" : "OpenCode"}.` + : isExternalServer + ? "Connected to the configured OpenCode server, but it did not report any connected upstream providers." + : "OpenCode is available, but it did not report any connected upstream providers.", }, }), ...installedVersion, diff --git a/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts b/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts index ac037da3f9c3..af8a44676243 100644 --- a/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts +++ b/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts @@ -301,6 +301,9 @@ it.effect("reads Forgejo jobs that did not run as finished checks", () => Effect.provide( Layer.mock(ForgejoCli.ForgejoCli)({ api: (input) => { + if (input.path.endsWith("branches/main")) { + return encodeJsonEffect({}).pipe(Effect.orDie, Effect.map(processOutput)); + } const status = (context: string, state: string) => ({ context, status: state, From d7a8d6f32001444e013aa07c8a468ce3e987f9cf Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Fri, 23 Oct 2026 03:23:40 -0500 Subject: [PATCH 15/59] fix(server): avoid full shell read on resume (#23987) --- .../transferBudgetV2.integration.test.ts | 2 ++ .../src/orchestration-v2/Orchestrator.ts | 13 ++++++++ .../orchestration-v2/ProjectionStore.test.ts | 30 +++++++++++++++++ .../src/orchestration-v2/ProjectionStore.ts | 32 +++++++++++++++++++ .../ProviderTurnControlService.test.ts | 1 + .../ThreadManagementService.ts | 2 ++ .../src/relay/AgentAwarenessRelay.test.ts | 1 + apps/server/src/ws.test.ts | 3 +- apps/server/src/ws.ts | 7 +--- 9 files changed, 84 insertions(+), 7 deletions(-) diff --git a/apps/server/integration/transferBudgetV2.integration.test.ts b/apps/server/integration/transferBudgetV2.integration.test.ts index 5064848571be..75b0be5ae57a 100644 --- a/apps/server/integration/transferBudgetV2.integration.test.ts +++ b/apps/server/integration/transferBudgetV2.integration.test.ts @@ -89,6 +89,8 @@ const layerManagement = Layer.unwrap( projections.getThreadSnapshotWindow(id, options).pipe(Effect.orDie), getThreadShell: (id) => projections.getThreadShell(id).pipe(Effect.orDie), getShellSnapshot: (options) => projections.getShellSnapshot(options).pipe(Effect.orDie), + listActiveSubagentThreadIds: () => + projections.listActiveSubagentThreadIds().pipe(Effect.orDie), readShellSnapshot: (options) => projections.readShellSnapshot(options).pipe(Effect.map(Effect.orDie), Effect.orDie), streamStoredEventsFrom: (input) => diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 5dd48ba548d2..4a3b101b4948 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -371,6 +371,10 @@ export interface OrchestratorV2Shape { readonly getShellSnapshot: ( options?: ShellSnapshotOptions, ) => Effect.Effect; + readonly listActiveSubagentThreadIds: () => Effect.Effect< + ReadonlyArray, + OrchestratorV2Error + >; readonly readShellSnapshot: ( options?: ShellSnapshotOptions, ) => Effect.Effect< @@ -12237,6 +12241,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio .pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause }))), getShellSnapshot: (options) => projectionStore.getShellSnapshot(options).pipe(Effect.mapError(shellProjectionError)), + listActiveSubagentThreadIds: () => + projectionStore.listActiveSubagentThreadIds().pipe(Effect.mapError(shellProjectionError)), readShellSnapshot: (options) => projectionStore .readShellSnapshot(options) @@ -12382,6 +12388,13 @@ const layerUnavailable: Layer.Layer = Layer.succeed( cause: "Orchestration V2 live runtime is not configured.", }), ), + listActiveSubagentThreadIds: () => + Effect.fail( + new OrchestratorProjectionError({ + threadId: ThreadId.make("thread:shell"), + cause: "Orchestration V2 live runtime is not configured.", + }), + ), readShellSnapshot: () => Effect.fail( new OrchestratorProjectionError({ diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index 6f87afb2eb23..fcadd8036b92 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -1105,6 +1105,36 @@ const assertProjectScopedShells = Effect.fn("assertProjectScopedShells")(functio return { projectId, activeThreadId, forkThreadId, otherThreadId }; }); +it.effect("lists only active subagent IDs without loading shell data", () => + Effect.gen(function* () { + const store = yield* ProjectionStore.ProjectionStoreV2; + const sql = yield* SqlClient.SqlClient; + const createdAt = "2026-01-05T00:00:00.000Z"; + for (const [id, relationship, archivedAt, deletedAt] of [ + ["parent", null, null, null], + ["active-subagent", "subagent", null, null], + ["archived-subagent", "subagent", createdAt, null], + ["deleted-subagent", "subagent", null, createdAt], + ] as const) { + yield* sql` + INSERT INTO orchestration_v2_projection_threads ( + thread_id, project_id, title, default_provider, runtime_mode, interaction_mode, + created_at, updated_at, archived_at, deleted_at, payload_json + ) VALUES ( + ${id}, 'project:test', ${id}, 'codex', 'full-access', 'default', + ${createdAt}, ${createdAt}, ${archivedAt}, ${deletedAt}, + ${JSON.stringify({ lineage: { relationshipToParent: relationship }, archivedAt })} + ) + `; + } + + assert.sameMembers( + [...(yield* store.listActiveSubagentThreadIds())], + [ThreadId.make("active-subagent")], + ); + }).pipe(Effect.provide(layerTest)), +); + it.layer(layerTest)("ProjectionStoreV2", (it) => { // regression: #21969 keep corrupt plan JSON in the typed read error it.effect("returns a typed read error for malformed stored plan JSON", () => diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 4f08378db528..00333529dd1f 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -424,6 +424,10 @@ export interface ProjectionStoreV2Shape { readonly getShellSnapshot: ( options?: ShellSnapshotOptions, ) => Effect.Effect; + readonly listActiveSubagentThreadIds: () => Effect.Effect< + ReadonlyArray, + ProjectionStoreV2Error + >; /** * Callers that combine the shell with other reads run this inside their own * transaction and the returned decode after it commits, so the shared @@ -6430,6 +6434,20 @@ export const layer: Layer.Layer = .withTransaction(readShellSnapshotRows(options)) .pipe(Effect.mapError(shellSnapshotReadError), Effect.flatMap(decodeShellSnapshot)); + const listActiveSubagentThreadIds = Effect.fn("ProjectionStore.listActiveSubagentThreadIds")( + function* () { + const rows = yield* sql<{ readonly thread_id: string }>` + SELECT thread_id + FROM orchestration_v2_projection_threads + WHERE deleted_at IS NULL + AND json_extract(payload_json, '$.archivedAt') IS NULL + AND json_extract(payload_json, '$.lineage.relationshipToParent') = 'subagent' + `; + return rows.map((row) => ThreadId.make(row.thread_id)); + }, + Effect.mapError(shellSnapshotReadError), + ); + const getThreadShell: ProjectionStoreV2Shape["getThreadShell"] = (threadId) => sql .withTransaction( @@ -6522,6 +6540,7 @@ export const layer: Layer.Layer = return { apply, getShellSnapshot: readOnReader(getShellSnapshot), + listActiveSubagentThreadIds: readOnReader(listActiveSubagentThreadIds), readShellSnapshot: readOnReader(readShellSnapshot), getThreadShell: readOnReader(getThreadShell), getThread: readOnReader(getThread), @@ -6630,6 +6649,19 @@ export const layerMemory: Layer.Layer = Layer.effect( archivedThreads: visible.filter((thread) => thread.archivedAt !== null), }; }), + listActiveSubagentThreadIds: Effect.fn("ProjectionStore.listActiveSubagentThreadIds")( + function* () { + const existing = (yield* Ref.get(replayState)).projections; + return [...existing.values()] + .filter( + ({ thread }) => + thread.deletedAt === null && + thread.archivedAt === null && + thread.lineage.relationshipToParent === "subagent", + ) + .map(({ thread }) => thread.id); + }, + ), readShellSnapshot: (options) => service.getShellSnapshot(options).pipe(Effect.map(Effect.succeed)), getThreadShell: Effect.fn("ProjectionStore.getThreadShell")(function* (threadId) { diff --git a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts index 178c6ec3dcc0..3ae3a9974b48 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts @@ -211,6 +211,7 @@ it.effect( apply: () => Effect.void, getLimitRecoveryCandidates: () => Effect.die("unused getLimitRecoveryCandidates"), getShellSnapshot: () => Effect.die("unused getShellSnapshot"), + listActiveSubagentThreadIds: () => Effect.die("unused listActiveSubagentThreadIds"), readShellSnapshot: () => Effect.die("unused readShellSnapshot"), getThreadShell: () => Effect.die("unused getThreadShell"), getThread: () => Ref.get(projection).pipe(Effect.map((state) => state.thread)), diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index 543b3e2f89fe..f2efb71637fe 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -362,6 +362,7 @@ export interface ThreadManagementServiceShape { readonly getShellSnapshot: (options?: { readonly location?: "active" | "archive"; }) => Effect.Effect; + readonly listActiveSubagentThreadIds: Orchestrator.OrchestratorV2["Service"]["listActiveSubagentThreadIds"]; readonly readShellSnapshot: (options?: { readonly location?: "active" | "archive"; }) => Effect.Effect< @@ -1173,6 +1174,7 @@ const make = Effect.gen(function* () { getProjectThreadRecords, getProjectThread, getShellSnapshot: orchestrator.getShellSnapshot, + listActiveSubagentThreadIds: orchestrator.listActiveSubagentThreadIds, readShellSnapshot: orchestrator.readShellSnapshot, getThreadShell: orchestrator.getThreadShell, listProjectThreads, diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 64b71140fdcf..d1ad9e7a58a6 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -187,6 +187,7 @@ const makeTestRelay = Effect.fnUntraced(function* ( catchUp.shellSnapshotReads += 1; return { schemaVersion: 2, snapshotSequence: 1, threads: [], archivedThreads: [] }; }), + listActiveSubagentThreadIds: unused, readShellSnapshot: unused, ensureLegacyTranscript: unused, dispatch: unused, diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index 75da37ad3b6c..787fdc268f4a 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -252,7 +252,8 @@ it.live.each(["thread", "shell"] as const)( const items = yield* subscription.pipe( Effect.provideService(ThreadManagement.ThreadManagementService, { ensureLegacyTranscript: () => Effect.void, - getShellSnapshot: () => Effect.succeed({ threads: [] }), + getShellSnapshot: () => Effect.die("shell resume must not load a full snapshot"), + listActiveSubagentThreadIds: () => Effect.succeed([]), streamStoredEventsFrom: ( input: Parameters< ThreadManagement.ThreadManagementService["Service"]["streamStoredEventsFrom"] diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 556a023a01c2..7656ddca9254 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1172,12 +1172,7 @@ export const subscribeOrchestrationV2Shell = Effect.fn("ws.orchestrationV2.subsc }); } - const currentShell = yield* threadManagement.getShellSnapshot({ location: "active" }); - subagentThreadIds = new Set( - currentShell.threads - .filter((thread) => thread.lineage.relationshipToParent === "subagent") - .map((thread) => thread.id), - ); + subagentThreadIds = new Set(yield* threadManagement.listActiveSubagentThreadIds()); const loaded = yield* loadProjectMetadataSnapshot(highWater); const replay = toShellStream( From 51a7118b700b19191fa0614785e0149a6d8a6e72 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 23 Oct 2026 03:08:06 -0700 Subject: [PATCH 16/59] test: delete low-value tests and dead test-only exports (#24058) --- .../desktop/src/updates/updateMachine.test.ts | 54 --- .../threadActivityFileNavigation.test.ts | 26 -- .../threads/threadActivityFileNavigation.ts | 20 -- .../src/lib/threadActivityInspector.test.ts | 96 ------ .../mobile/src/lib/threadActivityInspector.ts | 313 ------------------ .../server/src/cloud/servicePreflight.test.ts | 2 +- apps/server/src/mcp/threadAccess.test.ts | 9 - .../mcp/toolkits/orchestrator/tools.test.ts | 16 - .../src/mcp/toolkits/preview/tools.test.ts | 21 +- .../CodexDeveloperInstructions.test.ts | 60 +--- .../ProviderInstanceRegistryHydration.test.ts | 23 -- apps/server/src/vcs/VcsProcess.test.ts | 22 -- .../components/desktopUpdate.logic.test.ts | 25 +- .../pullRequestFileOrder.logic.test.ts | 11 - .../SnapShotSetupDialog.logic.test.ts | 32 -- .../settings/settingsScopeNavigation.test.ts | 7 +- apps/web/src/lib/embeddedScripts.test.ts | 5 - apps/web/src/lib/snapShotShortcut.test.ts | 10 - apps/web/src/lib/threadSort.test.ts | 85 ----- .../onboarding/projectImport.logic.test.ts | 22 -- apps/web/src/threadRoutes.test.ts | 2 - .../client-runtime/src/providerSkills.test.ts | 59 ---- packages/client-runtime/src/providerSkills.ts | 24 -- .../src/state/sharedSettings.test.ts | 168 +--------- .../src/state/sharedSettings.ts | 58 ---- .../src/state/threadFeedback.test.ts | 15 - .../src/state/threadFeedback.ts | 9 - .../src/work-log/commandLabel.test.ts | 20 -- .../src/work-log/presentation.test.ts | 64 ---- .../src/work-log/presentation.ts | 129 -------- packages/contracts/src/browserProfile.test.ts | 7 - packages/contracts/src/preview.test.ts | 13 +- packages/contracts/src/provider.test.ts | 38 +-- packages/contracts/src/server.test.ts | 20 -- packages/provider-acp/package.json | 4 - .../src/server/coreRuntimeEvents.test.ts | 247 -------------- .../src/server/coreRuntimeEvents.ts | 255 -------------- .../provider-acp/src/server/runtimeModel.ts | 2 +- .../src/server/xaiBackgroundTasks.test.ts | 190 ----------- .../src/server/xaiBackgroundTasks.ts | 155 --------- packages/shared/package.json | 4 - packages/shared/src/composerTrigger.test.ts | 3 - packages/shared/src/dateTime.test.ts | 54 --- packages/shared/src/dateTime.ts | 37 --- packages/shared/src/model.test.ts | 48 +-- .../shared/src/threadPullRequests.test.ts | 107 ++---- packages/shared/src/threadPullRequests.ts | 52 +-- packages/shared/src/threadReference.test.ts | 26 -- .../src/server/pullRequestChecks.test.ts | 30 +- 49 files changed, 69 insertions(+), 2630 deletions(-) delete mode 100644 apps/mobile/src/features/threads/threadActivityFileNavigation.test.ts delete mode 100644 apps/mobile/src/features/threads/threadActivityFileNavigation.ts delete mode 100644 apps/mobile/src/lib/threadActivityInspector.test.ts delete mode 100644 apps/mobile/src/lib/threadActivityInspector.ts delete mode 100644 apps/server/src/mcp/toolkits/orchestrator/tools.test.ts delete mode 100644 apps/web/src/lib/threadSort.test.ts delete mode 100644 packages/provider-acp/src/server/coreRuntimeEvents.test.ts delete mode 100644 packages/provider-acp/src/server/coreRuntimeEvents.ts delete mode 100644 packages/provider-grok/src/server/xaiBackgroundTasks.test.ts delete mode 100644 packages/provider-grok/src/server/xaiBackgroundTasks.ts delete mode 100644 packages/shared/src/dateTime.test.ts delete mode 100644 packages/shared/src/dateTime.ts diff --git a/apps/desktop/src/updates/updateMachine.test.ts b/apps/desktop/src/updates/updateMachine.test.ts index 41af75f61e96..03e864b93d27 100644 --- a/apps/desktop/src/updates/updateMachine.test.ts +++ b/apps/desktop/src/updates/updateMachine.test.ts @@ -8,7 +8,6 @@ import { reduceDesktopUpdateStateOnDownloadFailure, reduceDesktopUpdateStateOnInstallFailure, reduceDesktopUpdateStateOnNoUpdate, - reduceDesktopUpdateStateOnUpdateAvailable, } from "./updateMachine.ts"; const runtimeInfo = { @@ -50,30 +49,6 @@ describe("updateMachine", () => { expect(failed.message).toBeNull(); }); - it("keeps the installer when the feed still offers its version", () => { - const releaseNotes = [{ version: "1.1.0", items: ["fix: queued update"], totalItems: 1 }]; - const state = reduceDesktopUpdateStateOnUpdateAvailable( - { - ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"), - enabled: true, - status: "downloaded", - availableVersion: "1.1.0", - downloadedVersion: "1.1.0", - releaseNotes, - omittedReleaseCount: 2, - downloadPercent: 100, - }, - "1.1.0", - "2026-03-04T00:00:00.000Z", - ); - - expect(state.status).toBe("downloaded"); - expect(state.downloadedVersion).toBe("1.1.0"); - expect(state.releaseNotes).toEqual(releaseNotes); - expect(state.omittedReleaseCount).toBe(2); - expect(state.downloadPercent).toBe(100); - }); - it("preserves available version on download failure for retry", () => { const state = reduceDesktopUpdateStateOnDownloadFailure( { @@ -114,35 +89,6 @@ describe("updateMachine", () => { expect(failedInstall.canRetry).toBe(true); }); - it("preserves a downloaded update when no update is available", () => { - const releaseNotes = [{ version: "1.1.0", items: ["fix: queued update"], totalItems: 1 }]; - const state = reduceDesktopUpdateStateOnNoUpdate( - { - ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"), - enabled: true, - status: "error", - availableVersion: "1.1.0", - downloadedVersion: "1.1.0", - releaseNotes, - omittedReleaseCount: 2, - message: "old failure", - errorContext: "download", - canRetry: true, - }, - "2026-03-04T00:00:00.000Z", - ); - - expect(state.status).toBe("downloaded"); - expect(state.availableVersion).toBe("1.1.0"); - expect(state.downloadedVersion).toBe("1.1.0"); - expect(state.releaseNotes).toBe(releaseNotes); - expect(state.omittedReleaseCount).toBe(2); - expect(state.downloadPercent).toBe(100); - expect(state.message).toBeNull(); - expect(state.errorContext).toBeNull(); - expect(state.canRetry).toBe(true); - }); - it("clears stale available state when no update is available", () => { const state = reduceDesktopUpdateStateOnNoUpdate( { diff --git a/apps/mobile/src/features/threads/threadActivityFileNavigation.test.ts b/apps/mobile/src/features/threads/threadActivityFileNavigation.test.ts deleted file mode 100644 index a8f9343085b8..000000000000 --- a/apps/mobile/src/features/threads/threadActivityFileNavigation.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { EnvironmentId, ThreadId } from "@t3tools/contracts"; -import { describe, expect, it } from "@effect/vitest"; - -import { buildThreadActivityFileParams } from "./threadActivityFileNavigation"; - -describe("thread activity file navigation", () => { - it("keeps inherited activity file links on the currently selected thread", () => { - const sourceThreadId = ThreadId.make("source-thread"); - const currentThreadId = ThreadId.make("current-thread"); - - const params = buildThreadActivityFileParams({ - environmentId: EnvironmentId.make("environment"), - currentThreadId, - activitySourceThreadId: sourceThreadId, - relativePath: "apps/mobile/src/index.ts", - line: 12, - }); - - expect(params).toEqual({ - environmentId: "environment", - threadId: "current-thread", - path: ["apps", "mobile", "src", "index.ts"], - line: "12", - }); - }); -}); diff --git a/apps/mobile/src/features/threads/threadActivityFileNavigation.ts b/apps/mobile/src/features/threads/threadActivityFileNavigation.ts deleted file mode 100644 index b4cf50c8ce5d..000000000000 --- a/apps/mobile/src/features/threads/threadActivityFileNavigation.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; - -interface ThreadActivityFileContext { - readonly environmentId: EnvironmentId; - readonly currentThreadId: ThreadId; - readonly activitySourceThreadId: ThreadId; - readonly relativePath: string; - readonly line?: number | null; -} - -export function buildThreadActivityFileParams(input: ThreadActivityFileContext) { - return { - environmentId: String(input.environmentId), - threadId: String(input.currentThreadId), - path: input.relativePath.split("/").filter((segment) => segment.length > 0), - ...(Number.isFinite(input.line) && Number(input.line) > 0 - ? { line: String(Math.floor(Number(input.line))) } - : {}), - }; -} diff --git a/apps/mobile/src/lib/threadActivityInspector.test.ts b/apps/mobile/src/lib/threadActivityInspector.test.ts deleted file mode 100644 index 5a1647f8330a..000000000000 --- a/apps/mobile/src/lib/threadActivityInspector.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { EMPTY_V2_ITEM_SUPPORT } from "@t3tools/client-runtime/state/item-support"; -import { - CheckpointId, - CheckpointScopeId, - NodeId, - RunId, - ThreadId, - TurnItemId, - type OrchestrationV2ProjectedTurnItem, - type OrchestrationV2TurnItem, -} from "@t3tools/contracts"; -import * as DateTime from "effect/DateTime"; -import { describe, expect, it } from "vite-plus/test"; - -import type { ThreadFeedActivity } from "./threadActivity"; -import { buildThreadActivityInspector } from "./threadActivityInspector"; - -const threadId = ThreadId.make("thread-1"); -const sourceThreadId = ThreadId.make("thread-source"); -const runId = RunId.make("run-1"); -const nodeId = NodeId.make("node-1"); -const startedAt = DateTime.makeUnsafe("2026-06-20T00:00:00.000Z"); -const completedAt = DateTime.makeUnsafe("2026-06-20T00:00:02.000Z"); - -function itemBase(id: string) { - return { - id: TurnItemId.make(id), - threadId, - runId, - nodeId, - providerThreadId: null, - providerTurnId: null, - nativeItemRef: null, - parentItemId: null, - ordinal: 0, - status: "completed" as const, - title: null, - startedAt, - completedAt, - updatedAt: completedAt, - }; -} - -function activityFor(item: OrchestrationV2TurnItem): Pick { - const row: OrchestrationV2ProjectedTurnItem = { - position: 0, - visibility: "inherited", - sourceThreadId, - sourceItemId: item.id, - item, - }; - return { projectedItem: row }; -} - -describe("buildThreadActivityInspector", () => { - it("enables rollback only for the matching ready checkpoint", () => { - const checkpointId = CheckpointId.make("checkpoint-1"); - const scopeId = CheckpointScopeId.make("scope-1"); - const item: OrchestrationV2TurnItem = { - ...itemBase("checkpoint"), - type: "checkpoint", - checkpointId, - scopeId, - files: [{ path: "src/main.ts", kind: "modified", additions: 2, deletions: 1 }], - }; - const checkpoint = { id: checkpointId, scopeId, status: "ready" } as never; - const model = buildThreadActivityInspector( - activityFor(item), - { - ...EMPTY_V2_ITEM_SUPPORT, - item, - checkpoint, - }, - sourceThreadId, - ); - - expect(model.canRollback).toBe(true); - expect(model.rollbackTarget).toEqual({ - threadId: sourceThreadId, - checkpointId, - scopeId, - }); - - const inheritedModel = buildThreadActivityInspector( - activityFor(item), - { - ...EMPTY_V2_ITEM_SUPPORT, - item, - checkpoint, - }, - ThreadId.make("child-thread"), - ); - expect(inheritedModel.canRollback).toBe(false); - expect(inheritedModel.rollbackTarget).toBeNull(); - }); -}); diff --git a/apps/mobile/src/lib/threadActivityInspector.ts b/apps/mobile/src/lib/threadActivityInspector.ts deleted file mode 100644 index 041cd7c3bfa9..000000000000 --- a/apps/mobile/src/lib/threadActivityInspector.ts +++ /dev/null @@ -1,313 +0,0 @@ -import type { V2ItemSupport } from "@t3tools/client-runtime/state/item-support"; -import { toolItemForDisplay } from "@t3tools/client-runtime/work-log/presentation"; -import { formatDiffCount } from "@t3tools/client-runtime/diff-count"; -import type { ThreadId } from "@t3tools/contracts"; -import { formatDuration } from "@t3tools/shared/orchestrationTiming"; -import * as DateTime from "effect/DateTime"; - -import type { ThreadFeedActivity } from "./threadActivity"; - -export interface ThreadActivityInspectorField { - readonly label: string; - readonly value: string; -} - -export interface ThreadActivityInspectorBlock { - readonly label: string; - readonly value: string; - readonly monospaced: boolean; -} - -export interface ThreadActivityFileLink { - readonly label: string; - readonly path: string; - readonly line?: number; -} - -export interface ThreadActivityWebLink { - readonly label: string; - readonly url: string; -} - -export interface ThreadActivityInspectorModel { - readonly fields: ReadonlyArray; - readonly blocks: ReadonlyArray; - readonly fileLinks: ReadonlyArray; - readonly webLinks: ReadonlyArray; - readonly canRollback: boolean; - readonly rollbackTarget: { - readonly threadId: ThreadId; - readonly checkpointId: string; - readonly scopeId: string; - } | null; - readonly structuredDetails: string; -} - -function formatStructured(value: unknown): string { - if (typeof value === "string") return value; - try { - return JSON.stringify(value, null, 2) ?? String(value); - } catch { - return String(value); - } -} - -function durationLabel( - startedAt: DateTime.Utc | null, - completedAt: DateTime.Utc | null, -): string | null { - if (startedAt === null) return null; - const start = DateTime.toEpochMillis(startedAt); - const end = completedAt === null ? Date.now() : DateTime.toEpochMillis(completedAt); - return formatDuration(Math.max(0, end - start)); -} - -function addBlock( - blocks: ThreadActivityInspectorBlock[], - label: string, - value: unknown, - monospaced = true, -): void { - if (value === undefined || value === null || value === "") return; - blocks.push({ label, value: formatStructured(value), monospaced }); -} - -export function buildThreadActivityInspector( - activity: Pick, - support: V2ItemSupport, - currentThreadId: ThreadId, -): ThreadActivityInspectorModel { - const row = activity.projectedItem; - const item = row.item; - const fields: ThreadActivityInspectorField[] = [ - { label: "Item", value: item.type.replaceAll("_", " ") }, - { label: "Status", value: item.status.replaceAll("_", " ") }, - ]; - const duration = durationLabel(item.startedAt, item.completedAt); - if (duration) fields.push({ label: "Duration", value: duration }); - if (row.visibility !== "local") fields.push({ label: "Visibility", value: row.visibility }); - if (support.run) fields.push({ label: "Run", value: support.run.status }); - - const latestAttempt = support.attempts.at(-1); - if (latestAttempt) { - fields.push({ - label: "Attempt", - value: `${latestAttempt.attemptOrdinal} · ${latestAttempt.status} · ${latestAttempt.reason.replaceAll("_", " ")}`, - }); - } - if (support.node) { - fields.push({ - label: "Node", - value: `${support.node.kind.replaceAll("_", " ")} · ${support.node.status}`, - }); - } - if (support.providerThread) { - fields.push({ - label: "Provider thread", - value: `${support.providerThread.providerInstanceId} · ${support.providerThread.status}`, - }); - } - if (support.providerTurn) { - fields.push({ label: "Provider turn", value: support.providerTurn.status }); - } - if (support.providerSession) { - fields.push({ - label: "Session", - value: `${support.providerSession.status} · ${support.providerSession.model ?? "default model"}`, - }); - fields.push({ label: "Working directory", value: support.providerSession.cwd }); - } - if (support.runtimeRequest) { - fields.push({ - label: "Request", - value: `${support.runtimeRequest.status} · ${support.runtimeRequest.responseCapability.type.replaceAll("_", " ")}`, - }); - } - - const blocks: ThreadActivityInspectorBlock[] = []; - const fileLinks: ThreadActivityFileLink[] = []; - const webLinks: ThreadActivityWebLink[] = []; - - if (support.attempts.length > 1) { - addBlock( - blocks, - "Attempt history", - support.attempts - .map( - (attempt) => - `Attempt ${attempt.attemptOrdinal} · ${attempt.status} · ${attempt.reason.replaceAll("_", " ")}`, - ) - .join("\n"), - ); - } - - switch (item.type) { - case "reasoning": - addBlock(blocks, "Reasoning", item.text, false); - break; - case "command_execution": - addBlock(blocks, "Command", item.input); - if (item.exitCode !== undefined) { - addBlock(blocks, "Exit", `Process exited with code ${item.exitCode}`); - } - break; - case "file_change": - for (const change of item.changes ?? [{ path: item.fileName, operation: "modify" }]) { - fileLinks.push({ - label: `${change.operation} ${change.oldPath ? `${change.oldPath} → ` : ""}${change.path}`, - path: change.path, - }); - } - if (item.additions !== undefined || item.deletions !== undefined) { - fields.push({ - label: "Changes", - value: `+${formatDiffCount(item.additions ?? 0)} −${formatDiffCount(item.deletions ?? 0)}`, - }); - } - break; - case "file_search": - addBlock(blocks, "Query", item.pattern); - for (const result of item.results ?? []) { - fileLinks.push({ - label: result.preview ? `${result.fileName}: ${result.preview}` : result.fileName, - path: result.fileName, - ...(result.line === undefined ? {} : { line: result.line }), - }); - } - break; - case "web_search": - addBlock(blocks, "Queries", item.patterns?.join("\n")); - for (const result of item.results ?? []) { - if (result.url) { - webLinks.push({ - label: result.title ?? result.url, - url: result.url, - }); - } - if (result.snippet) - addBlock(blocks, result.title ?? "Search result", result.snippet, false); - } - break; - case "dynamic_tool": - addBlock(blocks, "Input", item.input); - break; - case "approval_request": - addBlock(blocks, "Prompt", item.prompt, false); - break; - case "user_input_request": - addBlock( - blocks, - "Questions", - item.questions.map((question) => question.question).join("\n"), - false, - ); - break; - case "checkpoint": - addBlock( - blocks, - "Files", - item.files - .map( - (file) => - `${file.path} +${formatDiffCount(file.additions)} −${formatDiffCount(file.deletions)} ${file.kind}`, - ) - .join("\n"), - ); - break; - case "subagent": - addBlock(blocks, "Prompt", item.prompt, false); - addBlock(blocks, "Progress", support.subagent?.progress ?? item.progress, false); - addBlock(blocks, "Result", support.subagent?.result ?? item.result, false); - if (support.subagent) { - fields.push({ - label: "Delegated task", - value: `${support.subagent.origin.replaceAll("_", " ")} · ${support.subagent.status}`, - }); - } - break; - case "handoff": - addBlock(blocks, "Summary", item.summary, false); - fields.push({ - label: "Handoff", - value: `${item.strategy.replaceAll("_", " ")} · ${support.contextHandoff?.status ?? item.status}`, - }); - if (support.contextTransfer) { - fields.push({ - label: "Transfer", - value: `${support.contextTransfer.type.replaceAll("_", " ")} · ${support.contextTransfer.status}`, - }); - if (support.contextTransfer.resolution) { - fields.push({ - label: "Context", - value: support.contextTransfer.resolution.strategy.replaceAll("_", " "), - }); - } - } - break; - case "error": - addBlock(blocks, "Error", item.failure.message, false); - if (item.failure.code) fields.push({ label: "Code", value: item.failure.code }); - if (item.failure.retryable !== null) { - fields.push({ label: "Retryable", value: item.failure.retryable ? "yes" : "no" }); - } - break; - case "proposed_plan": - addBlock(blocks, "Plan", item.markdown, false); - break; - case "todo_list": - addBlock( - blocks, - "Tasks", - item.steps - .map((step) => `${step.status === "completed" ? "✓" : "○"} ${step.text}`) - .join("\n"), - false, - ); - addBlock(blocks, "Explanation", item.explanation, false); - break; - case "compaction": - addBlock(blocks, "Summary", item.summary, false); - if (item.beforeTokenCount !== undefined || item.afterTokenCount !== undefined) { - fields.push({ - label: "Context tokens", - value: `${item.beforeTokenCount ?? "?"} → ${item.afterTokenCount ?? "?"}`, - }); - } - break; - case "run_interrupt_request": - case "run_interrupt_result": - addBlock(blocks, "Message", item.message, false); - break; - case "fork": - case "thread_created": - case "user_message": - case "assistant_message": - break; - } - - const checkpoint = - item.type === "checkpoint" && row.sourceThreadId === currentThreadId - ? support.checkpoint - : null; - return { - fields, - blocks, - fileLinks, - webLinks, - canRollback: checkpoint?.status === "ready", - rollbackTarget: - checkpoint?.status === "ready" - ? { - threadId: row.sourceThreadId, - checkpointId: checkpoint.id, - scopeId: checkpoint.scopeId, - } - : null, - structuredDetails: formatStructured({ - visibility: row.visibility, - sourceThreadId: row.sourceThreadId, - sourceItemId: row.sourceItemId, - item: toolItemForDisplay(item), - }), - }; -} diff --git a/apps/server/src/cloud/servicePreflight.test.ts b/apps/server/src/cloud/servicePreflight.test.ts index 9a65c5aad93f..14cc9f1a9323 100644 --- a/apps/server/src/cloud/servicePreflight.test.ts +++ b/apps/server/src/cloud/servicePreflight.test.ts @@ -2,7 +2,7 @@ import { expect, it } from "@effect/vitest"; import { runServicePreflight } from "./servicePreflight.ts"; -it.each([1, 2])("blocks legacy launcher protocol %i", (launcherProtocol) => { +it.each([1])("blocks legacy launcher protocol %i", (launcherProtocol) => { expect( runServicePreflight({ databasePath: "/missing/state.sqlite", diff --git a/apps/server/src/mcp/threadAccess.test.ts b/apps/server/src/mcp/threadAccess.test.ts index a40f63baa6ca..aa107711f334 100644 --- a/apps/server/src/mcp/threadAccess.test.ts +++ b/apps/server/src/mcp/threadAccess.test.ts @@ -38,12 +38,3 @@ it.effect("points a bare uuid at the mcp: thread it belongs to", () => expect(error.message).toBe(`The thread was not found. Did you mean \`mcp:${bareId}\`?`); }), ); - -it.effect("keeps the plain message for an id that matches nothing", () => - Effect.gen(function* () { - const error = yield* readBare([]); - - expect(error.code).toBe("thread_not_found"); - expect(error.message).toBe("The thread was not found."); - }), -); diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.test.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.test.ts deleted file mode 100644 index 75db3e4d5978..000000000000 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { expect, it } from "@effect/vitest"; -import { Tool } from "effect/ai"; - -import { OrchestratorToolkit } from "./tools.ts"; - -// Oversized page sizes clamp on decode, so the schema has no `maximum`; Codex also drops -// bounds from its TypeScript view. The description is the only place a model sees a limit. -it("states t3_thread_read page size limits in the parameter descriptions", () => { - const schema = Tool.getJsonSchema(OrchestratorToolkit.tools.t3_thread_read) as { - readonly properties: Readonly>; - }; - const limits = { limit: "100", runLimit: "50", maxCharsPerItem: "50000" }; - for (const [name, maximum] of Object.entries(limits)) { - expect(schema.properties[name]?.description, name).toContain(maximum); - } -}); diff --git a/apps/server/src/mcp/toolkits/preview/tools.test.ts b/apps/server/src/mcp/toolkits/preview/tools.test.ts index 6275e8cf41a4..cefe68c1b0e8 100644 --- a/apps/server/src/mcp/toolkits/preview/tools.test.ts +++ b/apps/server/src/mcp/toolkits/preview/tools.test.ts @@ -3,15 +3,6 @@ import { Tool } from "effect/ai"; import { PreviewToolkit } from "./tools.ts"; -const schemaHasDescription = (schema: unknown): boolean => { - if (!schema || typeof schema !== "object") return false; - const record = schema as Record; - if (typeof record.description === "string" && record.description.length > 0) return true; - return [record.anyOf, record.oneOf, record.allOf] - .filter(Array.isArray) - .some((members) => members.some(schemaHasDescription)); -}; - const schemaHasMultipleAllOfDescriptions = (schema: unknown): boolean => { if (!schema || typeof schema !== "object") return false; const record = schema as Record; @@ -25,7 +16,7 @@ const schemaHasMultipleAllOfDescriptions = (schema: unknown): boolean => { return descriptionCount > 1 || Object.values(record).some(schemaHasMultipleAllOfDescriptions); }; -it("exports provider-compatible object schemas with described parameters", () => { +it("exports provider-compatible object schemas", () => { for (const tool of Object.values(PreviewToolkit.tools)) { const schema = Tool.getJsonSchema(tool) as { readonly type?: unknown; @@ -33,10 +24,6 @@ it("exports provider-compatible object schemas with described parameters", () => readonly anyOf?: unknown; readonly oneOf?: unknown; }; - expect( - tool.description?.length ?? 0, - `${tool.name} should have a useful description`, - ).toBeGreaterThan(40); expect(schema.type, `${tool.name} must export a top-level object schema`).toBe("object"); expect(schema.anyOf, `${tool.name} must not export a root anyOf`).toBeUndefined(); expect(schema.oneOf, `${tool.name} must not export a root oneOf`).toBeUndefined(); @@ -47,11 +34,5 @@ it("exports provider-compatible object schemas with described parameters", () => schema.properties?.tabId, `${tool.name} must allow an explicit collaborative browser tab target`, ).toBeDefined(); - for (const [field, fieldSchema] of Object.entries(schema.properties ?? {})) { - expect( - schemaHasDescription(fieldSchema), - `${tool.name}.${field} should explain what data the agent must pass`, - ).toBe(true); - } } }); diff --git a/apps/server/src/provider/CodexDeveloperInstructions.test.ts b/apps/server/src/provider/CodexDeveloperInstructions.test.ts index 14a613967c11..55758f7cc40e 100644 --- a/apps/server/src/provider/CodexDeveloperInstructions.test.ts +++ b/apps/server/src/provider/CodexDeveloperInstructions.test.ts @@ -1,9 +1,5 @@ import * as NodeAssert from "node:assert/strict"; -import { - T3_CODE_ORCHESTRATION_INSTRUCTIONS, - T3_CODE_ORCHESTRATION_SECTIONS, -} from "@t3tools/provider-core/server/orchestrationInstructions"; import { describe, it } from "vite-plus/test"; import { @@ -13,49 +9,23 @@ import { describe("buildCodexAdditionalContext", () => { describe.each([ - { model: "gpt-5.3-codex", reasoningEffort: "high" }, { model: "custom-model", modelName: "Custom 🦊 Model", reasoningEffort: "xhigh" }, ])("with runtime $model", (runtime) => { - it.each([ - undefined, - false, - true, - { browser: false, device: false }, - { browser: true, device: false }, - { browser: false, device: true }, - { browser: true, device: true }, - ])("keeps every entry within Codex's per-entry cap with tools %j", (availability) => { - const context = buildCodexAdditionalContext(runtime, availability); - - for (const [key, entry] of Object.entries(context)) { - // Codex counts ceil(UTF-8 bytes / 4) tokens, excluding keys and wrappers. - const bytes = Buffer.byteLength(entry.value, "utf8"); - const limit = CODEX_ADDITIONAL_CONTEXT_VALUE_MAX_BYTES; - NodeAssert.ok( - bytes <= limit, - `${key}: ${bytes} UTF-8 bytes exceeds Codex's ${limit}-byte per-entry cap by ${bytes - limit} bytes; split the instructions into separate entries to avoid silent truncation`, - ); - } - }); - }); - - it("delivers all orchestration sections in authored order when keys are sorted", () => { - const context = buildCodexAdditionalContext({ - model: "gpt-5.3-codex", - reasoningEffort: "high", - }); - const sections = Object.keys(context) - .filter((key) => key.startsWith("t3_code_orchestration_")) - .sort() - .map((key) => context[key]!); - - NodeAssert.deepEqual( - sections, - T3_CODE_ORCHESTRATION_SECTIONS.map((value) => ({ kind: "application", value })), - ); - NodeAssert.equal( - sections.map((entry) => entry.value).join("\n\n"), - T3_CODE_ORCHESTRATION_INSTRUCTIONS.trim(), + it.each([undefined, { browser: true, device: true }])( + "keeps every entry within Codex's per-entry cap with tools %j", + (availability) => { + const context = buildCodexAdditionalContext(runtime, availability); + + for (const [key, entry] of Object.entries(context)) { + // Codex counts ceil(UTF-8 bytes / 4) tokens, excluding keys and wrappers. + const bytes = Buffer.byteLength(entry.value, "utf8"); + const limit = CODEX_ADDITIONAL_CONTEXT_VALUE_MAX_BYTES; + NodeAssert.ok( + bytes <= limit, + `${key}: ${bytes} UTF-8 bytes exceeds Codex's ${limit}-byte per-entry cap by ${bytes - limit} bytes; split the instructions into separate entries to avoid silent truncation`, + ); + } + }, ); }); }); diff --git a/apps/server/src/provider/ProviderInstanceRegistryHydration.test.ts b/apps/server/src/provider/ProviderInstanceRegistryHydration.test.ts index b825b29b674d..029904eac5b6 100644 --- a/apps/server/src/provider/ProviderInstanceRegistryHydration.test.ts +++ b/apps/server/src/provider/ProviderInstanceRegistryHydration.test.ts @@ -81,27 +81,4 @@ describe("deriveProviderInstanceConfigMap", () => { expect(merged[instanceId]).toBe(settings.providerInstances[instanceId]); }); - - it("folds overrides from multiple projects", () => { - const otherInstanceId = ProviderInstanceId.make("claudeAgent_personal"); - const otherProjectId = ProjectId.make("project-b"); - const settings = settingsWith({ - providerInstances: { - [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: false }, - [otherInstanceId]: { driver: ProviderDriverKind.make("claudeAgent"), enabled: false }, - }, - projectSettingsOverrides: { - [projectId]: { providerInstances: { [instanceId]: { enabled: true } } }, - [otherProjectId]: { providerInstances: { [otherInstanceId]: { enabled: true } } }, - }, - }); - - const merged = deriveProviderInstanceConfigMap(settings); - - expect(merged[instanceId]).toEqual({ driver: ProviderDriverKind.make("codex"), enabled: true }); - expect(merged[otherInstanceId]).toEqual({ - driver: ProviderDriverKind.make("claudeAgent"), - enabled: true, - }); - }); }); diff --git a/apps/server/src/vcs/VcsProcess.test.ts b/apps/server/src/vcs/VcsProcess.test.ts index c781bd5c6da8..03d274fdd823 100644 --- a/apps/server/src/vcs/VcsProcess.test.ts +++ b/apps/server/src/vcs/VcsProcess.test.ts @@ -341,28 +341,6 @@ describe("VcsProcess.run", () => { }).pipe(Effect.scoped, provideLive, Effect.provide(NodeServices.layer)), ); - it.effect("writes stdin before waiting for exit", () => - Effect.gen(function* () { - const result = yield* run({ - operation: "test.stdin", - command: "node", - args: [ - "-e", - [ - "process.stdin.setEncoding('utf8');", - "let data='';", - "process.stdin.on('data', chunk => { data += chunk; });", - "process.stdin.on('end', () => { process.stdout.write(data); });", - ].join(""), - ], - cwd: process.cwd(), - stdin: "stdin payload", - }); - - expect(result.stdout).toBe("stdin payload"); - }).pipe(provideLive), - ); - it.effect("classifies authentication failures without retaining stderr", () => Effect.gen(function* () { const secretStderr = "authentication failed for token super-secret-token"; diff --git a/apps/web/src/components/desktopUpdate.logic.test.ts b/apps/web/src/components/desktopUpdate.logic.test.ts index 99c0f45f94fb..c7d7c607ea23 100644 --- a/apps/web/src/components/desktopUpdate.logic.test.ts +++ b/apps/web/src/components/desktopUpdate.logic.test.ts @@ -65,20 +65,17 @@ describe("desktop update button state", () => { }); describe("desktop update UI helpers", () => { - it.each([ - "owner/repo/extra", - "owner#notes/repo", - "owner/repo#notes", - "owner?tab=notes/repo", - "owner/repo?tab=notes", - ])("falls back to upstream for an invalid configured repository: %s", (repository) => { - expect(getDesktopUpdateReleaseHistoryUrl(repository)).toBe( - "https://github.com/pingdotgg/t3code/releases", - ); - expect(getDesktopUpdateReleaseUrl("1.2.3", repository)).toBe( - "https://github.com/pingdotgg/t3code/releases/tag/v1.2.3", - ); - }); + it.each(["owner/repo/extra", "owner#notes/repo", "owner/repo?tab=notes"])( + "falls back to upstream for an invalid configured repository: %s", + (repository) => { + expect(getDesktopUpdateReleaseHistoryUrl(repository)).toBe( + "https://github.com/pingdotgg/t3code/releases", + ); + expect(getDesktopUpdateReleaseUrl("1.2.3", repository)).toBe( + "https://github.com/pingdotgg/t3code/releases/tag/v1.2.3", + ); + }, + ); }); describe("canCheckForUpdate", () => { diff --git a/apps/web/src/components/pullRequest/pullRequestFileOrder.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestFileOrder.logic.test.ts index 967841af34e6..a6e20a6c8c83 100644 --- a/apps/web/src/components/pullRequest/pullRequestFileOrder.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestFileOrder.logic.test.ts @@ -118,15 +118,4 @@ describe("orderDiffFiles", () => { "src/orphan.spec.ts", ]); }); - - it("orders the same diff the same way whatever order it arrives in", () => { - const files = [ - file("src/a.ts", ['import { b } from "./b";']), - file("src/b.ts"), - file("src/c.ts"), - file("src/a.test.ts"), - file("yarn.lock"), - ]; - expect(order(files)).toEqual(order(files.toReversed())); - }); }); diff --git a/apps/web/src/components/settings/SnapShotSetupDialog.logic.test.ts b/apps/web/src/components/settings/SnapShotSetupDialog.logic.test.ts index cd1ad79dfd29..069fa24dc9a0 100644 --- a/apps/web/src/components/settings/SnapShotSetupDialog.logic.test.ts +++ b/apps/web/src/components/settings/SnapShotSetupDialog.logic.test.ts @@ -128,38 +128,6 @@ it("resumes KDE setup after the helper passes its real permission check", () => expect(captureSetupInitialStep({ ...state, shortcutRegistered: false })).toBe("shortcut"); }); -it("uses the current desktop's access requirements while preserving setup for the others", () => { - const kde: DesktopSnapShotState = { - ...gnome, - linuxDesktop: "kde", - linuxBackend: "kde", - kdeHelper: { status: "not-installed", message: "Install the helper" }, - shortcutVerified: false, - }; - expect(captureSetupDesktopName(kde)).toBe("KDE Plasma"); - expect(captureSetupInitialStep(kde)).toBe("access"); - expect( - captureSetupInitialStep({ - ...kde, - kdeHelper: { status: "ready", message: "Ready" }, - shortcutRegistered: false, - }), - ).toBe("shortcut"); - - const niri: DesktopSnapShotState = { - ...gnome, - linuxDesktop: "niri", - linuxBackend: "niri", - shortcutRegistered: false, - }; - expect(captureSetupDesktopName(niri)).toBe("Niri"); - expect(captureSetupInitialStep(niri)).toBe("shortcut"); - - expect(captureSetupDesktopName(gnome)).toBe("GNOME"); - expect(captureSetupInitialStep(gnome)).toBe("shortcut"); - expect(captureSetupShortcutReady(gnome, false)).toBe(true); -}); - it("does not report a successful check when capture support could not be read", () => { expect(captureSetupCheckMessage({ ...gnome, message: "Desktop disconnected" })).toContain( "Still unable to check access", diff --git a/apps/web/src/components/settings/settingsScopeNavigation.test.ts b/apps/web/src/components/settings/settingsScopeNavigation.test.ts index a89468eb80d9..a749bd45422b 100644 --- a/apps/web/src/components/settings/settingsScopeNavigation.test.ts +++ b/apps/web/src/components/settings/settingsScopeNavigation.test.ts @@ -106,12 +106,7 @@ describe("settings scope navigation", () => { expect(router.state.location.search).toEqual(checkoutSearch); }); - it.each([ - "/settings/projects", - "/settings/integrations", - "/settings/source-control", - "/settings/scheduled-tasks", - ] as const)( + it.each(["/settings/projects", "/settings/scheduled-tasks"] as const)( "keeps %s when regrouping or selecting a target from the shared settings layout", async (to) => { const router = createSettingsRouter(); diff --git a/apps/web/src/lib/embeddedScripts.test.ts b/apps/web/src/lib/embeddedScripts.test.ts index d7a7667b17c1..48af202c5a77 100644 --- a/apps/web/src/lib/embeddedScripts.test.ts +++ b/apps/web/src/lib/embeddedScripts.test.ts @@ -49,29 +49,24 @@ describe("embeddedScripts", () => { it.each([ [`python3 -c 'print(1)'`, "python", "print(1)"], - [`python3 -X dev -c 'print(1)'`, "python", "print(1)"], [`py -3 -c 'print(1)'`, "python", "print(1)"], - [`C:\\Python312\\python.exe -c 'print(1)'`, "python", "print(1)"], [`"C:\\Program Files\\Python312\\python.exe" -c 'print(1)'`, "python", "print(1)"], [`node --eval "process.exit(0)"`, "javascript", "process.exit(0)"], [`bun -e 'console.log(Bun.version)'`, "typescript", "console.log(Bun.version)"], [`deno eval 'console.log(1)'`, "typescript", "console.log(1)"], [`deno eval --config deno.json 'console.log(1)'`, "typescript", "console.log(1)"], - [`deno eval --env-file 'console.log(1)'`, "typescript", "console.log(1)"], [`psql -f schema.sql -c 'select 1'`, "sql", "select 1"], [`node -C development -e 'console.log(1)'`, "javascript", "console.log(1)"], [`ruby -e 'puts 1'`, "ruby", "puts 1"], [`ruby -I lib -e 'puts 1'`, "ruby", "puts 1"], [`perl -E 'say 1'`, "perl", "say 1"], [`sqlite3 app.db "select * from users"`, "sql", "select * from users"], - [`psql -d app -c 'select 1'`, "sql", "select 1"], [`pwsh -NoProfile -Command "Get-ChildItem"`, "powershell", "Get-ChildItem"], [`env FOO=1 sudo -u bob python3 -c 'print(1)'`, "python", "print(1)"], [`env -- FOO=1 python3 -c 'print(1)'`, "python", "print(1)"], [`node --eval="process.exit(0)"`, "javascript", "process.exit(0)"], [`psql --command='select 1'`, "sql", "select 1"], [`mysql -c -e 'select 1'`, "sql", "select 1"], - [`mysql -f app <<'EOF'\nselect 1;\nEOF`, "sql", "select 1;\n"], [`psql app <<'EOF'\nselect 1;\nEOF`, "sql", "select 1;\n"], [`echo "$(node -e 'console.log(1)')"`, "javascript", "console.log(1)"], [`python3 <<< 'print(1)'`, "python", "print(1)"], diff --git a/apps/web/src/lib/snapShotShortcut.test.ts b/apps/web/src/lib/snapShotShortcut.test.ts index 9caba3aa82ca..f8bd382fec40 100644 --- a/apps/web/src/lib/snapShotShortcut.test.ts +++ b/apps/web/src/lib/snapShotShortcut.test.ts @@ -5,18 +5,13 @@ import { parseDesktopSnapShotShortcut, snapShotShortcutKeyLabels } from "./snapS describe("desktop-approved window capture shortcut labels", () => { it.each([ ["Press 2", ["Ctrl", "⇧", "2"]], - ["2", ["Ctrl", "⇧", "2"]], ["Ctrl+Shift+8", ["Ctrl", "⇧", "8"]], - ["CTRL+SHIFT+8", ["Ctrl", "⇧", "8"]], ["Meta+F12", ["Super", "F12"]], - ["Super+F8", ["Super", "F8"]], ["Return", ["Ctrl", "Alt", "Enter"]], ["Left", ["Super", "Left"]], ["space", ["Ctrl", "Space"]], ["plus", ["Ctrl", "+"]], - ["+", ["Ctrl", "+"]], ["Ctrl++", ["Ctrl", "+"]], - [" Ctrl + Shift + 2 ", ["Ctrl", "⇧", "2"]], ])("formats %s using the shared keycap labels", (label, expected) => { const shortcut = parseDesktopSnapShotShortcut(label); expect(shortcut).not.toBeNull(); @@ -25,14 +20,9 @@ describe("desktop-approved window capture shortcut labels", () => { it.each([ "", - " ", - "Press ", "Ctrl+", "Press the shortcut configured in your desktop", - "Ctrl+Shift+2 or Ctrl+Shift+3", - "Press 2 or 3", "2", - "", "UnknownKey", ])("does not guess keys from an unrecognized description: %s", (label) => { expect(parseDesktopSnapShotShortcut(label)).toBeNull(); diff --git a/apps/web/src/lib/threadSort.test.ts b/apps/web/src/lib/threadSort.test.ts deleted file mode 100644 index a957cc12149c..000000000000 --- a/apps/web/src/lib/threadSort.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; -import { - DEFAULT_RUNTIME_MODE, - EnvironmentId, - ProjectId, - ProviderInstanceId, - ThreadId, -} from "@t3tools/contracts"; -import type { Thread } from "../types"; -import { makeThreadFixture, type ThreadFixtureOverrides } from "../test-fixtures"; -import { sortThreads } from "./threadSort"; - -const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); -const PROJECT_ID = ProjectId.make("project-1"); - -function makeThread(overrides: ThreadFixtureOverrides = {}): Thread { - return makeThreadFixture({ - id: ThreadId.make("thread-1"), - environmentId: LOCAL_ENVIRONMENT_ID, - projectId: PROJECT_ID, - title: "Thread", - modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, - runtimeMode: DEFAULT_RUNTIME_MODE, - interactionMode: "default", - runtime: null, - messages: [], - proposedPlans: [], - createdAt: "2026-03-09T10:00:00.000Z", - archivedAt: null, - settledOverride: null, - settledAt: null, - deletedAt: null, - updatedAt: "2026-03-09T10:00:00.000Z", - latestRun: null, - branch: null, - worktreePath: null, - ...overrides, - }); -} - -describe("sortThreads", () => { - it("sorts threads by the latest user message in recency mode", () => { - const sorted = sortThreads( - [ - makeThread({ - id: ThreadId.make("thread-1"), - updatedAt: "2026-03-09T10:10:00.000Z", - messages: [ - { - id: "message-1" as never, - role: "user", - text: "older", - runId: null, - createdAt: "2026-03-09T10:01:00.000Z", - updatedAt: "2026-03-09T10:01:00.000Z", - streaming: false, - }, - ], - }), - makeThread({ - id: ThreadId.make("thread-2"), - createdAt: "2026-03-09T10:05:00.000Z", - updatedAt: "2026-03-09T10:05:00.000Z", - messages: [ - { - id: "message-2" as never, - role: "user", - text: "newer", - runId: null, - createdAt: "2026-03-09T10:06:00.000Z", - updatedAt: "2026-03-09T10:06:00.000Z", - streaming: false, - }, - ], - }), - ], - "updated_at", - ); - - expect(sorted.map((thread) => thread.id)).toEqual([ - ThreadId.make("thread-2"), - ThreadId.make("thread-1"), - ]); - }); -}); diff --git a/apps/web/src/onboarding/projectImport.logic.test.ts b/apps/web/src/onboarding/projectImport.logic.test.ts index 090d4806122a..36766215fc2a 100644 --- a/apps/web/src/onboarding/projectImport.logic.test.ts +++ b/apps/web/src/onboarding/projectImport.logic.test.ts @@ -192,28 +192,6 @@ describe("resolveOnboardingProjectId", () => { ), ).toBeNull(); }); - - it("finds the current root owner when the scan has no project ID", () => { - const recreatedProjectId = ProjectId.make("recreated-project"); - expect( - resolveOnboardingProjectId( - [ - { - id: localProjectId, - environmentId: localEnvironmentId, - workspaceRoot: "/projects/other", - }, - { - id: recreatedProjectId, - environmentId: localEnvironmentId, - workspaceRoot: "/projects/repo", - }, - ], - localEnvironmentId, - candidate("/projects/repo"), - ), - ).toBe(recreatedProjectId); - }); }); describe("resolveOnboardingLandingProject", () => { diff --git a/apps/web/src/threadRoutes.test.ts b/apps/web/src/threadRoutes.test.ts index 3a028503015d..4df7e17fbb74 100644 --- a/apps/web/src/threadRoutes.test.ts +++ b/apps/web/src/threadRoutes.test.ts @@ -57,9 +57,7 @@ describe("threadRoutes", () => { it.each([ { status: "cached", source: "server thread" }, - { status: "synchronizing", source: "server thread" }, { status: "cached", source: "draft" }, - { status: "synchronizing", source: "draft" }, ] as const)( "keeps available $source visible while the shell is $status", ({ status, source }) => { diff --git a/packages/client-runtime/src/providerSkills.test.ts b/packages/client-runtime/src/providerSkills.test.ts index 198779af001d..a4ae4b5aefdd 100644 --- a/packages/client-runtime/src/providerSkills.test.ts +++ b/packages/client-runtime/src/providerSkills.test.ts @@ -4,7 +4,6 @@ import { describe, expect, it } from "vite-plus/test"; import { getProviderSkillsForSlashMenu, hasCompleteProviderWorkspaceSnapshot, - resolveProviderForCwd, resolveProviderSkillsForCwd, resolveProviderSlashCommandsForCwd, } from "./providerSkills.ts"; @@ -124,61 +123,3 @@ describe("workspace provider snapshots", () => { expect(hasCompleteProviderWorkspaceSnapshot(provider, null)).toBe(false); }); }); - -describe("resolveProviderForCwd", () => { - it("switches workspace options for every model without changing the machine catalog", () => { - const reasoning = { - id: "reasoning", - label: "Reasoning", - type: "select" as const, - options: [{ id: "high", label: "High" }], - }; - const globalAgent = { - id: "agent", - label: "Agent", - type: "select" as const, - options: [{ id: "build", label: "Build" }], - }; - const workspaceAgent = { - ...globalAgent, - options: [{ id: "readonly", label: "Readonly" }], - }; - const emptyAgent = { ...globalAgent, options: [] }; - const catalog: ServerProvider = { - ...provider, - models: [ - { - slug: "model", - name: "Model", - isCustom: false, - capabilities: { optionDescriptors: [globalAgent, reasoning] }, - }, - { slug: "custom", name: "Custom", isCustom: true, capabilities: null }, - { slug: "legacy", name: "Legacy", isCustom: false, capabilities: {} }, - ], - workspaceSnapshots: [ - ...provider.workspaceSnapshots, - ...[workspaceAgent, emptyAgent].map((agent, index) => ({ - ...provider.workspaceSnapshots[0]!, - cwd: `/workspace/options-${index}`, - optionDescriptors: [agent], - })), - ], - }; - const original = structuredClone(catalog); - - for (const [index, agent] of [workspaceAgent, emptyAgent, workspaceAgent].entries()) { - const scoped = resolveProviderForCwd(catalog, `/workspace/options-${index % 2}`); - expect(scoped.models.map((model) => model.capabilities?.optionDescriptors)).toEqual([ - expect.arrayContaining([reasoning, agent]), - [agent], - [agent], - ]); - expect(scoped.models[0]?.capabilities?.optionDescriptors).toHaveLength(2); - } - expect(catalog).toEqual(original); - expect(resolveProviderForCwd(catalog, "/workspace/unknown")).toBe(catalog); - expect(resolveProviderForCwd(catalog, "/workspace/project-a")).toBe(catalog); - expect(resolveProviderForCwd(catalog, null)).toBe(catalog); - }); -}); diff --git a/packages/client-runtime/src/providerSkills.ts b/packages/client-runtime/src/providerSkills.ts index 61be359529b7..005c058615fa 100644 --- a/packages/client-runtime/src/providerSkills.ts +++ b/packages/client-runtime/src/providerSkills.ts @@ -114,30 +114,6 @@ export function hasCurrentProviderWorkspaceSnapshot( ); } -export function resolveProviderForCwd( - provider: ServerProvider, - cwd: string | null | undefined, -): ServerProvider { - const descriptors = resolveProviderWorkspaceSnapshot(provider, cwd)?.optionDescriptors; - if (descriptors === undefined) return provider; - const overriddenIds = new Set(descriptors.map((descriptor) => descriptor.id)); - return { - ...provider, - models: provider.models.map((model) => ({ - ...model, - capabilities: { - ...model.capabilities, - optionDescriptors: [ - ...(model.capabilities?.optionDescriptors ?? []).filter( - (descriptor) => !overriddenIds.has(descriptor.id), - ), - ...descriptors, - ], - }, - })), - }; -} - export function resolveProviderSkillsForCwd( provider: ServerProvider, cwd: string | null | undefined, diff --git a/packages/client-runtime/src/state/sharedSettings.test.ts b/packages/client-runtime/src/state/sharedSettings.test.ts index 923462281d46..0ef9f6715a01 100644 --- a/packages/client-runtime/src/state/sharedSettings.test.ts +++ b/packages/client-runtime/src/state/sharedSettings.test.ts @@ -1,20 +1,12 @@ import { DEFAULT_SERVER_SETTINGS, - EnvironmentId, ProviderDriverKind, ProviderInstanceId, } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; -import { - filterSharedServerPatch, - findSharedSettingsMismatches, - pickSharedServerSettings, -} from "./sharedSettings.ts"; +import { filterSharedServerPatch, pickSharedServerSettings } from "./sharedSettings.ts"; -const primaryId = EnvironmentId.make("env-primary"); -const laptopId = EnvironmentId.make("env-laptop"); -const boxId = EnvironmentId.make("env-box"); const restartCapabilities = { threadRestartContinuation: true }; describe("filterSharedServerPatch", () => { @@ -82,19 +74,6 @@ describe("filterSharedServerPatch", () => { expect(filterSharedServerPatch(patch, restartCapabilities, settings, sourceSettings)).toEqual( availability === "enabled" ? patch : { sidebarAutoSettleAfterDays: 7 }, ); - const primarySettings = { - ...sourceSettings, - textGenerationModelSelection: selection, - }; - expect( - findSharedSettingsMismatches({ - primaryEnvironmentId: primaryId, - primarySettings, - environments: [ - { environmentId: boxId, label: "Remote Box", syncEligible: true, settings }, - ], - }), - ).toEqual(availability === "enabled" ? [{ environmentId: boxId, label: "Remote Box" }] : []); }, ); @@ -113,148 +92,3 @@ describe("filterSharedServerPatch", () => { }, ); }); - -describe("findSharedSettingsMismatches", () => { - const primarySettings = { ...DEFAULT_SERVER_SETTINGS, sidebarAutoSettleAfterDays: 7 }; - - it.each([true, false])( - "detects remote restart continuation drift when the preference is %s", - (enabled) => { - const settings = { ...primarySettings, continueThreadsAfterServerUpdate: enabled }; - const remoteSettings = { ...settings, continueThreadsAfterServerUpdate: !enabled }; - const environment = { - environmentId: boxId, - label: "Remote Box", - syncEligible: true, - settings: remoteSettings, - capabilities: restartCapabilities, - }; - expect( - findSharedSettingsMismatches({ - primaryEnvironmentId: primaryId, - primarySettings: settings, - primaryCapabilities: restartCapabilities, - environments: [environment], - }), - ).toEqual([{ environmentId: boxId, label: "Remote Box" }]); - expect( - findSharedSettingsMismatches({ - primaryEnvironmentId: primaryId, - primarySettings: settings, - primaryCapabilities: restartCapabilities, - environments: [ - { - ...environment, - settings: Object.assign( - {}, - remoteSettings, - pickSharedServerSettings(settings, restartCapabilities), - ), - }, - ], - }), - ).toEqual([]); - }, - ); - - it.each([ - [undefined, restartCapabilities], - [restartCapabilities, undefined], - [undefined, undefined], - ])( - "ignores restart drift unless both servers support it (%j, %j)", - (primaryCapabilities, capabilities) => { - const environment = { - environmentId: boxId, - label: "Remote Box", - syncEligible: true, - capabilities, - settings: { ...primarySettings, continueThreadsAfterServerUpdate: true }, - }; - const input = { - primaryEnvironmentId: primaryId, - primarySettings, - primaryCapabilities, - environments: [environment], - }; - expect(findSharedSettingsMismatches(input)).toEqual([]); - expect( - findSharedSettingsMismatches({ - ...input, - environments: [ - { - ...environment, - settings: { ...environment.settings, sidebarAutoSettleAfterDays: 14 }, - }, - ], - }), - ).toEqual([{ environmentId: boxId, label: "Remote Box" }]); - }, - ); - - it("lists sync-eligible environments whose shared settings differ", () => { - const mismatches = findSharedSettingsMismatches({ - primaryEnvironmentId: primaryId, - primarySettings, - environments: [ - { - environmentId: primaryId, - label: "Desktop", - syncEligible: true, - settings: primarySettings, - }, - { - environmentId: laptopId, - label: "Laptop", - syncEligible: true, - settings: primarySettings, - }, - { - environmentId: boxId, - label: "Remote Box", - syncEligible: true, - settings: DEFAULT_SERVER_SETTINGS, - }, - ], - }); - expect(mismatches).toEqual([{ environmentId: boxId, label: "Remote Box" }]); - }); - - it("ignores machine-only differences", () => { - const mismatches = findSharedSettingsMismatches({ - primaryEnvironmentId: primaryId, - primarySettings, - environments: [ - { - environmentId: boxId, - label: "Remote Box", - syncEligible: true, - settings: { - ...primarySettings, - enableAgentBrowserAccess: false, - defaultThreadEnvMode: - primarySettings.defaultThreadEnvMode === "local" ? "worktree" : "local", - }, - }, - ], - }); - expect(mismatches).toEqual([]); - }); - - it("skips ineligible environments and environments without a loaded config", () => { - const mismatches = findSharedSettingsMismatches({ - primaryEnvironmentId: primaryId, - primarySettings, - environments: [ - { - environmentId: laptopId, - label: "Laptop", - syncEligible: false, - settings: DEFAULT_SERVER_SETTINGS, - }, - { environmentId: boxId, label: "Remote Box", syncEligible: true, settings: null }, - ], - }); - expect(mismatches).toEqual([]); - }); -}); diff --git a/packages/client-runtime/src/state/sharedSettings.ts b/packages/client-runtime/src/state/sharedSettings.ts index 549ab5422e40..e689c3b1b8d1 100644 --- a/packages/client-runtime/src/state/sharedSettings.ts +++ b/packages/client-runtime/src/state/sharedSettings.ts @@ -5,7 +5,6 @@ import type { ServerSettingsPatch, } from "@t3tools/contracts"; import { isModelSelectionProviderEnabled } from "@t3tools/shared/serverSettings"; -import * as Equal from "effect/Equal"; import * as Struct from "effect/Struct"; import type { EnvironmentConnectionPhase } from "../connection/presentation.ts"; @@ -97,60 +96,3 @@ export function supportsSharedSettingsSync(environment: { environment.serverConfig?.environment.capabilities.threadAutoSettlement === true ); } - -export interface SharedSettingsEnvironment { - readonly environmentId: EnvironmentId; - readonly label: string; - readonly syncEligible: boolean; - readonly settings: ServerSettings | null; - readonly capabilities?: - | Pick - | undefined; -} - -/** - * Callers must pass the real loaded settings, never a default fallback, or - * "apply to all" would push defaults over real values. - */ -export function findSharedSettingsMismatches(input: { - readonly primaryEnvironmentId: EnvironmentId | null; - readonly primarySettings: ServerSettings | null; - readonly primaryCapabilities?: - | Pick - | undefined; - readonly environments: ReadonlyArray; -}): ReadonlyArray<{ readonly environmentId: EnvironmentId; readonly label: string }> { - if (input.primaryEnvironmentId === null || input.primarySettings === null) { - return []; - } - const primarySettings = pickSharedServerSettings( - input.primarySettings, - input.primaryCapabilities, - ); - return input.environments.flatMap((environment) => { - if ( - environment.environmentId === input.primaryEnvironmentId || - !environment.syncEligible || - environment.settings === null - ) { - return []; - } - const expected = filterSharedServerPatch( - primarySettings, - environment.capabilities, - environment.settings, - input.primarySettings ?? undefined, - ); - let actual = filterSharedServerPatch( - pickSharedServerSettings(environment.settings, environment.capabilities), - input.primaryCapabilities, - environment.settings, - ); - if (!expected.textGenerationModelSelection) { - actual = Struct.omit(actual, ["textGenerationModelSelection"]); - } - return Equal.equals(actual, expected) - ? [] - : [{ environmentId: environment.environmentId, label: environment.label }]; - }); -} diff --git a/packages/client-runtime/src/state/threadFeedback.test.ts b/packages/client-runtime/src/state/threadFeedback.test.ts index 640826526bd3..18f104b5441d 100644 --- a/packages/client-runtime/src/state/threadFeedback.test.ts +++ b/packages/client-runtime/src/state/threadFeedback.test.ts @@ -4,26 +4,11 @@ import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/reactivity"; import { - beginCodexFeedbackSubmission, codexFeedbackNotice, submitCodexFeedback, type CodexFeedbackSubmission, } from "./threadFeedback.ts"; -describe("beginCodexFeedbackSubmission", () => { - it("allows only one upload per thread until the active upload releases its guard", () => { - const inFlight = new Set(); - const finish = beginCodexFeedbackSubmission(inFlight, "environment:thread"); - - expect(finish).not.toBeNull(); - expect(beginCodexFeedbackSubmission(inFlight, "environment:thread")).toBeNull(); - expect(beginCodexFeedbackSubmission(inFlight, "environment:other-thread")).not.toBeNull(); - - finish?.(); - expect(beginCodexFeedbackSubmission(inFlight, "environment:thread")).not.toBeNull(); - }); -}); - describe("submitCodexFeedback", () => { const submission = { id: MessageId.make("feedback-message-1"), diff --git a/packages/client-runtime/src/state/threadFeedback.ts b/packages/client-runtime/src/state/threadFeedback.ts index b060f56dd53f..d7b711a9025f 100644 --- a/packages/client-runtime/src/state/threadFeedback.ts +++ b/packages/client-runtime/src/state/threadFeedback.ts @@ -44,15 +44,6 @@ export function codexFeedbackNotice(submission: CodexFeedbackSubmission) { } } -export function beginCodexFeedbackSubmission( - submissionsInFlight: Set, - threadKey: string, -): (() => void) | null { - if (submissionsInFlight.has(threadKey)) return null; - submissionsInFlight.add(threadKey); - return () => submissionsInFlight.delete(threadKey); -} - export interface LocalChatMessage { readonly id: MessageId; readonly role: "user" | "assistant"; diff --git a/packages/client-runtime/src/work-log/commandLabel.test.ts b/packages/client-runtime/src/work-log/commandLabel.test.ts index 405019d6943b..59adc73701e2 100644 --- a/packages/client-runtime/src/work-log/commandLabel.test.ts +++ b/packages/client-runtime/src/work-log/commandLabel.test.ts @@ -31,37 +31,17 @@ describe("commandProgramName", () => { it.each([ "if test -f package.json; then vp test; fi", - "[ -f package.json ]", "[[ -f package.json ]]", "test -f package.json", 'for file in *; do echo "$file"; done', - "while true; do sleep 1; done", - "until false; do sleep 1; done", - "case $name in test) vp test;; esac", - 'select item in one two; do echo "$item"; done', - "function check() { vp test; }", "check() { vp test; }", - "k(){ echo ok; }; k", "{ vp test; }", - "(vp test)", "(( count += 1 ))", - "! vp test", - ":", ". ./script.sh", - "source ./script.sh", - "eval 'vp test'", - "cd packages/client-runtime", "export NODE_ENV=test", - "local name=value", - "set -e", - "alias ll='ls -la'", "repeat 3 echo ok", - "and vp test", "return 1", - "break", - "continue", "true", - "false", ])("falls back for shell syntax and internal control commands: %s", (command) => { expect(commandProgramName(command)).toBeNull(); }); diff --git a/packages/client-runtime/src/work-log/presentation.test.ts b/packages/client-runtime/src/work-log/presentation.test.ts index 2d29cca63610..6d4cc88c5c61 100644 --- a/packages/client-runtime/src/work-log/presentation.test.ts +++ b/packages/client-runtime/src/work-log/presentation.test.ts @@ -4,8 +4,6 @@ import { ThreadId, TurnItemId, type OrchestrationV2TurnItem } from "@t3tools/con import * as DateTime from "effect/DateTime"; import { - commandDetailRepeatsCommand, - extractCommandOutputText, resolveWorkEntryToolPresentation, toolItemForDisplay, type WorkLogPresentationEntry, @@ -182,65 +180,3 @@ describe("resolveWorkEntryToolPresentation", () => { ).toBeNull(); }); }); - -describe("command work-log details", () => { - it("extracts Claude result blocks and projected output", () => { - expect( - extractCommandOutputText({ - result: { - content: [ - { type: "text", text: "first" }, - { type: "text", text: "second" }, - ], - }, - }), - ).toBe("first\nsecond"); - expect(extractCommandOutputText({ rawOutput: { content: "projected summary" } })).toBe( - "projected summary", - ); - }); - - it("treats an ingestion-truncated echo of a long command as a repeat", () => { - const command = `git add -A && git commit -m "${"x".repeat(200)}"`; - const truncated = `Bash: ${command}`.slice(0, 177) + "..."; - expect( - commandDetailRepeatsCommand({ - detail: truncated, - command, - rawCommand: null, - toolName: "Bash", - data: { toolName: "Bash", command }, - }), - ).toBe(true); - expect( - commandDetailRepeatsCommand({ - detail: "Bash: printf hello...", - command: "printf goodbye", - rawCommand: null, - toolName: "Bash", - data: { toolName: "Bash", command: "printf goodbye" }, - }), - ).toBe(false); - }); - - it("treats ACP command echoes as synthetic even without a tool kind", () => { - expect( - commandDetailRepeatsCommand({ - detail: "pnpm test", - command: "pnpm test", - rawCommand: null, - toolName: undefined, - data: { toolCallId: "tool-1", command: "pnpm test" }, - }), - ).toBe(true); - expect( - commandDetailRepeatsCommand({ - detail: "pnpm test", - command: "pnpm test", - rawCommand: null, - toolName: undefined, - data: { command: "pnpm test" }, - }), - ).toBe(false); - }); -}); diff --git a/packages/client-runtime/src/work-log/presentation.ts b/packages/client-runtime/src/work-log/presentation.ts index f16b0722abe3..57bbc2db2b6e 100644 --- a/packages/client-runtime/src/work-log/presentation.ts +++ b/packages/client-runtime/src/work-log/presentation.ts @@ -235,135 +235,6 @@ function nonEmptyString(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 ? value : null; } -function commandResultContent(value: unknown): string | null { - const direct = nonEmptyString(value); - if (direct) return direct; - - const directContent = Array.isArray(value) ? value : null; - const record = asRecord(value); - const content = record?.content; - const contentText = nonEmptyString(content); - if (contentText) return contentText; - const blocks = directContent ?? (Array.isArray(content) ? content : null); - if (!blocks) return null; - - const chunks = blocks.flatMap((entry) => { - const text = nonEmptyString(entry) ?? nonEmptyString(asRecord(entry)?.text); - return text ? [text] : []; - }); - return chunks.length > 0 ? chunks.join("\n") : null; -} - -export function extractCommandOutputText(dataValue: unknown): string | null { - const data = asRecord(dataValue); - const item = asRecord(data?.item); - const itemResult = asRecord(item?.result); - const rawOutput = asRecord(data?.rawOutput); - const outputStreams = [ - nonEmptyString(rawOutput?.stdout), - nonEmptyString(rawOutput?.stderr), - ].filter((value): value is string => value !== null); - const acpContent = Array.isArray(data?.content) - ? data.content - .flatMap((entryValue) => { - const entry = asRecord(entryValue); - const content = asRecord(entry?.content); - const text = entry?.type === "content" ? nonEmptyString(content?.text) : null; - return text ? [text] : []; - }) - .join("\n") - : null; - - const candidates = [ - item?.aggregatedOutput, - itemResult?.content, - data?.rawOutput, - rawOutput?.content, - outputStreams.length > 0 ? outputStreams.join("\n") : null, - rawOutput?.output, - acpContent, - data?.result, - ]; - for (const candidate of candidates) { - const text = commandResultContent(candidate); - if (text) return text; - } - return null; -} - -/** - * Ingestion caps tool details at 180 chars and appends "...", so a long command - * echo no longer equals the command it repeats. Treat a truncated prefix of the - * command as the same echo. - */ -function textRepeatsCommand(text: string, commands: ReadonlyArray): boolean { - const truncated = text.endsWith("...") - ? text.slice(0, -3) - : text.endsWith("\u2026") - ? text.slice(0, -1) - : null; - return commands.some((candidate) => { - const command = candidate?.trim(); - if (!command) return false; - if (command === text) return true; - return ( - truncated !== null && - truncated.length > 0 && - command.length > truncated.length && - command.startsWith(truncated) - ); - }); -} - -/** - * OpenCode stores completed output in `detail` with no other output channel, so - * plain equality is only treated as synthetic when the payload shape shows the - * detail came from the command: Codex item metadata, an ACP tool call - * (`data.toolCallId`, `kind: "execute"`), a Claude tool-name prefix, or no - * structured command at all. - */ -export function commandDetailRepeatsCommand(input: { - readonly detail: string; - readonly command: string | null; - readonly rawCommand: string | null; - readonly toolName: unknown; - readonly data: unknown; -}): boolean { - const toolName = nonEmptyString(input.toolName)?.trim(); - const detail = input.detail.trim(); - const commands = [input.command, input.rawCommand]; - if (toolName) { - const prefix = `${toolName}:`; - if (detail.toLowerCase().startsWith(prefix.toLowerCase())) { - const unprefixed = detail.slice(prefix.length).trim(); - if (textRepeatsCommand(unprefixed, commands)) return true; - } - } - - if (!textRepeatsCommand(detail, commands)) return false; - - const data = asRecord(input.data); - const item = asRecord(data?.item); - const itemInput = asRecord(item?.input); - const itemResult = asRecord(item?.result); - const hasStructuredCommand = [ - item?.command, - itemInput?.command, - itemResult?.command, - data?.command, - ].some((value) => - Array.isArray(value) - ? value.some((part) => nonEmptyString(part) !== null) - : nonEmptyString(value) !== null, - ); - return ( - !hasStructuredCommand || - item !== null || - data?.toolCallId !== undefined || - nonEmptyString(data?.kind)?.toLowerCase() === "execute" - ); -} - function workLogEntryIsToolLike(entry: WorkLogPresentationEntry): boolean { if (entry.tone === "tool" || entry.tone === "thinking" || entry.tone === "error") return true; if (entry.command !== undefined && entry.command.trim().length > 0) return true; diff --git a/packages/contracts/src/browserProfile.test.ts b/packages/contracts/src/browserProfile.test.ts index 092587345ced..89eda6328cb2 100644 --- a/packages/contracts/src/browserProfile.test.ts +++ b/packages/contracts/src/browserProfile.test.ts @@ -4,7 +4,6 @@ import { BUILT_IN_BROWSER_PROFILES, DEFAULT_BROWSER_PROFILE_ID, INCOGNITO_BROWSER_PROFILE_ID, - findBrowserProfile, resolveBrowserProfiles, type BrowserProfile, } from "./browserProfile.ts"; @@ -21,12 +20,6 @@ describe("resolveBrowserProfiles", () => { expect(resolved).toEqual([...BUILT_IN_BROWSER_PROFILES, work]); }); - - it("keeps incognito ephemeral", () => { - const incognito = findBrowserProfile(resolveBrowserProfiles([]), INCOGNITO_BROWSER_PROFILE_ID); - - expect(incognito?.kind).toBe("incognito"); - }); }); describe("resolveBrowserProfiles normalization", () => { diff --git a/packages/contracts/src/preview.test.ts b/packages/contracts/src/preview.test.ts index d02fc0e333cd..6618646f1f4f 100644 --- a/packages/contracts/src/preview.test.ts +++ b/packages/contracts/src/preview.test.ts @@ -1,22 +1,11 @@ import { Schema } from "effect"; import { describe, expect, it } from "vite-plus/test"; -import { - PreviewAutomationHost, - PreviewAutomationOpenInput, - PreviewAutomationStatus, -} from "./previewAutomation.ts"; +import { PreviewAutomationHost, PreviewAutomationStatus } from "./previewAutomation.ts"; -const decodeOpenInput = Schema.decodeUnknownSync(PreviewAutomationOpenInput); const decodeAutomationHost = Schema.decodeUnknownSync(PreviewAutomationHost); const decodeAutomationStatus = Schema.decodeUnknownSync(PreviewAutomationStatus); -describe("PreviewAutomationOpenInput", () => { - it("retains the legacy show visibility alias", () => { - expect(decodeOpenInput({ show: false })).toEqual({ show: false }); - }); -}); - describe("PreviewAutomationHost", () => { it("accepts legacy hosts and current operation advertisements", () => { expect(decodeAutomationHost({ clientId: "legacy", environmentId: "environment-1" })).toEqual({ diff --git a/packages/contracts/src/provider.test.ts b/packages/contracts/src/provider.test.ts index 9ef58a0968f8..150ef849b9b9 100644 --- a/packages/contracts/src/provider.test.ts +++ b/packages/contracts/src/provider.test.ts @@ -2,46 +2,10 @@ import { describe, expect, it } from "vite-plus/test"; import * as Schema from "effect/Schema"; import { ThreadId } from "./baseSchemas.ts"; -import { - ProviderSendTurnInput, - ProviderSessionStartInput, - ProviderUploadFeedbackError, -} from "./provider.ts"; +import { ProviderSendTurnInput, ProviderUploadFeedbackError } from "./provider.ts"; -const decodeProviderSessionStartInput = Schema.decodeUnknownSync(ProviderSessionStartInput); const decodeProviderSendTurnInput = Schema.decodeUnknownSync(ProviderSendTurnInput); -function getOptionValue( - options: ReadonlyArray<{ id: string; value: unknown }> | undefined, - id: string, -): unknown { - return options?.find((option) => option.id === id)?.value; -} - -describe("ProviderSessionStartInput", () => { - it("accepts codex-compatible payloads", () => { - const parsed = decodeProviderSessionStartInput({ - threadId: "thread-1", - provider: "codex", - cwd: "/tmp/workspace", - modelSelection: { - provider: "codex", - model: "gpt-5.3-codex", - options: [ - { id: "reasoningEffort", value: "high" }, - { id: "fastMode", value: true }, - ], - }, - runtimeMode: "full-access", - }); - expect(parsed.runtimeMode).toBe("full-access"); - expect(parsed.modelSelection?.instanceId).toBe("codex"); - expect(parsed.modelSelection?.model).toBe("gpt-5.3-codex"); - expect(getOptionValue(parsed.modelSelection?.options, "reasoningEffort")).toBe("high"); - expect(getOptionValue(parsed.modelSelection?.options, "fastMode")).toBe(true); - }); -}); - describe("ProviderSendTurnInput", () => { it.each(["image", "file"])( "caps total image bytes for %s attachments without charging videos", diff --git a/packages/contracts/src/server.test.ts b/packages/contracts/src/server.test.ts index f1b4be2bbe89..a79c499afa3a 100644 --- a/packages/contracts/src/server.test.ts +++ b/packages/contracts/src/server.test.ts @@ -75,26 +75,6 @@ describe("server config forward compatibility", () => { describe("ServerObservability", () => { const decodeServerObservability = Schema.decodeUnknownSync(ServerObservability); - it("reads where each running endpoint came from", () => { - const parsed = decodeServerObservability({ - logsDirectoryPath: "/tmp/t3/logs", - localTracingEnabled: true, - otlpTracesUrl: "https://collector.example.com/v1/traces", - otlpTracesSource: "environment", - otlpTracesVariable: "OTEL_EXPORTER_OTLP_ENDPOINT", - otlpTracesEnabled: true, - otlpMetricsUrl: "https://collector.example.com/v1/metrics", - otlpMetricsSource: "settings", - otlpMetricsEnabled: true, - otlpLogsEnabled: false, - }); - - expect(parsed.otlpTracesSource).toBe("environment"); - expect(parsed.otlpTracesVariable).toBe("OTEL_EXPORTER_OTLP_ENDPOINT"); - expect(parsed.otlpMetricsSource).toBe("settings"); - expect(parsed.otlpLogsSource).toBeUndefined(); - }); - it("rejects an unknown endpoint source", () => { expect(() => decodeServerObservability({ diff --git a/packages/provider-acp/package.json b/packages/provider-acp/package.json index f3bbfa0dad38..cf31b05a05f9 100644 --- a/packages/provider-acp/package.json +++ b/packages/provider-acp/package.json @@ -19,10 +19,6 @@ "types": "./src/server/clientTerminals.ts", "import": "./src/server/clientTerminals.ts" }, - "./server/coreRuntimeEvents": { - "types": "./src/server/coreRuntimeEvents.ts", - "import": "./src/server/coreRuntimeEvents.ts" - }, "./server/mcpOverAcpBridge": { "types": "./src/server/mcpOverAcpBridge.ts", "import": "./src/server/mcpOverAcpBridge.ts" diff --git a/packages/provider-acp/src/server/coreRuntimeEvents.test.ts b/packages/provider-acp/src/server/coreRuntimeEvents.test.ts deleted file mode 100644 index dc4f5ea70605..000000000000 --- a/packages/provider-acp/src/server/coreRuntimeEvents.test.ts +++ /dev/null @@ -1,247 +0,0 @@ -import { ProviderDriverKind, RuntimeRequestId, TurnId } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; - -import { - makeAcpAssistantItemEvent, - makeAcpContentDeltaEvent, - makeAcpPlanUpdatedEvent, - makeAcpRequestOpenedEvent, - makeAcpRequestResolvedEvent, - makeAcpToolCallEvent, -} from "./coreRuntimeEvents.ts"; - -describe("AcpCoreRuntimeEvents", () => { - it("maps ACP permission requests to canonical runtime events", () => { - const stamp = { eventId: "event-1" as never, createdAt: "2026-03-27T00:00:00.000Z" }; - const turnId = TurnId.make("turn-1"); - const permissionRequest = { - kind: "execute" as const, - detail: "cat package.json", - toolCall: { - toolCallId: "tool-1", - kind: "execute", - status: "pending" as const, - command: "cat package.json", - detail: "cat package.json", - data: { toolCallId: "tool-1", kind: "execute" }, - }, - }; - - const openedEvent = makeAcpRequestOpenedEvent({ - stamp, - provider: ProviderDriverKind.make("cursor"), - threadId: "thread-1" as never, - turnId, - requestId: RuntimeRequestId.make("request-1"), - permissionRequest, - detail: "cat package.json", - args: { command: ["cat", "package.json"] }, - source: "acp.jsonrpc", - method: "session/request_permission", - rawPayload: { sessionId: "session-1" }, - }); - expect(openedEvent).toMatchObject({ - type: "request.opened", - payload: { - requestType: "exec_command_approval", - detail: "cat package.json", - }, - }); - expect(openedEvent).not.toHaveProperty("payload.options"); - - expect( - makeAcpRequestResolvedEvent({ - stamp, - provider: ProviderDriverKind.make("cursor"), - threadId: "thread-1" as never, - turnId, - requestId: RuntimeRequestId.make("request-1"), - permissionRequest, - decision: "accept", - }), - ).toMatchObject({ - type: "request.resolved", - payload: { - requestType: "exec_command_approval", - decision: "accept", - }, - }); - }); - - it("preserves a native file approval without a remembered-allow choice", () => { - const event = makeAcpRequestOpenedEvent({ - stamp: { eventId: "approval-1" as never, createdAt: "2026-09-02T00:00:00.000Z" }, - provider: ProviderDriverKind.make("antigravity"), - threadId: "thread-1" as never, - turnId: TurnId.make("turn-1"), - requestId: RuntimeRequestId.make("request-1"), - permissionRequest: { kind: "edit" }, - approvalOptions: [ - { decision: "accept", label: "Allow once" }, - { decision: "decline", label: "Reject" }, - ], - detail: "Edit package.json", - args: {}, - source: "acp.jsonrpc", - method: "session/request_permission", - rawPayload: { sessionId: "session-1" }, - }); - - expect(event.payload).toEqual({ - requestType: "file_change_approval", - detail: "Edit package.json", - args: {}, - options: [ - { decision: "accept", label: "Allow once" }, - { decision: "decline", label: "Reject" }, - ], - }); - }); - - it("maps generic ACP permission kinds to dynamic tool approvals", () => { - const stamp = { eventId: "event-1" as never, createdAt: "2026-03-27T00:00:00.000Z" }; - - for (const kind of ["search", "fetch", "other", "unknown", "future-tool-kind"]) { - const permissionRequest = { kind }; - const request = { - stamp, - provider: ProviderDriverKind.make("cursor"), - threadId: "thread-1" as never, - turnId: TurnId.make("turn-1"), - requestId: RuntimeRequestId.make(`request-${kind}`), - permissionRequest, - }; - - expect( - makeAcpRequestOpenedEvent({ - ...request, - detail: kind, - args: {}, - source: "acp.jsonrpc", - method: "session/request_permission", - rawPayload: { sessionId: "session-1" }, - }), - ).toMatchObject({ - type: "request.opened", - payload: { requestType: "dynamic_tool_call" }, - }); - - expect( - makeAcpRequestResolvedEvent({ - ...request, - decision: "accept", - }), - ).toMatchObject({ - type: "request.resolved", - payload: { requestType: "dynamic_tool_call" }, - }); - } - }); - - it("maps ACP core plan, tool-call, and content updates", () => { - const stamp = { eventId: "event-1" as never, createdAt: "2026-03-27T00:00:00.000Z" }; - const turnId = TurnId.make("turn-1"); - - expect( - makeAcpPlanUpdatedEvent({ - stamp, - provider: ProviderDriverKind.make("cursor"), - threadId: "thread-1" as never, - turnId, - payload: { - nativePlanId: "plan-1", - kind: "items", - plan: [{ step: "Inspect state", status: "inProgress" }], - }, - source: "acp.cursor.extension", - method: "cursor/update_todos", - rawPayload: { todos: [] }, - }), - ).toMatchObject({ - type: "turn.plan.updated", - raw: { - method: "cursor/update_todos", - }, - }); - - expect( - makeAcpToolCallEvent({ - stamp, - provider: ProviderDriverKind.make("cursor"), - threadId: "thread-1" as never, - turnId, - toolCall: { - toolCallId: "tool-1", - kind: "execute", - status: "completed", - title: "Terminal", - detail: "bun run test", - data: { command: "bun run test" }, - }, - rawPayload: { sessionId: "session-1" }, - }), - ).toMatchObject({ - type: "item.completed", - payload: { - itemType: "command_execution", - status: "completed", - }, - }); - - expect( - makeAcpContentDeltaEvent({ - stamp, - provider: ProviderDriverKind.make("cursor"), - threadId: "thread-1" as never, - turnId, - itemId: "assistant:session-1:segment:0", - text: "hello", - rawPayload: { sessionId: "session-1" }, - }), - ).toMatchObject({ - type: "content.delta", - itemId: "assistant:session-1:segment:0", - payload: { - delta: "hello", - }, - }); - - expect( - makeAcpAssistantItemEvent({ - stamp, - provider: ProviderDriverKind.make("cursor"), - threadId: "thread-1" as never, - turnId, - itemId: "assistant:session-1:segment:0", - lifecycle: "item.started", - }), - ).toMatchObject({ - type: "item.started", - itemId: "assistant:session-1:segment:0", - payload: { - itemType: "assistant_message", - status: "inProgress", - }, - }); - }); - - it("maps thoughts to the reasoning stream", () => { - expect( - makeAcpContentDeltaEvent({ - stamp: { eventId: "thought-1" as never, createdAt: "2026-09-02T00:00:00.000Z" }, - provider: ProviderDriverKind.make("cursor"), - threadId: "thread-1" as never, - turnId: TurnId.make("turn-1"), - streamKind: "reasoning_text", - text: "Inspect the current implementation first.", - rawPayload: { sessionId: "session-1" }, - }), - ).toMatchObject({ - type: "content.delta", - payload: { - streamKind: "reasoning_text", - delta: "Inspect the current implementation first.", - }, - }); - }); -}); diff --git a/packages/provider-acp/src/server/coreRuntimeEvents.ts b/packages/provider-acp/src/server/coreRuntimeEvents.ts deleted file mode 100644 index 3218e23cd88c..000000000000 --- a/packages/provider-acp/src/server/coreRuntimeEvents.ts +++ /dev/null @@ -1,255 +0,0 @@ -import { - type RuntimeEventRawSource, - RuntimeItemId, - type CanonicalRequestType, - type EventId, - type ProviderApprovalDecision, - type ProviderApprovalOption, - type ProviderDriverKind, - type ProviderRuntimeEvent, - type RuntimeRequestId, - type ThreadId, - type TurnId, -} from "@t3tools/contracts"; - -import { - type AcpPermissionRequest, - type AcpPlanUpdate, - type AcpToolCallState, - canonicalItemTypeFromAcpToolKind, -} from "./runtimeModel.ts"; - -type AcpAdapterRawSource = Extract< - RuntimeEventRawSource, - "acp.jsonrpc" | `acp.${string}.extension` ->; - -interface AcpEventStamp { - readonly eventId: EventId; - readonly createdAt: string; -} - -type AcpCanonicalRequestType = Extract< - CanonicalRequestType, - "exec_command_approval" | "file_read_approval" | "file_change_approval" | "dynamic_tool_call" ->; - -function canonicalRequestTypeFromAcpKind(kind: string | "unknown"): AcpCanonicalRequestType { - switch (kind) { - case "execute": - return "exec_command_approval"; - case "read": - return "file_read_approval"; - case "edit": - case "delete": - case "move": - return "file_change_approval"; - default: - return "dynamic_tool_call"; - } -} - -function runtimeItemStatusFromAcpToolStatus( - status: AcpToolCallState["status"], -): "inProgress" | "completed" | "failed" | undefined { - switch (status) { - case "pending": - case "inProgress": - return "inProgress"; - case "completed": - return "completed"; - case "failed": - return "failed"; - default: - return undefined; - } -} - -export function makeAcpRequestOpenedEvent(input: { - readonly stamp: AcpEventStamp; - readonly provider: ProviderDriverKind; - readonly threadId: ThreadId; - readonly turnId: TurnId | undefined; - readonly requestId: RuntimeRequestId; - readonly permissionRequest: AcpPermissionRequest; - readonly approvalOptions?: ReadonlyArray; - readonly detail: string; - readonly args: unknown; - readonly source: AcpAdapterRawSource; - readonly method: string; - readonly rawPayload: unknown; -}): ProviderRuntimeEvent { - return { - type: "request.opened", - ...input.stamp, - provider: input.provider, - threadId: input.threadId, - turnId: input.turnId, - requestId: input.requestId, - payload: { - requestType: canonicalRequestTypeFromAcpKind(input.permissionRequest.kind), - detail: input.detail, - args: input.args, - ...(input.approvalOptions !== undefined ? { options: input.approvalOptions } : {}), - }, - raw: { - source: input.source, - method: input.method, - payload: input.rawPayload, - }, - }; -} - -export function makeAcpRequestResolvedEvent(input: { - readonly stamp: AcpEventStamp; - readonly provider: ProviderDriverKind; - readonly threadId: ThreadId; - readonly turnId: TurnId | undefined; - readonly requestId: RuntimeRequestId; - readonly permissionRequest: AcpPermissionRequest; - readonly decision: ProviderApprovalDecision; -}): ProviderRuntimeEvent { - return { - type: "request.resolved", - ...input.stamp, - provider: input.provider, - threadId: input.threadId, - turnId: input.turnId, - requestId: input.requestId, - payload: { - requestType: canonicalRequestTypeFromAcpKind(input.permissionRequest.kind), - decision: input.decision, - }, - }; -} - -export function makeAcpPlanUpdatedEvent(input: { - readonly stamp: AcpEventStamp; - readonly provider: ProviderDriverKind; - readonly threadId: ThreadId; - readonly turnId: TurnId | undefined; - readonly payload: AcpPlanUpdate; - readonly source: AcpAdapterRawSource; - readonly method: string; - readonly rawPayload: unknown; -}): ProviderRuntimeEvent { - const payload = - input.payload.kind === "items" - ? { - plan: input.payload.plan, - ...(input.payload.explanation == null ? {} : { explanation: input.payload.explanation }), - } - : input.payload.kind === "removed" - ? { plan: [] } - : { - plan: [ - { - step: - input.payload.kind === "markdown" - ? input.payload.markdown - : input.payload.kind === "file" - ? `Plan file: ${input.payload.uri}` - : `[Unsupported ACP plan content: ${input.payload.contentType}]`, - status: "pending" as const, - }, - ], - }; - return { - type: "turn.plan.updated", - ...input.stamp, - provider: input.provider, - threadId: input.threadId, - turnId: input.turnId, - payload, - raw: { - source: input.source, - method: input.method, - payload: input.rawPayload, - }, - }; -} - -export function makeAcpToolCallEvent(input: { - readonly stamp: AcpEventStamp; - readonly provider: ProviderDriverKind; - readonly threadId: ThreadId; - readonly turnId: TurnId | undefined; - readonly toolCall: AcpToolCallState; - readonly rawPayload: unknown; -}): ProviderRuntimeEvent { - const runtimeStatus = runtimeItemStatusFromAcpToolStatus(input.toolCall.status); - return { - type: - input.toolCall.status === "completed" || input.toolCall.status === "failed" - ? "item.completed" - : "item.updated", - ...input.stamp, - provider: input.provider, - threadId: input.threadId, - turnId: input.turnId, - itemId: RuntimeItemId.make(input.toolCall.toolCallId), - payload: { - itemType: canonicalItemTypeFromAcpToolKind(input.toolCall.kind), - ...(runtimeStatus ? { status: runtimeStatus } : {}), - ...(input.toolCall.title ? { title: input.toolCall.title } : {}), - ...(input.toolCall.detail ? { detail: input.toolCall.detail } : {}), - ...(Object.keys(input.toolCall.data).length > 0 ? { data: input.toolCall.data } : {}), - }, - raw: { - source: "acp.jsonrpc", - method: "session/update", - payload: input.rawPayload, - }, - }; -} - -export function makeAcpAssistantItemEvent(input: { - readonly stamp: AcpEventStamp; - readonly provider: ProviderDriverKind; - readonly threadId: ThreadId; - readonly turnId: TurnId | undefined; - readonly itemId: string; - readonly lifecycle: "item.started" | "item.completed"; -}): ProviderRuntimeEvent { - return { - type: input.lifecycle, - ...input.stamp, - provider: input.provider, - threadId: input.threadId, - turnId: input.turnId, - itemId: RuntimeItemId.make(input.itemId), - payload: { - itemType: "assistant_message", - status: input.lifecycle === "item.completed" ? "completed" : "inProgress", - }, - }; -} - -export function makeAcpContentDeltaEvent(input: { - readonly stamp: AcpEventStamp; - readonly provider: ProviderDriverKind; - readonly threadId: ThreadId; - readonly turnId: TurnId | undefined; - readonly itemId?: string; - readonly streamKind?: "assistant_text" | "reasoning_text"; - readonly text: string; - readonly rawPayload: unknown; -}): ProviderRuntimeEvent { - return { - type: "content.delta", - ...input.stamp, - provider: input.provider, - threadId: input.threadId, - turnId: input.turnId, - ...(input.itemId ? { itemId: RuntimeItemId.make(input.itemId) } : {}), - payload: { - streamKind: input.streamKind ?? "assistant_text", - delta: input.text, - }, - raw: { - source: "acp.jsonrpc", - method: "session/update", - payload: input.rawPayload, - }, - }; -} diff --git a/packages/provider-acp/src/server/runtimeModel.ts b/packages/provider-acp/src/server/runtimeModel.ts index 70ce4336f55f..609a271658f1 100644 --- a/packages/provider-acp/src/server/runtimeModel.ts +++ b/packages/provider-acp/src/server/runtimeModel.ts @@ -793,7 +793,7 @@ function normalizeToolKind(kind: unknown): string | undefined { return typeof kind === "string" && kind.trim().length > 0 ? kind.trim() : undefined; } -export function canonicalItemTypeFromAcpToolKind(kind: string | undefined): ToolLifecycleItemType { +function canonicalItemTypeFromAcpToolKind(kind: string | undefined): ToolLifecycleItemType { switch (kind) { case "read": return "dynamic_tool_call"; diff --git a/packages/provider-grok/src/server/xaiBackgroundTasks.test.ts b/packages/provider-grok/src/server/xaiBackgroundTasks.test.ts deleted file mode 100644 index 544e9460290c..000000000000 --- a/packages/provider-grok/src/server/xaiBackgroundTasks.test.ts +++ /dev/null @@ -1,190 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; -import { TurnId } from "@t3tools/contracts"; - -import { - buildGrokBackgroundTaskEvents, - type GrokBackgroundTaskRecord, -} from "./xaiBackgroundTasks.ts"; - -const turnId = TurnId.make("turn-1"); -const monitor = { type: "Monitor", taskId: "monitor-1", timeoutMs: 60_000 }; -const shell = { type: "BackgroundTaskStarted", task_id: "shell-1", command: "sleep 40" }; - -function mapper() { - const tasks = new Map(); - const update = ( - rawOutput: unknown, - overrides: Partial[0]> = {}, - ) => - buildGrokBackgroundTaskEvents({ - tasks, - toolCallId: "call-1", - rawInput: { description: "Watch" }, - rawOutput, - toolCallStatus: "completed", - turnId, - ...overrides, - }); - return { tasks, update }; -} - -describe("Grok background tasks", () => { - it.each([ - [monitor, "monitor-1", "monitor", "Watch"], - [shell, "shell-1", "shell", "sleep 40"], - ] as const)("starts and deduplicates %j", (output, id, taskType, description) => { - const { tasks, update } = mapper(); - expect(update(output)).toEqual([ - { - type: "task.started", - turnId, - payload: { taskId: id, taskType, description, title: description, toolUseId: "call-1" }, - }, - ]); - expect(update(output)).toEqual([]); - expect(tasks.size).toBe(1); - }); - - it("accepts automatic backgrounding before the tool becomes terminal", () => { - const { update } = mapper(); - expect(update(shell, { toolCallStatus: "inProgress" })[0]?.type).toBe("task.started"); - expect(update(monitor, { toolCallStatus: "inProgress" })).toEqual([]); - expect(update(monitor, { toolCallStatus: "failed" })).toEqual([]); - }); - - it.each([ - ["running", null, "task.progress", undefined], - ["failed", 1, "task.completed", "failed"], - ["stopped", 137, "task.completed", "stopped"], - [undefined, 1, "task.completed", "failed"], - ])("maps poll status %s / exit %s", (status, exit_code, type, expectedStatus) => { - const { tasks, update } = mapper(); - update(monitor); - const events = update({ - type: "TaskOutput", - Result: { - task_id: "monitor-1", - command: "[monitor:Watch]", - status, - exit_code, - output: "\n result\nmore", - }, - }); - expect(events).toEqual([ - { - type, - turnId, - payload: { - taskId: "monitor-1", - taskType: "monitor", - description: "Watch", - title: "Watch", - toolUseId: "call-1", - summary: "result", - ...(expectedStatus ? { status: expectedStatus } : {}), - }, - }, - ]); - expect(tasks.size).toBe(type === "task.progress" ? 1 : 0); - }); - - it.each([undefined, TurnId.make("turn-2")])( - "does not attribute old tasks to a later turn: %s", - (laterTurnId) => { - const { tasks, update } = mapper(); - update(shell); - const events = update( - { - type: "TaskOutput", - Result: { task_id: "shell-1", command: "sleep 40", status: "completed" }, - }, - { turnId: laterTurnId }, - ); - expect(events).toHaveLength(1); - expect(events[0]?.turnId).toBeUndefined(); - expect(tasks.size).toBe(0); - }, - ); - - it("starts unknown poll tasks before progress or completion and ignores subagents", () => { - const { tasks, update } = mapper(); - const events = update({ - type: "TaskOutput", - MultiResult: { - results: [ - { task_id: "shell-1", command: "sleep 40", status: "running" }, - { task_id: "monitor-1", command: "[monitor] Watch", status: "completed" }, - { task_id: "agent-1", command: "[subagent:executor] work", status: "running" }, - ], - }, - }); - expect(events.map(({ type }) => type)).toEqual([ - "task.started", - "task.progress", - "task.started", - "task.completed", - ]); - expect(events.map(({ payload }) => payload.taskType)).toEqual([ - "shell", - "shell", - "monitor", - "monitor", - ]); - expect([...tasks.keys()]).toEqual(["shell-1"]); - expect(events.map((event) => event.turnId)).toEqual([ - undefined, - undefined, - undefined, - undefined, - ]); - }); - - it("retires only successfully killed tasks, including mixed results", () => { - const { tasks, update } = mapper(); - update(shell); - update(monitor); - const result = { type: "KillTask", Result: { task_id: "shell-1", outcome: "killed" } }; - expect(update(result, { toolCallStatus: "failed" })).toEqual([]); - expect(tasks.size).toBe(2); - const events = update({ - type: "KillTask", - MultiResult: { - results: [ - result.Result, - { task_id: "monitor-1", outcome: "error" }, - { task_id: "unknown", outcome: "killed" }, - ], - }, - }); - expect(events).toEqual([ - { - type: "task.completed", - turnId, - payload: { - taskId: "shell-1", - taskType: "shell", - description: "sleep 40", - title: "sleep 40", - toolUseId: "call-1", - status: "stopped", - }, - }, - ]); - expect([...tasks.keys()]).toEqual(["monitor-1"]); - }); - - it.each([ - { type: "Text", text: "subagent_id: fake\ntype: executor\ndescription: fake" }, - { type: "BackgroundTaskStarted", task_id: "shell-1" }, - { - type: "TaskOutput", - MultiResult: { - results: [null, {}, { task_id: "task", command: "sleep 40", exit_code: Infinity }], - }, - }, - ])("ignores malformed or unrelated outputs: %j", (output) => { - const { tasks, update } = mapper(); - expect(update(output)).toEqual([]); - expect(tasks.size).toBe(0); - }); -}); diff --git a/packages/provider-grok/src/server/xaiBackgroundTasks.ts b/packages/provider-grok/src/server/xaiBackgroundTasks.ts deleted file mode 100644 index d526a13ab7b7..000000000000 --- a/packages/provider-grok/src/server/xaiBackgroundTasks.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { - RuntimeTaskId, - type ProviderRuntimeTaskStartedEvent, - type ProviderRuntimeTaskProgressEvent, - type ProviderRuntimeTaskCompletedEvent, - type TurnId, -} from "@t3tools/contracts"; - -type TaskEvent = - | Pick - | Pick - | Pick; - -export interface GrokBackgroundTaskRecord { - readonly payload: { - readonly taskId: RuntimeTaskId; - readonly taskType: "monitor" | "shell"; - readonly description: string; - readonly title: string; - readonly toolUseId?: string; - }; - readonly turnId: TurnId | undefined; -} - -function record(value: unknown): Record { - return typeof value === "object" && value !== null && !Array.isArray(value) - ? (value as Record) - : {}; -} - -function text(value: unknown): string | undefined { - return typeof value === "string" ? value.trim() || undefined : undefined; -} - -function lifecycle(status: unknown, exitCode: unknown) { - switch (text(status)?.toLowerCase()) { - case "pending": - case "running": - return "running"; - case "completed": - case "success": - case "succeeded": - return "completed"; - case "failed": - case "error": - return "failed"; - case "stopped": - case "killed": - case "cancelled": - return "stopped"; - default: - return typeof exitCode === "number" && Number.isFinite(exitCode) - ? exitCode === 0 - ? "completed" - : "failed" - : undefined; - } -} - -export function buildGrokBackgroundTaskEvents(input: { - readonly tasks: Map; - readonly toolCallId: string; - readonly rawInput: unknown; - readonly rawOutput: unknown; - readonly toolCallStatus: string | undefined; - readonly turnId?: TurnId | undefined; -}): TaskEvent[] { - const { tasks, toolCallId, toolCallStatus, turnId } = input; - const output = record(input.rawOutput); - const events: TaskEvent[] = []; - if ( - toolCallStatus !== "completed" && - toolCallStatus !== "failed" && - output.type !== "BackgroundTaskStarted" - ) { - return events; - } - const attribution = (task: GrokBackgroundTaskRecord) => - task.turnId !== undefined && task.turnId === turnId ? { turnId } : {}; - const start = ( - id: string, - taskType: "monitor" | "shell", - description: string, - toolUseId?: string, - ) => { - const known = tasks.get(id); - if (known) return known; - const task: GrokBackgroundTaskRecord = { - payload: { - taskId: RuntimeTaskId.make(id), - taskType, - description, - title: description, - ...(toolUseId ? { toolUseId } : {}), - }, - // Polls can rediscover older tasks without establishing their originating turn. - turnId: toolUseId ? turnId : undefined, - }; - tasks.set(id, task); - events.push({ type: "task.started", payload: task.payload, ...attribution(task) }); - return task; - }; - const complete = ( - task: GrokBackgroundTaskRecord, - status: "completed" | "failed" | "stopped", - summary?: string, - ) => { - tasks.delete(task.payload.taskId); - events.push({ - type: "task.completed", - payload: { ...task.payload, status, ...(summary ? { summary } : {}) }, - ...attribution(task), - }); - }; - - if (output.type === "Monitor" && toolCallStatus === "completed") { - const id = text(output.taskId); - if (id) start(id, "monitor", text(record(input.rawInput).description) ?? "Monitor", toolCallId); - } else if (output.type === "BackgroundTaskStarted") { - const id = text(output.task_id) ?? text(output.taskId); - const command = text(output.command); - if (id && command) start(id, "shell", command.split("\n")[0]!.slice(0, 200), toolCallId); - } else if (output.type === "TaskOutput" || output.type === "KillTask") { - const results = record(output.MultiResult).results; - for (const value of Array.isArray(results) ? results : [output.Result]) { - const result = record(value); - const id = text(result.task_id); - if (!id) continue; - if (output.type === "KillTask") { - const task = tasks.get(id); - if (task && toolCallStatus === "completed" && result.outcome === "killed") - complete(task, "stopped"); - continue; - } - const command = text(result.command); - const status = lifecycle(result.status, result.exit_code); - if (!command || !status || command.startsWith("[subagent:")) continue; - const task = start(id, /^\[monitor[:\]]/.test(command) ? "monitor" : "shell", command); - const summary = text(result.output) - ?.split("\n") - .find((line) => line.trim()) - ?.trim(); - if (status === "running") { - events.push({ - type: "task.progress", - payload: { ...task.payload, ...(summary ? { summary } : {}) }, - ...attribution(task), - }); - } else { - complete(task, status, summary); - } - } - } - return events; -} diff --git a/packages/shared/package.json b/packages/shared/package.json index 50e15ca422c5..5fa05fda74bb 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -411,10 +411,6 @@ "types": "./src/hostClassification.ts", "import": "./src/hostClassification.ts" }, - "./dateTime": { - "types": "./src/dateTime.ts", - "import": "./src/dateTime.ts" - }, "./cliRelease": { "types": "./src/cliRelease.ts", "import": "./src/cliRelease.ts" diff --git a/packages/shared/src/composerTrigger.test.ts b/packages/shared/src/composerTrigger.test.ts index 221ddb5b804c..e22545b9a23c 100644 --- a/packages/shared/src/composerTrigger.test.ts +++ b/packages/shared/src/composerTrigger.test.ts @@ -43,7 +43,6 @@ describe("detectComposerTrigger", () => { }); it.each([ - "It costs $5", "budget $9.99", "only $.99", "about €20", @@ -51,8 +50,6 @@ describe("detectComposerTrigger", () => { "$1,000", "£2e6", "raise $10k.", - "is it $5?", - "(about $9.99)", ])("does not open the skill menu for the amount in %j", (text) => expect(detectComposerTrigger(text, text.length)).toBeNull(), ); diff --git a/packages/shared/src/dateTime.test.ts b/packages/shared/src/dateTime.test.ts deleted file mode 100644 index a036cce1c6c6..000000000000 --- a/packages/shared/src/dateTime.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; - -import { compareDateTimeStrings } from "./dateTime.ts"; - -describe("compareDateTimeStrings", () => { - it("compares valid date-time strings by absolute time", () => { - expect( - compareDateTimeStrings("2026-09-01T12:00:00.000Z", "2026-09-01T05:00:00.000-07:00"), - ).toBe(0); - expect( - compareDateTimeStrings("2026-09-01T12:00:01.000Z", "2026-09-01T12:00:00.000Z"), - ).toBeGreaterThan(0); - }); - - it.each([ - ["2024-02-29T12:00:00Z", "2024-02-29T17:30:00+05:30"], - ["0000-01-01T00:00:00.000Z", "+000000-01-01T00:00:00.000+00:00"], - ["2026-09-01T24:00:00Z", "2026-09-02T00:00:00Z"], - ])("preserves equal ISO instants %s and %s", (left, right) => { - expect(compareDateTimeStrings(left, right)).toBe(0); - }); - - it.each(["2014-03-02", "2014-02-30T00:00:00.000Z", "2026-09-01T24:01:00Z"])( - "treats %s as malformed without native date guessing", - (malformed) => { - const valid = "1970-01-01T00:00:00.000Z"; - expect(compareDateTimeStrings(malformed, valid)).toBeLessThan(0); - expect(compareDateTimeStrings(valid, malformed)).toBeGreaterThan(0); - expect(compareDateTimeStrings(malformed, "invalid")).toBeLessThan(0); - expect(compareDateTimeStrings("invalid", malformed)).toBeGreaterThan(0); - expect(compareDateTimeStrings(malformed, malformed)).toBe(0); - }, - ); - - it("gives every permutation of mixed values the same order", () => { - const early = "2026-09-01T12:00:00.000+14:00"; - const late = "2026-09-01T00:00:00.000-12:00"; - const malformed = "2026-09-01T06:invalid"; - const expected = [malformed, early, late]; - - const permutations = [ - [early, late, malformed], - [early, malformed, late], - [late, early, malformed], - [late, malformed, early], - [malformed, early, late], - [malformed, late, early], - ]; - - for (const values of permutations) { - expect(values.toSorted(compareDateTimeStrings)).toEqual(expected); - } - }); -}); diff --git a/packages/shared/src/dateTime.ts b/packages/shared/src/dateTime.ts deleted file mode 100644 index 67bdd01e465e..000000000000 --- a/packages/shared/src/dateTime.ts +++ /dev/null @@ -1,37 +0,0 @@ -import * as DateTime from "effect/DateTime"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; - -const isZonedIsoDateTime = Schema.is( - Schema.String.check( - Schema.isPattern( - /^(?:\d{4}|[+-]\d{6})-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?|24:00(?::00(?:\.0+)?)?)(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/, - ), - Schema.isTrimmed(), - ), -); - -function parseTimestamp(value: string): number { - if (!isZonedIsoDateTime(value)) return Number.NaN; - - // Engines can normalize invalid calendar dates instead of rejecting them. - const datePart = value.slice(0, value.indexOf("T")); - const date = DateTime.make(`${datePart}T00:00:00.000Z`); - if (Option.isNone(date)) return Number.NaN; - const parts = DateTime.toPartsUtc(date.value); - if (parts.month !== Number(datePart.slice(-5, -3)) || parts.day !== Number(datePart.slice(-2))) { - return Number.NaN; - } - return Date.parse(value); -} - -export function compareDateTimeStrings(left: string, right: string): number { - const leftTimestamp = parseTimestamp(left); - const rightTimestamp = parseTimestamp(right); - const leftIsValid = !Number.isNaN(leftTimestamp); - const rightIsValid = !Number.isNaN(rightTimestamp); - - if (leftIsValid !== rightIsValid) return leftIsValid ? 1 : -1; - if (leftIsValid) return leftTimestamp - rightTimestamp; - return left < right ? -1 : left > right ? 1 : 0; -} diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index 55dc1e5dc434..fdeac6c8ac91 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vite-plus/test"; -import { ProviderDriverKind, ProviderInstanceId, type ModelCapabilities } from "@t3tools/contracts"; +import { ProviderInstanceId, type ModelCapabilities } from "@t3tools/contracts"; import { applyClaudePromptEffortPrefix, @@ -10,8 +10,6 @@ import { getProviderOptionCurrentLabel, getProviderOptionDescriptors, readCustomModelEntries, - normalizeCustomModelSlug, - normalizeModelSlug, modelSelectionsEqual, } from "./model.ts"; @@ -35,15 +33,6 @@ const codexCaps: ModelCapabilities = createModelCapabilities({ ], }); -describe("model slug normalization", () => { - it("preserves exact custom slugs instead of expanding provider aliases", () => { - const claude = ProviderDriverKind.make("claudeAgent"); - - expect(normalizeModelSlug("opus", claude)).toBe("opus"); - expect(normalizeCustomModelSlug(" opus ")).toBe("opus"); - }); -}); - const claudeCaps: ModelCapabilities = createModelCapabilities({ optionDescriptors: [ { @@ -72,41 +61,6 @@ const claudeCaps: ModelCapabilities = createModelCapabilities({ }); describe("descriptor helpers", () => { - it("applies selection values to capability descriptors", () => { - expect( - getProviderOptionDescriptors({ - caps: claudeCaps, - selections: [ - { id: "effort", value: "medium" }, - { id: "contextWindow", value: "200k" }, - ], - }), - ).toEqual([ - { - id: "effort", - label: "Reasoning", - type: "select", - options: [ - { id: "medium", label: "Medium" }, - { id: "high", label: "High", isDefault: true }, - { id: "ultrathink", label: "Ultrathink" }, - ], - currentValue: "medium", - promptInjectedValues: ["ultrathink"], - }, - { - id: "contextWindow", - label: "Context Window", - type: "select", - options: [ - { id: "200k", label: "200k" }, - { id: "1m", label: "1M", isDefault: true }, - ], - currentValue: "200k", - }, - ]); - }); - it("builds dispatch options only from explicit selections", () => { const descriptors = getProviderOptionDescriptors({ caps: codexCaps, diff --git a/packages/shared/src/threadPullRequests.test.ts b/packages/shared/src/threadPullRequests.test.ts index 5c9914eb296b..3f39d76e06ed 100644 --- a/packages/shared/src/threadPullRequests.test.ts +++ b/packages/shared/src/threadPullRequests.test.ts @@ -6,7 +6,6 @@ import { import { beforeEach, describe, expect, it } from "vite-plus/test"; import { - legacyLinkedPullRequestOf, legacyThreadPullRequestKey, threadPullRequestSearchTerms, resolveThreadCurrentPullRequest, @@ -83,6 +82,26 @@ describe("threadPullRequestKeysEqual", () => { }), ).toBe(false); }); + + it("recovers Azure repository keys from stored URLs", () => { + const own = link(7, { + host: "dev.azure.com", + repository: "org-a/project/_git/web", + url: "https://dev.azure.com/org-a/project/_git/web/pullrequest/7", + }); + expect(legacyThreadPullRequestKey({ ...own, repository: "web" })).toEqual({ + host: "dev.azure.com", + repository: "org-a/project/_git/web", + number: 7, + }); + expect( + threadPullRequestKeysEqual(own, { + host: "org-a.visualstudio.com", + repository: "DefaultCollection/project/_git/web", + number: 7, + }), + ).toBe(true); + }); }); describe("resolveThreadCurrentPullRequest", () => { @@ -131,92 +150,6 @@ describe("resolveThreadCurrentPullRequest", () => { }); }); -describe("legacyLinkedPullRequestOf", () => { - const identity = { - canonicalKey: "github.com/pingdotgg/t3code", - provider: "github", - displayName: "pingdotgg/t3code", - locator: { - source: "git-remote" as const, - remoteName: "origin", - remoteUrl: "https://github.com/pingdotgg/t3code.git", - }, - }; - it("projects only links the owning project can route without a host", () => { - expect(legacyLinkedPullRequestOf([link(7)], "project-1" as never, identity)).toEqual({ - projectId: "project-1", - repository: "pingdotgg/t3code", - number: 7, - url: "https://github.com/pingdotgg/t3code/pull/7", - }); - expect(legacyLinkedPullRequestOf([], "project-1" as never, identity)).toBeNull(); - }); - it.each([ - { - host: "github.enterprise.test", - url: "https://github.enterprise.test/pingdotgg/t3code/pull/7", - }, - { repository: "acme/other", url: "https://github.com/acme/other/pull/7" }, - ])("omits an unsafe legacy route %j", (foreign) => { - expect( - legacyLinkedPullRequestOf([link(7, foreign)], "project-1" as never, identity), - ).toBeNull(); - expect( - legacyLinkedPullRequestOf([link(7, foreign), link(8)], "project-1" as never, identity) - ?.number, - ).toBe(8); - }); - it.each([ - "dev.azure.com/org-a/project/_git/web", - "ssh.dev.azure.com/v3/org-a/project/web", - "org-a.visualstudio.com/DefaultCollection/project/_git/web", - ])("projects Azure links through the legacy selector for %s", (canonicalKey) => { - const azureIdentity = { - ...identity, - provider: "azure-devops", - canonicalKey, - displayName: canonicalKey.slice(canonicalKey.indexOf("/") + 1), - name: "web", - }; - const own = link(7, { - host: "dev.azure.com", - repository: "org-a/project/_git/web", - url: "https://dev.azure.com/org-a/project/_git/web/pullrequest/7", - }); - const foreign = link(7, { - host: "dev.azure.com", - repository: "web", - url: "https://dev.azure.com/org-b/project/_git/web/pullrequest/7", - linkedAt: "2026-01-02T00:00:00.000Z", - }); - for (const repository of ["web", "org-a/project/_git/web"]) { - expect( - legacyLinkedPullRequestOf( - [{ ...own, repository }, foreign], - "project-1" as never, - azureIdentity, - ), - ).toEqual({ projectId: "project-1", repository: "web", number: 7, url: own.url }); - } - expect(legacyLinkedPullRequestOf([foreign], "project-1" as never, azureIdentity)).toBeNull(); - expect(legacyThreadPullRequestKey({ ...own, repository: "web" })).toEqual({ - host: "dev.azure.com", - repository: "org-a/project/_git/web", - number: 7, - }); - expect( - threadPullRequestKeysEqual(own, { - host: "org-a.visualstudio.com", - repository: "DefaultCollection/project/_git/web", - number: 7, - }), - ).toBe(true); - }); - it("does not guess when the project identity is unavailable", () => { - expect(legacyLinkedPullRequestOf([link(7)], "project-1" as never, null)).toBeNull(); - }); -}); - describe("resolveThreadPullRequestChains", () => { it("chains links by base → head within a repository, bottom to top", () => { const chains = resolveThreadPullRequestChains([ diff --git a/packages/shared/src/threadPullRequests.ts b/packages/shared/src/threadPullRequests.ts index cfda147bba70..796ce3d7af87 100644 --- a/packages/shared/src/threadPullRequests.ts +++ b/packages/shared/src/threadPullRequests.ts @@ -1,15 +1,12 @@ import type { OrchestrationV2AppThread, - RepositoryIdentity, - SourceControlProviderKind, ThreadLinkedPullRequest, ThreadPullRequestKey, ThreadPullRequestLink, } from "@t3tools/contracts"; -import { pullRequestHostOf } from "@t3tools/contracts"; import { parseChangeRequestUrl } from "./changeRequestUrl.ts"; -import { canonicalRepositoryKey, sourceControlRepositorySelector } from "./sourceControl.ts"; +import { canonicalRepositoryKey } from "./sourceControl.ts"; type ThreadPullRequestKeySource = ThreadPullRequestKey & { readonly authority?: string; @@ -159,53 +156,6 @@ export function resolveThreadCurrentPullRequestLink( return current.kind === "single" ? current.link : current.top; } -export function legacyLinkedPullRequestOf( - links: ReadonlyArray, - projectId: ThreadLinkedPullRequest["projectId"], - identity: RepositoryIdentity | null | undefined, -): ThreadLinkedPullRequest | null { - if (!identity) return null; - const host = pullRequestHostOf(identity, identity.provider as SourceControlProviderKind); - if (typeof host !== "string") return null; - const repository = sourceControlRepositorySelector(identity); - if (repository === null) return null; - const azureKey = - identity.provider === "azure-devops" - ? canonicalRepositoryKey(identity.canonicalKey.toLowerCase()) - : null; - const link = resolveThreadCurrentPullRequestLink( - links.filter((link) => { - if (azureKey !== null) { - const key = legacyThreadPullRequestKey(link, link.host); - return canonicalRepositoryKey(`${key.host}/${key.repository}`) === azureKey; - } - const parsed = parseChangeRequestUrl(link.url); - if (parsed?.authority !== undefined) { - try { - const remote = new URL(identity.locator.remoteUrl); - if (remote.protocol === "http:" || remote.protocol === "https:") { - return ( - parsed.authority === remote.host && parsed.repository === repository.toLowerCase() - ); - } - } catch {} - return parsed.host === host && parsed.repository === repository.toLowerCase(); - } - return ( - link.host.toLowerCase() === host.toLowerCase() && - link.repository.toLowerCase() === repository.toLowerCase() - ); - }), - ); - if (link === null) return null; - return { - projectId, - repository: azureKey === null ? link.repository : repository, - number: link.number, - url: link.url, - }; -} - export interface ThreadPullRequestChain { readonly kind: "native" | "derived"; readonly layers: ReadonlyArray; diff --git a/packages/shared/src/threadReference.test.ts b/packages/shared/src/threadReference.test.ts index f582d17efc10..cf6cb8a0fac1 100644 --- a/packages/shared/src/threadReference.test.ts +++ b/packages/shared/src/threadReference.test.ts @@ -57,32 +57,6 @@ describe("resolveThreadReferenceCopyTarget", () => { }, ); - it("copies the highest open stack layer instead of the first link", () => { - const stack: NonNullable = { - kind: "native", - id: "stack-1", - number: 1, - url: "https://github.com/other/repo/stacks/1", - base: "main", - layers: [ - { number: 42, headBranch: "first", state: "open" }, - { number: 43, headBranch: "second", state: "open" }, - ], - }; - const top = { - ...crossRepositoryPullRequest, - number: 43, - url: "https://github.com/other/repo/pull/43", - stack, - }; - expect( - resolveThreadReferenceCopyTarget({ - threadId: "thread-1", - pullRequests: [{ ...crossRepositoryPullRequest, stack }, top], - }), - ).toMatchObject({ kind: "pull-request", value: top.url }); - }); - it("uses the fallback URL when native links are dismissed", () => { expect( resolveThreadReferenceCopyTarget({ diff --git a/packages/source-control-core/src/server/pullRequestChecks.test.ts b/packages/source-control-core/src/server/pullRequestChecks.test.ts index 92d74450f245..21616059ca87 100644 --- a/packages/source-control-core/src/server/pullRequestChecks.test.ts +++ b/packages/source-control-core/src/server/pullRequestChecks.test.ts @@ -24,7 +24,6 @@ describe("dedupeChecks", () => { it.each([ ["2026-10-16T12:00:00+02:00", "2026-10-16T10:30:00Z"], ["2026-10-16T12:00:00.00009Z", "2026-10-16T12:00:00.0001Z"], - ["2024-03-01T01:00:00Z", "2024-02-29T23:30:00-02:00"], ])("keeps the newer instant between %s and %s in either listing order", (older, newer) => { const oldRun = entry("build", "success", { at: older }); const newRun = entry("build", "failure", { at: newer }); @@ -70,21 +69,20 @@ describe("dedupeChecks", () => { ).toEqual([expected, expected]); }); - it.each([ - ["2026-02-30T00:00:00Z", "2026-03-01T00:00:00Z"], - ["1900-02-29T00:00:00Z", "1900-03-01T00:00:00Z"], - ["2026-04-31T00:00:00.0001-02:00", "2026-05-01T00:00:00Z"], - ])("uses text fallback for impossible date %s", (malformed, valid) => { - const malformedRun = entry("build", "success", { at: malformed }); - const validRun = entry("build", "failure", { at: valid }); - - expect( - [ - [malformedRun, validRun], - [validRun, malformedRun], - ].map((runs) => dedupeChecks(runs)[0]?.status), - ).toEqual(["failure", "failure"]); - }); + it.each([["2026-02-30T00:00:00Z", "2026-03-01T00:00:00Z"]])( + "uses text fallback for impossible date %s", + (malformed, valid) => { + const malformedRun = entry("build", "success", { at: malformed }); + const validRun = entry("build", "failure", { at: valid }); + + expect( + [ + [malformedRun, validRun], + [validRun, malformedRun], + ].map((runs) => dedupeChecks(runs)[0]?.status), + ).toEqual(["failure", "failure"]); + }, + ); it("holds a check at the place it first appeared, so a re-run does not reshuffle the list", () => { const checks = dedupeChecks([ From 12c91aef7ac3577fd7df6ca39c8667578cd7e40c Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 23 Oct 2026 03:13:40 -0700 Subject: [PATCH 17/59] test(server): restore DST scheduling tests deleted in the test snap (#23991) --- .../ScheduledTaskService.interval.test.ts | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 apps/server/src/scheduledTasks/ScheduledTaskService.interval.test.ts diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.interval.test.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.interval.test.ts new file mode 100644 index 000000000000..4a1a58505c5c --- /dev/null +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.interval.test.ts @@ -0,0 +1,131 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import { expect, it } from "@effect/vitest"; +import { ScheduledTaskUpsertInput } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as TestClock from "effect/testing/TestClock"; +import { afterEach, vi } from "vite-plus/test"; + +import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts"; +import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; +import * as ManagedProjectFolders from "../project/ManagedProjectFolders.ts"; +import * as Scheduler from "../scheduling/Scheduler.ts"; +import * as SecretRequests from "../secrets/SecretRequests.ts"; +import { ServerActivation } from "../serverActivation.ts"; +import * as ScheduledTaskService from "./ScheduledTaskService.ts"; + +afterEach(() => vi.unstubAllEnvs()); + +const dependencies = Layer.mergeAll( + NodeCrypto.layer, + Scheduler.layer, + Layer.mock(ThreadLaunchService.ThreadLaunchService)({ + launch: () => Effect.die("synthetic dispatch failure"), + }), + Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ + namedProjectsRoot: "/projects", + isScratchProject: () => Effect.succeed(false), + }), + Layer.mock(ThreadManagementService.ThreadManagementService)({}), + Layer.mock(SecretRequests.SecretRequests)({}), +); +const serviceLayer = ScheduledTaskService.layer.pipe(Layer.provide(dependencies)); +const decodeInput = Schema.decodeUnknownEffect(ScheduledTaskUpsertInput); +const input = { + commandId: "interval-duration", + title: "Interval review", + prompt: "Review the open pull requests.", + enabled: true, + projectId: "project-interval-duration", + workspaceStrategy: { type: "root" }, + modelSelection: { instanceId: "codex", model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", +}; + +it.effect.each([ + { + zone: "America/New_York", + from: "2026-03-08T06:30:00.000Z", + everyMs: 7_200_000, + expected: "2026-03-08T08:30:00.000Z", + }, + { + zone: "America/New_York", + from: "2026-11-01T05:30:00.000Z", + everyMs: 7_200_000, + expected: "2026-11-01T07:30:00.000Z", + }, + { + zone: "America/New_York", + from: "2026-11-01T06:30:00.000Z", + everyMs: 60_000, + expected: "2026-11-01T06:31:00.000Z", + }, + { + zone: "UTC", + from: "2026-11-01T06:30:00.000Z", + everyMs: 60_000, + expected: "2026-11-01T06:31:00.000Z", + }, + { + zone: "Asia/Tokyo", + from: "2026-03-08T06:30:00.000Z", + everyMs: 7_200_000, + expected: "2026-03-08T08:30:00.000Z", + }, +] as const)("persists elapsed interval $zone from $from", ({ zone, from, everyMs, expected }) => + Effect.gen(function* () { + vi.stubEnv("TZ", zone); + yield* TestClock.setTime(Date.parse(from)); + yield* Effect.gen(function* () { + const service = yield* ScheduledTaskService.ScheduledTaskService; + const decoded = yield* decodeInput({ ...input, schedule: { type: "interval", everyMs } }); + const created = yield* service.upsert(decoded); + expect(created.task.nextRunAt).toBe(expected); + expect((yield* service.list()).tasks[0]?.nextRunAt).toBe(expected); + + const paused = yield* service.setEnabled({ id: created.task.id, enabled: false }); + expect(paused.task.nextRunAt).toBeNull(); + const resumed = yield* service.setEnabled({ id: created.task.id, enabled: true }); + expect(resumed.task.nextRunAt).toBe(expected); + + // Exercise completion rescheduling without starting any provider process. + const completed = yield* service.runNow({ id: created.task.id }); + expect(completed.task.lastRunStatus).toBe("failed"); + expect(completed.threadId).toBeNull(); + expect(completed.task.runCount).toBe(1); + expect(completed.task.nextRunAt).toBe(expected); + expect((yield* service.list()).tasks[0]?.nextRunAt).toBe(expected); + }).pipe(Effect.provide(serviceLayer)); + }).pipe( + // Keep the real scheduler parked while exercising finite service actions. + Effect.provideService(ServerActivation, Effect.never), + Effect.provide(SqlitePersistence.layerMemory), + ), +); + +it.effect.each([ + { from: "2026-03-07T23:30:00.000Z", expected: "2026-03-08T13:00:00.000Z" }, + { from: "2026-10-31T23:30:00.000Z", expected: "2026-11-01T14:00:00.000Z" }, +] as const)("keeps fixed-time schedules on the local calendar from $from", ({ from, expected }) => + Effect.gen(function* () { + vi.stubEnv("TZ", "America/New_York"); + yield* TestClock.setTime(Date.parse(from)); + yield* Effect.gen(function* () { + const service = yield* ScheduledTaskService.ScheduledTaskService; + const decoded = yield* decodeInput({ + ...input, + schedule: { type: "fixed_time", timeOfDay: "09:00" }, + }); + const created = yield* service.upsert(decoded); + expect(created.task.nextRunAt).toBe(expected); + expect((yield* service.list()).tasks[0]?.nextRunAt).toBe(expected); + }).pipe(Effect.provide(serviceLayer)); + }).pipe( + Effect.provideService(ServerActivation, Effect.never), + Effect.provide(SqlitePersistence.layerMemory), + ), +); From 30a405bbc1eb78720aaacd0049b97a44a3f2e8b0 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 23 Oct 2026 03:17:47 -0700 Subject: [PATCH 18/59] test(web): restore project settings permission tests deleted in the test snap (#24011) --- .../ProjectSettingsPanel.browser.test.tsx | 554 ++++++++++++++++++ apps/web/src/reactCompilerCoverage.test.ts | 4 +- 2 files changed, 556 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/components/settings/ProjectSettingsPanel.browser.test.tsx diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.browser.test.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.browser.test.tsx new file mode 100644 index 000000000000..5cf1930d95de --- /dev/null +++ b/apps/web/src/components/settings/ProjectSettingsPanel.browser.test.tsx @@ -0,0 +1,554 @@ +import { RegistryContext } from "@effect/atom-react"; +import type { + DeleteProjectInput, + UpdateProjectInput, +} from "@t3tools/client-runtime/state/projects"; +import { + AuthOrchestrationOperateScope, + AuthSettingsWriteScope, + DEFAULT_SERVER_SETTINGS, + EnvironmentId, + ProjectId, + ProviderInstanceId, + type AuthEnvironmentScope, + type AuthSessionState, +} from "@t3tools/contracts"; +import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; +import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + RouterProvider, +} from "@tanstack/react-router"; +import * as Cause from "effect/Cause"; +import { AsyncResult, Atom } from "effect/reactivity"; +import { describe, expect, it, vi } from "vite-plus/test"; +import { page, userEvent } from "vite-plus/test/browser"; + +import { act, render, type Screen } from "../../../test/render"; +import type { Project } from "../../types"; + +type SessionResult = AsyncResult.AsyncResult; +const state = vi.hoisted(() => ({ + sessions: new Map>(), + projects: [] as Project[], + update: + vi.fn< + (request: { + environmentId: EnvironmentId; + input: UpdateProjectInput; + }) => Promise> + >(), + delete: + vi.fn< + (request: { + environmentId: EnvironmentId; + input: DeleteProjectInput; + }) => Promise> + >(), + confirm: vi.fn<() => Promise>(), + releaseUploads: vi.fn(), + clearDraft: vi.fn(), + toast: vi.fn(), +})); + +// Session grants come from the server; each test drives them through these atoms. +vi.mock(import("@t3tools/client-runtime/state/session"), async (original) => { + const actual = await original(); + return { + ...actual, + createEnvironmentSessionAtoms: (( + ...args: Parameters + ) => ({ + ...actual.createEnvironmentSessionAtoms(...args), + sessionStateAtom: (id: EnvironmentId) => state.sessions.get(id)!, + })) as typeof actual.createEnvironmentSessionAtoms, + }; +}); +vi.mock(import("~/state/environments"), async (original) => ({ + ...(await original()), + useEnvironments: (() => ({ environments })) as never, + usePrimaryEnvironmentId: () => primaryId, +})); +vi.mock(import("~/state/entities"), async (original) => ({ + ...(await original()), + useProjects: (() => state.projects) as never, + useThreadShells: () => [], + waitForProjectRemoval: async () => true, +})); +vi.mock(import("~/state/projects"), async (original) => { + const actual = await original(); + return { + ...actual, + projectEnvironment: { + ...actual.projectEnvironment, + update: "update", + delete: "delete", + } as unknown as typeof actual.projectEnvironment, + }; +}); +vi.mock(import("~/state/use-atom-command"), async (original) => ({ + ...(await original()), + useAtomCommand: ((command: "update" | "delete") => state[command]) as never, +})); +vi.mock(import("~/hooks/useSettings"), async (original) => ({ + ...(await original()), + useClientSettings: ((selector?: (settings: typeof DEFAULT_CLIENT_SETTINGS) => unknown) => + selector ? selector(DEFAULT_CLIENT_SETTINGS) : DEFAULT_CLIENT_SETTINGS) as never, +})); +vi.mock(import("~/localApi"), async (original) => ({ + ...(await original()), + readLocalApi: (() => ({ dialogs: { confirm: state.confirm } })) as never, +})); +vi.mock(import("~/lib/composerDraftUploads"), async (original) => ({ + ...(await original()), + releaseProjectDraftUploads: state.releaseUploads, +})); +// No toaster is mounted in these tests; the guard's error toast is read at this seam. +vi.mock(import("../ui/toast"), async (original) => { + const actual = await original(); + return { ...actual, toastManager: { ...actual.toastManager, add: state.toast } }; +}); +// These sections have separate data sources and their own tests. +vi.mock(import("./ProjectDefaultsSettings"), async (original) => ({ + ...(await original()), + ProjectDefaultsSettings: (() => null) as never, +})); +vi.mock(import("./ProjectActionsSettings"), async (original) => ({ + ...(await original()), + ProjectActionsSettings: (() => null) as never, +})); + +vi.mock(import("../files/projectFilesQueryState"), async (original) => ({ + ...(await original()), + getProjectFileQueryAtom: () => missingProjectFile, +})); + +import { useComposerDraftStore } from "../../composerDraftStore"; +import { appAtomRegistry } from "../../rpc/atomRegistry"; +import { buildSidebarProjectSnapshots } from "../../sidebarProjectGrouping"; +import { ProjectSettingsPanel } from "./ProjectSettingsPanel"; +import { ProjectsSettings } from "./ProjectsSettings"; +import { SettingsScopeProvider } from "./SettingsScopeContext"; +import { validateSettingsScopeSearch, type SettingsScopeSearch } from "./settingsScope"; +import { retainSettingsScope, validateSettingsRouteSearch } from "./settingsScopeNavigation"; + +const primaryId = EnvironmentId.make("primary"); +const remoteId = EnvironmentId.make("remote"); +const instanceId = ProviderInstanceId.make("codex"); +const environments = [primaryId, remoteId].map((environmentId) => ({ + environmentId, + label: environmentId, + connection: { phase: "connected" }, + serverConfig: { + keybindings: DEFAULT_RESOLVED_KEYBINDINGS, + settings: DEFAULT_SERVER_SETTINGS, + environment: { platform: { machine: "server" } }, + }, +})); +const session = (scopes: ReadonlyArray): AuthSessionState => ({ + authenticated: true, + scopes, + auth: { + policy: "remote-reachable", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["bearer-access-token"], + sessionCookieName: "t3_session", + }, +}); +const writable = () => AsyncResult.success(session([AuthOrchestrationOperateScope])); +const missingProjectFile = Atom.make(AsyncResult.initial()); + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((complete) => { + resolve = complete; + }); + return { promise, resolve }; +} + +function setup() { + vi.clearAllMocks(); + state.sessions.clear(); + for (const id of [primaryId, remoteId]) + state.sessions.set(id, Atom.make(writable()).pipe(Atom.keepAlive)); + state.projects = [primaryId, remoteId].map((environmentId) => ({ + id: ProjectId.make(`project-${environmentId}`), + environmentId, + title: "Shared repo", + workspaceRoot: `/work/${environmentId}`, + repositoryIdentity: { + canonicalKey: "github.com/example/shared-repo", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "https://github.com/example/shared-repo.git", + }, + }, + defaultModelSelection: { instanceId, model: "gpt-5-codex" }, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + scripts: [], + })); + state.update.mockImplementation(async ({ environmentId, input }) => { + const { projectId, ...updates } = input; + const patch = Object.fromEntries( + Object.entries(updates).filter(([, value]) => value !== undefined), + ) as Partial; + state.projects = state.projects.map((project) => + project.environmentId === environmentId && project.id === projectId + ? { ...project, ...patch } + : project, + ); + return AsyncResult.success(undefined); + }); + state.delete.mockResolvedValue(AsyncResult.success(undefined)); + state.confirm.mockResolvedValue(true); + useComposerDraftStore.setState({ clearProjectDraftThreadId: state.clearDraft }); +} + +function projectKey() { + return buildSidebarProjectSnapshots({ + projects: state.projects, + settings: DEFAULT_CLIENT_SETTINGS, + primaryEnvironmentId: primaryId, + resolveEnvironmentLabel: (id) => id, + })[0]!.projectKey; +} + +async function mountPanel(search: Omit = {}) { + const key = projectKey(); + const rootRoute = createRootRoute(); + const settingsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "settings", + validateSearch: validateSettingsRouteSearch, + search: { middlewares: [retainSettingsScope] }, + }); + const projectsRoute = createRoute({ + getParentRoute: () => settingsRoute, + path: "projects", + component: () => ( + + ), + }); + const query = new URLSearchParams({ project: key }); + for (const [name, value] of Object.entries(search)) { + if (value !== undefined) query.set(name, value); + } + const router = createRouter({ + routeTree: rootRoute.addChildren([settingsRoute.addChildren([projectsRoute])]), + history: createMemoryHistory({ initialEntries: [`/settings/projects?${query}`] }), + }); + await router.load(); + await using stack = new AsyncDisposableStack(); + const screen = stack.use( + // The panel's own route tree validates settings search params, which the default router lacks. + await render( + + + , + { router: false }, + ), + ); + await vi.waitFor(() => expect(nameInput(screen)).not.toBeNull()); + stack.move(); + return { ...screen, router }; +} + +async function mountSettings(search: SettingsScopeSearch) { + const searchParams = new URLSearchParams( + Object.entries(search).flatMap(([key, value]) => (value === undefined ? [] : [[key, value]])), + ); + const route = createRootRoute({ + validateSearch: validateSettingsScopeSearch, + component: () => ( + void router.navigate({ to: "/settings/projects", search: () => next })} + > + + + ), + }); + const router = createRouter({ + routeTree: route, + history: createMemoryHistory({ + initialEntries: [`/settings/projects?${searchParams.toString()}`], + }), + }); + await router.load(); + // The settings route tree above validates scope search params, which the default router lacks. + const screen = await render( + + + , + { router: false }, + ); + return { ...screen, router }; +} + +const nameInput = (screen: Screen) => + screen.container.querySelector('[aria-label="Project name"]')!; +const removeCheckout = (screen: Screen, root: string) => + screen.container.querySelector(`[aria-label="Remove checkout ${root}"]`)!; +const removeAll = (screen: Screen) => + [...screen.container.querySelectorAll("button")].find((button) => + button.textContent?.includes("Remove all entries"), + )!; + +async function grant(id: EnvironmentId, result: SessionResult) { + await act(async () => appAtomRegistry.set(state.sessions.get(id)!, result)); +} + +async function rename(screen: Screen, title: string) { + await userEvent.fill(nameInput(screen), title); + await userEvent.keyboard("{Enter}"); +} + +// The recheck reports the denial by toast; seeing it proves the recheck ran. +async function expectProjectAccessDeniedToast(title: string) { + await vi.waitFor(() => + expect(state.toast).toHaveBeenCalledWith( + expect.objectContaining({ + type: "error", + title, + description: "This connection cannot change projects in remote.", + }), + ), + ); + expect(state.toast).toHaveBeenCalledOnce(); +} + +describe("project settings permissions", () => { + it("disables shared edits when any member lacks project access", async () => { + setup(); + await grant(remoteId, AsyncResult.success(session([AuthSettingsWriteScope]))); + await using screen = await mountPanel(); + + expect(nameInput(screen).disabled).toBe(true); + expect(removeAll(screen).disabled).toBe(true); + expect(screen.container.textContent).toContain( + "Shared settings require permission to change every checkout in this group.", + ); + expect(state.update).not.toHaveBeenCalled(); + }); + + it("renames every member when all have project access without settings access", async () => { + setup(); + await using screen = await mountPanel(); + await rename(screen, "Renamed"); + expect(state.update.mock.calls.map(([request]) => request.environmentId)).toEqual([ + primaryId, + remoteId, + ]); + expect(nameInput(screen).value).toBe("Renamed"); + }); + + it("stops before the next member if its grant is revoked during an earlier update", async () => { + setup(); + const firstUpdate = deferred>(); + state.update.mockReturnValueOnce(firstUpdate.promise); + await using screen = await mountPanel(); + await rename(screen, "Renamed"); + expect(state.update).toHaveBeenCalledOnce(); + + await grant(remoteId, AsyncResult.success(session([]))); + await act(async () => { + firstUpdate.resolve(AsyncResult.success(undefined)); + await firstUpdate.promise; + }); + await expectProjectAccessDeniedToast("Failed to rename project"); + expect(state.update).toHaveBeenCalledOnce(); + expect(state.update.mock.calls[0]![0].environmentId).toBe(primaryId); + }); + + it("disables removal for a checkout whose project grant is missing", async () => { + setup(); + await grant(remoteId, AsyncResult.success(session([]))); + await using screen = await mountPanel(); + expect(removeCheckout(screen, "/work/remote").disabled).toBe(true); + expect(removeCheckout(screen, "/work/primary").disabled).toBe(false); + }); + + it("removes a writable checkout while removal of the mixed group stays denied", async () => { + setup(); + await grant(remoteId, AsyncResult.success(session([]))); + await using screen = await mountPanel(); + const initialHref = screen.router.state.location.href; + expect(removeAll(screen).disabled).toBe(true); + + await userEvent.click(removeCheckout(screen, "/work/primary")); + await vi.waitFor(() => expect(state.delete).toHaveBeenCalledOnce()); + expect(state.delete).toHaveBeenCalledWith({ + environmentId: primaryId, + input: { projectId: state.projects[0]!.id, force: true }, + }); + await vi.waitFor(() => expect(state.releaseUploads).toHaveBeenCalledOnce()); + expect(state.clearDraft).toHaveBeenCalledOnce(); + expect(screen.router.state.location.href).toBe(initialHref); + }); + + it("rechecks every removal target after confirmation before deleting or clearing drafts", async () => { + setup(); + const confirmed = deferred(); + state.confirm.mockReturnValueOnce(confirmed.promise); + await using screen = await mountPanel(); + await userEvent.click(removeAll(screen)); + expect(state.confirm).toHaveBeenCalledOnce(); + + await grant(remoteId, AsyncResult.success(session([]))); + await act(async () => { + confirmed.resolve(true); + await confirmed.promise; + }); + await expectProjectAccessDeniedToast("Failed to remove project"); + expect(state.delete).not.toHaveBeenCalled(); + expect(state.releaseUploads).not.toHaveBeenCalled(); + expect(state.clearDraft).not.toHaveBeenCalled(); + }); + + it("does not save an edit whose grant was revoked mid-edit, and recovers when restored", async () => { + setup(); + await using screen = await mountPanel(); + await userEvent.fill(nameInput(screen), "Renamed"); + await grant(remoteId, AsyncResult.success(session([]))); + // Chromium takes focus off a newly disabled input without firing blur. Send the + // focusout so the save attempt runs and hits the same recheck as a real blur. + await act(async () => { + nameInput(screen).dispatchEvent(new FocusEvent("focusout", { bubbles: true })); + }); + await expectProjectAccessDeniedToast("Failed to rename project"); + expect(state.update).not.toHaveBeenCalled(); + expect(nameInput(screen).disabled).toBe(true); + + await grant(remoteId, writable()); + expect(nameInput(screen).disabled).toBe(false); + await rename(screen, "Recovered"); + await vi.waitFor(() => expect(state.update).toHaveBeenCalledTimes(2)); + expect(nameInput(screen).value).toBe("Recovered"); + }); + + it.each([ + ["loading", () => AsyncResult.initial(), false], + [ + "failed", + () => AsyncResult.failure(Cause.fail(new Error("Disconnected"))), + false, + ], + ["refreshing", () => AsyncResult.waiting(writable()), true], + ] as const)( + "uses the current grant while a member's session is %s", + async (_state, result, allowed) => { + setup(); + await grant(remoteId, result()); + await using screen = await mountPanel(); + expect(nameInput(screen).disabled).toBe(!allowed); + if (!allowed) return; + await rename(screen, "Renamed"); + expect(state.update).toHaveBeenCalledTimes(2); + }, + ); +}); + +describe("project settings scope", () => { + const environmentMenu = () => page.getByRole("button", { name: /^Environment scope:/ }); + const primaryOption = () => page.getByRole("menuitemradio", { name: /^primary/ }); + const remoteOption = () => page.getByRole("menuitemradio", { name: /^remote/ }); + + it("offers every environment while no project is selected", async () => { + setup(); + state.projects = state.projects.filter((project) => project.environmentId === primaryId); + await using _screen = await mountSettings({}); + + await userEvent.click(environmentMenu()); + await expect.element(primaryOption()).toBeVisible(); + await expect.element(remoteOption()).toBeVisible(); + }); + + it("offers only the selected project's checkout environments", async () => { + setup(); + state.projects = state.projects.filter((project) => project.environmentId === primaryId); + await using _screen = await mountSettings({ project: projectKey() }); + + await expect.element(page.getByRole("textbox", { name: "Project name" })).toBeVisible(); + await userEvent.click(environmentMenu()); + await expect.element(primaryOption()).toBeVisible(); + await expect.element(remoteOption()).not.toBeInTheDocument(); + }); + + it("keeps an unknown project recoverable from any environment", async () => { + setup(); + state.projects = state.projects.filter((project) => project.environmentId === primaryId); + await using screen = await mountSettings({ project: "removed-project" }); + + await userEvent.click(environmentMenu()); + await expect.element(primaryOption()).toBeVisible(); + await expect.element(remoteOption()).toBeVisible(); + await userEvent.click(remoteOption()); + await vi.waitFor(() => + expect(screen.router.state.location.search).toEqual({ + project: "removed-project", + machine: remoteId, + }), + ); + }); + + it.each([ + [undefined, "This project has no checkout on this environment."], + [ + "removed-checkout", + "This checkout is no longer available in the selected project and environment.", + ], + ])("recovers a stale environment link with checkout %s", async (checkout, message) => { + setup(); + state.projects = state.projects.filter((project) => project.environmentId === primaryId); + const key = projectKey(); + await using screen = await mountSettings({ + project: key, + machine: remoteId, + ...(checkout ? { checkout } : {}), + }); + + await expect.element(page.getByText(message, { exact: true })).toBeVisible(); + await expect.element(page.getByRole("button", { name: /^Project scope:/ })).toBeVisible(); + await expect + .element(page.getByRole("button", { name: "Environment scope: remote" })) + .toBeVisible(); + await userEvent.click(page.getByRole("button", { name: "Show all environments" })); + + await expect + .element(page.getByRole("textbox", { name: "Project name" })) + .toHaveValue("Shared repo"); + expect(screen.router.state.location.search).toEqual({ project: key }); + await expect + .element(page.getByRole("button", { name: "Environment scope: All environments" })) + .toBeVisible(); + await expect.element(page.getByText(message, { exact: true })).not.toBeInTheDocument(); + await expect + .element(page.getByRole("button", { name: "Remove project", exact: true })) + .toBeEnabled(); + }); +}); + +it("returns to the project overview after removal, clearing the selection but keeping the environment", async () => { + setup(); + state.projects = [state.projects[1]!]; + await using screen = await mountPanel({ machine: remoteId, checkout: "remote:/work/remote" }); + const removeProject = [...screen.container.querySelectorAll("button")].find( + (button) => button.textContent === "Remove project", + )!; + + await userEvent.click(removeProject); + + await vi.waitFor(() => { + expect(screen.router.state.location.pathname).toBe("/settings/projects"); + expect(screen.router.state.location.search).toEqual({ machine: remoteId }); + }); +}); diff --git a/apps/web/src/reactCompilerCoverage.test.ts b/apps/web/src/reactCompilerCoverage.test.ts index f0e479d27102..b0e4d9dfb9a5 100644 --- a/apps/web/src/reactCompilerCoverage.test.ts +++ b/apps/web/src/reactCompilerCoverage.test.ts @@ -18,8 +18,8 @@ const webSourceFiles = NodeFS.readdirSync(NodePath.join(repoRoot, "apps/web/src" .filter((file) => !file.endsWith(".gen.ts")) .map((file) => `apps/web/src/${file}`); -// ChatView is ~9k lines. Compiling it takes ~9s on a fast machine and passes the -// 15s project timeout on loaded CI runners. +// ChatView is ~9k lines. Compiling it takes ~9s on a fast machine and can pass the +// 15s project limit on loaded CI runners, so slow files get a 60s limit below. const slowFiles = new Set(["apps/web/src/components/ChatView.tsx"]); const compile = async (file: string) => { From 0cb033c5e991b19eb081069c1696d765aa998210 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 23 Oct 2026 03:20:06 -0700 Subject: [PATCH 19/59] test(mobile): restore clipboard secret-redaction test deleted in the test snap (#23980) --- .../mobile/src/lib/copyTextWithHaptic.test.ts | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 apps/mobile/src/lib/copyTextWithHaptic.test.ts diff --git a/apps/mobile/src/lib/copyTextWithHaptic.test.ts b/apps/mobile/src/lib/copyTextWithHaptic.test.ts new file mode 100644 index 000000000000..9a6b1a8910de --- /dev/null +++ b/apps/mobile/src/lib/copyTextWithHaptic.test.ts @@ -0,0 +1,92 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const mocks = vi.hoisted(() => ({ + impactAsync: vi.fn(), + selectionAsync: vi.fn(), + setStringAsync: vi.fn(), +})); + +vi.mock("expo-clipboard", () => ({ + setStringAsync: mocks.setStringAsync, +})); + +vi.mock("expo-haptics", () => ({ + ImpactFeedbackStyle: { + Light: "light", + }, + impactAsync: mocks.impactAsync, + selectionAsync: mocks.selectionAsync, +})); + +import { copyTextWithHaptic, tryCopyTextWithHaptic } from "./copyTextWithHaptic"; + +describe("copyTextWithHaptic", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.setStringAsync.mockReturnValue(new Promise(() => undefined)); + mocks.impactAsync.mockResolvedValue(undefined); + mocks.selectionAsync.mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("triggers haptic feedback without waiting for the clipboard promise", () => { + copyTextWithHaptic("trace-123"); + + expect(mocks.setStringAsync).toHaveBeenCalledWith("trace-123"); + expect(mocks.impactAsync).toHaveBeenCalledWith("light"); + }); + + it("preserves selection feedback for thread work rows", () => { + copyTextWithHaptic("work output", { + target: "thread-work-row", + feedback: "selection", + }); + + expect(mocks.setStringAsync).toHaveBeenCalledWith("work output"); + expect(mocks.selectionAsync).toHaveBeenCalledOnce(); + expect(mocks.impactAsync).not.toHaveBeenCalled(); + }); + + it("reports whether the clipboard write succeeded", async () => { + mocks.setStringAsync.mockResolvedValueOnce(undefined); + + await expect(tryCopyTextWithHaptic("thread-123")).resolves.toBe(true); + }); + + it("returns false when the clipboard write fails", async () => { + vi.spyOn(console, "error").mockImplementation(() => undefined); + mocks.setStringAsync.mockRejectedValueOnce(new Error("native clipboard failure")); + + await expect(tryCopyTextWithHaptic("thread-123")).resolves.toBe(false); + }); + + it("reports structured failures without including clipboard contents", async () => { + const content = "https://accounts.google.com/auth?state=private-state&code=private-code"; + const clipboardCause = new Error(`Cannot copy ${content}`); + const hapticCause = new Error(`Native failure for ${content}`); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + mocks.setStringAsync.mockRejectedValueOnce(clipboardCause); + mocks.impactAsync.mockRejectedValueOnce(hapticCause); + + await tryCopyTextWithHaptic(content, { target: "provider-sign-in-link" }); + + expect(consoleError).toHaveBeenCalledWith( + "Failed to copy provider-sign-in-link to the clipboard.", + expect.objectContaining({ + _tag: "CopyTextClipboardWriteError", + target: "provider-sign-in-link", + }), + ); + expect(consoleError).toHaveBeenCalledWith( + "Failed to trigger light-impact haptic feedback after copying provider-sign-in-link.", + expect.objectContaining({ _tag: "CopyTextHapticFeedbackError", feedback: "light-impact" }), + ); + const diagnostics = JSON.stringify(consoleError.mock.calls); + expect(diagnostics).not.toContain("private-state"); + expect(diagnostics).not.toContain("private-code"); + expect(diagnostics).not.toContain("cause"); + }); +}); From 7d6b5bec87a4df0d2a2e6bb60e8a1efefda0ba86 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 23 Oct 2026 03:21:30 -0700 Subject: [PATCH 20/59] fix(web): Cmd+B toggles the sidebar unless text is selected (#24063) Co-authored-by: Claude Opus 5.5 --- apps/web/src/components/AppSidebarLayout.tsx | 6 +++--- ...rPromptEditorTiptap.input.browser.test.tsx | 20 +++++++++++++++++++ .../components/ComposerPromptEditorTiptap.tsx | 2 ++ apps/web/src/composer-rich-text-doc.ts | 15 ++++++++++++++ apps/web/src/keybindings.test.ts | 18 ++++++++++++++--- apps/web/src/keybindings.ts | 14 +++++++++---- 6 files changed, 65 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 03f1903ae4b9..bc3474e514f8 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -149,12 +149,12 @@ function SidebarControl() { return; } if ( - isRichTextBoldShortcut(event) && + isRichTextBoldShortcut(event, window.getSelection()) && event.target instanceof HTMLElement && event.target.closest('[data-composer-rich-text="true"]') ) { - // The rich-text composer claims Mod+B for bold; the toggle stays - // available everywhere else, including the plain-text composer. + // The rich-text composer claims Mod+B only to bold selected text; + // the toggle stays available everywhere else. return; } if ( diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx index 751e7e36b8f0..0ee6b9a0c7cc 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx @@ -16,6 +16,7 @@ import { userEvent } from "vite-plus/test/browser"; import { act, render } from "../../test/render"; import { serializeEditorDoc } from "~/composer-rich-text-doc"; +import { isMacPlatform } from "~/lib/utils"; import { ComposerPromptEditor, type ComposerPromptEditorHandle, @@ -148,6 +149,25 @@ it.each([ expect(textWithMark(editor, "code")).toEqual(code); }); +it("bolds with Mod+B only when text is selected", async () => { + await using screen = await mountEditor("ship it"); + const { editor } = screen; + const modB = isMacPlatform(navigator.platform) ? "{Meta>}b{/Meta}" : "{Control>}b{/Control}"; + await act(async () => { + editor.commands.focus("end"); + }); + await vi.waitFor(() => expect(document.activeElement).toBe(editor.view.dom)); + await userEvent.keyboard(modB); + // Without the guard, Tiptap stores bold for the next typed text. + expect(editor.state.storedMarks).toBeNull(); + + await act(async () => { + editor.commands.setTextSelection({ from: 1, to: 5 }); + }); + await userEvent.keyboard(modB); + await vi.waitFor(() => expect(textWithMark(editor, "bold")).toEqual(["ship"])); +}); + it.each([false, true])( "composes an umlaut without wrapping the preedit text (rich text: %s)", async (richTextEnabled) => { diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 824d061496b9..0f5d7e533fc6 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -68,6 +68,7 @@ import { ComposerListExtensions, buildTiptapContent, caretTakesMarksBefore, + ComposerBoldSelectionOnlyExtension, convertBulletItemToTask, ComposerCodeExtension, ComposerItalicExtension, @@ -874,6 +875,7 @@ function createComposerExtensions(richText: boolean) { ComposerMarkersExtension, ...(richText ? [ + ComposerBoldSelectionOnlyExtension, ComposerItalicExtension, ComposerCodeExtension, ComposerCodeBlockExtension.extend({ diff --git a/apps/web/src/composer-rich-text-doc.ts b/apps/web/src/composer-rich-text-doc.ts index e9403e6527df..bada1b7131c3 100644 --- a/apps/web/src/composer-rich-text-doc.ts +++ b/apps/web/src/composer-rich-text-doc.ts @@ -15,6 +15,7 @@ import { Italic, starInputRegex, starPasteRegex } from "@tiptap/extension-italic import { type Command, type Editor, + Extension, markInputRule, markPasteRule, mergeAttributes, @@ -153,6 +154,20 @@ export const ComposerItalicExtension = Italic.extend({ }, }); +/** + * Mod+B bolds selected text only. Without a selection Tiptap would toggle bold + * for the next typed text, which users hit by accident when they mean the + * sidebar toggle. Runs ahead of the Bold extension and swallows the chord. + */ +export const ComposerBoldSelectionOnlyExtension = Extension.create({ + name: "composerBoldSelectionOnly", + priority: 1000, + addKeyboardShortcuts() { + const ignoreWithoutSelection = () => this.editor.state.selection.empty; + return { "Mod-b": ignoreWithoutSelection, "Mod-B": ignoreWithoutSelection }; + }, +}); + /** * Tiptap's block extensions each bind a chord that turns the current block into * their node (Mod-Shift-8 for a list, Mod-Alt-c for a fence, and so on). The diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index 2bbd8c5d5d11..a0ba7fa7ee04 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -713,9 +713,11 @@ describe("resolveShortcutCommand", () => { }); describe("isRichTextBoldShortcut", () => { + const selected = { isCollapsed: false }; + it("matches the B key on non-Latin layouts, like the sidebar toggle does", () => { const cyrillicB = event({ key: "и", code: "KeyB", ctrlKey: true }); - assert.isTrue(isRichTextBoldShortcut(cyrillicB)); + assert.isTrue(isRichTextBoldShortcut(cyrillicB, selected)); assert.strictEqual( resolveShortcutCommand(cyrillicB, DEFAULT_BINDINGS, { platform: "Win32" }), "sidebar.toggle", @@ -723,8 +725,18 @@ describe("isRichTextBoldShortcut", () => { }); it("follows the letter a Latin layout types, not the physical key", () => { - assert.isFalse(isRichTextBoldShortcut(event({ key: "x", code: "KeyB", ctrlKey: true }))); - assert.isTrue(isRichTextBoldShortcut(event({ key: "b", code: "KeyN", ctrlKey: true }))); + assert.isFalse( + isRichTextBoldShortcut(event({ key: "x", code: "KeyB", ctrlKey: true }), selected), + ); + assert.isTrue( + isRichTextBoldShortcut(event({ key: "b", code: "KeyN", ctrlKey: true }), selected), + ); + }); + + it("leaves the chord to the sidebar toggle when no text is selected", () => { + const modB = event({ key: "b", metaKey: true }); + assert.isFalse(isRichTextBoldShortcut(modB, { isCollapsed: true })); + assert.isFalse(isRichTextBoldShortcut(modB, null)); }); }); diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index 3fe5486aed10..51d69287e15f 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -371,15 +371,21 @@ export function isOpenFavoriteEditorShortcut( } /** - * Whether the keypress is the rich-text bold chord (Mod+B without extra - * modifiers). Tiptap binds the same chord, so app shortcuts captured ahead - * of the editor must yield when the rich-text composer is focused. + * Whether the keypress should bold text in the rich-text composer: Mod+B + * without extra modifiers, with text selected. Tiptap binds the same chord, so + * app shortcuts captured ahead of the editor yield only in that case. With no + * selection the chord stays with the app (the sidebar toggle by default). */ -export function isRichTextBoldShortcut(event: ShortcutEventLike): boolean { +export function isRichTextBoldShortcut( + event: ShortcutEventLike, + selection: Pick | null, +): boolean { if (event.type !== undefined && event.type !== "keydown") { return false; } return ( + selection !== null && + !selection.isCollapsed && resolveEventKeys(event).has("b") && (event.metaKey || event.ctrlKey) && !event.altKey && From 1aa71796709b6dd2c1f1f6ee111b819b69ec9d35 Mon Sep 17 00:00:00 2001 From: Joseph Vidal <135430578+josephv123@users.noreply.github.com> Date: Fri, 23 Oct 2026 06:22:09 -0400 Subject: [PATCH 21/59] fix(server): agent-browser and Chrome no longer outlive the agent that started them (#21299) Co-authored-by: Julius Marminge Co-authored-by: Theo Browne Co-authored-by: Claude Opus 5.5 --- .../Adapters/ClaudeAdapterV2.ts | 18 ++- .../ClaudeAgentSdkQueryRunner.test.ts | 21 ++- apps/server/src/process/agentScope.test.ts | 149 +++++++++++++++++- apps/server/src/process/agentScope.ts | 39 ++++- .../provider/acp/AcpJsonRpcConnection.test.ts | 32 +++- .../src/server/AcpSessionRuntime.ts | 37 +++-- .../provider-muse/src/server/adapter.test.ts | 139 +++++++++++++++- packages/provider-muse/src/server/adapter.ts | 31 +++- packages/provider-muse/src/server/sdk.test.ts | 81 +++++++++- packages/provider-muse/src/server/sdk.ts | 2 + .../provider-pi/src/server/adapter.test.ts | 55 +++++++ packages/provider-pi/src/server/adapter.ts | 12 ++ packages/shared/src/AgentScope.ts | 8 + patches/@muse-code__sdk@1.4.6.patch | 46 ++++++ pnpm-lock.yaml | 7 +- pnpm-workspace.yaml | 2 + 16 files changed, 642 insertions(+), 37 deletions(-) create mode 100644 patches/@muse-code__sdk@1.4.6.patch diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 91c08f565a29..0e0868918eaa 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -323,14 +323,18 @@ const CLAUDE_GROUP_WATCH_SCRIPT = [ * When the CLI exits it stops the MCP servers it started, but not their children: a plugin's * `bun run start` wrapper dies and its `bun server.ts` keeps running, writing to a pipe nobody * reads (#3089). Those stay in the CLI's group, so the rest of the group gets SIGKILL once the - * CLI exits for any reason. + * CLI exits for any reason. `onExit` then stops the CLI's agent scope, for daemons a tool + * detached from the group with setsid. * * That cleanup needs this server alive, and the CLI finishes its turn after its pipes close. A * server that crashes or is killed would leave the CLI working next to the session it resumes * (#23370), so a watcher in the group reads a pipe only this server holds and stops the group at * its end-of-file. */ -export function spawnClaudeProcessGroup(options: ClaudeSpawnOptions): ClaudeSpawnedProcess { +export function spawnClaudeProcessGroup( + options: ClaudeSpawnOptions, + onExit?: () => void, +): ClaudeSpawnedProcess { const child = NodeChildProcess.spawn( "/bin/sh", ["-c", CLAUDE_GROUP_WATCH_SCRIPT, "t3-claude", options.command, ...options.args], @@ -346,6 +350,7 @@ export function spawnClaudeProcessGroup(options: ClaudeSpawnOptions): ClaudeSpaw try { signalProcessGroup(child.pid ?? 0, "SIGKILL"); } catch {} + onExit?.(); }); // Node's typings make stdin and stdout nullable once stdio has a fourth entry; both are pipes. return child as ClaudeSpawnedProcess; @@ -892,6 +897,7 @@ export const layerQueryRunner: Layer.Layer< const { native: nativeEventLogger } = yield* ProviderEventLoggers.ProviderEventLoggers; const agentScope = yield* AgentScope; const platform = yield* HostProcess.Platform; + const runFork = Effect.runForkWith(yield* Effect.context()); return ClaudeAgentSdkQueryRunner.of({ allocateSessionId: crypto @@ -942,10 +948,16 @@ export const layerQueryRunner: Layer.Layer< pathToClaudeCodeExecutable: launch.command, executableArgs: [...launch.args, ...(queryOptions.executableArgs ?? [])], }; + const unit = launch?.unit; + const stopScope = + unit === undefined ? undefined : () => void runFork(agentScope.stop(unit)); // Windows has no process groups, so there the SDK spawns the CLI itself. const spawnClaudeCodeProcess = options.spawnClaudeCodeProcess ?? - (platform === "win32" ? undefined : spawnClaudeProcessGroup); + (platform === "win32" + ? undefined + : (spawnOptions: ClaudeSpawnOptions) => + spawnClaudeProcessGroup(spawnOptions, stopScope)); const queryRuntime = yield* Effect.uninterruptibleMask((restore) => Effect.try({ try: () => diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts index 248e78a1796e..9a4a7ba3cc7a 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts @@ -8,12 +8,14 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import { ProviderSessionId, ThreadId } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as Deferred from "effect/Deferred"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import { vi } from "vite-plus/test"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import { AgentScope } from "@t3tools/shared/AgentScope"; import { ProviderEventLoggers } from "@t3tools/provider-core/server/ProviderEventLoggers"; import { signalProcessGroup } from "@t3tools/provider-core/server/processGroup"; import { @@ -162,11 +164,26 @@ describe.skipIf(HostProcess.Platform.defaultValue() === "win32")("Claude CLI pro const leftoverStopped = new Promise((resolve) => alive.once("close", () => resolve())); alive.resume(); - const session = yield* openFakeCli(cli); + const stopped = yield* Deferred.make(); + const session = yield* openFakeCli(cli).pipe( + Effect.provide( + realQueryRunner.pipe( + Layer.provide( + Layer.succeed(AgentScope, { + ...AgentScope.defaultValue(), + wrap: ({ command, args }) => + Effect.succeed({ command, args, unit: "t3code-claude-test.scope" }), + stop: (unit) => Deferred.succeed(stopped, unit).pipe(Effect.asVoid), + }), + ), + ), + ), + ); yield* Effect.promise(() => leftoverStarted); yield* session.close; yield* Effect.promise(() => leftoverStopped); - }).pipe(Effect.provide(realQueryRunner)), + assert.strictEqual(yield* Deferred.await(stopped), "t3code-claude-test.scope"); + }), ); // The CLI keeps running its turn after its pipes close, so a server that diff --git a/apps/server/src/process/agentScope.test.ts b/apps/server/src/process/agentScope.test.ts index 30be9346dc1d..44e9d6dbf4c2 100644 --- a/apps/server/src/process/agentScope.test.ts +++ b/apps/server/src/process/agentScope.test.ts @@ -8,7 +8,9 @@ import { assert, describe, expect, it } from "@effect/vitest"; import { AgentScope } from "@t3tools/shared/AgentScope"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; import * as ServerConfig from "../config.ts"; @@ -35,21 +37,33 @@ describe("classifyScope", () => { }); }); +interface FakeUnit { + activeState: string; + result: string; + controlGroup?: string; +} + const withFakeSystemd = ( body: (input: { readonly scope: Effect.Success; - readonly units: Map; + readonly units: Map; readonly calls: ReadonlyArray>; readonly stateDir: string; + readonly cgroupRoot: string; }) => Effect.Effect, - { oomPolicy = true }: { readonly oomPolicy?: boolean } = {}, + // A user manager older than systemd 253 rejects OOMPolicy on scopes. + { + oomPolicy = true, + stopFails = false, + }: { readonly oomPolicy?: boolean; readonly stopFails?: boolean } = {}, ) => Effect.gen(function* () { const bin = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-agent-scope-")); for (const name of ["systemd-run", "systemctl", "agent"]) { NodeFS.writeFileSync(NodePath.join(bin, name), "", { mode: 0o755 }); } - const units = new Map(); + const cgroupRoot = NodePath.join(bin, "cgroup"); + const units = new Map(); const calls: Array> = []; const output = (stdout: string, code = 0): ProcessRunner.ProcessRunOutput => ({ stdout, @@ -69,23 +83,37 @@ const withFakeSystemd = ( return output("", 1); } const [, verb, unit = ""] = args; + if (stopFails && verb === "stop") { + return { + ...output(""), + stderr: `Failed to stop ${args.at(-1)}: Unit not loaded.`, + code: ChildProcessSpawner.ExitCode(5), + }; + } if (verb === "reset-failed") units.delete(unit); if (verb !== "show") return output(""); const state = units.get(unit); return output( state === undefined ? "LoadState=not-found\nActiveState=inactive\nResult=success\n" - : `LoadState=loaded\nActiveState=${state.activeState}\nResult=${state.result}\n`, + : `LoadState=loaded\nActiveState=${state.activeState}\nResult=${state.result}\n` + + `ControlGroup=${state.controlGroup ?? ""}\n`, ); }), }); + const fileSystem = yield* FileSystem.FileSystem; const scope = yield* make.pipe( Effect.provideService(ProcessRunner.ProcessRunner, runner), Effect.provideService(HostProcess.Platform, "linux"), Effect.provideService(HostProcess.Environment, { PATH: bin, XDG_RUNTIME_DIR: "/run/user/1" }), + Effect.provideService(FileSystem.FileSystem, { + ...fileSystem, + readFileString: (path, encoding) => + fileSystem.readFileString(path.replace("/sys/fs/cgroup", cgroupRoot), encoding), + }), ); const { stateDir } = yield* ServerConfig.ServerConfig; - return yield* body({ scope, units, calls, stateDir }).pipe( + return yield* body({ scope, units, calls, stateDir, cgroupRoot }).pipe( Effect.ensuring(Effect.sync(() => NodeFS.rmSync(bin, { recursive: true, force: true }))), ); }).pipe( @@ -103,6 +131,31 @@ const unitOf = (launch: { readonly args: ReadonlyArray }) => const sliceOf = (launch: { readonly args: ReadonlyArray }) => launch.args.find((arg) => arg.startsWith("--slice="))?.slice("--slice=".length) ?? ""; +/** An active scope as systemd and the kernel report it, with the agent itself gone. */ +const exitedAgent = ( + units: Map, + cgroupRoot: string, + unit: string, + { populated, oomKills }: { readonly populated: boolean; readonly oomKills: number | undefined }, +) => { + const controlGroup = `/${unit}`; + NodeFS.mkdirSync(NodePath.join(cgroupRoot, controlGroup), { recursive: true }); + NodeFS.writeFileSync( + NodePath.join(cgroupRoot, controlGroup, "cgroup.events"), + `populated ${populated ? 1 : 0}\nfrozen 0\n`, + ); + // An undefined count leaves memory.events unreadable. + if (oomKills !== undefined) { + NodeFS.writeFileSync( + NodePath.join(cgroupRoot, controlGroup, "memory.events"), + `low 0\nhigh 0\nmax 0\noom ${oomKills}\noom_kill ${oomKills}\noom_group_kill 0\n`, + ); + } + units.set(unit, { activeState: "active", result: "success", controlGroup }); +}; + +const stopsOf = (calls: ReadonlyArray>) => + calls.filter(([, verb]) => verb === "stop"); const launchCodex = (scope: Effect.Success, threadId: string) => makeCodexAppServerSpawnCommand({ command: "agent", args: ["app-server"], threadId }).pipe( Effect.provideService(AgentScope, scope), @@ -194,4 +247,90 @@ describe("AgentScope service", () => { }), ), ); + + it.effect("stops leftover processes only in an active agent scope", () => + withFakeSystemd(({ scope, units, calls, cgroupRoot }) => + Effect.gen(function* () { + const agent = yield* scope.wrap({ command: "agent", args: [], name: "t", threadId: "a" }); + const unit = unitOf(agent); + expect(agent.unit).toBe(unit); + + yield* scope.stop("session-2.scope"); + expect(calls.some((args) => args.includes("session-2.scope"))).toBe(false); + yield* scope.stop(unit); + exitedAgent(units, cgroupRoot, unit, { populated: false, oomKills: 0 }); + yield* scope.stop(unit); + exitedAgent(units, cgroupRoot, unit, { populated: true, oomKills: 0 }); + units.get(unit)!.activeState = "deactivating"; + yield* scope.stop(unit); + expect(stopsOf(calls)).toEqual([]); + + units.get(unit)!.activeState = "active"; + yield* scope.stop(unit); + expect(stopsOf(calls)).toEqual([["--user", "stop", "--no-block", unit]]); + expect(yield* scope.oomKilled("a")).toBe(false); + }), + ), + ); + + it.effect.each([true, false])( + "cleans up an OOM exit without losing its diagnosis (OOMPolicy=%s)", + (oomPolicy) => + withFakeSystemd( + ({ scope, units, calls, cgroupRoot }) => + Effect.gen(function* () { + const agent = yield* scope.wrap({ + command: "agent", + args: [], + name: "t", + threadId: "a", + }); + const unit = unitOf(agent); + exitedAgent(units, cgroupRoot, unit, { populated: true, oomKills: undefined }); + yield* scope.stop(unit); + expect(stopsOf(calls)).toEqual( + oomPolicy ? [] : [["--user", "stop", "--no-block", unit]], + ); + + // The kernel records the kill before systemd records the OOM result. + exitedAgent(units, cgroupRoot, unit, { populated: true, oomKills: 1 }); + yield* scope.stop(unit); + expect(stopsOf(calls)).toHaveLength(oomPolicy ? 0 : 2); + + if (oomPolicy) { + units.set(unit, { activeState: "failed", result: "oom-kill" }); + expect(yield* scope.oomKilled("a")).toBe(true); + units.delete(unit); + expect(yield* scope.oomKilled("a")).toBe(true); + } + }), + { oomPolicy }, + ), + ); + + it.effect("reports failed daemon cleanup without failing session teardown", () => { + const warnings: Array = []; + const logger = Logger.make(({ message }) => { + warnings.push(message); + }); + return withFakeSystemd( + ({ scope, units, cgroupRoot }) => + Effect.gen(function* () { + const agent = yield* scope.wrap({ command: "agent", args: [], name: "t", threadId: "a" }); + exitedAgent(units, cgroupRoot, unitOf(agent), { populated: true, oomKills: 0 }); + yield* scope.stop(unitOf(agent)); + expect(warnings.flat()).toContain("Could not stop an exited agent's scope"); + }).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false }))), + { stopFails: true }, + ); + }); + + it.effect("leaves a quiet slice alone", () => + withFakeSystemd(({ scope, calls }) => + Effect.gen(function* () { + yield* scope.wrap({ command: "agent", args: [], name: "t", threadId: "a" }); + expect(calls.filter(([, verb]) => verb === "stop")).toEqual([]); + }), + ), + ); }); diff --git a/apps/server/src/process/agentScope.ts b/apps/server/src/process/agentScope.ts index 74858468d1b8..d923a1bc7e9e 100644 --- a/apps/server/src/process/agentScope.ts +++ b/apps/server/src/process/agentScope.ts @@ -44,7 +44,7 @@ export interface ScopeState { readonly loadState: string; readonly activeState: string; readonly result: string; - readonly oomKills: number; + readonly oomKills: number | undefined; readonly populated: boolean | undefined; } @@ -62,7 +62,11 @@ export function classifyScope(state: ScopeState): "oom-killed" | "gone" | "runni ) { return "gone"; } - if (state.activeState === "deactivating" || state.oomKills > 0 || state.populated === false) { + if ( + state.activeState === "deactivating" || + (state.oomKills ?? 0) > 0 || + state.populated === false + ) { return "stopping"; } return "running"; @@ -220,7 +224,7 @@ export const make = Effect.gen(function* () { loadState: values.get("LoadState") ?? "", activeState: values.get("ActiveState") ?? "", result: values.get("Result") ?? "", - oomKills: parseCgroupCounter(memoryEvents, "oom_kill") ?? 0, + oomKills: parseCgroupCounter(memoryEvents, "oom_kill"), populated: populated === undefined ? undefined : populated === 1, } satisfies ScopeState; }); @@ -300,6 +304,32 @@ export const make = Effect.gen(function* () { }, ); + const stop = Effect.fn("AgentScope.stop")(function* (unit: string) { + if (!unit.startsWith("t3code-") || !unit.endsWith(".scope")) return; + const scope = yield* systemd; + if (scope === undefined) return; + const state = yield* readScope(scope.systemctl, unit); + if ( + state === undefined || + state.loadState !== "loaded" || + state.activeState !== "active" || + state.result === "oom-kill" || + state.populated === false + ) + return; + // The kernel counts OOM kills before signaling the victim. Let OOMPolicy + // record the result before a manual stop can overwrite it; an unreadable + // counter cannot rule out that race. Older managers need us to stop it. + if (scope.supportsOomPolicy && (state.oomKills === undefined || state.oomKills > 0)) return; + const stopped = yield* run(scope.systemctl, ["--user", "stop", "--no-block", unit]); + if (stopped._tag === "None" || stopped.value.code !== 0) { + yield* Effect.logWarning("Could not stop an exited agent's scope", { + unit, + exitCode: stopped._tag === "Some" ? stopped.value.code : undefined, + }); + } + }); + const clear: AgentScopeShape["clear"] = Effect.fn("agentScope.clear")(function* (threadId) { if (!scopeByThread.has(threadId)) return; const scope = yield* systemd; @@ -341,9 +371,10 @@ export const make = Effect.gen(function* () { ...(owned ? [] : ["--collect"]), ], }; - return agentScopeCommand({ command: resolved, args, scope: systemdScope }); + return { ...agentScopeCommand({ command: resolved, args, scope: systemdScope }), unit }; }), oomKilled, + stop, clear, } satisfies AgentScopeShape; }); diff --git a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts index 36fee6411d76..9e2da61be2af 100644 --- a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts +++ b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts @@ -14,9 +14,11 @@ import * as Fiber from "effect/Fiber"; import * as Scope from "effect/Scope"; import * as Result from "effect/Result"; import * as Stream from "effect/Stream"; +import { ChildProcessSpawner } from "effect/process"; import { describe, expect } from "vite-plus/test"; import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; +import { AgentScope } from "@t3tools/shared/AgentScope"; import * as EffectAcpErrors from "effect-acp/errors"; const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); @@ -193,10 +195,38 @@ describe("AcpSessionRuntime", () => { it.effect("reports an idle child exit and rejects later prompts", () => Effect.gen(function* () { - const runtime = yield* AcpSessionRuntime.make(mockRuntimeOptions); + const stopped = yield* Deferred.make(); + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const runtime = yield* AcpSessionRuntime.make(mockRuntimeOptions).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => + spawner.spawn(command).pipe( + Effect.map((child) => + ChildProcessSpawner.makeHandle({ + ...child, + // Model an inherited pipe held open until detached work is stopped. + stdout: Stream.concat( + child.stdout, + Stream.fromEffect(Deferred.await(stopped)).pipe(Stream.drain), + ), + }), + ), + ), + ), + ), + Effect.provideService(AgentScope, { + ...AgentScope.defaultValue(), + wrap: ({ command, args }) => + Effect.succeed({ command, args, unit: "t3code-acp-test.scope" }), + stop: (unit) => Deferred.succeed(stopped, unit).pipe(Effect.asVoid), + }), + ); yield* runtime.start(); + expect(yield* Deferred.isDone(stopped)).toBe(false); yield* runtime.notify("_test/exit", {}); const events = yield* runtime.getEvents().pipe(Stream.take(1), Stream.runCollect); + expect(yield* Deferred.await(stopped)).toBe("t3code-acp-test.scope"); const event = events[0]; expect(event).toMatchObject({ _tag: "ConnectionTerminated", error: { code: 19 } }); if (event?._tag !== "ConnectionTerminated") return; diff --git a/packages/provider-acp/src/server/AcpSessionRuntime.ts b/packages/provider-acp/src/server/AcpSessionRuntime.ts index 5aef32a1a5a4..f69be279a9b8 100644 --- a/packages/provider-acp/src/server/AcpSessionRuntime.ts +++ b/packages/provider-acp/src/server/AcpSessionRuntime.ts @@ -1435,19 +1435,24 @@ export const make = Effect.fn("AcpSessionRuntime.make")(function* ( cause: new Error("Contained ACP command was not found on PATH"), }); }); - const scopedSpawnCommand = + const agentScope = yield* AgentScope; + const scopedLaunch = linuxCgroupLease !== undefined || spawnCommand.shell - ? spawnCommand - : { - ...(yield* (yield* AgentScope).wrap({ - command: spawnCommand.command, - args: spawnCommand.args, - name: "acp", - threadId: yield* AgentScopeThreadId, - env: { ...process.env, ...options.spawn.env }, - })), - shell: false, - }; + ? undefined + : yield* agentScope.wrap({ + command: spawnCommand.command, + args: spawnCommand.args, + name: "acp", + threadId: yield* AgentScopeThreadId, + env: { ...process.env, ...options.spawn.env }, + }); + const scopedSpawnCommand = + scopedLaunch === undefined ? spawnCommand : { ...scopedLaunch, shell: false }; + // Finalizers run in reverse order: reap detached work after terminating the agent. + const scopeUnit = scopedLaunch?.unit; + const stopScope = + scopeUnit === undefined ? Effect.void : yield* Effect.cached(agentScope.stop(scopeUnit)); + yield* Scope.addFinalizer(runtimeScope, stopScope); const containedSpawnCommand = linuxCgroupLease === undefined ? scopedSpawnCommand @@ -1486,6 +1491,14 @@ export const make = Effect.fn("AcpSessionRuntime.make")(function* ( ), ); + // Detached children can keep stdout open, delaying protocol termination. + if (scopeUnit !== undefined) { + yield* child.exitCode.pipe( + Effect.matchEffect({ onFailure: () => stopScope, onSuccess: () => stopScope }), + Effect.forkIn(runtimeScope), + ); + } + const posixOwnershipLedger = new Map(); const posixOwnershipFrontier = new Map(); const posixOwnershipChildQueues = new Map>(); diff --git a/packages/provider-muse/src/server/adapter.test.ts b/packages/provider-muse/src/server/adapter.test.ts index 122e77a760f8..63e0a1779d20 100644 --- a/packages/provider-muse/src/server/adapter.test.ts +++ b/packages/provider-muse/src/server/adapter.test.ts @@ -19,6 +19,7 @@ import { type OrchestrationV2ProviderThread, } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import { AgentScope } from "@t3tools/shared/AgentScope"; import { MuseSettings } from "../settings.ts"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; @@ -30,11 +31,12 @@ import * as Layer from "effect/Layer"; import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; import type { MuseItem, MuseSubscriptionUsage } from "./protocol.ts"; -import type { MuseSdkHost } from "./sdk.ts"; +import type { MuseSdkHost, MuseSdkHostOptions } from "./sdk.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import type * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; @@ -87,8 +89,10 @@ const makeFakeMuse = Effect.fnUntraced(function* (idPrefix = "native") { let notify: Parameters[0] = () => {}; let sequence = 0; let sessionId = ""; + const closing = pendingPromise(); let closeCount = 0; let closeGate: Promise | undefined; + let onProcessExit: MuseSdkHostOptions["onProcessExit"]; let history: MuseItem[] = []; const responses = new Map< string, @@ -143,6 +147,7 @@ const makeFakeMuse = Effect.fnUntraced(function* (idPrefix = "native") { exited: exited.promise, close: async () => { closeCount += 1; + closing.resolve(); await closeGate; closed.resolve(); exited.resolve({ code: 0, signal: null }); @@ -152,6 +157,11 @@ const makeFakeMuse = Effect.fnUntraced(function* (idPrefix = "native") { host, calls, closeCount: () => closeCount, + closing: closing.promise, + observeProcessExit: (callback: MuseSdkHostOptions["onProcessExit"]) => { + onProcessExit = callback; + }, + exitProcess: () => Effect.sync(() => onProcessExit?.({ code: 1, signal: null })), holdClose: (gate: Promise) => { closeGate = gate; }, @@ -204,7 +214,11 @@ const makeHarness = Effect.fnUntraced(function* ( instanceId, settings: museSettings, environment: { PATH: "/fake/bin" }, - createHost: async () => (hostCount++ === 0 ? fake.host : (replacement ?? fake).host), + createHost: async (options) => { + const selected = hostCount++ === 0 ? fake : (replacement ?? fake); + selected.observeProcessExit(options.onProcessExit); + return selected.host; + }, ...overrides, }); const runtime = yield* adapter.openSession({ @@ -751,6 +765,57 @@ describe("MuseAdapterV2", () => { }).pipe(Effect.scoped, Effect.provide(testLayer)), ); + it.effect.each(["normal", "inherited pipe"] as const)( + "closes its SDK host exactly once on session teardown with %s shutdown", + (shutdown) => + Effect.gen(function* () { + const fake = yield* makeFakeMuse(); + const pipeClosed = pendingPromise(); + if (shutdown === "inherited pipe") fake.holdClose(pipeClosed.promise); + let hostExited = false; + void fake.host.exited.then(() => { + hostExited = true; + }); + const stopped: Array<{ unit: string; hostExited: boolean }> = []; + const release = makeHarness(fake).pipe( + Effect.scoped, + Effect.provideService(AgentScope, { + ...AgentScope.defaultValue(), + wrap: ({ command, args }) => + Effect.succeed({ command, args, unit: "t3code-muse-test.scope" }), + stop: (unit) => + Effect.sync(() => { + stopped.push({ unit, hostExited }); + pipeClosed.resolve(); + }), + }), + ); + if (shutdown === "inherited pipe") { + yield* Effect.gen(function* () { + const releasing = yield* release.pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.promise(() => fake.closing); + yield* TestClock.adjust("12999 millis"); + assert.strictEqual(fake.closeCount(), 1); + assert.deepStrictEqual(stopped, []); + yield* TestClock.adjust("1 millis"); + assert.deepStrictEqual( + [...stopped], + [{ unit: "t3code-muse-test.scope", hostExited: false }], + ); + yield* Fiber.join(releasing); + }).pipe(Effect.ensuring(Effect.sync(() => pipeClosed.resolve()))); + } else { + yield* release; + } + assert.strictEqual(fake.closeCount(), 1); + assert.deepStrictEqual(stopped, [ + { unit: "t3code-muse-test.scope", hostExited: shutdown === "normal" }, + ]); + yield* Effect.promise(() => fake.host.exited); + assert.isTrue(hostExited); + }).pipe(Effect.provide(testLayer)), + ); + it.effect( "closes a newly opened host when native session registration returns another identity", () => @@ -768,6 +833,76 @@ describe("MuseAdapterV2", () => { }).pipe(Effect.scoped, Effect.provide(testLayer)), ); + it.effect( + "settles an active turn and reaps its scope on raw exit before inherited stdio closes", + () => + Effect.gen(function* () { + const fake = yield* makeFakeMuse(); + const pipeClosed = pendingPromise(); + fake.holdClose(pipeClosed.promise); + const stopped = yield* Deferred.make(); + const units: string[] = []; + let hostExited = false; + let connectionClosed = false; + void fake.host.exited.then(() => { + hostExited = true; + }); + void fake.host.connection.closed.then(() => { + connectionClosed = true; + }); + yield* Effect.gen(function* () { + const harness = yield* makeHarness(fake); + const { nativeId } = yield* startConversation(harness, fake); + yield* fake.emit("approval/requested", approval(nativeId)); + yield* harness.takeEvent( + "runtime_request.updated", + (event) => event.runtimeRequest.status === "pending", + ); + yield* fake.exitProcess(); + yield* Deferred.await(stopped); + assert.deepStrictEqual(units, ["t3code-muse-exit.scope"]); + const terminal = yield* harness.takeEvent("turn.terminal"); + assert.strictEqual(terminal.status, "failed"); + assert.strictEqual(terminal.threadDisposition, "broken"); + assert.isFalse(hostExited); + assert.isFalse(connectionClosed); + assert.isTrue( + harness.allEvents.some( + (event) => + event.type === "runtime_request.updated" && + event.runtimeRequest.status === "cancelled", + ), + ); + pipeClosed.resolve(); + yield* fake.disconnect(); + yield* Deferred.await(harness.eventsEnded); + const result = yield* harness.runtime + .startTurn(yield* turnInput(harness.providerThread, 2)) + .pipe(Effect.exit); + assert.strictEqual(result._tag, "Failure"); + assert.strictEqual( + harness.allEvents.filter((event) => event.type === "turn.terminal").length, + 1, + ); + assert.strictEqual(fake.calls.filter((call) => call.method === "turn/start").length, 1); + }).pipe( + Effect.ensuring(Effect.sync(() => pipeClosed.resolve())), + Effect.scoped, + Effect.provideService(AgentScope, { + ...AgentScope.defaultValue(), + wrap: ({ command, args }) => + Effect.succeed({ command, args, unit: "t3code-muse-exit.scope" }), + stop: (unit) => + Effect.gen(function* () { + units.push(unit); + yield* Deferred.succeed(stopped, undefined); + }), + }), + ); + assert.deepStrictEqual(units, ["t3code-muse-exit.scope"]); + }).pipe(Effect.provide(testLayer)), + ); + it.effect("settles an active turn once when both the connection and host exit unexpectedly", () => 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 c098a0aa7ea2..4ea37a2cc9eb 100644 --- a/packages/provider-muse/src/server/adapter.ts +++ b/packages/provider-muse/src/server/adapter.ts @@ -1,4 +1,4 @@ -import { MspError, type SendUserTurnOptions } from "@muse-code/sdk"; +import { MspError, type ProcessExit, type SendUserTurnOptions } from "@muse-code/sdk"; import { MUSE_DEFAULT_MODEL, ProviderDriverKind, @@ -1678,6 +1678,14 @@ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* ( threadId: input.threadId, env: environment, }); + // Finalizers run in reverse order: reap detached work after closing the host. + const scopeUnit = launch.unit; + const stopScope = + scopeUnit === undefined ? Effect.void : yield* Effect.cached(agentScope.stop(scopeUnit)); + yield* Scope.addFinalizer(scope, stopScope); + const processExited = yield* Deferred.make(); + // Observe raw exit before acquisition: inherited stdout can also block startup cleanup. + yield* Deferred.await(processExited).pipe(Effect.andThen(stopScope), Effect.forkIn(scope)); const created = yield* Effect.acquireRelease( createMuseSdkHostEffect( { @@ -1686,6 +1694,9 @@ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* ( cwd, environment, runtimeMode: input.runtimePolicy.runtimeMode, + onProcessExit: (exit) => { + Deferred.doneUnsafe(processExited, Effect.succeed(exit)); + }, }, options.createHost, ).pipe( @@ -1704,7 +1715,14 @@ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* ( closed = true; hostEpoch++; } - yield* Effect.tryPromise(() => created.close()).pipe(Effect.ignore); + const close = Effect.tryPromise(() => created.close()); + // SDK shutdown sends SIGKILL after 10s + 2s, but inherited pipes can + // keep its close promise pending. Let the scope reap those holders. + yield* ( + scopeUnit === undefined + ? close + : close.pipe(Effect.interruptible, Effect.timeoutOption("13 seconds")) + ).pipe(Effect.ignore); }), { interruptible: true }, ).pipe(Effect.provideService(Scope.Scope, scope)); @@ -1733,7 +1751,7 @@ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* ( epoch, }); }); - void created.exited.then((exit) => { + const reportExit = (exit: ProcessExit) => { if (!closed && epoch === hostEpoch) { const reason = created .stderrTail?.() @@ -1747,7 +1765,12 @@ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* ( epoch, }); } - }); + }; + void created.exited.then(reportExit); + yield* Deferred.await(processExited).pipe( + Effect.tap((exit) => Effect.sync(() => reportExit(exit))), + Effect.forkIn(scope), + ); }); yield* Effect.addFinalizer(() => Effect.gen(function* () { diff --git a/packages/provider-muse/src/server/sdk.test.ts b/packages/provider-muse/src/server/sdk.test.ts index d2ac0821bb5f..565f8aa6a756 100644 --- a/packages/provider-muse/src/server/sdk.test.ts +++ b/packages/provider-muse/src/server/sdk.test.ts @@ -9,7 +9,7 @@ import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; -import { afterEach, describe, expect, vi } from "vite-plus/test"; +import { afterEach, describe, expect, onTestFinished, vi } from "vite-plus/test"; import { createMuseSdkHost, createMuseSdkHostEffect, @@ -135,6 +135,85 @@ describe("Muse SDK host", () => { }, ); + it.effect( + "observes native host exit while the SDK still waits for a detached stdout holder", + () => + Effect.gen(function* () { + if ((yield* HostProcess.Platform) === "win32") return; + yield* Effect.promise(async () => { + const sdk = await vi.importActual("@muse-code/sdk"); + const processExited = pending<{ code: number | null; signal: NodeJS.Signals | null }>(); + const holderSource = `process.send("ready", () => { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0); + });`; + const fixture = ` + const { spawn } = require("node:child_process"); + require("node:readline").createInterface({ input: process.stdin }).on("line", async line => { + const frame = JSON.parse(line); + if (!("id" in frame)) return; + let result = { schema: { version: 1, fingerprint: "t3-native-fixture" }, grantedCapabilities: [] }; + if (frame.method === "fixture/exit") { + const holder = spawn(process.execPath, ["-e", ${JSON.stringify(holderSource)}], { + detached: true, stdio: ["ignore", 1, 2, "ipc"] + }); + await new Promise(resolve => holder.once("message", resolve)); + result = { pid: holder.pid }; + } + process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: frame.id, result }) + "\\n", + () => { if (frame.method === "fixture/exit") process.exit(7); }); + }); + `; + let holderPid: number | undefined; + let host: MuseSdkHost | undefined; + const reap = () => { + if (holderPid !== undefined) { + try { + process.kill(holderPid, "SIGKILL"); + } catch { + /* Already reaped. */ + } + holderPid = undefined; + } + }; + onTestFinished(async () => { + reap(); + await host?.close(); + }); + try { + host = await createMuseSdkHost( + { + binaryPath: process.execPath, + launch: { command: process.execPath, args: ["-e", fixture] }, + onProcessExit: processExited.resolve, + }, + sdk.spawnMspConnection, + ); + let sdkExited = false; + let connectionClosed = false; + void host.exited.then(() => { + sdkExited = true; + }); + void host.connection.closed.then(() => { + connectionClosed = true; + }); + const response = await host.connection.request("fixture/exit", {}); + expect(response).toMatchObject({ pid: expect.any(Number) }); + holderPid = Number((response as { pid: number }).pid); + expect(await processExited.promise).toEqual({ code: 7, signal: null }); + expect(sdkExited).toBe(false); + expect(connectionClosed).toBe(false); + reap(); + expect(await host.exited).toEqual({ code: 7, signal: null }); + await host.close(); + expect(connectionClosed).toBe(true); + } finally { + reap(); + await host?.close(); + } + }); + }), + ); + it.effect("closes a fulfilled host when interruption wins before acquisition is delivered", () => Effect.gen(function* () { const started = pending(); diff --git a/packages/provider-muse/src/server/sdk.ts b/packages/provider-muse/src/server/sdk.ts index 0ae8c2b3ebd2..5f3514b0d6d8 100644 --- a/packages/provider-muse/src/server/sdk.ts +++ b/packages/provider-muse/src/server/sdk.ts @@ -48,6 +48,7 @@ export interface MuseSdkHostOptions { readonly sessionLogging?: boolean; readonly signal?: AbortSignal; readonly startupTimeoutMs?: number; + readonly onProcessExit?: (exit: ProcessExit) => void; } export function makeMuseEnvironment( @@ -172,6 +173,7 @@ export async function createMuseSdkHost( ...(options.cwd ? { cwd: options.cwd } : {}), env: options.environment ?? makeMuseEnvironment(), shutdownTimeoutMs: 10_000, + ...(options.onProcessExit ? { onProcessExit: options.onProcessExit } : {}), }); let closePromise: Promise | undefined; const close = () => { diff --git a/packages/provider-pi/src/server/adapter.test.ts b/packages/provider-pi/src/server/adapter.test.ts index be502f953373..fcd2fa5478cd 100644 --- a/packages/provider-pi/src/server/adapter.test.ts +++ b/packages/provider-pi/src/server/adapter.test.ts @@ -20,6 +20,8 @@ import { } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; @@ -38,6 +40,7 @@ import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import { handoffBudget } from "@t3tools/provider-core/server/handoffBudget"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import { AgentScope } from "@t3tools/shared/AgentScope"; import { toolOutputImages, compactDynamicToolOutput } from "@t3tools/shared/toolOutput"; import { makePiAdapterV2, @@ -1366,6 +1369,58 @@ describe("PiAdapterV2", () => { }).pipe(Effect.scoped, Effect.provide(layerTest)), ); + it.effect.each(["session", "process"] as const)("cleans up detached work on Pi %s exit", (exit) => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const exited = yield* Deferred.make(); + const scopeStopped = yield* Deferred.make(); + let processReleased = false; + const stopped: Array<{ unit: string; processReleased: boolean }> = []; + yield* Effect.gen(function* () { + yield* openRuntime({ + ...fake, + spawner: ChildProcessSpawner.make((command) => + Effect.acquireRelease( + fake.spawner + .spawn(command) + .pipe( + Effect.map((child) => + ChildProcessSpawner.makeHandle({ ...child, exitCode: Deferred.await(exited) }), + ), + ), + () => + Effect.sync(() => { + processReleased = true; + }), + ), + ), + }); + assert.deepStrictEqual(stopped, []); + if (exit === "process") { + // Stdout stays open as it would for a child holding the inherited pipe. + yield* Deferred.succeed(exited, ChildProcessSpawner.ExitCode(19)); + yield* Deferred.await(scopeStopped); + assert.isFalse(processReleased); + } + }).pipe( + Effect.scoped, + Effect.provideService(AgentScope, { + ...AgentScope.defaultValue(), + wrap: ({ command, args }) => + Effect.succeed({ command, args, unit: "t3code-pi-test.scope" }), + stop: (unit) => + Effect.sync(() => void stopped.push({ unit, processReleased })).pipe( + Effect.andThen(Deferred.succeed(scopeStopped, undefined)), + Effect.asVoid, + ), + }), + ); + assert.deepStrictEqual(stopped, [ + { unit: "t3code-pi-test.scope", processReleased: exit === "session" }, + ]); + }).pipe(Effect.provide(layerTest)), + ); + it.effect("gives notify and extension-error items distinct ids in each thread", () => Effect.gen(function* () { const itemIds = new Set(); diff --git a/packages/provider-pi/src/server/adapter.ts b/packages/provider-pi/src/server/adapter.ts index 6cac78ea9451..30a493fb0b02 100644 --- a/packages/provider-pi/src/server/adapter.ts +++ b/packages/provider-pi/src/server/adapter.ts @@ -481,6 +481,11 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( threadId: input.threadId, env: launch.env, }); + // Finalizers run in reverse order: reap detached work after terminating Pi. + const scopeUnit = scopedLaunch.unit; + const stopScope = + scopeUnit === undefined ? Effect.void : yield* Effect.cached(agentScope.stop(scopeUnit)); + yield* Effect.addFinalizer(() => stopScope); const connection: PiRpcConnection = yield* makePiRpcConnection({ command: scopedLaunch.command, args: scopedLaunch.args, @@ -497,6 +502,13 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( }), ), ); + // Detached children can keep stdout open after Pi exits. + if (scopeUnit !== undefined) { + yield* connection.exited.pipe( + Effect.matchEffect({ onFailure: () => stopScope, onSuccess: () => stopScope }), + Effect.forkIn(scope), + ); + } const now = yield* DateTime.now; let sessionEntity: OrchestrationV2ProviderSession = { id: input.providerSessionId, diff --git a/packages/shared/src/AgentScope.ts b/packages/shared/src/AgentScope.ts index 80902e8bbd16..ed53b4311d3b 100644 --- a/packages/shared/src/AgentScope.ts +++ b/packages/shared/src/AgentScope.ts @@ -21,6 +21,11 @@ export interface AgentScopeShape { readonly shared?: boolean | undefined; }) => Effect.Effect; readonly oomKilled: (threadId: string) => Effect.Effect; + /** + * Reaps processes left after the owning agent exits, including setsid + * descendants outside its process group. Preserves systemd OOM diagnosis. + */ + readonly stop: (unit: string) => Effect.Effect; /** * Forgets the thread's scope. The session manager calls it before it opens * a provider session, so a scope only answers for the session that launched @@ -32,12 +37,15 @@ export interface AgentScopeShape { export interface AgentScopeCommand { readonly command: string; readonly args: ReadonlyArray; + /** The systemd scope the command runs in, for `stop`. */ + readonly unit?: string | undefined; } export const AgentScope = Context.Reference("@t3tools/shared/AgentScope", { defaultValue: () => ({ wrap: ({ command, args }) => Effect.succeed({ command, args }), oomKilled: () => Effect.succeed(false), + stop: () => Effect.void, clear: () => Effect.void, }), }); diff --git a/patches/@muse-code__sdk@1.4.6.patch b/patches/@muse-code__sdk@1.4.6.patch new file mode 100644 index 000000000000..e35a859700a5 --- /dev/null +++ b/patches/@muse-code__sdk@1.4.6.patch @@ -0,0 +1,46 @@ +diff --git a/dist/src/connection/spawn.d.ts b/dist/src/connection/spawn.d.ts +index b67e690eae07748e6aee2c8ee59bbfef13b2eea8..581ef3e9184da89cde8471a8fe03c86209ac7c81 100644 +--- a/dist/src/connection/spawn.d.ts ++++ b/dist/src/connection/spawn.d.ts +@@ -20,6 +20,8 @@ export interface SpawnMspConnectionOptions { + readonly onStderr?: (chunk: string) => void; + /** See {@link MuseServeChildOptions.shutdownTimeoutMs}. */ + readonly shutdownTimeoutMs?: number; ++ /** Raw process exit, before inherited stdio drains and `exited` settles. */ ++ readonly onProcessExit?: (exit: ProcessExit) => void; + } + /** + * How the host process ended: its exit code, or the signal that ended it. +@@ -74,6 +76,8 @@ export interface MuseServeChildOptions { + * `0..2147483647`; anything else throws `RangeError` at spawn. + */ + readonly shutdownTimeoutMs?: number; ++ /** Raw process exit, before inherited stdio drains and `exited` settles. */ ++ readonly onProcessExit?: (exit: ProcessExit) => void; + /** + * Deterministic-test seam; production always uses `child_process.spawn`. + * A stub here observes the exact options the call site passes, so a +diff --git a/dist/src/connection/spawn.js b/dist/src/connection/spawn.js +index 28aeb6246ba937c5321f1393dc120eface0707f3..0c835bb9d45a73eacd6a35b5e589191f04104537 100644 +--- a/dist/src/connection/spawn.js ++++ b/dist/src/connection/spawn.js +@@ -390,7 +390,10 @@ export class MuseServeChild { + detached: ownsProcessGroup, + }); + const spawnChild = options.spawnFn ?? spawn; +- return new MuseServeChild(spawnChild(options.museBin, [...(options.args ?? [])], spawnOptions), options.onStderr, options.shutdownTimeoutMs, ownsProcessGroup); ++ const child = spawnChild(options.museBin, [...(options.args ?? [])], spawnOptions); ++ if (options.onProcessExit !== undefined) ++ child.once("exit", (code, signal) => options.onProcessExit({ code, signal })); ++ return new MuseServeChild(child, options.onStderr, options.shutdownTimeoutMs, ownsProcessGroup); + } + get stderrTail() { + return this.#tail.lines(); +@@ -414,6 +417,7 @@ export class MspHandshake { + constructor(options) { + this.child = MuseServeChild.spawn({ + museBin: options.command, ++ ...(options.onProcessExit === undefined ? {} : { onProcessExit: options.onProcessExit }), + ...(options.args === undefined ? {} : { args: options.args }), + ...(options.cwd === undefined ? {} : { cwd: options.cwd }), + ...(options.env === undefined ? {} : { env: options.env }), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 54e2889fa5de..d440b8830fe1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -350,6 +350,7 @@ patchedDependencies: '@expo/ui@58.0.20': 5a26480373850c1a7943919aa21f904151b335a0cb01042109e0746def37923e '@ff-labs/fff-node@0.11.0': cc71fcec015f4d39bc96ae2e2bf6b0ca678124c722c66a7841efce9d34b5983c '@legendapp/list@3.3.5': 011a862a7116ec5b5f4bba17d37bc58c9b5a8f64a6a9f527345f316ce9c96e63 + '@muse-code/sdk@1.4.6': 7b495b607fc5d76b444c7c4d58821e839726a30e6f3c53564f0d59f2d5b42101 '@pierre/diffs@1.5.2': 2ec81ba1e8ce4681095a3473c2bf78e630417740c622720ef624febb467c25fc '@react-native-ai/apple@0.12.0': 2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d '@react-native-menu/menu@2.0.0': a4866ecfcd44f318043de839d20110c17e14909004900f002ffe12050376f2ba @@ -850,7 +851,7 @@ importers: version: 0.11.0(patch_hash=cc71fcec015f4d39bc96ae2e2bf6b0ca678124c722c66a7841efce9d34b5983c) '@muse-code/sdk': specifier: 1.4.6 - version: 1.4.6(@types/node@24.12.4) + version: 1.4.6(patch_hash=7b495b607fc5d76b444c7c4d58821e839726a30e6f3c53564f0d59f2d5b42101)(@types/node@24.12.4) '@napi-rs/keyring': specifier: ^1.3.0 version: 1.3.0 @@ -1613,7 +1614,7 @@ importers: dependencies: '@muse-code/sdk': specifier: 1.4.6 - version: 1.4.6(@types/node@24.12.4) + version: 1.4.6(patch_hash=7b495b607fc5d76b444c7c4d58821e839726a30e6f3c53564f0d59f2d5b42101)(@types/node@24.12.4) '@t3tools/contracts': specifier: workspace:* version: link:../contracts @@ -14706,7 +14707,7 @@ snapshots: sparse-bitfield: 3.0.3 optional: true - '@muse-code/sdk@1.4.6(@types/node@24.12.4)': + '@muse-code/sdk@1.4.6(patch_hash=7b495b607fc5d76b444c7c4d58821e839726a30e6f3c53564f0d59f2d5b42101)(@types/node@24.12.4)': optionalDependencies: '@types/node': 24.12.4 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a8b2ec7ca419..9a0f8f5aa4c9 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -386,6 +386,8 @@ patchedDependencies: # Android: Maven-resolved gradle plugins (expo-widgets) have no sourceDir; without this # skip, the fingerprint silently drops every Expo native module and OTAs reach stale binaries. "@expo/fingerprint@0.21.5": patches/@expo__fingerprint@0.21.5.patch + # Observe the owned host exit independently of descendants retaining its stdio. + "@muse-code/sdk@1.4.6": patches/@muse-code__sdk@1.4.6.patch app-builder-lib@26.16.1: patches/app-builder-lib@26.16.1.patch peerDependencyRules: From abffeedeac39ebb2a14f0839450f6317358c327c Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 23 Oct 2026 03:24:06 -0700 Subject: [PATCH 22/59] test(client-runtime): restore render scheduler tests deleted in the test snap (#23977) --- .../src/device/renderScheduler.test.ts | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 packages/client-runtime/src/device/renderScheduler.test.ts diff --git a/packages/client-runtime/src/device/renderScheduler.test.ts b/packages/client-runtime/src/device/renderScheduler.test.ts new file mode 100644 index 000000000000..9a3e98db7e42 --- /dev/null +++ b/packages/client-runtime/src/device/renderScheduler.test.ts @@ -0,0 +1,27 @@ +import { expect, it, vi } from "vite-plus/test"; +import { createRenderScheduler } from "./renderScheduler.ts"; + +it("coalesces frame and pointer invalidations, then stops scheduling when idle or disposed", () => { + const callbacks: FrameRequestCallback[] = []; + const request = vi.fn((callback: FrameRequestCallback) => { + callbacks.push(callback); + return callbacks.length; + }); + const cancel = vi.fn(); + const render = vi.fn(); + const scheduler = createRenderScheduler(render, request, cancel); + scheduler.invalidate(); + scheduler.invalidate(); + expect(request).toHaveBeenCalledTimes(1); + callbacks[0]?.(0); + expect(render).toHaveBeenCalledTimes(1); + expect(request).toHaveBeenCalledTimes(1); + scheduler.invalidate(); + scheduler.dispose(); + scheduler.dispose(); + scheduler.invalidate(); + callbacks[1]?.(0); + expect(cancel).toHaveBeenCalledExactlyOnceWith(2); + expect(render).toHaveBeenCalledTimes(1); + expect(request).toHaveBeenCalledTimes(2); +}); From 4812dcad02a86874533540e6a0c4b7f5eaea297a Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 23 Oct 2026 03:25:29 -0700 Subject: [PATCH 23/59] fix(web): cmd+k search hides subagents and favors recent, whole-word matches (#24032) Co-authored-by: Claude Opus 5.5 --- .../src/features/keyboard/CommandPalette.tsx | 5 +- .../components/CommandPalette.logic.test.ts | 110 ++++++++++++++++++ .../src/components/CommandPalette.logic.ts | 43 ++++--- apps/web/src/components/Sidebar.logic.ts | 2 +- 4 files changed, 142 insertions(+), 18 deletions(-) diff --git a/apps/mobile/src/features/keyboard/CommandPalette.tsx b/apps/mobile/src/features/keyboard/CommandPalette.tsx index c70062ab04c7..bbc66e8ff51e 100644 --- a/apps/mobile/src/features/keyboard/CommandPalette.tsx +++ b/apps/mobile/src/features/keyboard/CommandPalette.tsx @@ -324,7 +324,10 @@ export function CommandPalette(props: { }), })); const threadItems: CommandPaletteItem[] = threads - .filter((thread) => thread.archivedAt === null) + .filter( + (thread) => + thread.archivedAt === null && thread.lineage.relationshipToParent !== "subagent", + ) .sort((left, right) => (right.latestUserMessageAt ?? right.updatedAt).localeCompare( left.latestUserMessageAt ?? left.updatedAt, diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 8d9f93a51a69..a81455c56f4c 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -300,6 +300,116 @@ describe("buildThreadActionItems", () => { expect.objectContaining({ value: `thread:${idThread.id}` }), ]); }); + + it("hides subagent threads but keeps forks", () => { + const parentId = ThreadId.make("parent"); + const items = buildThreadActionItems({ + threads: [ + makeThread({ id: parentId, title: "Parent" }), + makeThread({ + id: ThreadId.make("subagent"), + title: "Subagent", + lineage: { + rootThreadId: parentId, + parentThreadId: parentId, + relationshipToParent: "subagent", + }, + }), + makeThread({ + id: ThreadId.make("fork"), + title: "Fork", + lineage: { + rootThreadId: parentId, + parentThreadId: parentId, + relationshipToParent: "fork", + }, + }), + ], + projectTitleById: new Map(), + sortOrder: "updated_at", + icon: null, + runThread: async () => undefined, + }); + + expect(items.map((item) => item.value).toSorted()).toEqual(["thread:fork", "thread:parent"]); + }); + + it("ranks word-start title matches above newer mid-word matches", () => { + const items = buildThreadActionItems({ + threads: [ + makeThread({ + id: ThreadId.make("mid-word"), + title: "Fix oauth redirect", + updatedAt: "2026-03-20T00:00:00.000Z", + }), + makeThread({ + id: ThreadId.make("word-start"), + title: "Clean up auth tokens", + updatedAt: "2026-03-02T00:00:00.000Z", + }), + makeThread({ + id: ThreadId.make("branch-word-start"), + title: "Refactor login", + branch: "feature/auth-cleanup", + updatedAt: "2026-03-01T00:00:00.000Z", + }), + ], + projectTitleById: new Map(), + sortOrder: "updated_at", + icon: null, + runThread: async () => undefined, + }); + + const groups = filterCommandPaletteGroups({ + activeGroups: [], + query: "auth", + isInSubmenu: false, + projectSearchItems: [], + threadSearchItems: items, + }); + + expect(groups[0]?.items.map((item) => item.value)).toEqual([ + "thread:word-start", + "thread:branch-word-start", + "thread:mid-word", + ]); + }); + + it("breaks ties within a match tier by latest user message", () => { + const items = buildThreadActionItems({ + threads: [ + makeThread({ + id: ThreadId.make("prefix-stale"), + title: "Auth refactor", + updatedAt: "2026-03-20T00:00:00.000Z", + latestUserMessageAt: "2025-09-01T00:00:00.000Z", + }), + makeThread({ + id: ThreadId.make("word-fresh"), + title: "Fix auth refresh", + updatedAt: "2026-03-10T00:00:00.000Z", + latestUserMessageAt: "2026-03-10T00:00:00.000Z", + }), + ], + projectTitleById: new Map(), + sortOrder: "updated_at", + icon: null, + runThread: async () => undefined, + }); + + const groups = filterCommandPaletteGroups({ + activeGroups: [], + query: "auth", + isInSubmenu: false, + projectSearchItems: [], + threadSearchItems: items, + }); + + expect(groups[0]?.items.map((item) => item.value)).toEqual([ + "thread:word-fresh", + "thread:prefix-stale", + ]); + }); }); describe("filterPinnedBrowseEntries", () => { diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 6fe9b7cfb693..ce9d335b4d08 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -25,6 +25,7 @@ import { getThreadSortTimestamp, sortThreads } from "../lib/threadSort"; import { normalizeSearchText } from "../lib/utils"; import { formatRelativeTimeLabel } from "../timestampFormat"; import { type Project, type SidebarThreadSummary, type Thread } from "../types"; +import { isSidebarSubagentThread } from "./Sidebar.logic"; export const RECENT_THREAD_LIMIT = 12; export const ITEM_ICON_CLASS = "size-4 text-icon-muted"; @@ -353,6 +354,7 @@ export type BuildThreadActionItemsThread = Pick< | "createdAt" | "environmentId" | "id" + | "lineage" | "modelSelection" | "projectId" | "runtime" @@ -378,7 +380,9 @@ export function buildThreadActionItems thread.archivedAt === null), + input.threads.filter( + (thread) => thread.archivedAt === null && !isSidebarSubagentThread(thread), + ), input.sortOrder, ); const visibleThreads = @@ -437,6 +441,16 @@ export function buildThreadActionItems startsWord(normalizedField, token))) { return 1; } return 0; } +// A word-start match in any field beats a mid-word match, then earlier fields win. +// Thread titles collapse prefix and word-start into one tier so recency picks between them. function rankCommandPaletteItemMatch( item: CommandPaletteActionItem | CommandPaletteSubmenuItem, normalizedQuery: string, queryTokens: ReadonlyArray, ): number { - const terms = item.searchTerms.filter((term) => term.length > 0); - if (terms.length === 0) { - return 0; - } - - for (const [index, field] of terms.entries()) { + let best = 0; + for (const [index, field] of item.searchTerms.filter((term) => term.length > 0).entries()) { const fieldRank = rankSearchFieldMatch(field, normalizedQuery, queryTokens); - if (fieldRank !== Number.NEGATIVE_INFINITY) { - if (index === 0 && item.searchRecency !== undefined) { - return 1_000 + Number(fieldRank === 3); - } - return 1_000 - index * 100 + fieldRank; - } + if (fieldRank === Number.NEGATIVE_INFINITY) continue; + const fieldScore = + index === 0 && item.searchRecency !== undefined + ? 1_000 + Number(fieldRank === 3) + : 1_000 - index * 100 + fieldRank; + best = Math.max(best, (fieldRank > 0 ? 10_000 : 0) + fieldScore); } - - return 0; + return best; } export function filterCommandPaletteGroups(input: { diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index e5bcdffc980c..e24439a17f09 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -512,7 +512,7 @@ export function buildMultiSelectThreadContextMenuItems(input: { ]; } -function isSidebarSubagentThread(thread: Pick): boolean { +export function isSidebarSubagentThread(thread: Pick): boolean { return thread.lineage.relationshipToParent === "subagent"; } From 24c063ae804b38e4a900ae54fda55865293a333a Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 23 Oct 2026 04:11:08 -0700 Subject: [PATCH 24/59] test(lint): new polling loops in tests now fail lint (#23983) --- vite.config.ts | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/vite.config.ts b/vite.config.ts index 97182c5345ef..c3f5e39500ae 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -225,8 +225,8 @@ export default defineConfig({ "t3code/no-low-value-comments": "error", "t3code/no-manual-effect-runtime-in-tests": "error", "t3code/no-native-title-tooltip": "error", - // Test files only (the rule checks the filename). Becomes an error once existing polls are converted. - "t3code/no-polling-loop-in-tests": "warn", + // Test files only (the rule checks the filename). + "t3code/no-polling-loop-in-tests": "error", "t3code/no-raw-mcp-registration": "error", "t3code/no-real-time-wait-in-tests": "error", "t3code/no-test-in-loop": "error", @@ -258,8 +258,26 @@ export default defineConfig({ rules: { "t3code/no-frame-waits-in-browser-tests": "error" }, }, { - files: ["**/*.browser.test.*"], - rules: { "t3code/no-real-time-wait-in-tests": "off" }, + // Burn-down list: these files still have polling loops, so they stay at "warn". + // Remove a file when you convert its loops. New polling loops in all other + // test files fail lint. Do not add new files to this list. + files: [ + "apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts", + "apps/server/src/background/HostPowerMonitor.test.ts", + "apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts", + "apps/server/src/orchestration-v2/FoundationPersistence.test.ts", + "apps/server/src/project/ProjectEnrichmentService.test.ts", + "apps/server/src/project/ProjectService.test.ts", + "apps/server/src/scheduledTasks/ScheduledTaskService.webhook.test.ts", + "apps/server/src/usage/UsageService.test.ts", + "packages/client-runtime/src/connection/supervisor.test.ts", + "packages/client-runtime/src/rpc/client.test.ts", + "packages/client-runtime/src/rpc/session.test.ts", + "packages/client-runtime/src/state/shell-sync.test.ts", + "packages/client-runtime/src/state/threads-sync.test.ts", + "packages/shared/src/atomicWrite.test.ts", + ], + rules: { "t3code/no-polling-loop-in-tests": "warn" }, }, { files: ["apps/**/src/**", "packages/**/src/**"], From b2377db9432591a4b844ebff8237ae958bf6a828 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 23 Oct 2026 04:29:49 -0700 Subject: [PATCH 25/59] ci: unused dependency patches fail in check:changed, not after push (#23988) --- scripts/check-changed.ts | 80 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 78 insertions(+), 2 deletions(-) diff --git a/scripts/check-changed.ts b/scripts/check-changed.ts index d717c7bbbe63..11376b85106b 100644 --- a/scripts/check-changed.ts +++ b/scripts/check-changed.ts @@ -2,7 +2,8 @@ // Runs CI's fast checks for this branch's changes: format and lint of the // changed files, knip, generated GraphQL operations, typecheck of the affected // packages and their dependents, and the test files beside the changed files. -// CI still runs the full test suites and the jobs listed in the summary. +// A dependency change also gets the unused-patch install check below. CI still +// runs the full test suites and the jobs listed in the summary. // // vp run check:changed compare against the merge base with origin/main // vp run check:changed compare against the merge base with @@ -11,6 +12,7 @@ // .github/scripts/ci-changes.cjs, the same mapping CI uses to gate its jobs. import * as NodeChildProcess from "node:child_process"; import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import { decide, readWorkspace } from "../.github/scripts/ci-changes.cjs"; @@ -71,6 +73,72 @@ const runKnip = files.some( (file) => CODE.test(file) || NodePath.basename(file) === "package.json" || file === "knip.jsonc", ); +// A version bump can strand a patch in pnpm-workspace.yaml: no resolved +// version uses it and pnpm rejects the install with ERR_PNPM_UNUSED_PATCH. +// CI catches that before merge through Release Smoke, because ci-changes.cjs +// runs everything for any package.json and for root paths it does not map to +// a package (pnpm-workspace.yaml, pnpm-lock.yaml, patches/). The local run +// had no such check, so a stranded patch passed here and failed after push. +const runPatchCheck = files.some( + (file) => + file === "pnpm-workspace.yaml" || + file === "pnpm-lock.yaml" || + file === ".pnpmfile.cjs" || + file.startsWith("patches/") || + file.startsWith("apps/mobile/deps/") || + NodePath.basename(file) === "package.json", +); + +// Mirror Release Smoke's temp-copy install: copy the workspace manifests to +// a temp dir, omit the lockfile, and install with --lockfile-only, so the +// working tree and its lockfile stay untouched (the mobile modules are +// mobile file: dependencies, not workspace packages). Local pnpm may resolve +// from cached registry metadata, so this catches patches stranded by your +// own change, not registry drift. +const checkPatches = (): boolean => { + const temp = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-patch-check-")); + try { + const copy = (relative: string) => { + const dest = NodePath.join(temp, relative); + NodeFS.mkdirSync(NodePath.dirname(dest), { recursive: true }); + NodeFS.cpSync(NodePath.join(root, relative), dest, { recursive: true }); + }; + copy("package.json"); + copy("pnpm-workspace.yaml"); + for (const pkg of packages) copy(`${pkg.dir}/package.json`); + const modulesDir = NodePath.join(root, "apps/mobile/modules"); + if (NodeFS.existsSync(modulesDir)) { + for (const entry of NodeFS.readdirSync(modulesDir)) { + const manifest = `apps/mobile/modules/${entry}/package.json`; + if (NodeFS.existsSync(NodePath.join(root, manifest))) copy(manifest); + } + } + for (const extra of ["apps/mobile/deps", "patches", ".pnpmfile.cjs"]) { + if (NodeFS.existsSync(NodePath.join(root, extra))) copy(extra); + } + const { status, stdout, stderr, error } = NodeChildProcess.spawnSync( + "vp", + ["install", "--lockfile-only", "--ignore-scripts"], + { cwd: temp, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }, + ); + if (status === 0) return true; + if (stdout) console.log(stdout); + if (stderr) console.error(stderr); + if (error) console.error(error.message); + if (`${stdout ?? ""}${stderr ?? ""}`.includes("ERR_PNPM_UNUSED_PATCH")) { + console.error( + "Unused dependency patches: remove each patch named above from " + + "patchedDependencies in pnpm-workspace.yaml and from patches/, " + + "update it to the version the install resolves, " + + "or pin the dependency that pulls it in to the patched version.", + ); + } + return false; + } finally { + NodeFS.rmSync(temp, { recursive: true, force: true }); + } +}; + // CI's Lint job checks that generated GraphQL operations match their sources. const graphql = ["apps/server", "packages/source-control-github"].filter((dir) => files.some((file) => file.startsWith(`${dir}/`)), @@ -78,6 +146,8 @@ const graphql = ["apps/server", "packages/source-control-github"].filter((dir) = const filters = (names: ReadonlyArray) => names.flatMap((name) => ["--filter", name]); const steps = [ + // Runs first; it takes about a second. + runPatchCheck && { name: "unused patches", run: checkPatches }, present.length > 0 && { name: "format", args: ["fmt", "--check", "--no-error-on-unmatched-pattern", ...present], @@ -103,7 +173,12 @@ console.log(`check:changed: ${files.length} files since ${base.slice(0, 12)}`); const results = steps.map((step) => { console.log(`\n> vp ${step.name}`); const started = performance.now(); - const { status } = NodeChildProcess.spawnSync("vp", step.args, { cwd: root, stdio: "inherit" }); + const status = + "args" in step + ? NodeChildProcess.spawnSync("vp", step.args, { cwd: root, stdio: "inherit" }).status + : step.run() + ? 0 + : 1; const seconds = ((performance.now() - started) / 1000).toFixed(1); return { name: step.name, ok: status === 0, seconds }; }); @@ -120,5 +195,6 @@ const ciOnly = Object.entries({ "release smoke": outputs.release_smoke, }).flatMap(([job, runs]) => (runs ? [job] : [])); if (ciOnly.length > 0) console.log(` CI also runs: ${ciOnly.join(", ")}`); +if (!runPatchCheck) console.log(" unused patch check skipped: no dependency files changed"); if (!runKnip) console.log(" knip skipped: no code or manifest changed"); if (results.some((result) => !result.ok)) process.exit(1); From 6ba807810b2fa93ed5ef75fde82639f7007868e4 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:09:12 +0530 Subject: [PATCH 26/59] fix(desktop): PDFs in the preview can be annotated (#24022) Co-authored-by: PR Batch Tester Co-authored-by: Claude Opus 5.5 --- apps/desktop/src/preview/PickPreload.ts | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/preview/PickPreload.ts b/apps/desktop/src/preview/PickPreload.ts index e49d27d40ba9..4059468da22e 100644 --- a/apps/desktop/src/preview/PickPreload.ts +++ b/apps/desktop/src/preview/PickPreload.ts @@ -35,6 +35,7 @@ import { START_PICK_CHANNEL, } from "./GuestProtocol.ts"; const OVERLAY_ATTRIBUTE = "data-t3code-annotation-ui"; +const INPUT_LAYER_ATTRIBUTE = "data-t3code-annotation-input"; const Z_INDEX_OVERLAY = 2147483646; const PRIMARY = "var(--t3-primary)"; const PRIMARY_FILL = "color-mix(in srgb, var(--t3-primary) 10%, transparent)"; @@ -268,7 +269,7 @@ function isAnnotationNode(element: Element): boolean { function pickFromPoint(clientX: number, clientY: number): Element | null { for (const candidate of document.elementsFromPoint(clientX, clientY)) { if (!(candidate instanceof Element)) continue; - if (isAnnotationNode(candidate)) continue; + if (isAnnotationNode(candidate) || candidate.hasAttribute(INPUT_LAYER_ATTRIBUTE)) continue; if (candidate === document.documentElement || candidate === document.body) continue; return candidate; } @@ -636,7 +637,18 @@ function startAnnotation(sendEnabled: boolean): void { const strokes: PreviewAnnotationStrokeTarget[] = []; const styleChanges = new Map(); const toolButtons = new Map(); - let tool: AnnotationTool = "select"; + // Chromium draws a PDF in its own viewer frame, so pointer and key input over it + // never reaches this document. A transparent layer above the viewer catches the + // pointer and takes focus for Escape and the tool keys. A PDF has no elements to + // select, so it starts on Region. + const isPdf = document.contentType === "application/pdf"; + const inputLayer = isPdf ? document.createElement("div") : null; + if (inputLayer) { + inputLayer.setAttribute(INPUT_LAYER_ATTRIBUTE, ""); + inputLayer.tabIndex = -1; + inputLayer.style.cssText = `position:fixed;inset:0;z-index:${Z_INDEX_OVERLAY - 1};cursor:crosshair;outline:none`; + } + let tool: AnnotationTool = isPdf ? "marquee" : "select"; let dragStart: PreviewAnnotationPoint | null = null; let activeStroke: { target: PreviewAnnotationStrokeTarget; path: SVGPathElement } | null = null; let pendingCapture = false; @@ -1356,6 +1368,7 @@ function startAnnotation(sendEnabled: boolean): void { ipcRenderer.off(ANNOTATION_CAPTURED_CHANNEL, onCaptured); document.documentElement.removeAttribute("data-t3code-annotation-tool"); cursorStyle.remove(); + inputLayer?.remove(); host.remove(); activeSession = null; if (notifyMain) ipcRenderer.send(ELEMENT_PICKED_CHANNEL, null); @@ -1467,6 +1480,10 @@ function startAnnotation(sendEnabled: boolean): void { window.addEventListener("resize", repaint, { passive: true }); ipcRenderer.on(CANCEL_PICK_CHANNEL, onCancel); ipcRenderer.on(ANNOTATION_CAPTURED_CHANNEL, onCaptured); + if (inputLayer) { + document.documentElement.appendChild(inputLayer); + inputLayer.focus({ preventScroll: true }); + } document.documentElement.appendChild(host); rootResizeObserver.observe(document.documentElement); refreshToolButtons(); From 16aaf5ba7d75538c9c004ea7fb7ed62cd6b65e5b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 23 Oct 2026 04:40:50 -0700 Subject: [PATCH 27/59] fix(web): instructed compact runs without a bare compaction first (#24008) Co-authored-by: fixfon <12512757+fixfon@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 --- .../web/src/components/ChatView.logic.test.ts | 23 ++++++++++++------- apps/web/src/components/ChatView.logic.ts | 6 ++++- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index a03a7929b091..426818dba7a2 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -1102,14 +1102,21 @@ describe("planComposerTurn", () => { }); it("queues behind a compaction of a stale session, except for /compact itself", () => { - expect(planComposerTurn({ ...base, resumeCompactionTokens: 120_000 })).toMatchObject({ - compactBeforeSend: true, - turnDispatchMode: "queue", - shouldQueueBehindActiveRun: true, - }); - expect( - planComposerTurn({ ...base, resumeCompactionTokens: 120_000, messageText: "/COMPACT" }), - ).toMatchObject({ compactBeforeSend: false, turnDispatchMode: "auto" }); + const stale = { ...base, resumeCompactionTokens: 120_000 }; + for (const messageText of ["hello", "/compactfoo"]) { + expect(planComposerTurn({ ...stale, messageText })).toMatchObject({ + compactBeforeSend: true, + turnDispatchMode: "queue", + shouldQueueBehindActiveRun: true, + }); + } + for (const messageText of ["/COMPACT", "/compact keep the API notes", "/compact\n tests"]) { + expect(planComposerTurn({ ...stale, messageText })).toMatchObject({ + compactBeforeSend: false, + turnDispatchMode: "auto", + shouldQueueBehindActiveRun: false, + }); + } }); it("queues behind a running turn only when asked to", () => { diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 856a2b4e548c..0ce51ee5e553 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -931,6 +931,10 @@ export function shouldShowPlanFollowUpPrompt(input: { ); } +// A typed `/compact`, with or without instructions, is already the compaction. Queueing a +// bare one ahead of it would run an uninstructed compaction first and charge for two. +const COMPACT_COMMAND_PATTERN = /^\/compact(?:\s|$)/i; + const sessionDismissedBranchMismatchKeys = new Set(); export function dismissBranchMismatchForSession(key: string): void { @@ -1541,7 +1545,7 @@ export function planComposerTurn(input: { const compactBeforeSend = input.resumeCompactionTokens !== null && !input.keepFullHistory && - input.messageText.toLowerCase() !== "/compact"; + !COMPACT_COMMAND_PATTERN.test(input.messageText); return { // In worktree mode, require an explicit base branch so we don't silently // fall back to local execution when branch selection is missing. From cccb78c459a9cabb8dcdc647f7264bd0042b4296 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:13:54 +0530 Subject: [PATCH 28/59] fix(desktop): reduce app crashes when a preview tab's renderer dies during agent screenshots (#24056) Co-authored-by: Claude Opus 5.5 --- .../src/preview/DesktopBrowserHost.test.ts | 109 +++++++++++++++++- .../desktop/src/preview/DesktopBrowserHost.ts | 58 +++++++++- 2 files changed, 161 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/preview/DesktopBrowserHost.test.ts b/apps/desktop/src/preview/DesktopBrowserHost.test.ts index 04dcd22cf3b2..50039c4e4b04 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.test.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "@effect/vitest"; import { DesktopBrowserEvent } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; +import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as NodeEvents from "node:events"; @@ -15,32 +16,54 @@ const decodeCdpReply = Schema.decodeUnknownSync( Schema.fromJsonString(Schema.Struct({ id: Schema.Number })), ); const key = { threadId: "thread-1", tabId: "tab-1" }; +/** Sends a page command whose id the fake debugger records as it receives it. */ +const command = ( + host: DesktopBrowserHost.DesktopBrowserHost["Service"], + id: number, + method: string, +) => + host.handleCommandLine( + encodeJson({ + type: "cdp", + ...key, + message: encodeJson({ id, method, params: { id }, sessionId: "t3-preview-page" }), + }), + ); const makeDebuggee = () => { const emitter = new NodeEvents.EventEmitter(); const pending: Array<() => void> = []; + const sent: Array = []; + const sendWaiters: Array<() => void> = []; const debuggee = Object.assign(emitter, { - sendCommand: (method: string) => + sendCommand: (method: string, params?: { id?: number }) => new Promise((resolve) => { if (method === "Target.getTargetInfo") { resolve({ targetInfo: { targetId: "GUEST" } }); return; } + sent.push(params?.id); pending.push(() => resolve({ method })); + sendWaiters.shift()?.(); }), }); - const webContents = { + const webContents = Object.assign(new NodeEvents.EventEmitter(), { getURL: () => "http://localhost/", getTitle: () => "Page", getUserAgent: () => "Electron", - }; + }); return { tab: { webContents: webContents as unknown as Electron.WebContents, debugger: debuggee as unknown as Electron.Debugger, }, emit: (method: string, params: unknown) => emitter.emit("message", {}, method, params, ""), + navigate: () => webContents.emit("did-navigate"), + /** Ids of the commands the debugger received, in order. */ + sent, + nextSend: () => new Promise((resolve) => sendWaiters.push(resolve)), release: () => pending.splice(0).forEach((resolve) => resolve()), + releaseFirst: () => pending.shift()?.(), }; }; @@ -51,6 +74,25 @@ const takeEvents = (host: DesktopBrowserHost.DesktopBrowserHost["Service"], coun Effect.map((lines) => lines.map((line) => decodeEvent(new TextDecoder().decode(line)))), ); +/** + * Ids of the replies the host sends, in order. A screenshot's reply leaves after its turn + * has passed on, so by then the next waiting screenshot, if any, has been sent. Subscribe + * before sending commands: a new subscriber releases the tab's relay. + */ +const watchReplies = (host: DesktopBrowserHost.DesktopBrowserHost["Service"]) => + Effect.gen(function* () { + const replies = yield* Queue.unbounded(); + yield* host.events.pipe( + Stream.map((line) => decodeEvent(new TextDecoder().decode(line))), + Stream.runForEach((event) => + event.type === "cdp" ? Queue.offer(replies, decodeCdpReply(event.message).id) : Effect.void, + ), + Effect.forkScoped, + ); + yield* Effect.yieldNow; + return replies; + }); + describe("DesktopBrowserHost", () => { it.effect("announces tabs already attached to a backend that starts later", () => Effect.gen(function* () { @@ -89,6 +131,67 @@ describe("DesktopBrowserHost", () => { }).pipe(Effect.scoped), ); + it.effect("sends a page one screenshot at a time", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + const replies = yield* watchReplies(host); + + yield* command(host, 1, "Page.captureScreenshot"); + yield* command(host, 2, "Page.captureScreenshot"); + yield* command(host, 3, "DOM.enable"); + expect(debuggee.sent).toEqual([1, 3]); + + debuggee.releaseFirst(); + expect(yield* Queue.take(replies)).toBe(1); + expect(debuggee.sent).toEqual([1, 3, 2]); + }).pipe(Effect.scoped), + ); + + it.effect("stops waiting on a screenshot the page navigated away from", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + const replies = yield* watchReplies(host); + + yield* command(host, 1, "Page.captureScreenshot"); + yield* command(host, 2, "Page.captureScreenshot"); + yield* command(host, 3, "Page.captureScreenshot"); + debuggee.navigate(); + expect(debuggee.sent).toEqual([1, 2]); + + // The superseded screenshot answering late must not start a third beside the second. + debuggee.releaseFirst(); + expect(yield* Queue.take(replies)).toBe(1); + expect(debuggee.sent).toEqual([1, 2]); + + debuggee.releaseFirst(); + expect(yield* Queue.take(replies)).toBe(2); + expect(debuggee.sent).toEqual([1, 2, 3]); + }).pipe(Effect.scoped), + ); + + it.effect("drops screenshots a released relay left waiting", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + + yield* command(host, 1, "Page.captureScreenshot"); + yield* command(host, 2, "Page.captureScreenshot"); + yield* host.handleCommandLine(encodeJson({ type: "release", ...key })); + yield* command(host, 3, "Page.captureScreenshot"); + expect(debuggee.sent).toEqual([1]); + + const sent = debuggee.nextSend(); + debuggee.release(); + yield* Effect.promise(() => sent); + expect(debuggee.sent).toEqual([1, 3]); + }), + ); + it.effect("saves a server tab's download under its CDP guid where the server asked", () => Effect.gen(function* () { const host = yield* DesktopBrowserHost.make; diff --git a/apps/desktop/src/preview/DesktopBrowserHost.ts b/apps/desktop/src/preview/DesktopBrowserHost.ts index cef42d4f35fc..7028d16b4be2 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.ts @@ -43,6 +43,8 @@ interface AttachedTab { readonly key: DesktopBrowserTabKey; readonly debuggee: DesktopBrowserTabDebugger; relay: CdpRelayConnection | null; + screenshotTurn: object | null; + readonly waitingScreenshots: Array<() => void>; downloadDirectory: string | null; pendingDownloadGuid: string | null; agentInputAt: number; @@ -52,6 +54,7 @@ interface AttachedTab { params: unknown, sessionId: string, ) => void; + readonly onNewDocument: () => void; } export class DesktopBrowserHost extends Context.Service< @@ -84,15 +87,54 @@ export const make = Effect.gen(function* () { const tabs = new Map(); const emit = (event: DesktopBrowserEventType) => runFork(PubSub.publish(outbox, event)); + const nextScreenshot = (tab: AttachedTab) => { + tab.screenshotTurn = null; + tab.waitingScreenshots.shift()?.(); + }; + + /** + * Chromium below 157 crashes the whole app when a guest's renderer exits with a + * screenshot copy in flight (crbug.com/567321049). Overlapping screenshots make that + * far likelier, and one the server gave up on stays pending in Chromium, so each guest + * gets one at a time. One queued by a relay released since then is dropped unsent. + */ + const takeScreenshot = ( + tab: AttachedTab, + relay: CdpRelayConnection, + capture: () => Promise, + ) => + new Promise((resolve, reject) => { + const start = () => { + if (tab.relay !== relay) { + reject(new Error("The preview connection that asked for this screenshot closed.")); + nextScreenshot(tab); + return; + } + const turn = {}; + tab.screenshotTurn = turn; + capture() + .finally(() => { + if (tab.screenshotTurn === turn) nextScreenshot(tab); + }) + .then(resolve, reject); + }; + if (tab.screenshotTurn === null) start(); + else tab.waitingScreenshots.push(start); + }); + const relayFor = (tab: AttachedTab) => { if (tab.relay) return tab.relay; const { webContents, debugger: debuggee } = tab.debuggee; + const send = (method: string, params: Record, sessionId?: string) => + sessionId === undefined + ? sendPageCommand(webContents, debuggee, method, params) + : debuggee.sendCommand(method, params, sessionId); const relay: CdpRelayConnection = createCdpRelayConnection( { send: (method, params, sessionId) => - sessionId === undefined - ? sendPageCommand(webContents, debuggee, method, params) - : debuggee.sendCommand(method, params, sessionId), + method === "Page.captureScreenshot" + ? takeScreenshot(tab, relay, () => send(method, params, sessionId)) + : send(method, params, sessionId), targetId: () => debuggee .sendCommand("Target.getTargetInfo") @@ -133,7 +175,9 @@ export const make = Effect.gen(function* () { const tab = tabs.get(id); if (!tab) return; tabs.delete(id); + tab.relay = null; tab.debuggee.debugger.off("message", tab.onMessage); + tab.debuggee.webContents.off("did-navigate", tab.onNewDocument); emit({ type: "detached", ...key }); }; @@ -145,6 +189,8 @@ export const make = Effect.gen(function* () { key, debuggee, relay: null, + screenshotTurn: null, + waitingScreenshots: [], downloadDirectory: null, pendingDownloadGuid: null, agentInputAt: Number.NEGATIVE_INFINITY, @@ -155,9 +201,15 @@ export const make = Effect.gen(function* () { } tab.relay?.event(method, params, sessionId); }, + // A screenshot pending when the page loads a new document, a reload included, can + // stay unanswered. A crashed page lands here once it reloads, well after its renderer died. + onNewDocument: () => { + if (tab.screenshotTurn !== null) nextScreenshot(tab); + }, }; tabs.set(id, tab); debuggee.debugger.on("message", tab.onMessage); + debuggee.webContents.on("did-navigate", tab.onNewDocument); emit({ type: "attached", ...key }); }; From fbc6a08159c81d14ac02f43f421ca8739c82b56b Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:15:43 +0530 Subject: [PATCH 29/59] fix(server): Stop stops a thread's subagents again (#23886) Co-authored-by: PR Batch Tester Co-authored-by: Claude Opus 5.5 --- apps/server/src/mcp/OrchestratorMcpService.ts | 1 + apps/server/src/orchestration-v2/EventSink.ts | 7 +- .../src/orchestration-v2/Orchestrator.ts | 38 +++-- .../ThreadManagementService.ts | 1 + .../src/orchestration-v2/ThreadStop.test.ts | 139 +++++++++++++----- packages/contracts/src/orchestrationV2.ts | 5 + 6 files changed, 140 insertions(+), 51 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index edaa8777b422..2c11d6e3b6ee 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -2385,6 +2385,7 @@ const make = Effect.gen(function* () { type: "thread.stop", commandId, threadId: current.childThreadId, + stopDelegatedTasks: false, ...(reason === undefined ? {} : { reason }), }) .pipe( diff --git a/apps/server/src/orchestration-v2/EventSink.ts b/apps/server/src/orchestration-v2/EventSink.ts index bf50bbc8395e..3d2bc8e4b45a 100644 --- a/apps/server/src/orchestration-v2/EventSink.ts +++ b/apps/server/src/orchestration-v2/EventSink.ts @@ -661,7 +661,12 @@ const layerBase: Layer.Layer< commandId: input.commandId, events: normalized, }); - const sequence = storedEvents.at(-1)?.sequence; + // A Stop that changes nothing can still queue the stop of its delegated tasks. + const sequence = + storedEvents.at(-1)?.sequence ?? + (input.effects.length === 0 + ? undefined + : yield* eventStore.latestSequence({ threadId: input.threadId })); if (sequence === undefined) { return yield* Effect.die( new Error(`Command ${input.commandId} produced no orchestration events.`), diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 4a3b101b4948..33b434169fed 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -535,6 +535,22 @@ function pendingThreadTitleGenerationEffect( }; } +function delegatedTasksStopEffect(command: { + readonly commandId: CommandId; + readonly threadId: ThreadId; + readonly reason?: string | undefined; +}): PendingOrchestrationEffectV2 { + return { + id: `effect:${command.commandId}:delegated-tasks.stop`, + commandId: command.commandId, + threadId: command.threadId, + request: { + type: "delegated-tasks.stop", + ...(command.reason === undefined ? {} : { reason: command.reason }), + }, + }; +} + const WORKSPACE_PREPARATION_INPUT = "Preparing workspace"; function withoutPreparationResult( @@ -10150,6 +10166,14 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ), ); if (projection.thread.deletedAt !== null) return undefined; + // Queued after the interrupt, since a thread's effects run in order. + const stopDelegatedTasks = + command.stopDelegatedTasks === false || + !projection.subagents.some( + (task) => task.origin === "app_owned" && task.childThreadId !== null, + ) + ? Effect.void + : Ref.update(effects, (existing) => [...existing, delegatedTasksStopEffect(command)]); const latestRun = projection.runs.findLast((run) => run.status !== "queued"); const target = projection.runs.findLast( @@ -10189,6 +10213,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio yield* Ref.update(events, (existing) => [...existing, ...written]); yield* Ref.update(effects, (existing) => [...existing, ...enqueued]); yield* cancelIdleOrphans({ command, events, projection }); + yield* stopDelegatedTasks; return interrupted.value; } yield* Effect.logWarning("thread.stop could not interrupt the running turn", { @@ -10209,6 +10234,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio } yield* holdStoppedThread({ command, events, projection, cohortRunIds: [], now }); yield* cancelIdleOrphans({ command, events, projection }); + yield* stopDelegatedTasks; return undefined; }); @@ -11509,15 +11535,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }); yield* Ref.update(effects, (existing) => [ ...existing, - { - id: `effect:${command.commandId}:delegated-tasks.stop`, - commandId: command.commandId, - threadId: command.threadId, - request: { - type: "delegated-tasks.stop", - ...(command.reason === undefined ? {} : { reason: command.reason }), - }, - } satisfies PendingOrchestrationEffectV2, + delegatedTasksStopEffect(command), ]); } break; @@ -11787,7 +11805,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ), ); - if (plan.events.length === 0) { + if (plan.events.length === 0 && plan.effects.length === 0) { // A settle that ended nothing still records its receipt: a replayed Stop // effect then finds it instead of settling work that appeared since. const resultSequence = yield* Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index f2efb71637fe..fba2df1de5c2 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -1115,6 +1115,7 @@ const make = Effect.gen(function* () { type: "thread.stop", commandId: CommandId.make(`${input.commandId}:stop:${threadId}`), threadId, + stopDelegatedTasks: false, ...(input.reason === undefined ? {} : { reason: input.reason }), }).pipe( Effect.andThen(stopDelegatedTasks({ ...input, threadId })), diff --git a/apps/server/src/orchestration-v2/ThreadStop.test.ts b/apps/server/src/orchestration-v2/ThreadStop.test.ts index c8b490008763..5952e5c217a3 100644 --- a/apps/server/src/orchestration-v2/ThreadStop.test.ts +++ b/apps/server/src/orchestration-v2/ThreadStop.test.ts @@ -530,65 +530,124 @@ it.effect("Stop still stops the thread when a delegated child cannot be read", ( }).pipe(Effect.provide(layerTest)), ); -it.effect("Stop ends watches, holds queues, and stops the delegated tasks under the thread", () => +it.effect.each(["run.interrupt", "thread.stop"] as const)( + "%s Stop ends watches, holds queues, and stops the delegated tasks under the thread", + (type) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const threads = yield* ThreadManagementService.ThreadManagementService; + const sql = yield* SqlClient.SqlClient; + const parentThreadId = ThreadId.make("thread:stop-parent"); + yield* createWatchingThread(parentThreadId, 1); + yield* send(parentThreadId, "first", "start_immediately"); + const childThreadId = yield* delegate(parentThreadId, "child task"); + assert.isTrue(Exit.isFailure(yield* Effect.exit(watch(childThreadId, 2)))); + const grandchildThreadId = yield* delegate(childThreadId, "grandchild task"); + yield* send(childThreadId, "child follow-up", "queue_after_active"); + + const now = yield* DateTime.now; + const firstRun = (yield* orchestrator.getThreadProjection(parentThreadId)).runs[0]!; + yield* projections.apply({ + id: EventId.make("event:stop-parent:first-run-completed"), + type: "run.updated", + threadId: parentThreadId, + runId: firstRun.id, + occurredAt: now, + payload: { ...firstRun, status: "completed", completedAt: now }, + }); + yield* send(parentThreadId, "second", "start_immediately"); + const secondRun = (yield* orchestrator.getThreadProjection(parentThreadId)).runs.at(-1)!; + + const stopCommandId = CommandId.make("stop-parent"); + yield* orchestrator.dispatch( + type === "thread.stop" + ? { type, commandId: stopCommandId, threadId: parentThreadId } + : { + type, + commandId: stopCommandId, + threadId: parentThreadId, + runId: secondRun.id, + holdQueue: true, + }, + ); + + assert.deepEqual(yield* threadState(parentThreadId), { + runs: ["completed", "interrupted"], + watched: [], + }); + const parent = yield* orchestrator.getThreadProjection(parentThreadId); + assert.equal(parent.subagents[0]?.completionDelivery?.state, "disposed"); + const effects = yield* sql<{ readonly effect_type: string }>` + SELECT effect_type FROM orchestration_v2_effect_outbox WHERE command_id = ${stopCommandId} + `; + assert.include( + effects.map((row) => row.effect_type), + "delegated-tasks.stop", + ); + + yield* threads.stopDelegatedTasks({ threadId: parentThreadId, commandId: stopCommandId }); + assert.deepEqual(yield* threadState(childThreadId), { + runs: ["interrupted", "queued:held"], + watched: [], + }); + assert.deepEqual((yield* threadState(grandchildThreadId)).runs, ["interrupted"]); + + yield* threads.stopDelegatedTasks({ threadId: parentThreadId, commandId: stopCommandId }); + assert.deepEqual((yield* threadState(childThreadId)).runs, ["interrupted", "queued:held"]); + // The walk stops each level itself, so the child stops it sends queue no walk of their own. + const walks = yield* sql<{ readonly command_id: string }>` + SELECT command_id FROM orchestration_v2_effect_outbox + WHERE effect_type = 'delegated-tasks.stop' + `; + assert.deepEqual( + walks.map((row) => row.command_id), + [stopCommandId], + ); + }).pipe(Effect.provide(layerTest)), +); + +it.effect("a repeated Stop that changes nothing still stops the delegated tasks", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; const projections = yield* ProjectionStore.ProjectionStoreV2; - const threads = yield* ThreadManagementService.ThreadManagementService; const sql = yield* SqlClient.SqlClient; - const parentThreadId = ThreadId.make("thread:stop-parent"); + const parentThreadId = ThreadId.make("thread:stop-again"); yield* createWatchingThread(parentThreadId, 1); yield* send(parentThreadId, "first", "start_immediately"); - const childThreadId = yield* delegate(parentThreadId, "child task"); - assert.isTrue(Exit.isFailure(yield* Effect.exit(watch(childThreadId, 2)))); - const grandchildThreadId = yield* delegate(childThreadId, "grandchild task"); - yield* send(childThreadId, "child follow-up", "queue_after_active"); - + yield* delegate(parentThreadId, "stop-again child"); const now = yield* DateTime.now; - const firstRun = (yield* orchestrator.getThreadProjection(parentThreadId)).runs[0]!; + const run = (yield* orchestrator.getThreadProjection(parentThreadId)).runs[0]!; yield* projections.apply({ - id: EventId.make("event:stop-parent:first-run-completed"), + id: EventId.make("event:stop-again:run-completed"), type: "run.updated", threadId: parentThreadId, - runId: firstRun.id, + runId: run.id, occurredAt: now, - payload: { ...firstRun, status: "completed", completedAt: now }, + payload: { ...run, status: "completed", completedAt: now }, }); - yield* send(parentThreadId, "second", "start_immediately"); - const secondRun = (yield* orchestrator.getThreadProjection(parentThreadId)).runs.at(-1)!; - - const stopCommandId = CommandId.make("stop-parent"); yield* orchestrator.dispatch({ - type: "run.interrupt", - commandId: stopCommandId, + type: "thread.stop", + commandId: CommandId.make("stop-again:first"), threadId: parentThreadId, - runId: secondRun.id, - holdQueue: true, }); - assert.deepEqual(yield* threadState(parentThreadId), { - runs: ["completed", "interrupted"], - watched: [], + // The first Stop already disposed the delivery and ended the watch, so this one writes no + // events, like Stop on a thread an older server stopped without stopping its children. + const again = yield* orchestrator.dispatch({ + type: "thread.stop", + commandId: CommandId.make("stop-again:second"), + threadId: parentThreadId, }); - const parent = yield* orchestrator.getThreadProjection(parentThreadId); - assert.equal(parent.subagents[0]?.completionDelivery?.state, "disposed"); - const effects = yield* sql<{ readonly effect_type: string }>` - SELECT effect_type FROM orchestration_v2_effect_outbox WHERE command_id = ${stopCommandId} + assert.lengthOf(again.storedEvents, 0); + const walks = yield* sql<{ readonly command_id: string }>` + SELECT command_id FROM orchestration_v2_effect_outbox + WHERE effect_type = 'delegated-tasks.stop' `; - assert.include( - effects.map((row) => row.effect_type), - "delegated-tasks.stop", + assert.deepEqual( + walks.map((row) => row.command_id), + ["stop-again:first", "stop-again:second"], ); - - yield* threads.stopDelegatedTasks({ threadId: parentThreadId, commandId: stopCommandId }); - assert.deepEqual(yield* threadState(childThreadId), { - runs: ["interrupted", "queued:held"], - watched: [], - }); - assert.deepEqual((yield* threadState(grandchildThreadId)).runs, ["interrupted"]); - - yield* threads.stopDelegatedTasks({ threadId: parentThreadId, commandId: stopCommandId }); - assert.deepEqual((yield* threadState(childThreadId)).runs, ["interrupted", "queued:held"]); }).pipe(Effect.provide(layerTest)), ); diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index cb748cc5005e..ff7c295b8bf9 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -2577,6 +2577,11 @@ export const OrchestrationV2Command = Schema.Union([ commandId: CommandId, threadId: ThreadId, reason: Schema.optional(Schema.String), + /** + * False when the caller stops the thread's delegated tasks itself, as the server's tree walks + * do under their caller's dispatch limit. Otherwise Stop also stops every task under the thread. + */ + stopDelegatedTasks: Schema.optional(Schema.Boolean), }), Schema.Struct({ type: Schema.Literal("thread.create"), From c58d558e10e3fb238afc62746a857c6bfa764296 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:23:45 +0530 Subject: [PATCH 30/59] fix(desktop): a stuck quit no longer leaves the app unkillable (#23873) Co-authored-by: PR Batch Tester Co-authored-by: Claude Opus 5.5 --- apps/desktop/src/app/DesktopLifecycle.test.ts | 58 +++++++++++++- apps/desktop/src/app/DesktopLifecycle.ts | 75 ++++++++++++++----- 2 files changed, 115 insertions(+), 18 deletions(-) diff --git a/apps/desktop/src/app/DesktopLifecycle.test.ts b/apps/desktop/src/app/DesktopLifecycle.test.ts index f9f38dea3dd4..6a6e991fc6b4 100644 --- a/apps/desktop/src/app/DesktopLifecycle.test.ts +++ b/apps/desktop/src/app/DesktopLifecycle.test.ts @@ -3,6 +3,7 @@ import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; +import * as TestClock from "effect/testing/TestClock"; import type * as Electron from "electron"; @@ -18,6 +19,7 @@ import * as DesktopWindow from "../window/DesktopWindow.ts"; function layerElectronApp( appListeners: Map void>, quit: Effect.Effect = Effect.void, + exit: (code: number) => Effect.Effect = () => Effect.void, ) { const registerListener = (eventName: string, listener: (...args: readonly unknown[]) => void) => Effect.acquireRelease( @@ -37,7 +39,7 @@ function layerElectronApp( whenReady: Effect.void, requestSingleInstanceLock: Effect.succeed(true), quit, - exit: () => Effect.void, + exit, relaunch: () => Effect.void, setPath: () => Effect.void, setName: () => Effect.void, @@ -221,6 +223,60 @@ describe("DesktopLifecycle", () => { }), ); + it.effect("exits on a repeated quit only once shutdown has stalled", () => + Effect.gen(function* () { + const appListeners = new Map void>(); + const exitCodes: number[] = []; + const exit = (code: number) => + Effect.sync(() => { + exitCodes.push(code); + }); + const layerEnvironment = Layer.succeed(DesktopEnvironment.DesktopEnvironment, { + platform: "darwin", + isDevelopment: false, + } as DesktopEnvironment.DesktopEnvironment["Service"]); + const layer = DesktopLifecycle.layer.pipe( + Layer.provideMerge(layerElectronApp(appListeners, Effect.void, exit)), + Layer.provideMerge(layerElectronTheme), + Layer.provideMerge(layerElectronWindow()), + Layer.provideMerge(layerDesktopWindow()), + Layer.provideMerge(layerEnvironment), + // Nothing marks this shutdown complete, so it stays pending. + Layer.provideMerge(DesktopShutdown.layer), + Layer.provideMerge(DesktopState.layer), + ); + + yield* Effect.scoped( + Effect.gen(function* () { + const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; + yield* lifecycle.register; + + let prevented = 0; + const event = { + preventDefault: () => { + prevented += 1; + }, + } as Electron.Event; + appListeners.get("before-quit")?.(event); + appListeners.get("before-quit")?.(event); + yield* TestClock.adjust("9999 millis"); + appListeners.get("before-quit")?.(event); + assert.deepEqual( + exitCodes, + [], + "a repeat before the threshold must not cut shutdown short", + ); + + yield* TestClock.adjust("1 millis"); + appListeners.get("before-quit")?.(event); + + assert.deepEqual(exitCodes, [0]); + assert.equal(prevented, 4); + }), + ).pipe(Effect.provide(layer)); + }), + ); + it.effect("ignores app activation while quitting", () => Effect.gen(function* () { const appListeners = new Map void>(); diff --git a/apps/desktop/src/app/DesktopLifecycle.ts b/apps/desktop/src/app/DesktopLifecycle.ts index 924341781aa6..7512d2bc1741 100644 --- a/apps/desktop/src/app/DesktopLifecycle.ts +++ b/apps/desktop/src/app/DesktopLifecycle.ts @@ -1,4 +1,6 @@ +import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; @@ -94,15 +96,50 @@ const requestDesktopShutdownAndWait = Effect.fn("desktop.lifecycle.requestShutdo }, ); +interface QuitState { + phase: "running" | "waiting" | "allowed"; + waitingSinceNanos: bigint; +} + +// Longer than a backend's stop budget (SIGTERM, 5s grace, SIGKILL, 1s wait), +// so a double Cmd-Q at the start of a normal quit cannot cut the backend stop +// short and orphan it. +const SHUTDOWN_STALL_THRESHOLD = Duration.seconds(10); + +const startWaitingForShutdown = Effect.fn("desktop.lifecycle.startWaitingForShutdown")(function* ( + quit: QuitState, +) { + quit.phase = "waiting"; + quit.waitingSinceNanos = yield* Clock.monotonicTimeNanos; +}); + +// While a quit waits on shutdown, `quitting` makes activation open nothing. If +// that shutdown stalls, a repeated Quit or SIGTERM is the user's only way out +// short of SIGKILL. +const exitIfShutdownStalled = Effect.fn("desktop.lifecycle.exitIfShutdownStalled")(function* ( + quit: QuitState, + source: string, +) { + const pendingMs = Number( + ((yield* Clock.monotonicTimeNanos) - quit.waitingSinceNanos) / 1_000_000n, + ); + if (pendingMs < Duration.toMillis(SHUTDOWN_STALL_THRESHOLD)) { + yield* logLifecycleInfo("quit already in progress", { source, pendingMs }); + return; + } + yield* logLifecycleError("desktop shutdown stalled; exiting without it", { source, pendingMs }); + const electronApp = yield* ElectronApp.ElectronApp; + yield* electronApp.exit(0); +}); + function handleBeforeQuit( event: Electron.Event, runEffect: ( effect: Effect.Effect, ) => Promise, - allowQuit: () => boolean, - markQuitAllowed: () => void, + quit: QuitState, ): void { - if (allowQuit()) { + if (quit.phase === "allowed") { void runEffect( Effect.gen(function* () { const state = yield* DesktopState.DesktopState; @@ -114,10 +151,15 @@ function handleBeforeQuit( } event.preventDefault(); + if (quit.phase === "waiting") { + void runEffect(exitIfShutdownStalled(quit, "before-quit")); + return; + } void runEffect( Effect.gen(function* () { const state = yield* DesktopState.DesktopState; const electronWindow = yield* ElectronWindow.ElectronWindow; + yield* startWaitingForShutdown(quit); yield* Ref.set(state.quitting, true); yield* logLifecycleInfo("before-quit received"); yield* requestDesktopShutdownAndWait( @@ -129,7 +171,7 @@ function handleBeforeQuit( ); }).pipe(Effect.withSpan("desktop.lifecycle.beforeQuit")), ).finally(() => { - markQuitAllowed(); + quit.phase = "allowed"; void runEffect( Effect.gen(function* () { const electronApp = yield* ElectronApp.ElectronApp; @@ -144,7 +186,12 @@ function quitFromSignal( runEffect: ( effect: Effect.Effect, ) => Promise, + quit: QuitState, ): void { + if (quit.phase === "waiting") { + void runEffect(exitIfShutdownStalled(quit, signal)); + return; + } void runEffect( Effect.gen(function* () { yield* Effect.annotateCurrentSpan({ signal }); @@ -152,8 +199,10 @@ function quitFromSignal( const state = yield* DesktopState.DesktopState; const wasQuitting = yield* Ref.getAndSet(state.quitting, true); if (wasQuitting) return; + yield* startWaitingForShutdown(quit); yield* logLifecycleInfo("process signal received", { signal }); yield* requestDesktopShutdownAndWait(); + quit.phase = "allowed"; yield* electronApp.quit; }).pipe(Effect.withSpan("desktop.lifecycle.processSignal")), ); @@ -196,8 +245,7 @@ export const make = DesktopLifecycle.of({ const environment = yield* DesktopEnvironment.DesktopEnvironment; const context = yield* Effect.context(); const runEffect = Effect.runPromiseWith(context); - let quitAllowed = false; - let updaterQuitAllowed = false; + const quit: QuitState = { phase: "running", waitingSinceNanos: 0n }; yield* electronTheme.onUpdated(() => { void runEffect( desktopWindow.syncAppearance.pipe(Effect.withSpan("desktop.lifecycle.themeUpdated")), @@ -207,7 +255,7 @@ export const make = DesktopLifecycle.of({ // Electron's updater owns the remaining quit/install/relaunch sequence. // Cancelling the following app "before-quit" event breaks that sequence, // most visibly on macOS where the native updater performs the relaunch. - updaterQuitAllowed = true; + quit.phase = "allowed"; // This event is synchronous and the updater's quit proceeds as soon as // the listener returns, so a forked destroyAll would race the quit // and windows could still be open when the process exits (visible on @@ -223,14 +271,7 @@ export const make = DesktopLifecycle.of({ ); }); yield* electronApp.on("before-quit", (event: Electron.Event) => { - handleBeforeQuit( - event, - runEffect, - () => quitAllowed || updaterQuitAllowed, - () => { - quitAllowed = true; - }, - ); + handleBeforeQuit(event, runEffect, quit); }); yield* electronApp.on("activate", () => { void runEffect( @@ -255,10 +296,10 @@ export const make = DesktopLifecycle.of({ if (environment.platform !== "win32") { yield* addScopedListener(process, "SIGINT", () => { - quitFromSignal("SIGINT", runEffect); + quitFromSignal("SIGINT", runEffect, quit); }); yield* addScopedListener(process, "SIGTERM", () => { - quitFromSignal("SIGTERM", runEffect); + quitFromSignal("SIGTERM", runEffect, quit); }); } }).pipe(Effect.withSpan("desktop.lifecycle.register")), From 1df9c32943892d458c50534a9f7f896c5386e773 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Fri, 23 Oct 2026 07:06:51 -0500 Subject: [PATCH 31/59] fix(web): keep blockquote text readable in every theme (#24036) --- apps/web/src/index.css | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 01fa601ab666..814cfa484dcd 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1980,16 +1980,13 @@ code { } :is(.chat-markdown, .composer-tiptap) blockquote { - border-inline-start: 2px solid var(--contrast-border); + border-inline-start: 2px solid color-mix(in srgb, currentcolor 35%, transparent); padding-inline-start: 0.8rem; - color: var(--contrast-muted-foreground); + color: inherit; } -/* A quote in the composer is text the user is still writing; muting it reads - as disabled in an input, so the border alone carries the quote. */ .composer-tiptap blockquote { margin: 0.65rem 0; - color: var(--foreground); } /* Preflight already draws the 1px rule, as it does in chat; only the block From c96a4bb481042eb175f1b1c099079f6907200e08 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:38:33 +0530 Subject: [PATCH 32/59] fix(bitbucket): stop the startup sweep from flooding Bitbucket with 429s (#23889) Co-authored-by: PR Batch Tester Co-authored-by: Claude Opus 5.5 --- apps/server/src/git/GitManager.ts | 9 +- .../src/sourceControl/BitbucketApi.test.ts | 93 +++++++++++- .../SourceControlRepositoryService.ts | 32 +++-- .../src/sourceControl/builtInDrivers.ts | 5 +- .../src/server/BitbucketApi.ts | 135 ++++++++++++------ .../BitbucketPullRequestProvider.test.ts | 12 ++ .../server/BitbucketPullRequestProvider.ts | 3 + 7 files changed, 226 insertions(+), 63 deletions(-) diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 1b5002ec110e..ae1b22157a99 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -95,6 +95,7 @@ import * as ServerSettings from "../serverSettings.ts"; import type { GitManagerServiceError } from "@t3tools/contracts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; +import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; import type { ChangeRequest } from "@t3tools/contracts"; @@ -2715,6 +2716,9 @@ export const make = Effect.gen(function* () { }, ); + // A user is waiting on these, so a host's background rate-limit pause must not refuse them. + const asUserAction = Effect.provideService(SourceControlRateLimit.Interactive, true); + const resolvePullRequest: GitManager["Service"]["resolvePullRequest"] = Effect.fn( "resolvePullRequest", )(function* (input) { @@ -2726,7 +2730,7 @@ export const make = Effect.gen(function* () { .pipe(Effect.map((resolved) => toResolvedPullRequest(resolved))); return { pullRequest }; - }); + }, asUserAction); const ensureNoTrackedChanges = Effect.fn("ensureNoTrackedChanges")(function* ( cwd: string, @@ -3036,7 +3040,7 @@ export const make = Effect.gen(function* () { isOnPullRequestHead: true, }; }).pipe(Effect.ensuring(invalidateStatus(input.cwd))); - }); + }, asUserAction); const runFeatureBranchStep = Effect.fn("runFeatureBranchStep")(function* ( settings: SourceControlTextGenerationSettings, @@ -3281,6 +3285,7 @@ export const make = Effect.gen(function* () { ), ); }, + asUserAction, ); return GitManager.of({ diff --git a/apps/server/src/sourceControl/BitbucketApi.test.ts b/apps/server/src/sourceControl/BitbucketApi.test.ts index 38a5f112c382..cfbcbd6a4fd0 100644 --- a/apps/server/src/sourceControl/BitbucketApi.test.ts +++ b/apps/server/src/sourceControl/BitbucketApi.test.ts @@ -3,14 +3,19 @@ import { assert, it, vi } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as ConfigProvider from "effect/ConfigProvider"; import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; +import * as TestClock from "effect/testing/TestClock"; import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/http"; import * as BitbucketApi from "@t3tools/source-control-bitbucket/server/BitbucketApi"; +import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; import * as ServerSettings from "../serverSettings.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; @@ -60,6 +65,8 @@ const repositoryJson = { function makeLayer(input: { readonly response: (request: HttpClientRequest.HttpClientRequest) => Response; + /** Holds the answer until it resolves, in place of answering at once. */ + readonly answer?: (response: Response) => Effect.Effect; readonly requestFailure?: ( request: HttpClientRequest.HttpClientRequest, ) => HttpClientError.HttpClientError; @@ -72,7 +79,9 @@ function makeLayer(input: { ? Effect.never : input.requestFailure ? Effect.fail(input.requestFailure(request)) - : Effect.succeed(HttpClientResponse.fromWeb(request, input.response(request))), + : (input.answer ?? Effect.succeed)(input.response(request)).pipe( + Effect.map((response) => HttpClientResponse.fromWeb(request, response)), + ), ); const gitMock = { readConfigValue: vi.fn(() => @@ -144,6 +153,7 @@ function makeLayer(input: { const layer = BitbucketApi.layer.pipe( Layer.provide(ServerSourceControlHost.layer), + Layer.provide(SourceControlRateLimit.layer), Layer.provide(Layer.mock(VcsProcess.VcsProcess)({})), Layer.provide( Layer.succeed( @@ -736,3 +746,84 @@ it.effect("cuts a response short rather than reading an unbounded diff into memo ), ), ); + +it.effect("pauses background reads after a 429 until Bitbucket's retry-after passes", () => { + let answered = 0; + const { execute, layer } = makeLayer({ + response: () => { + answered += 1; + return answered === 1 + ? new Response("Rate limit exceeded", { status: 429, headers: { "retry-after": "60" } }) + : Response.json({ values: [] }); + }, + }); + + return Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + const lookup = bitbucket.listPullRequests({ + cwd: "/repo", + headSelector: "feature/a", + state: "all", + }); + + yield* Effect.flip(lookup); + const paused = yield* Effect.flip(lookup); + // Keyed by the repository host, where PullRequestService records its own backoff. + assert.deepInclude(paused, { + _tag: "SourceControlRateLimitPausedError", + host: "bitbucket.org", + retryAt: 60_000, + }); + assert.strictEqual(execute.mock.calls.length, 1); + + // A user waiting on an answer is not held back by the background pause, and their success + // does not end it early. + yield* lookup.pipe(Effect.provideService(SourceControlRateLimit.Interactive, true)); + yield* bitbucket.request({ + method: "POST", + url: "/repositories/acme/web/pullrequests/1/approve", + }); + + yield* TestClock.adjust("59 seconds"); + assert.strictEqual((yield* Effect.flip(lookup))._tag, "SourceControlRateLimitPausedError"); + + yield* TestClock.adjust("1 second"); + yield* lookup; + }).pipe(Effect.provide(layer)); +}); + +it.effect("keeps a sweep over many branches to a few Bitbucket requests at a time", () => + Effect.gen(function* () { + const started = yield* Queue.unbounded(); + const release = yield* Deferred.make(); + let inFlight = 0; + let peak = 0; + const { layer } = makeLayer({ + response: () => Response.json({ values: [] }), + answer: (response) => + Effect.gen(function* () { + inFlight += 1; + peak = Math.max(peak, inFlight); + yield* Queue.offer(started, undefined); + yield* Deferred.await(release); + inFlight -= 1; + return response; + }), + }); + + yield* Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + const sweep = yield* Effect.forEach( + Array.from({ length: 12 }, (_, index) => `feature/${index}`), + (branch) => + bitbucket.listPullRequests({ cwd: "/repo", headSelector: branch, state: "all" }), + { concurrency: "unbounded", discard: true }, + ).pipe(Effect.forkChild); + for (let index = 0; index < 4; index += 1) yield* Queue.take(started); + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(sweep); + }).pipe(Effect.provide(layer)); + + assert.strictEqual(peak, 4); + }), +); diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.ts index 64fd0c9eec64..cba0df9d9056 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.ts @@ -24,6 +24,7 @@ import { import * as ServerConfig from "../config.ts"; import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; +import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; import { parseGitCloneProgressLine, type GitCloneProgressLine, @@ -231,20 +232,23 @@ export const make = Effect.gen(function* () { ); }; - const lookupRepository = Effect.fn("SourceControlRepositoryService.lookupRepository")(function* ( - input: SourceControlRepositoryLookupInput, - ) { - const providerKind = yield* ensureConcreteProvider({ - operation: "lookupRepository", - provider: input.provider, - }); - const provider = yield* providers.get(providerKind); - const urls = yield* provider.getRepositoryCloneUrls({ - cwd: input.cwd ?? config.cwd, - repository: input.repository.trim(), - }); - return toRepositoryInfo(providerKind, urls); - }); + const lookupRepository = Effect.fn("SourceControlRepositoryService.lookupRepository")( + function* (input: SourceControlRepositoryLookupInput) { + const providerKind = yield* ensureConcreteProvider({ + operation: "lookupRepository", + provider: input.provider, + }); + const provider = yield* providers.get(providerKind); + const urls = yield* provider.getRepositoryCloneUrls({ + cwd: input.cwd ?? config.cwd, + repository: input.repository.trim(), + }); + return toRepositoryInfo(providerKind, urls); + }, + // Someone adding or cloning a repository is waiting, so a background rate-limit pause must not + // refuse the lookup. + Effect.provideService(SourceControlRateLimit.Interactive, true), + ); const normalizeDestinationPath = Effect.fn("SourceControlRepositoryService.normalizeDestination")( function* (destinationPath: string) { diff --git a/apps/server/src/sourceControl/builtInDrivers.ts b/apps/server/src/sourceControl/builtInDrivers.ts index 9212122d195e..9af54853a049 100644 --- a/apps/server/src/sourceControl/builtInDrivers.ts +++ b/apps/server/src/sourceControl/builtInDrivers.ts @@ -16,6 +16,7 @@ import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli"; import * as GitLabPullRequestCli from "@t3tools/source-control-gitlab/server/GitLabPullRequestCli"; import * as GitLabDriver from "@t3tools/source-control-gitlab/server/driver"; import type { SourceControlDriver } from "@t3tools/source-control-core/server/driver"; +import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; import * as Layer from "effect/Layer"; import * as ServerSourceControlHost from "./ServerSourceControlHost.ts"; @@ -41,7 +42,9 @@ export const layer = Layer.mergeAll( // reader shares, so the server builds it once, here. GitHubPullRequestApi.layer.pipe(Layer.provideMerge(GitHubRest.layerWithDependencies)), AzureDevOpsPullRequestCli.layer.pipe(Layer.provideMerge(AzureDevOpsCli.layer)), - BitbucketPullRequestApi.layer.pipe(Layer.provideMerge(BitbucketApi.layer)), + BitbucketPullRequestApi.layer.pipe( + Layer.provideMerge(BitbucketApi.layer.pipe(Layer.provide(SourceControlRateLimit.layer))), + ), ForgejoCli.layer, GitCafeApi.layer.pipe(Layer.provideMerge(GitCafeCredentials.layer)), GitLabPullRequestCli.layer.pipe(Layer.provideMerge(GitLabCli.layer)), diff --git a/packages/source-control-bitbucket/src/server/BitbucketApi.ts b/packages/source-control-bitbucket/src/server/BitbucketApi.ts index eaa607e3ef18..2da9b4630b4b 100644 --- a/packages/source-control-bitbucket/src/server/BitbucketApi.ts +++ b/packages/source-control-bitbucket/src/server/BitbucketApi.ts @@ -6,8 +6,10 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; import { NonNegativeInt, + SourceControlProviderKind, TrimmedNonEmptyString, type SourceControlProviderAuth, type SourceControlRepositoryCloneUrls, @@ -32,12 +34,14 @@ import * as SourceControlHost from "@t3tools/source-control-core/server/SourceCo import { readSourceControlHostSettings } from "@t3tools/source-control-core/client/definition"; import * as BitbucketClient from "../client/definition.ts"; -import { retryAtFromHeader } from "@t3tools/source-control-core/server/SourceControlRateLimit"; +import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; const DEFAULT_API_BASE_URL = "https://api.bitbucket.org/2.0"; const DEFAULT_MAX_RESPONSE_BYTES = 8 * 1024 * 1024; const MAX_REDIRECTS = 3; const BITBUCKET_API_REQUEST_TIMEOUT = "30 seconds"; +/** A sweep over many branches would otherwise reach Bitbucket as one burst of lookups. */ +const CONCURRENCY = 4; const BitbucketApiEnvConfig = Config.all({ baseUrl: Config.String("T3CODE_BITBUCKET_API_BASE_URL").pipe( @@ -262,6 +266,7 @@ export const BitbucketApiError = Schema.Union([ BitbucketRepositoryRemoteNotFoundError, BitbucketPullRequestBodyReadError, BitbucketCheckoutError, + SourceControlRateLimit.SourceControlRateLimitPausedError, ]); export type BitbucketApiError = typeof BitbucketApiError.Type; const isBitbucketApiError = Schema.is(BitbucketApiError); @@ -607,7 +612,7 @@ const responseError = Effect.fn("BitbucketApi.responseError")(function* ( response: HttpClientResponse.HttpClientResponse, ): Effect.fn.Return { const now = yield* Clock.currentTimeMillis; - const retryAt = retryAtFromHeader(response.headers["retry-after"], now); + const retryAt = SourceControlRateLimit.retryAtFromHeader(response.headers["retry-after"], now); const collected = yield* collectUint8StreamText({ stream: response.stream, maxBytes: DEFAULT_MAX_RESPONSE_BYTES, @@ -637,6 +642,13 @@ export const make = Effect.gen(function* () { const git = host.git; const httpClient = yield* HttpClient.HttpClient; const fileSystem = yield* FileSystem.FileSystem; + const limits = yield* SourceControlRateLimit.SourceControlRateLimit; + const slots = yield* Semaphore.make(CONCURRENCY); + // The repository host PullRequestService keys its own backoff under, so both share one pause. + const rateLimitKey = { + provider: SourceControlProviderKind.make("bitbucket"), + host: "bitbucket.org", + }; const apiUrl = (path: string) => `${config.baseUrl.replace(/\/+$/u, "")}${path}`; @@ -673,6 +685,33 @@ export const make = Effect.gen(function* () { ), ); + /** + * Every request waits for one of the shared slots, then honors the pause a 429 starts for the + * whole host. Each queued lookup would otherwise spend its own 429 and keep the quota exhausted. + * Background reads fail at once while paused; a write or an interactive read has a user waiting + * on it, so it still goes out. + */ + const throttled = Effect.fn("BitbucketApi.throttled")( + function* (method: string, effect: Effect.Effect) { + const interactive = yield* SourceControlRateLimit.Interactive; + const lease = yield* limits.check( + rateLimitKey, + interactive || method !== "GET" ? { allowPaused: true } : undefined, + ); + return yield* effect.pipe( + Effect.tap(() => limits.recordSuccess({ ...rateLimitKey, lease })), + Effect.tapError((error) => + (error._tag === "BitbucketResponseError" || + error._tag === "BitbucketResponseBodyReadError") && + error.status === 429 + ? limits.recordRateLimit({ ...rateLimitKey, lease, retryAt: error.retryAt }) + : Effect.void, + ), + ); + }, + (effect) => slots.withPermit(effect), + ); + const decodeResponse = ( operation: BitbucketApiOperation, schema: S, @@ -698,20 +737,23 @@ export const make = Effect.gen(function* () { request: HttpClientRequest.HttpClientRequest, schema: S, ): Effect.Effect => - withAuth(request.pipe(HttpClientRequest.acceptJson)).pipe( - Effect.flatMap(httpClient.execute), - Effect.mapError( - (cause) => - new BitbucketRequestError({ - operation, - cause, - }), + throttled( + request.method, + withAuth(request.pipe(HttpClientRequest.acceptJson)).pipe( + Effect.flatMap(httpClient.execute), + Effect.mapError( + (cause) => + new BitbucketRequestError({ + operation, + cause, + }), + ), + Effect.flatMap((response) => decodeResponse(operation, schema, response)), + Effect.timeout(BITBUCKET_API_REQUEST_TIMEOUT), + Effect.catchTags({ + TimeoutError: (cause) => Effect.fail(new BitbucketRequestError({ operation, cause })), + }), ), - Effect.flatMap((response) => decodeResponse(operation, schema, response)), - Effect.timeout(BITBUCKET_API_REQUEST_TIMEOUT), - Effect.catchTags({ - TimeoutError: (cause) => Effect.fail(new BitbucketRequestError({ operation, cause })), - }), ); const resolveRepository = Effect.fn("BitbucketApi.resolveRepository")(function* (input: { @@ -913,38 +955,41 @@ export const make = Effect.gen(function* () { }; const request: BitbucketApi["Service"]["request"] = (input) => - send({ ...input, redirects: 0 }).pipe( - Effect.flatMap((response) => - HttpClientResponse.matchStatus({ - // Read through the body stream rather than `text`, so an oversized diff is stopped as - // it arrives instead of being materialized whole and then cut. - "2xx": (success) => - collectUint8StreamText({ - stream: success.stream, - maxBytes: input.maxBytes ?? DEFAULT_MAX_RESPONSE_BYTES, - }).pipe( - Effect.mapError( - (cause) => - new BitbucketResponseBodyReadError({ - operation: "request", - status: success.status, - cause, - }), + throttled( + input.method, + send({ ...input, redirects: 0 }).pipe( + Effect.flatMap((response) => + HttpClientResponse.matchStatus({ + // Read through the body stream rather than `text`, so an oversized diff is stopped as + // it arrives instead of being materialized whole and then cut. + "2xx": (success) => + collectUint8StreamText({ + stream: success.stream, + maxBytes: input.maxBytes ?? DEFAULT_MAX_RESPONSE_BYTES, + }).pipe( + Effect.mapError( + (cause) => + new BitbucketResponseBodyReadError({ + operation: "request", + status: success.status, + cause, + }), + ), + Effect.map((collected) => ({ + body: collected.text, + truncated: collected.truncated, + })), ), - Effect.map((collected) => ({ - body: collected.text, - truncated: collected.truncated, - })), - ), - orElse: (failed) => responseError("request", failed), - })(response), + orElse: (failed) => responseError("request", failed), + })(response), + ), + // This also caps redirect hops: no request can finish headers then stall its body forever. + Effect.timeout(BITBUCKET_API_REQUEST_TIMEOUT), + Effect.catchTags({ + TimeoutError: (cause) => + Effect.fail(new BitbucketRequestError({ operation: "request", cause })), + }), ), - // This also caps redirect hops: no request can finish headers then stall its body forever. - Effect.timeout(BITBUCKET_API_REQUEST_TIMEOUT), - Effect.catchTags({ - TimeoutError: (cause) => - Effect.fail(new BitbucketRequestError({ operation: "request", cause })), - }), ); return BitbucketApi.of({ diff --git a/packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.test.ts b/packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.test.ts index 4211a3c93e09..0a485e3ea888 100644 --- a/packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.test.ts +++ b/packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.test.ts @@ -1,7 +1,9 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Result from "effect/Result"; +import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; import * as BitbucketApi from "./BitbucketApi.ts"; import * as BitbucketPullRequestApi from "./BitbucketPullRequestApi.ts"; @@ -188,6 +190,16 @@ describe("bitbucketProviderFailure", () => { expect(bitbucketProviderFailure(responseError(401)).reason).toBe("unauthenticated"); expect(bitbucketProviderFailure(responseError(403)).reason).toBe("failed"); }); + + it("reports a request held back by the host-wide pause as rate limited until it ends", () => { + const paused = new SourceControlRateLimit.SourceControlRateLimitPausedError({ + provider: SourceControlProviderKind.make("bitbucket"), + host: "bitbucket.org", + retryAt: 120_000, + }); + + expect(bitbucketProviderFailure(paused)).toEqual({ reason: "rate-limited", retryAt: 120_000 }); + }); }); describe("bitbucketViewerPermissions", () => { diff --git a/packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.ts b/packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.ts index e040a08f6a67..a266a328d62f 100644 --- a/packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.ts +++ b/packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.ts @@ -61,6 +61,9 @@ export function bitbucketProviderFailure( if (error._tag === "BitbucketResponseError" && error.status === 401) { return { reason: "unauthenticated" }; } + if (error._tag === "SourceControlRateLimitPausedError") { + return { reason: "rate-limited", retryAt: error.retryAt }; + } if ( (error._tag === "BitbucketResponseError" || error._tag === "BitbucketResponseBodyReadError") && error.status === 429 From eb1510d2493cbf28600432d5193cc22a1af8f3d8 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:48:30 +0530 Subject: [PATCH 33/59] fix(web): screen readers hear when the open thread starts, needs you or finishes (#23752) Co-authored-by: PR Batch Tester Co-authored-by: Claude Opus 5.5 --- apps/web/src/components/ChatView.tsx | 32 ++- .../src/components/chat/MessagesTimeline.tsx | 216 ++++++++++------- .../chat/ThreadActivityAnnouncer.tsx | 57 +++++ .../chat/threadActivityAnnouncement.test.ts | 221 ++++++++++++++++++ .../chat/threadActivityAnnouncement.ts | 166 +++++++++++++ .../components/sidebar/SidebarThreadRow.tsx | 8 +- 6 files changed, 603 insertions(+), 97 deletions(-) create mode 100644 apps/web/src/components/chat/ThreadActivityAnnouncer.tsx create mode 100644 apps/web/src/components/chat/threadActivityAnnouncement.test.ts create mode 100644 apps/web/src/components/chat/threadActivityAnnouncement.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 92707af8ca11..62223ba82096 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,5 +1,6 @@ import { ScrollToEndButton, WorkspaceFileDropOverlay } from "./chat/TimelineOverlays"; import { usePasteToFocusComposer } from "./chat/usePasteToFocusComposer"; +import { announcedRun, isDetailBehindShell } from "./chat/threadActivityAnnouncement"; import { deriveAvailableRevertTurnCounts, deriveThreadPendingBackgroundTasks, @@ -3275,11 +3276,22 @@ export default function ChatView(props: ChatViewProps) { } isCompacting={!paintOnlyDisplayedTimeline && isCompacting} awaitingUser={ - !paintOnlyDisplayedTimeline && - (activePendingApproval !== null || - activePendingUserInput !== null || - // A secret request has no runtime request; the shell carries it. - activeThreadShell?.hasPendingUserInput === true) + paintOnlyDisplayedTimeline + ? null + : activePendingApproval !== null + ? "approval" + : activePendingUserInput !== null || + // A secret request has no runtime request; the shell carries it. + activeThreadShell?.hasPendingUserInput === true + ? "input" + : null + } + awaitingRequestId={ + paintOnlyDisplayedTimeline + ? null + : (activePendingApproval?.requestId ?? + activePendingUserInput?.requestId ?? + null) } activeTurnStartedAt={paintOnlyDisplayedTimeline ? null : activeWorkStartedAt} worktreeSetup={paintOnlyDisplayedTimeline ? null : worktreeSetup} @@ -3315,6 +3327,15 @@ export default function ChatView(props: ChatViewProps) { listRef={legendListRef} runs={paintOnlyDisplayedTimeline ? [] : (serverProjection?.runs ?? [])} latestRun={paintOnlyDisplayedTimeline ? null : activeActivityRun} + announcedRun={ + paintOnlyDisplayedTimeline + ? null + : announcedRun(activeActivityRun, serverProjection?.runs ?? []) + } + detailBehindShell={ + !paintOnlyDisplayedTimeline && + isDetailBehindShell(activeLatestRun, activeThreadShell?.latestRun ?? null) + } runningRunId={paintOnlyDisplayedTimeline ? null : activeRunningTurnId} turnDiffSummaries={ paintOnlyDisplayedTimeline ? EMPTY_HELD_TURN_DIFF_SUMMARIES : turnDiffSummaries @@ -3325,6 +3346,7 @@ export default function ChatView(props: ChatViewProps) { routeThreadKey={displayedTimelineKey} displayThreadKey={displayedTimelineKey} paintOnly={paintOnlyDisplayedTimeline} + announceTurnStatus={!isProviderSubagent} onOpenTurnDiff={ paintOnlyDisplayedTimeline ? noopHeldTurnDiff : onOpenTurnDiffStable } diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index c3e2007c9aad..19b1aeb55a5d 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -112,6 +112,7 @@ import { import { V2EventTimelineRow } from "./TimelineV2EventRow"; import { useTimelinePositionRestore } from "./useTimelinePositionRestore"; import { useThreadFindPositionReader } from "./useThreadFindPositionReader"; +import { ThreadActivityAnnouncer } from "./ThreadActivityAnnouncer"; import { useStableHandoffRuns, useIncrementalTimelineRows, useStableRows } from "./useTimelineRows"; function timelineListStateFor(identity: string): { @@ -192,17 +193,25 @@ interface MessagesTimelineProps { onOpenWorktreeSetupTerminal?: (terminalId: string) => void; isPreparingWorktree?: boolean; isCompacting?: boolean; - awaitingUser?: boolean; + awaitingUser?: "approval" | "input" | null; + /** Which request is waiting, so a second one of the same kind is announced too. */ + awaitingRequestId?: string | null; footer?: ReactNode; listRef: React.RefObject; timelineEntries: ReadonlyArray; latestRun: TimelineLatestRun | null; + /** The run the screen reader announcer follows; see announcedRun. */ + announcedRun?: { readonly runId: RunId; readonly status: TimelineLatestRun["status"] } | null; + /** See isDetailBehindShell. */ + detailBehindShell?: boolean; runningRunId?: RunId | null; turnDiffSummaries: ReadonlyArray; routeThreadKey: string; displayThreadKey?: string; paintOnly?: boolean; + /** Off where another region already speaks the thread's status, like a subagent's bar. */ + announceTurnStatus?: boolean; onOpenTurnDiff: (runId: RunId, filePath?: string) => void; onOpenThread: (threadId: OrchestrationV2TurnItem["threadId"]) => void; parentThreadLink?: { @@ -299,7 +308,8 @@ const ConversationTimeline = memo(function ConversationTimeline({ onOpenWorktreeSetupTerminal, isPreparingWorktree = false, isCompacting = false, - awaitingUser = false, + awaitingUser = null, + awaitingRequestId = null, listRef, timelineEntries, findExpanded = false, @@ -308,11 +318,14 @@ const ConversationTimeline = memo(function ConversationTimeline({ findNavigationId = 0, findPositionReaderRef, latestRun, + announcedRun = null, + detailBehindShell = false, runningRunId = null, turnDiffSummaries, routeThreadKey, displayThreadKey, paintOnly = false, + announceTurnStatus = true, onOpenTurnDiff, onOpenThread, parentThreadLink = null, @@ -947,10 +960,22 @@ const ConversationTimeline = memo(function ConversationTimeline({ isRevertingCheckpoint, backgroundWorktreeSetup, activeTurnInProgress, - awaitingUser, + awaitingUser: awaitingUser !== null, isPreparingWorktree, latestRunId: latestRun?.runId ?? null, }; + // Mounted in both the empty and filled timeline so a thread's first turn + // starts from a baseline instead of a fresh, silent region. + const activityAnnouncer = ( + + ); const listHeaderLeadingContent = parentThreadLink === null ? ( topFadeEnabled ? ( @@ -1044,100 +1069,111 @@ const ConversationTimeline = memo(function ConversationTimeline({ footer === null ) { if (hideEmptyPlaceholder) { - return
; + return ( + <> + {activityAnnouncer} +
+ + ); } return ( -
-

- Send a message to start the conversation. -

-
+ <> + {activityAnnouncer} +
+

+ Send a message to start the conversation. +

+
+ ); } return ( - - - - {onCiteAssistantText && citationThreadRef ? ( - + {activityAnnouncer} + + + + {onCiteAssistantText && citationThreadRef ? ( + + ) : null} + + ref={setTimelineList} + data={rows} + extraData={listExtraData} + keyExtractor={keyExtractor} + getItemType={getItemType} + renderItem={renderItem} + estimatedItemSize={90} + initialScrollAtEnd={ + !findActive && citationRequest === null && rememberedPosition?.atEnd !== false + } + // Legend needs a data refresh to mount new pins without a scroll event. + dataVersion={readyCitationRequest?.key ?? listIdentityKey} + {...(alwaysRender ? { alwaysRender } : {})} + onLoad={handleListLoad} + {...(anchoredEndSpace ? { anchoredEndSpace } : {})} + contentInsetEndAdjustment={anchoredEndSpace ? contentInsetEndAdjustment : 0} + maintainScrollAtEnd={ + citationPositioning || + (restoringThreadPosition && rememberedPosition?.atEnd === false) || + anchoredEndSpace || + !liveFollowEnabled || + fullscreenAppRowId !== null || + disclosureToggleSettling + ? false + : isWorking && !prefersReducedMotion && settlingListIdentity === null + ? TIMELINE_MAINTAIN_SCROLL_AT_END_SMOOTH + : TIMELINE_MAINTAIN_SCROLL_AT_END + } + maintainVisibleContentPosition={ + findActive || + citationPositioning || + (restoringThreadPosition && rememberedPosition?.atEnd === false) + ? false + : maintainVisibleContentPosition + } + maintainScrollAtEndThreshold={1} + onScroll={handleScroll} + onItemSizeChanged={handleItemSizeChanged} + className={cn( + "messages-timeline-scroll scrollbar-gutter-both h-full min-h-0 overflow-x-hidden overscroll-y-contain [overflow-anchor:none]", + topFadeEnabled && "topbar-scroll-fade", + )} + ListHeaderComponent={listHeader} + ListFooterComponent={timelineListFooter} /> - ) : null} - - ref={setTimelineList} - data={rows} - extraData={listExtraData} - keyExtractor={keyExtractor} - getItemType={getItemType} - renderItem={renderItem} - estimatedItemSize={90} - initialScrollAtEnd={ - !findActive && citationRequest === null && rememberedPosition?.atEnd !== false - } - // Legend needs a data refresh to mount new pins without a scroll event. - dataVersion={readyCitationRequest?.key ?? listIdentityKey} - {...(alwaysRender ? { alwaysRender } : {})} - onLoad={handleListLoad} - {...(anchoredEndSpace ? { anchoredEndSpace } : {})} - contentInsetEndAdjustment={anchoredEndSpace ? contentInsetEndAdjustment : 0} - maintainScrollAtEnd={ - citationPositioning || - (restoringThreadPosition && rememberedPosition?.atEnd === false) || - anchoredEndSpace || - !liveFollowEnabled || - fullscreenAppRowId !== null || - disclosureToggleSettling - ? false - : isWorking && !prefersReducedMotion && settlingListIdentity === null - ? TIMELINE_MAINTAIN_SCROLL_AT_END_SMOOTH - : TIMELINE_MAINTAIN_SCROLL_AT_END - } - maintainVisibleContentPosition={ - findActive || - citationPositioning || - (restoringThreadPosition && rememberedPosition?.atEnd === false) - ? false - : maintainVisibleContentPosition - } - maintainScrollAtEndThreshold={1} - onScroll={handleScroll} - onItemSizeChanged={handleItemSizeChanged} - className={cn( - "messages-timeline-scroll scrollbar-gutter-both h-full min-h-0 overflow-x-hidden overscroll-y-contain [overflow-anchor:none]", - topFadeEnabled && "topbar-scroll-fade", - )} - ListHeaderComponent={listHeader} - ListFooterComponent={timelineListFooter} - /> - { - onManualNavigation(); - void listRef.current?.scrollToIndex({ - index: item.rowIndex, - animated: !window.matchMedia("(prefers-reduced-motion: reduce)").matches, - viewOffset: 24, - }); - }} - /> - - - + { + onManualNavigation(); + void listRef.current?.scrollToIndex({ + index: item.rowIndex, + animated: !window.matchMedia("(prefers-reduced-motion: reduce)").matches, + viewOffset: 24, + }); + }} + /> + + + + ); }); diff --git a/apps/web/src/components/chat/ThreadActivityAnnouncer.tsx b/apps/web/src/components/chat/ThreadActivityAnnouncer.tsx new file mode 100644 index 000000000000..0a1f13fcbc0c --- /dev/null +++ b/apps/web/src/components/chat/ThreadActivityAnnouncer.tsx @@ -0,0 +1,57 @@ +import { memo, useEffect, useRef } from "react"; + +import { + nextThreadAnnouncement, + type ThreadActivitySnapshot, + type ThreadAnnouncementMemory, +} from "./threadActivityAnnouncement"; + +/** + * One polite live region for the open thread. Text is written straight to the + * DOM so an announcement never re-renders the timeline. + */ +export const ThreadActivityAnnouncer = memo(function ThreadActivityAnnouncer({ + threadKey, + status, + requestId, + runId, + runStatus, + detailBehind, +}: ThreadActivitySnapshot & { + /** Null while the timeline shows another thread during a switch. */ + readonly threadKey: string | null; + /** See isDetailBehindShell. */ + readonly detailBehind: boolean; +}) { + const regionRef = useRef(null); + const memoryRef = useRef<{ threadKey: string; memory: ThreadAnnouncementMemory } | null>(null); + + useEffect(() => { + const region = regionRef.current; + if (threadKey === null) { + memoryRef.current = null; + if (region !== null) region.textContent = ""; + return; + } + if (detailBehind) return; + const previous = memoryRef.current?.threadKey === threadKey ? memoryRef.current.memory : null; + const { memory, announcement } = nextThreadAnnouncement(previous, { + status, + requestId, + runId, + runStatus, + }); + memoryRef.current = { threadKey, memory }; + if (region === null) return; + if (previous === null) { + region.textContent = ""; + } else if (announcement !== null) { + // Screen readers ignore a write that leaves the text unchanged, so a + // repeated "Done" gets a trailing non-breaking space to count as new. + region.textContent = region.textContent === announcement ? `${announcement} ` : announcement; + } + }, [threadKey, status, requestId, runId, runStatus, detailBehind]); + + // No role="status": Orca 50 maps it to a status bar in Chromium and drops its updates. + return
; +}); diff --git a/apps/web/src/components/chat/threadActivityAnnouncement.test.ts b/apps/web/src/components/chat/threadActivityAnnouncement.test.ts new file mode 100644 index 000000000000..b47895426306 --- /dev/null +++ b/apps/web/src/components/chat/threadActivityAnnouncement.test.ts @@ -0,0 +1,221 @@ +import { + MessageId, + type OrchestrationV2Run, + ProviderInstanceId, + RunId, + ThreadId, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import { describe, expect, it } from "vite-plus/test"; + +import { + announcedRun, + nextThreadAnnouncement, + type ThreadActivitySnapshot, + type ThreadAnnouncementMemory, +} from "./threadActivityAnnouncement"; + +const firstRun = RunId.make("run-1"); +const secondRun = RunId.make("run-2"); + +const idle = ( + runId: RunId | null, + runStatus: ThreadActivitySnapshot["runStatus"], +): ThreadActivitySnapshot => ({ status: "idle", requestId: null, runId, runStatus }); +const busy = ( + status: Exclude = "working", + runId: RunId | null = secondRun, + runStatus: ThreadActivitySnapshot["runStatus"] = "running", + requestId: string | null = status === "working" ? null : "request-1", +): ThreadActivitySnapshot => ({ status, requestId, runId, runStatus }); + +function speak(snapshots: ReadonlyArray) { + let memory: ThreadAnnouncementMemory | null = null; + const spoken: string[] = []; + for (const snapshot of snapshots) { + const result = nextThreadAnnouncement(memory, snapshot); + memory = result.memory; + if (result.announcement !== null) spoken.push(result.announcement); + } + return spoken; +} + +describe("nextThreadAnnouncement", () => { + it("follows a turn from start to finish", () => { + expect( + speak([idle(firstRun, "completed"), busy(), busy(), idle(secondRun, "completed")]), + ).toEqual(["Working", "Done"]); + }); + + it("says Working once when a send looks idle before its run appears", () => { + expect( + speak([idle(null, null), busy(), idle(null, null), busy(), idle(secondRun, "completed")]), + ).toEqual(["Working", "Done"]); + }); + + it("announces a queued message that starts as the last turn ends", () => { + const thirdRun = RunId.make("run-3"); + expect( + speak([ + idle(firstRun, "completed"), + busy("working", firstRun, "completed"), + busy(), + busy("working", secondRun, "completed"), + busy("working", thirdRun, "starting"), + idle(thirdRun, "completed"), + ]), + ).toEqual(["Working", "Working", "Done"]); + // The last turn's run is still settling when the follow-up is sent. + expect( + speak([ + idle(firstRun, "running"), + busy("working", firstRun, "running"), + busy("working", secondRun, "starting"), + ]), + ).toEqual(["Working"]); + expect( + speak([ + idle(firstRun, "running"), + busy("working", firstRun, "running"), + idle(firstRun, "completed"), + busy("working", secondRun, "starting"), + idle(secondRun, "completed"), + ]), + ).toEqual(["Working", "Done"]); + expect( + speak([ + busy("working", firstRun, "running"), + idle(firstRun, "running"), + busy("working", firstRun, "running"), + busy("working", secondRun, "starting"), + ]), + ).toEqual(["Working"]); + }); + + it("treats a run that starts while the thread looks idle as the next turn", () => { + const thirdRun = RunId.make("run-3"); + // A resume or a turn started from another device. + expect( + speak([ + idle(firstRun, "completed"), + idle(secondRun, "starting"), + busy("working", secondRun, "running"), + idle(secondRun, "completed"), + ]), + ).toEqual(["Working", "Done"]); + // A queued message waiting to start after the last turn. + expect( + speak([ + idle(firstRun, "completed"), + busy(), + idle(thirdRun, "queued"), + idle(thirdRun, "starting"), + busy("working", thirdRun, "running"), + idle(thirdRun, "completed"), + ]), + ).toEqual(["Working", "Working", "Done"]); + }); + + it("stays silent when a thread opens mid-turn or after one", () => { + expect(speak([idle(firstRun, "completed"), idle(firstRun, "completed")])).toEqual([]); + expect(speak([busy(), busy()])).toEqual([]); + }); + + it("announces the end of a turn that was already running when the thread opened", () => { + expect(speak([busy(), idle(secondRun, "failed")])).toEqual(["Failed"]); + }); + + it("says how the turn ended when a queued message was cancelled before it started", () => { + const now = DateTime.makeUnsafe("2026-06-20T00:00:00.000Z"); + const run = (id: RunId, ordinal: number, status: OrchestrationV2Run["status"]) => + ({ + id, + threadId: ThreadId.make("thread-1"), + ordinal, + providerInstanceId: ProviderInstanceId.make("codex"), + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + providerThreadId: null, + userMessageId: MessageId.make(`message-${ordinal}`), + rootNodeId: null, + activeAttemptId: null, + status, + requestedAt: now, + startedAt: status === "cancelled" ? null : now, + completedAt: now, + checkpointId: null, + contextHandoffId: null, + }) satisfies OrchestrationV2Run; + const cancelledRun = RunId.make("run-3"); + const ended = announcedRun({ runId: cancelledRun, status: "cancelled", startedAt: null }, [ + run(secondRun, 1, "completed"), + run(cancelledRun, 2, "cancelled"), + ]); + expect(speak([busy(), idle(ended?.runId ?? null, ended?.status ?? null)])).toEqual(["Done"]); + }); + + it("waits for the run to settle before announcing how the turn ended", () => { + expect( + speak([ + idle(firstRun, "completed"), + busy(), + idle(secondRun, "running"), + idle(secondRun, "interrupted"), + ]), + ).toEqual(["Working", "Stopped"]); + }); + + it("does not report the previous turn's ending when work stops before a new run exists", () => { + expect( + speak([ + idle(firstRun, "completed"), + busy("working", firstRun, "completed"), + idle(firstRun, "completed"), + busy(), + idle(secondRun, "completed"), + ]), + ).toEqual(["Working", "Working", "Done"]); + // The previous run finished while this client looked idle. + expect( + speak([ + idle(firstRun, "completed"), + idle(secondRun, "completed"), + busy("working", secondRun, "completed"), + idle(secondRun, "completed"), + ]), + ).toEqual(["Working"]); + }); + + it("lets the next turn start after one is rolled back", () => { + expect( + speak([ + idle(firstRun, "completed"), + busy(), + idle(secondRun, "rolled_back"), + busy("working", RunId.make("run-3")), + ]), + ).toEqual(["Working", "Working"]); + }); + + it("announces each request once without repeating that work resumed", () => { + expect( + speak([ + busy(), + busy("approval"), + busy("approval"), + busy(), + busy("input"), + idle(secondRun, "completed"), + ]), + ).toEqual(["Needs approval", "Needs your input", "Done"]); + }); + + it("announces a second request of the same kind once the first is answered", () => { + expect( + speak([ + busy(), + busy("approval", secondRun, "running", "request-1"), + busy("approval", secondRun, "running", "request-2"), + ]), + ).toEqual(["Needs approval", "Needs approval"]); + }); +}); diff --git a/apps/web/src/components/chat/threadActivityAnnouncement.ts b/apps/web/src/components/chat/threadActivityAnnouncement.ts new file mode 100644 index 000000000000..5e658748dbe6 --- /dev/null +++ b/apps/web/src/components/chat/threadActivityAnnouncement.ts @@ -0,0 +1,166 @@ +import type { OrchestrationV2Run, OrchestrationV2RunStatus, RunId } from "@t3tools/contracts"; +import { latestExecutedRun } from "@t3tools/shared/orchestrationV2ThreadError"; + +export type ThreadActivityStatus = "working" | "approval" | "input" | "idle"; + +export interface ThreadActivitySnapshot { + readonly status: ThreadActivityStatus; + /** The pending approval or question, when there is one. */ + readonly requestId: string | null; + readonly runId: RunId | null; + readonly runStatus: OrchestrationV2RunStatus | null; +} + +export interface ThreadAnnouncementMemory { + readonly snapshot: ThreadActivitySnapshot; + /** + * The turn under way: the run it belongs to, "unknown" until that run shows + * up, or null between turns. Its ending is announced once, then it clears. + */ + readonly turnRunId: RunId | "unknown" | null; + /** The run whose ending is already known, so a stale run is never announced as this turn's. */ + readonly settledRunId: RunId | null; + /** The run shown while idle belongs to the last turn, even if it is still settling. */ + readonly idleRunId: RunId | null; +} + +const ACTIVE_RUN_STATUSES: ReadonlySet = new Set([ + "preparing", + "starting", + "running", + "waiting", +]); + +const STARTING_RUN_STATUSES: ReadonlySet = new Set([ + "queued", + "preparing", + "starting", +]); + +function runOutcomeText(status: OrchestrationV2RunStatus | null): string | null { + switch (status) { + case "completed": + return "Done"; + case "failed": + return "Failed"; + case "interrupted": + case "cancelled": + return "Stopped"; + default: + return null; + } +} + +interface RunState { + readonly runId: RunId; + readonly status: OrchestrationV2RunStatus; +} + +/** + * The thread's detail hasn't caught up with the live thread shell: it isn't + * loaded yet, or a cached copy still shows an older run or state. Reading such + * a frame as the baseline replays what already happened, so it's skipped. + */ +export function isDetailBehindShell(detailRun: RunState | null, shellRun: RunState | null) { + return ( + shellRun !== null && + (detailRun?.runId !== shellRun.runId || detailRun.status !== shellRun.status) + ); +} + +/** + * A queued message cancelled before it started keeps its place in line, so it + * shows up as the thread's latest run. It never ran, so the turn ended however + * the last run that did ran. + */ +export function announcedRun( + activityRun: (RunState & { readonly startedAt: string | null }) | null, + runs: ReadonlyArray, +): RunState | null { + if (activityRun?.status !== "cancelled" || activityRun.startedAt !== null) return activityRun; + const run = latestExecutedRun(runs); + return run === null ? null : { runId: run.id, status: run.status }; +} + +/** + * Decides what a screen reader hears when the open thread's turn changes state. + * The first snapshot of a thread is a silent baseline, so opening or switching + * threads never replays what already happened. + */ +export function nextThreadAnnouncement( + previous: ThreadAnnouncementMemory | null, + next: ThreadActivitySnapshot, +): { readonly memory: ThreadAnnouncementMemory; readonly announcement: string | null } { + const idle = next.status === "idle"; + // A run still starting up while the thread looks idle (a queued message, + // a resume) is the next turn's, not a leftover from the last one. + const idleRunId = + idle && !STARTING_RUN_STATUSES.has(next.runStatus) ? next.runId : (previous?.idleRunId ?? null); + // A run doing new work; the run left over from the last turn doesn't count. + const newRunId = + !idle && ACTIVE_RUN_STATUSES.has(next.runStatus) && next.runId !== idleRunId + ? next.runId + : null; + + if (previous === null) { + return { + memory: { + snapshot: next, + turnRunId: idle ? null : (newRunId ?? "unknown"), + settledRunId: runOutcomeText(next.runStatus) === null ? null : next.runId, + idleRunId, + }, + announcement: null, + }; + } + const memory = { ...previous, snapshot: next, idleRunId }; + const { turnRunId } = previous; + + if (!idle) { + // Sending can look idle for a moment before the server reports the run, + // so a turn is tied to its run as soon as one appears, and only another + // run taking over (a queued message starting) is a new turn. + const turnStarted = + turnRunId === null || + (newRunId !== null && turnRunId !== "unknown" && newRunId !== turnRunId); + const nextTurnRunId = + turnStarted || turnRunId === "unknown" ? (newRunId ?? "unknown") : turnRunId; + const announcement = + next.status === "approval" + ? previous.snapshot.status === "approval" && previous.snapshot.requestId === next.requestId + ? null + : "Needs approval" + : next.status === "input" + ? previous.snapshot.status === "input" && previous.snapshot.requestId === next.requestId + ? null + : "Needs your input" + : turnStarted + ? "Working" + : null; + return { memory: { ...memory, turnRunId: nextTurnRunId }, announcement }; + } + + // Work can stop a moment before the run settles; the ending waits for the run. + const outcome = runOutcomeText(next.runStatus); + if (turnRunId === null) { + // A run that settled unseen is history too, or the next failed send would claim it. + const settledRunId = outcome === null ? previous.settledRunId : next.runId; + return { memory: { ...memory, settledRunId }, announcement: null }; + } + if (next.runStatus === "rolled_back") { + return { memory: { ...memory, turnRunId: null, settledRunId: next.runId }, announcement: null }; + } + if (outcome === null) return { memory, announcement: null }; + // Work stopped without a new run, e.g. a send that failed; the retry is a new turn. + if (next.runId === previous.settledRunId) { + return { memory: { ...memory, turnRunId: null }, announcement: null }; + } + // The last turn's run settling while this turn waits for its own run is not this turn's ending. + if (turnRunId === "unknown" && next.runId === previous.idleRunId) { + return { memory: { ...memory, settledRunId: next.runId }, announcement: null }; + } + return { + memory: { ...memory, turnRunId: null, settledRunId: next.runId }, + announcement: outcome, + }; +} diff --git a/apps/web/src/components/sidebar/SidebarThreadRow.tsx b/apps/web/src/components/sidebar/SidebarThreadRow.tsx index bff3c2c78aeb..ac7ae29afefc 100644 --- a/apps/web/src/components/sidebar/SidebarThreadRow.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadRow.tsx @@ -1344,8 +1344,12 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP ) : null} {/* The label alone is the live region: a role="status" wrapper around the ticking duration would make - screen readers announce every second. */} - {topStatus.label} + screen readers announce every second. The open + thread announces its own status, so its row stays + quiet instead of saying everything twice. */} + + {topStatus.label} + {status === "working" ? ( From f7f02d779e1d068a1458bd64ffe2933950dc0f41 Mon Sep 17 00:00:00 2001 From: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:10:20 +0530 Subject: [PATCH 34/59] perf(web): reduce allocations in streaming timeline scans (#23739) --- apps/web/src/components/chat/MessagesTimeline.logic.ts | 3 ++- apps/web/src/session-logic.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 45bb7936298d..5e9dd6b53959 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -2157,7 +2157,8 @@ function replaceStreamingMessageRows( return null; } const replacements = new Map>(); - for (const [index, entry] of timelineEntries.entries()) { + for (let index = 0; index < timelineEntries.length; index += 1) { + const entry = timelineEntries[index]!; const previousEntry = previousEntries[index]!; if (entry === previousEntry) continue; if ( diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index b504606b4d5e..1c2b06be8bbf 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -1005,7 +1005,8 @@ function reuseTimelineEntries( OrchestrationV2ProjectedTurnItem, OrchestrationV2ProjectedTurnItem >(); - for (const [index, previousItem] of before.visibleTurnItems.entries()) { + for (let index = 0; index < before.visibleTurnItems.length; index += 1) { + const previousItem = before.visibleTurnItems[index]!; const item = input.visibleTurnItems[index]!; if (item === previousItem) continue; if (!isStreamingTurnItemTextUpdate(previousItem, item)) return null; From 9828cad49f3e1ef4f9e037b3baaca19f4e4928fc Mon Sep 17 00:00:00 2001 From: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:11:19 +0530 Subject: [PATCH 35/59] perf(web): reuse a collator when sorting changed-file trees (#23776) --- apps/web/src/lib/turnDiffTree.test.ts | 38 +++++++++++++++++++++++++++ apps/web/src/lib/turnDiffTree.ts | 6 +++-- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/apps/web/src/lib/turnDiffTree.test.ts b/apps/web/src/lib/turnDiffTree.test.ts index e5a57693280b..582dba9569a8 100644 --- a/apps/web/src/lib/turnDiffTree.test.ts +++ b/apps/web/src/lib/turnDiffTree.test.ts @@ -3,6 +3,44 @@ import { describe, expect, it } from "vite-plus/test"; import { buildTurnDiffTree } from "./turnDiffTree"; describe("buildTurnDiffTree", () => { + it("sorts directory and file names numerically with directories first", () => { + const tree = buildTurnDiffTree([ + { path: "file10.ts", kind: "modified", additions: 1, deletions: 0 }, + { path: "dir10/file10.ts", kind: "modified", additions: 1, deletions: 0 }, + { path: "file2.ts", kind: "modified", additions: 1, deletions: 0 }, + { path: "dir2/file10.ts", kind: "modified", additions: 1, deletions: 0 }, + { path: "dir2/file2.ts", kind: "modified", additions: 1, deletions: 0 }, + ]); + + expect(tree.map((node) => node.name)).toEqual(["dir2", "dir10", "file2.ts", "file10.ts"]); + const directory = tree[0]; + expect(directory?.kind).toBe("directory"); + if (directory?.kind !== "directory") throw new Error("Expected a directory"); + expect(directory.children.map((node) => node.name)).toEqual(["file2.ts", "file10.ts"]); + }); + + it("preserves input order for case- and accent-insensitive name ties", () => { + const tree = buildTurnDiffTree([ + { path: "a.ts", kind: "modified", additions: 1, deletions: 0 }, + { path: "A.ts", kind: "modified", additions: 2, deletions: 0 }, + { path: "FOLDER/a.ts", kind: "modified", additions: 3, deletions: 0 }, + { path: "folder/a.ts", kind: "modified", additions: 4, deletions: 0 }, + { path: "café.ts", kind: "modified", additions: 5, deletions: 0 }, + { path: "Cafe.ts", kind: "modified", additions: 6, deletions: 0 }, + { path: "cafe.ts", kind: "modified", additions: 7, deletions: 0 }, + ]); + + expect(tree.map((node) => node.name)).toEqual([ + "FOLDER", + "folder", + "a.ts", + "A.ts", + "café.ts", + "Cafe.ts", + "cafe.ts", + ]); + }); + it("builds nested directory nodes with aggregated stats", () => { const tree = buildTurnDiffTree([ { path: "src/index.ts", kind: "modified", additions: 2, deletions: 1 }, diff --git a/apps/web/src/lib/turnDiffTree.ts b/apps/web/src/lib/turnDiffTree.ts index cd9bfc831fbb..810a04755e89 100644 --- a/apps/web/src/lib/turnDiffTree.ts +++ b/apps/web/src/lib/turnDiffTree.ts @@ -30,7 +30,7 @@ interface MutableDirectoryNode { files: TurnDiffTreeFileNode[]; } -const SORT_LOCALE_OPTIONS: Intl.CollatorOptions = { numeric: true, sensitivity: "base" }; +let sortCollator: Intl.Collator | undefined; function normalizePathSegments(pathValue: string): string[] { return pathValue @@ -40,7 +40,9 @@ function normalizePathSegments(pathValue: string): string[] { } function compareByName(a: { name: string }, b: { name: string }): number { - return a.name.localeCompare(b.name, undefined, SORT_LOCALE_OPTIONS); + // Defer ICU initialization until the first name comparison. + sortCollator ??= new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }); + return sortCollator.compare(a.name, b.name); } function readStat(file: TurnDiffFileChange): TurnDiffStat | null { From 66ee628e0875b2b735b199f42e6151e347833637 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 23 Oct 2026 07:09:37 -0700 Subject: [PATCH 36/59] perf(scripts): check:changed finishes in about 10 s (#23581) Co-authored-by: Claude Opus 5.5 --- docs/operations/development.md | 5 + knip.jsonc | 4 +- scripts/check-changed.ts | 179 +++++++++++++++++++++++---- scripts/knip-schemas.ts | 217 +++++++++++++++++++++++---------- 4 files changed, 314 insertions(+), 91 deletions(-) diff --git a/docs/operations/development.md b/docs/operations/development.md index 5b1212580388..c34dc108890b 100644 --- a/docs/operations/development.md +++ b/docs/operations/development.md @@ -160,6 +160,11 @@ files to packages with [ci-changes.cjs](../../.github/scripts/ci-changes.cjs), a compares against the merge base with `origin/main`, or with a ref you pass. CI still runs the full test suites and the jobs the summary lists, so a local pass does not promise a green CI run. +The steps run side by side, so a run takes about as long as its slowest step, usually knip's +exports pass at about 10 s. When the files are exactly those of the last passing run, it prints that +run's summary instead of running again. Any change to a file, the install, or a generated file runs +the checks again. + While you iterate, run single checks: ```sh diff --git a/knip.jsonc b/knip.jsonc index babc22932ca0..e85064e50914 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -32,7 +32,9 @@ ], "ignoreDependencies": ["esbuild"], // The `t3` npm package's files: copied into the package, never imported. - "ignore": ["npm-launcher/**"], + // knip-schemas.test.ts writes a throwaway project to .knip-test-*, which a + // knip run beside it in check:changed must not report. + "ignore": ["npm-launcher/**", ".knip-test-*/**"], }, "apps/server": { // Vite+ pack entries and the launcher used by installed background services. diff --git a/scripts/check-changed.ts b/scripts/check-changed.ts index 11376b85106b..81850389c4d4 100644 --- a/scripts/check-changed.ts +++ b/scripts/check-changed.ts @@ -10,7 +10,10 @@ // // Uncommitted and untracked files count. Affected packages come from // .github/scripts/ci-changes.cjs, the same mapping CI uses to gate its jobs. +// The steps run side by side; each prints its output when it finishes. A +// rerun on exactly the files of the last passing run prints that run's summary. import * as NodeChildProcess from "node:child_process"; +import * as NodeCrypto from "node:crypto"; import * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; @@ -19,8 +22,13 @@ import { decide, readWorkspace } from "../.github/scripts/ci-changes.cjs"; const root = NodePath.resolve(import.meta.dirname, ".."); const CODE = /\.[cm]?[jt]sx?$/; +// quotePath=false keeps non-ASCII paths as real paths instead of quoted escapes. const git = (...args: Array) => - NodeChildProcess.execFileSync("git", args, { cwd: root, encoding: "utf8" }) + NodeChildProcess.execFileSync("git", ["-c", "core.quotePath=false", ...args], { + cwd: root, + encoding: "utf8", + maxBuffer: 64 * 1024 * 1024, + }) .split("\n") .filter(Boolean); @@ -46,6 +54,75 @@ if (files.length === 0) { console.log(`check:changed: no changes since ${base.slice(0, 12)}.`); process.exit(0); } +console.log(`check:changed: ${files.length} files since ${base.slice(0, 12)}`); + +// The checked tree is the merge base plus the diff plus the untracked files. +// Ignored generated sources, .astro types, generated declarations such as +// apps/mobile/expo-env.d.ts, env files, and the install state can change results +// too, so they are part of the key. Other ignored folders (iOS Pods, dist, the +// Electron runtime) are large and no check reads them. +// Computed again after a passing run, so edits made during the run are never +// cached as passing. +const inputKey = () => { + const hash = NodeCrypto.createHash("sha256"); + // CI changes test behavior (vite.config.ts rejects .only under CI). + hash.update(`${process.version}\0${base}\0${process.env.CI ?? ""}\0`); + hash.update( + NodeChildProcess.execFileSync( + "git", + ["diff", "--binary", "--no-renames", "--no-ext-diff", "--no-textconv", base, "--"], + { cwd: root, maxBuffer: 1024 * 1024 * 1024 }, + ), + ); + const ignored = git( + "ls-files", + "--others", + "--ignored", + "--exclude-standard", + "--", + ":(glob)**/.generated/**", + ":(glob)**/.astro/**", + ":(glob)**/*.d.ts", + ":(glob)**/.env*", + ":(exclude,glob)**/node_modules/**", + ); + for (const file of [ + // Raw bytes of changed files too: .gitattributes normalizes line endings in + // the diff, but the formatter reads the file as it is on disk. + ...git("diff", "--name-only", "--no-renames", base, "--"), + ...git("ls-files", "--others", "--exclude-standard"), + ...ignored, + "node_modules/.modules.yaml", + "node_modules/.pnpm/lock.yaml", + ]) { + hash.update(`\0${file}\0`); + const path = NodePath.join(root, file); + // Untracked nested repositories are listed as directories. + if (NodeFS.statSync(path, { throwIfNoEntry: false })?.isFile()) { + hash.update(NodeFS.readFileSync(path)); + } + } + return hash.digest("hex"); +}; +const key = inputKey(); +const cachePath = NodePath.join(root, "node_modules/.cache/check-changed.json"); +// A missing or unreadable cache is a miss, never a failure. +const readCache = () => { + try { + return JSON.parse(NodeFS.readFileSync(cachePath, "utf8")) as { + key: string; + summary: Array; + }; + } catch { + return undefined; + } +}; +const cached = readCache(); +if (cached?.key === key) { + console.log("Nothing changed since the last passing run.\n"); + for (const line of cached.summary) console.log(line); + process.exit(0); +} const present = files.filter((file) => NodeFS.existsSync(NodePath.join(root, file))); const code = present.filter((file) => CODE.test(file)); @@ -66,12 +143,24 @@ for (const file of code) { } } -// knip:check takes about 35 s and cannot be scoped to a package. Any code -// change can leave a file, export, or dependency unused (removing the last -// import of a helper does), so it runs whenever code or a manifest changes. +// knip:check cannot be scoped to a package. Any code change can leave a file, +// export, or dependency unused (removing the last import of a helper does), so +// it runs whenever code or a manifest changes. const runKnip = files.some( (file) => CODE.test(file) || NodePath.basename(file) === "package.json" || file === "knip.jsonc", ); +// CI runs knip:check as one script of `knip ... && knip ...` passes. The passes +// do not depend on each other, so they run side by side here. +const knipCheck: string = JSON.parse( + NodeFS.readFileSync(NodePath.join(root, "package.json"), "utf8"), +).scripts["knip:check"]; +const knipPasses = knipCheck.split(" && ").map((command, index, all) => { + const [bin, ...args] = command.trim().split(/\s+/); + if (bin !== "knip" || /["'$\\|;&<>]/.test(command)) { + throw new Error(`check:changed cannot split knip:check: ${command}`); + } + return { name: `knip ${index + 1}/${all.length}`, args: ["exec", "knip", ...args] }; +}); // A version bump can strand a patch in pnpm-workspace.yaml: no resolved // version uses it and pnpm rejects the install with ERR_PNPM_UNUSED_PATCH. @@ -153,14 +242,24 @@ const steps = [ args: ["fmt", "--check", "--no-error-on-unmatched-pattern", ...present], }, code.length > 0 && { name: "lint", args: ["lint", ...code] }, - runKnip && { name: "knip", args: ["run", "knip:check"] }, + ...(runKnip ? knipPasses : []), ...graphql.map((dir) => ({ name: `graphql (${dir})`, args: ["run", "--filter", `./${dir}`, "generate", "--check"], })), affected.length > 0 && { name: `typecheck (${affected.length} packages)`, - args: ["run", "--cache", "--concurrency-limit", "4", ...filters(affected), "typecheck"], + // `tsc --noEmit` writes nothing another package reads, so dependency order + // only made dependents wait: --parallel cut a contracts change from 13 s to 9 s. + args: [ + "run", + "--cache", + "--parallel", + "--concurrency-limit", + "4", + ...filters(affected), + "typecheck", + ], }, // From the repo root, the root vite.config.ts hands each file to its package's config. tests.size > 0 && { @@ -169,23 +268,36 @@ const steps = [ }, ].filter((step) => step !== false); -console.log(`check:changed: ${files.length} files since ${base.slice(0, 12)}`); -const results = steps.map((step) => { - console.log(`\n> vp ${step.name}`); - const started = performance.now(); - const status = - "args" in step - ? NodeChildProcess.spawnSync("vp", step.args, { cwd: root, stdio: "inherit" }).status - : step.run() - ? 0 - : 1; - const seconds = ((performance.now() - started) / 1000).toFixed(1); - return { name: step.name, ok: status === 0, seconds }; -}); +// Output is piped, so keep the colors a terminal would get. +const env = process.stdout.isTTY ? { ...process.env, FORCE_COLOR: "1" } : process.env; +const results = await Promise.all( + steps.map( + (step) => + new Promise<{ name: string; ok: boolean; seconds: string }>((resolve) => { + const started = performance.now(); + const finish = (ok: boolean, output?: Buffer) => { + const seconds = ((performance.now() - started) / 1000).toFixed(1); + console.log(`\n> vp ${step.name} ${ok ? "pass" : "FAIL"} ${seconds}s`); + if (output) process.stdout.write(output); + resolve({ name: step.name, ok, seconds }); + }; + // In-process steps print their own output. The patch check is first and + // takes about a second, so the commands start right after it. + if ("run" in step) return finish(step.run()); + const output: Array = []; + const child = NodeChildProcess.spawn("vp", step.args, { cwd: root, env }); + child.stdout.on("data", (chunk: Buffer) => output.push(chunk)); + child.stderr.on("data", (chunk: Buffer) => output.push(chunk)); + // A failed spawn emits "error", then "close" with a nonzero code. + child.on("error", (error) => output.push(Buffer.from(`${error.message}\n`))); + child.on("close", (status) => finish(status === 0, Buffer.concat(output))); + }), + ), +); -console.log("\ncheck:changed summary"); +const summary = ["check:changed summary"]; for (const result of results) { - console.log(` ${result.ok ? "pass" : "FAIL"} ${result.name} ${result.seconds}s`); + summary.push(` ${result.ok ? "pass" : "FAIL"} ${result.name} ${result.seconds}s`); } const ciOnly = Object.entries({ "full test suites": outputs.test || outputs.test_web || outputs.test_server, @@ -194,7 +306,24 @@ const ciOnly = Object.entries({ "mobile native lint": outputs.mobile_native, "release smoke": outputs.release_smoke, }).flatMap(([job, runs]) => (runs ? [job] : [])); -if (ciOnly.length > 0) console.log(` CI also runs: ${ciOnly.join(", ")}`); -if (!runPatchCheck) console.log(" unused patch check skipped: no dependency files changed"); -if (!runKnip) console.log(" knip skipped: no code or manifest changed"); -if (results.some((result) => !result.ok)) process.exit(1); +if (ciOnly.length > 0) summary.push(` CI also runs: ${ciOnly.join(", ")}`); +if (!runPatchCheck) summary.push(" unused patch check skipped: no dependency files changed"); +if (!runKnip) summary.push(" knip skipped: no code or manifest changed"); +console.log(`\n${summary.join("\n")}`); +// exitCode, not exit(): piped stdout is asynchronous on macOS, and exit() +// would cut off a failing step's output. +if (results.some((result) => !result.ok)) { + process.exitCode = 1; +} else if (inputKey() !== key) { + console.log(" pass not cached: files changed during the run"); +} else { + try { + // Write then rename, so a concurrent run never reads half a file. + const temp = `${cachePath}.${process.pid}`; + NodeFS.mkdirSync(NodePath.dirname(cachePath), { recursive: true }); + NodeFS.writeFileSync(temp, JSON.stringify({ key, summary })); + NodeFS.renameSync(temp, cachePath); + } catch (error) { + console.warn(` pass not cached: ${error instanceof Error ? error.message : String(error)}`); + } +} diff --git a/scripts/knip-schemas.ts b/scripts/knip-schemas.ts index 824c43eb31ff..314702626c11 100644 --- a/scripts/knip-schemas.ts +++ b/scripts/knip-schemas.ts @@ -1,14 +1,117 @@ // @effect-diagnostics nodeBuiltinImport:off - Knip and the TypeScript compiler host use synchronous Node paths. +import * as NodeOS from "node:os"; import * as NodePath from "node:path"; +import * as NodeWorkerThreads from "node:worker_threads"; import type { Preprocessor } from "knip"; // Knip needs the legacy compiler API, which TypeScript 7 no longer exports. import ts from "typescript-legacy"; const schemaTypeId = "~effect/Schema/Schema"; -const preprocess: Preprocessor = (options) => { +// One tsconfig's files, each with the export names its issues mention. Only +// those names are checked: inferring the type of every export cost seconds. +interface Project { + readonly cwd: string; + readonly configPath: string | undefined; + readonly files: ReadonlyArray]>; +} + +// Each tsconfig needs its own program. The programs share parsed and bound +// files (Effect's and Node's declarations) through one registry, as the +// language service does. Without it, building 17 programs took about 13 s. +const registry = ts.createDocumentRegistry(); + +// Returns each file with those of its names that are Effect schemas. +function findSchemas({ cwd, configPath, files }: Project) { + const config = configPath + ? ts.getParsedCommandLineOfConfigFile( + configPath, + {}, + { + ...ts.sys, + onUnRecoverableConfigFileDiagnostic: (diagnostic) => { + throw new Error(ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n")); + }, + }, + ) + : undefined; + if (config?.errors.length) { + throw new Error( + config.errors + .map((error) => ts.flattenDiagnosticMessageText(error.messageText, "\n")) + .join("\n"), + ); + } + const options: ts.CompilerOptions = { + module: ts.ModuleKind.NodeNext, + allowJs: true, + ...config?.options, + noEmit: true, + }; + const host = ts.createCompilerHost(options); + const key = registry.getKeyForCompilationSettings(options); + host.getSourceFile = (fileName, languageVersionOrOptions) => { + const text = host.readFile(fileName); + if (text === undefined) return undefined; + return registry.acquireDocumentWithKey( + fileName, + ts.sys.resolvePath(fileName) as ts.Path, + options, + key, + ts.ScriptSnapshot.fromString(text), + // Files do not change during one run. + "1", + undefined, + languageVersionOrOptions, + ); + }; + const program = ts.createProgram( + files.map(([file]) => NodePath.resolve(cwd, file)), + options, + host, + ); + const checker = program.getTypeChecker(); + return files.flatMap(([filePath, names]) => { + const source = program.getSourceFile(NodePath.resolve(cwd, filePath)); + const module = source && checker.getSymbolAtLocation(source); + if (!source || !module) return []; + const schemas = checker + .getExportsOfModule(module) + .filter((symbol) => names.includes(symbol.name)) + .flatMap((symbol) => { + const exported = + symbol.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(symbol) : symbol; + const target = + exported.flags & ts.SymbolFlags.Value + ? exported + : checker.resolveName(symbol.name, source, ts.SymbolFlags.Value, false); + if (!target) return []; + const type = checker.getTypeOfSymbolAtLocation(target, target.valueDeclaration ?? source); + const marker = type.getProperty(schemaTypeId); + if (!marker) return []; + const markerType = checker.getTypeOfSymbolAtLocation(marker, source); + return markerType.isStringLiteral() && markerType.value === schemaTypeId + ? [symbol.name] + : []; + }); + return [[filePath, schemas] as const]; + }); +} + +// The preprocessor loads this file again as each worker. +if (!NodeWorkerThreads.isMainThread) { + const projects: ReadonlyArray = NodeWorkerThreads.workerData; + NodeWorkerThreads.parentPort?.postMessage(projects.flatMap(findSchemas)); +} + +// Three workers measured fastest. Each one parses Effect again, so more +// workers cost more than they save. +const WORKERS = Math.min(3, NodeOS.availableParallelism()); + +// Drops unused-export issues whose every symbol is an Effect schema. +const preprocess = async (options: Parameters[0]) => { const categories = ["exports", "nsExports", "duplicates"] as const; - const projects = new Map>(); + const projects = new Map>>(); for (const category of categories) { for (const [filePath, issues] of Object.entries(options.issues[category])) { if (Object.keys(issues).length === 0) continue; @@ -16,76 +119,60 @@ const preprocess: Preprocessor = (options) => { NodePath.dirname(NodePath.resolve(options.cwd, filePath)), ts.sys.fileExists, ); - const files = projects.get(configPath) ?? new Set(); - files.add(filePath); + const files = projects.get(configPath) ?? new Map>(); + const names = files.get(filePath) ?? new Set(); + for (const issue of Object.values(issues)) { + for (const { symbol } of issue.symbols ?? [{ symbol: issue.symbol }]) names.add(symbol); + } + files.set(filePath, names); projects.set(configPath, files); } } - for (const [configPath, files] of projects) { - const config = configPath - ? ts.getParsedCommandLineOfConfigFile( - configPath, - {}, - { - ...ts.sys, - onUnRecoverableConfigFileDiagnostic: (diagnostic) => { - throw new Error(ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n")); - }, - }, - ) - : undefined; - if (config?.errors.length) { - throw new Error( - config.errors - .map((error) => ts.flattenDiagnosticMessageText(error.messageText, "\n")) - .join("\n"), - ); - } - const program = ts.createProgram( - [...files].map((file) => NodePath.resolve(options.cwd, file)), - { - module: ts.ModuleKind.NodeNext, - allowJs: true, - ...config?.options, - noEmit: true, - }, - ); - const checker = program.getTypeChecker(); - for (const filePath of files) { - const source = program.getSourceFile(NodePath.resolve(options.cwd, filePath)); - const module = source && checker.getSymbolAtLocation(source); - if (!source || !module) continue; - const schemas = new Set( - checker.getExportsOfModule(module).flatMap((symbol) => { - const exported = - symbol.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(symbol) : symbol; - const target = - exported.flags & ts.SymbolFlags.Value - ? exported - : checker.resolveName(symbol.name, source, ts.SymbolFlags.Value, false); - if (!target) return []; - const type = checker.getTypeOfSymbolAtLocation(target, target.valueDeclaration ?? source); - const marker = type.getProperty(schemaTypeId); - if (!marker) return []; - const markerType = checker.getTypeOfSymbolAtLocation(marker, source); - return markerType.isStringLiteral() && markerType.value === schemaTypeId - ? [symbol.name] - : []; + // Deal the projects out largest first so the workers get similar shares. + const shares = Array.from({ length: WORKERS }, (): Array => []); + [...projects] + .sort(([, a], [, b]) => b.size - a.size) + .forEach(([configPath, files], index) => { + shares[index % WORKERS]!.push({ + cwd: options.cwd, + configPath, + files: [...files].map(([file, names]) => [file, [...names]]), + }); + }); + const workers = shares + .filter((share) => share.length > 0) + .map((share) => new NodeWorkerThreads.Worker(new URL(import.meta.url), { workerData: share })); + const results = await Promise.all( + workers.map( + (worker) => + new Promise>((resolve, reject) => { + worker.once("message", resolve); + worker.once("error", reject); + worker.once("exit", (code) => { + if (code !== 0) reject(new Error(`knip-schemas worker exited with code ${code}`)); + }); }), - ); - for (const category of categories) { - const issues = options.issues[category][filePath]; - if (!issues) continue; - for (const [key, issue] of Object.entries(issues)) { - const symbols = issue.symbols ?? [{ symbol: issue.symbol }]; - if (symbols.length > 0 && symbols.every(({ symbol }) => schemas.has(symbol))) { - delete issues[key]; - options.counters[category]--; - } + ), + ).catch(async (error: unknown) => { + // knip only sets an exit code on a failed preprocessor, so stop the + // siblings or the failed run waits for their work. + await Promise.allSettled(workers.map((worker) => worker.terminate())); + throw error; + }); + + for (const [filePath, names] of results.flat()) { + for (const category of categories) { + const issues = options.issues[category][filePath]; + if (!issues) continue; + for (const [key, issue] of Object.entries(issues)) { + const symbols = issue.symbols ?? [{ symbol: issue.symbol }]; + if (symbols.length > 0 && symbols.every(({ symbol }) => names.includes(symbol))) { + delete issues[key]; + options.counters[category]--; } - if (Object.keys(issues).length === 0) delete options.issues[category][filePath]; } + if (Object.keys(issues).length === 0) delete options.issues[category][filePath]; } } return options; From 97be291eb1562378f055ebce0f169d1d842b53f0 Mon Sep 17 00:00:00 2001 From: Yukun Shan <92423096+nateEc@users.noreply.github.com> Date: Sat, 24 Oct 2026 01:25:39 +0800 Subject: [PATCH 37/59] fix(web): size Usage header controls to the available width (#24123) --- apps/web/src/components/usage/UsagePage.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index fd61b63af4b7..6552e10c41b3 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -364,7 +364,7 @@ export function UsagePage() { ? `${formatDateTimeShort(shownWindow.sinceTime, shownWindow.timeZone, hour12)} to ${formatDateTimeShort(shownWindow.untilTime, shownWindow.timeZone, hour12)}` : `${formatDayShort(shownWindow.sinceDay)} to ${formatDayShort(shownWindow.untilDay)}`; const topbarContent = ( -
+

Usage

@@ -393,11 +393,11 @@ export function UsagePage() {
{!showingLimits ? ( - + {windowLabel} ) : null} -
+
-
+
{ + // Menu typeahead would otherwise take the letters; arrows still reach the items. + if (event.key !== "ArrowDown" && event.key !== "Escape") event.stopPropagation(); + onKeyDown?.(event); + }} + /> +
+
+ ); +} + function MenuGroup(props: MenuPrimitive.Group.Props) { return ; } @@ -327,6 +367,7 @@ export { MenuTrigger as DropdownMenuTrigger, MenuPopup, MenuPopup as DropdownMenuContent, + MenuSearchInput, MenuGroup, MenuGroup as DropdownMenuGroup, MenuItem, diff --git a/apps/web/src/state/pullRequests.ts b/apps/web/src/state/pullRequests.ts index 64d761f4d53b..8757c4b6caf0 100644 --- a/apps/web/src/state/pullRequests.ts +++ b/apps/web/src/state/pullRequests.ts @@ -9,6 +9,7 @@ import type { EnvironmentId, ProjectId, PullRequestListInput, + PullRequestFilterOptionsInput, PullRequestListStatsInput, PullRequestListEntry, PullRequestRef, @@ -308,6 +309,11 @@ const usePullRequestStatsQuery = createMergedEnvironmentQuery( pullRequestEnvironment.listStats, ); +const usePullRequestFilterOptionsQuery = createMergedEnvironmentQuery( + "web-pull-requests:filter-options", + pullRequestEnvironment.filterOptions, +); + const usePullRequestTurnRefreshQuery = createMergedEnvironmentQuery( "web-pull-requests:turn-refreshes", ({ environmentId }: EnvironmentQueryTarget>>) => @@ -376,3 +382,15 @@ export function usePullRequestListStats( ); return { stats, isPending: query.isPending, refresh: query.refresh }; } + +export function usePullRequestFilterOptions( + targets: ReadonlyArray>, +) { + const query = usePullRequestFilterOptionsQuery(targets); + return { + labels: query.values.flatMap(([, options]) => options.labels), + authors: query.values.flatMap(([, options]) => options.authors), + error: query.error, + isPending: query.isPending, + }; +} diff --git a/packages/client-runtime/src/state/pullRequests.ts b/packages/client-runtime/src/state/pullRequests.ts index fa5385c09fad..4ebde3f59fb7 100644 --- a/packages/client-runtime/src/state/pullRequests.ts +++ b/packages/client-runtime/src/state/pullRequests.ts @@ -322,6 +322,11 @@ export function createPullRequestEnvironmentAtoms( staleTimeMs: 60_000, refreshTrigger: ({ environmentId }) => refreshes({ environmentId, input: {} }), }), + filterOptions: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:pull-requests:filter-options", + tag: WS_METHODS.pullRequestsFilterOptions, + staleTimeMs: 5 * 60_000, + }), detail, preview, checks: normalizePullRequestQuery( diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index 5f9bc23048dd..bf8f124c3606 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -525,6 +525,26 @@ export const PullRequestListStatsResult = Schema.Struct({ }); export type PullRequestListStatsResult = typeof PullRequestListStatsResult.Type; +export const PullRequestFilterOptionKind = Schema.Literals(["labels", "authors"]); +export type PullRequestFilterOptionKind = typeof PullRequestFilterOptionKind.Type; + +/** Scoped like `PullRequestListInput`, so the options come from the repositories it lists. */ +export const PullRequestFilterOptionsInput = Schema.Struct({ + kind: PullRequestFilterOptionKind, + projectId: Schema.optional(ProjectId), + projectIds: Schema.optional(Schema.Array(ProjectId).check(Schema.isMaxLength(100))), + host: Schema.optional(TrimmedNonEmptyString), + query: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(100))), +}); +export type PullRequestFilterOptionsInput = typeof PullRequestFilterOptionsInput.Type; + +export const PullRequestFilterOptions = Schema.Struct({ + labels: Schema.Array(PullRequestLabel), + authors: Schema.Array(PullRequestActor), + truncated: Schema.Boolean, +}); +export type PullRequestFilterOptions = typeof PullRequestFilterOptions.Type; + /** * A separate request rather than a flag on the reads, so an explicit "refresh" one person * presses is the only thing that spends host requests — every ordinary read shares. diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index d7a11234c446..9723ea5a8723 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -180,6 +180,8 @@ import { PullRequestListResult, PullRequestListStatsInput, PullRequestListStatsResult, + PullRequestFilterOptions, + PullRequestFilterOptionsInput, PullRequestOperationError, PullRequestReactionInput, PullRequestRef, @@ -526,6 +528,7 @@ export const WS_METHODS = { pullRequestsList: "pullRequests.list", pullRequestsListStats: "pullRequests.listStats", + pullRequestsFilterOptions: "pullRequests.filterOptions", pullRequestsSummary: "pullRequests.summary", pullRequestsRouting: "pullRequests.routing", pullRequestsRoutingIdentity: "pullRequests.routingIdentity", @@ -963,6 +966,12 @@ const WsPullRequestsListStatsRpc = Rpc.make(WS_METHODS.pullRequestsListStats, { error: PullRequestRpcError, }); +const WsPullRequestsFilterOptionsRpc = Rpc.make(WS_METHODS.pullRequestsFilterOptions, { + payload: PullRequestFilterOptionsInput, + success: PullRequestFilterOptions, + error: PullRequestRpcError, +}); + const WsPullRequestsRoutingRpc = Rpc.make(WS_METHODS.pullRequestsRouting, { payload: PullRequestRef, success: PullRequestRoutingResult, @@ -1948,6 +1957,7 @@ export const WsRpcGroup = RpcGroup.make( WsCloudInstallRelayClientRpc, WsPullRequestsListRpc, WsPullRequestsListStatsRpc, + WsPullRequestsFilterOptionsRpc, WsPullRequestsSummaryRpc, WsPullRequestsRoutingRpc, WsPullRequestsRoutingIdentityRpc, diff --git a/packages/source-control-core/src/server/PullRequestProvider.ts b/packages/source-control-core/src/server/PullRequestProvider.ts index 882bef676a8a..32a06d2b314e 100644 --- a/packages/source-control-core/src/server/PullRequestProvider.ts +++ b/packages/source-control-core/src/server/PullRequestProvider.ts @@ -258,6 +258,11 @@ export interface ProviderFileRevisions { readonly complete?: boolean; } +export interface ProviderFilterOptionPage
{ + readonly items: ReadonlyArray; + readonly truncated: boolean; +} + export interface ProviderRepositoryRef { readonly cwd: string; readonly repository: string; @@ -529,6 +534,22 @@ export interface PullRequestProviderApi { input: ProviderRepositoryRef & { readonly number: number }, ) => Effect.Effect; + /** + * The labels a repository offers, for narrowing a listing by. `query` narrows by name as far as + * the host can; the caller still matches the answer against it. + */ + readonly listRepositoryLabels?: ( + input: ProviderRepositoryRef & { readonly query?: string | undefined }, + ) => Effect.Effect, PullRequestProviderError>; + + /** + * People who could have opened a change request in the repository, for narrowing a listing by + * author. A host that can only search its whole user base answers from that. + */ + readonly searchRepositoryAuthors?: ( + input: ProviderRepositoryRef & { readonly query?: string | undefined }, + ) => Effect.Effect, PullRequestProviderError>; + readonly setLabels?: ( input: ProviderRepositoryRef & { readonly number: number; diff --git a/packages/source-control-forgejo/src/server/ForgejoPullRequestProvider.ts b/packages/source-control-forgejo/src/server/ForgejoPullRequestProvider.ts index cbcc294ede86..b7616c276c3c 100644 --- a/packages/source-control-forgejo/src/server/ForgejoPullRequestProvider.ts +++ b/packages/source-control-forgejo/src/server/ForgejoPullRequestProvider.ts @@ -58,6 +58,11 @@ const CAPABILITIES: PullRequestCapabilities = { reviewers: { request: true, listCandidates: true }, edit: { changeRequest: true, comment: true }, }; +const USER_SEARCH_LIMIT = 50; +const ForgejoUserSearch = Schema.Struct({ + data: Schema.optional(Schema.NullOr(Schema.Array(ForgejoUser))), +}); + const repoPath = (input: ProviderRepositoryRef) => `repos/${input.repository.split("/").map(encodeURIComponent).join("/")}`; const pullPath = (input: ProviderRepositoryRef & { readonly number: number }) => @@ -100,7 +105,8 @@ export const make = Effect.gen(function* () { }), ), ); - const read = Effect.fn("ForgejoPullRequestProvider.read")(function* ( + /** A response, and whether its `link` header points at a further page. */ + const readLinked = Effect.fn("ForgejoPullRequestProvider.readLinked")(function* ( input: ForgejoCli.ForgejoApiInput, schema: Schema.Codec, ) { @@ -108,10 +114,15 @@ export const make = Effect.gen(function* () { if (result.stdoutTruncated) return yield* failure(input.path, "Forgejo response exceeded the output limit."); const decoded = decodeJsonResult(schema)(result.stdout); - return Result.isSuccess(decoded) - ? decoded.success - : yield* failure(input.path, "Forgejo returned an invalid response.", decoded.failure); + if (Result.isFailure(decoded)) + return yield* failure(input.path, "Forgejo returned an invalid response.", decoded.failure); + const links = /^link:\s*(.*)$/im.exec(result.stderr)?.[1]; + return { value: decoded.success, next: links !== undefined && /rel="?next"?/i.test(links) }; }); + const read = ( + input: ForgejoCli.ForgejoApiInput, + schema: Schema.Codec, + ) => readLinked(input, schema).pipe(Effect.map(({ value }) => value)); const readArray = ( input: ForgejoCli.ForgejoApiInput, schema: Schema.Codec, @@ -632,6 +643,48 @@ export const make = Effect.gen(function* () { }; }, ), + listRepositoryLabels: Effect.fn("ForgejoPullRequestProvider.listRepositoryLabels")( + function* (input) { + const labels = yield* page({ ...input, path: `${repoPath(input)}/labels` }, ForgejoLabel); + return { + items: labels.items.map((label) => ({ name: label.name, color: label.color ?? null })), + truncated: labels.truncated, + }; + }, + ), + searchRepositoryAuthors: Effect.fn("ForgejoPullRequestProvider.searchRepositoryAuthors")( + function* (input) { + const query = input.query?.trim() ?? ""; + const actors = (users: ReadonlyArray) => + users.flatMap((user) => { + const actor = forgejoActor(user); + return actor ? [actor] : []; + }); + // Before anything is typed the repository's own people stand in; a search reaches + // contributors from forks, who are never assignable. + if (query.length === 0) { + const users = yield* page( + { ...input, path: `${repoPath(input)}/assignees` }, + ForgejoUser, + 100, + ); + return { items: actors(users.items), truncated: users.truncated }; + } + const found = yield* readLinked( + { + ...input, + path: `users/search?${new URLSearchParams({ q: query, limit: String(USER_SEARCH_LIMIT) })}`, + }, + ForgejoUserSearch, + ); + const users = found.value.data ?? []; + // A server may cap pages below the limit asked for; its `link` header still says more. + return { + items: actors(users), + truncated: found.next || users.length >= USER_SEARCH_LIMIT, + }; + }, + ), setLabels: Effect.fn("ForgejoPullRequestProvider.setLabels")(function* (input) { const labels = yield* page({ ...input, path: `${repoPath(input)}/labels` }, ForgejoLabel); const selected = labels.items.filter((label) => input.labels.includes(label.name)); diff --git a/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts b/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts index d6ec02216480..c3e8146f5f0f 100644 --- a/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts +++ b/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts @@ -554,6 +554,40 @@ it.effect("lists nothing for an author Forgejo has no account for", () => ), ); +it.effect("says an author search was cut short by a server that pages below its limit", () => + Effect.gen(function* () { + const provider = yield* ForgejoPullRequestProvider.make; + const search = provider.searchRepositoryAuthors; + if (search === undefined) return yield* Effect.die("author search missing"); + const found = yield* search({ + cwd: "/repo", + repository: "maria/project", + host: "forgejo.test", + query: "dev", + }); + assert.strictEqual(found.items.length, 30); + assert.isTrue(found.truncated); + }).pipe( + Effect.provide( + Layer.mock(ForgejoCli.ForgejoCli)({ + api: () => + encodeJsonEffect({ + ok: true, + data: Array.from({ length: 30 }, (_, index) => ({ login: `dev-${index}` })), + }).pipe( + Effect.orDie, + Effect.map((stdout) => + processOutput(stdout, { + stderr: + 'HTTP/1.1 200\nlink: ; rel="next"\n', + }), + ), + ), + }), + ), + ), +); + it.effect("gives Forgejo identities the browser URL of the login that serves them", () => Effect.gen(function* () { const provider = yield* ForgejoSourceControlProvider.make; diff --git a/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts b/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts index 23c488e03b45..88cd6294b179 100644 --- a/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts +++ b/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts @@ -1073,6 +1073,30 @@ export const make = Effect.gen(function* () { }; }, ), + listRepositoryLabels: Effect.fn("GitCafePullRequestProvider.listRepositoryLabels")( + function* (input) { + const operation = "listRepositoryLabels"; + const base = yield* repositoryPath(operation, input); + const labels = yield* readPages(operation, input, `${base}/labels`, Json.GitCafeLabels); + return { + items: labels.items.map((label) => ({ name: label.name, color: label.color })), + truncated: labels.next !== null, + }; + }, + ), + searchRepositoryAuthors: Effect.fn("GitCafePullRequestProvider.searchRepositoryAuthors")( + function* (input) { + const operation = "searchRepositoryAuthors"; + const query = input.query?.trim() ?? ""; + // User search needs two characters; before that the repository's own people stand in. + const path = + query.length < 2 + ? `${yield* repositoryPath(operation, input)}/collaborators?${new URLSearchParams({ limit: "50" })}` + : `/users?${new URLSearchParams({ q: query, limit: "50" })}`; + const people = yield* read(operation, input, { path }, Json.GitCafePeople); + return { items: Json.toPeople(people, input.host), truncated: people.next !== null }; + }, + ), setLabels: Effect.fn("GitCafePullRequestProvider.setLabels")(function* (input) { const base = yield* repositoryPath("setLabels", input); const labels = yield* readPages("setLabels", input, `${base}/labels`, Json.GitCafeLabels); diff --git a/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.ts b/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.ts index 8c3b0ac56e2f..f6c700dba197 100644 --- a/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.ts +++ b/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.ts @@ -267,6 +267,29 @@ export const GitCafeChecks = Schema.Struct({ next: Schema.NullOr(Schema.String), }); +/** A user search answer and a collaborator page carry the same fields a picker shows. */ +export const GitCafePeople = Schema.Struct({ + items: Schema.Array( + Schema.Struct({ + handle: TrimmedNonEmptyString, + displayName: Schema.optional(Schema.NullOr(Schema.String)), + avatarUrl: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), + next: Schema.NullOr(Schema.String), +}); + +export function toPeople( + people: typeof GitCafePeople.Type, + host: string, +): ReadonlyArray { + return people.items.map((person) => ({ + login: person.handle, + name: person.displayName ?? null, + avatarUrl: person.avatarUrl == null ? null : new URL(person.avatarUrl, `https://${host}`).href, + })); +} + interface RepositoryOnHost { readonly repository: string; readonly host: string; diff --git a/packages/source-control-github/src/server/GitHubPullRequestApi.ts b/packages/source-control-github/src/server/GitHubPullRequestApi.ts index c25699714d14..741f238f4b44 100644 --- a/packages/source-control-github/src/server/GitHubPullRequestApi.ts +++ b/packages/source-control-github/src/server/GitHubPullRequestApi.ts @@ -37,6 +37,7 @@ import { type PullRequestReviewVerdict, type PullRequestReviewerCandidateList, type PullRequestReviewerKind, + type PullRequestLabel, type PullRequestLabelCandidateList, type PullRequestThreadCommentsResult, type PullRequestUpdateMethod, @@ -68,6 +69,8 @@ import { pullRequestLookup, toActorAvatars, toLabelCandidates, + toRepositoryAuthors, + toRepositoryLabels, toPullRequestActivityPage, toPullRequestCheckContextsPage, toPullRequestCore, @@ -645,6 +648,26 @@ export class GitHubPullRequestApi extends Context.Service< readonly number: number; }) => Effect.Effect; + readonly listRepositoryLabels: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly query?: string | undefined; + }) => Effect.Effect< + { readonly items: ReadonlyArray; readonly truncated: boolean }, + GitHubPullRequestApiError + >; + + readonly searchRepositoryAuthors: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly query?: string | undefined; + }) => Effect.Effect< + { readonly items: ReadonlyArray; readonly truncated: boolean }, + GitHubPullRequestApiError + >; + readonly setLabels: (input: { readonly cwd: string; readonly repository: string; @@ -2334,6 +2357,30 @@ export const make = Effect.gen(function* () { ); }, + listRepositoryLabels: (input) => { + const { owner, name } = parseRepositorySelector(input.repository); + const operation = "listRepositoryLabels"; + return client + .RepositoryLabels({ owner, name, query: input.query ?? null }) + .pipe( + graphql({ ...input, operation, allowReserve: true }), + Effect.map(toRepositoryLabels), + Effect.flatMap(found({ ...input, operation })), + ); + }, + + searchRepositoryAuthors: (input) => { + const { owner, name } = parseRepositorySelector(input.repository); + const operation = "searchRepositoryAuthors"; + return client + .RepositoryAuthors({ owner, name, query: input.query ?? null }) + .pipe( + graphql({ ...input, operation, allowReserve: true }), + Effect.map(toRepositoryAuthors), + Effect.flatMap(found({ ...input, operation })), + ); + }, + setLabels: (input) => { const { owner, name } = parseRepositorySelector(input.repository); const issue = `repos/${owner}/${name}/issues/${input.number}/labels`; diff --git a/packages/source-control-github/src/server/GitHubPullRequestProvider.ts b/packages/source-control-github/src/server/GitHubPullRequestProvider.ts index dd710dffcfd6..ab770dac4e03 100644 --- a/packages/source-control-github/src/server/GitHubPullRequestProvider.ts +++ b/packages/source-control-github/src/server/GitHubPullRequestProvider.ts @@ -547,6 +547,12 @@ export const make = Effect.gen(function* () { listLabelCandidates: (input) => cli.listLabelCandidates(input).pipe(Effect.mapError(fail("listLabelCandidates"))), + listRepositoryLabels: (input) => + cli.listRepositoryLabels(input).pipe(Effect.mapError(fail("listRepositoryLabels"))), + + searchRepositoryAuthors: (input) => + cli.searchRepositoryAuthors(input).pipe(Effect.mapError(fail("searchRepositoryAuthors"))), + setLabels: (input) => cli .setLabels({ diff --git a/packages/source-control-github/src/server/gitHubPullRequestJson.ts b/packages/source-control-github/src/server/gitHubPullRequestJson.ts index 318ba41468c3..6d9abb6d604e 100644 --- a/packages/source-control-github/src/server/gitHubPullRequestJson.ts +++ b/packages/source-control-github/src/server/gitHubPullRequestJson.ts @@ -60,6 +60,8 @@ import type { PullRequestHeads, PullRequestPreview as PullRequestPreviewQuery, PullRequestSubject, + RepositoryAuthors, + RepositoryLabels, ReviewerCandidates, ViewerPermissions, } from "./pullRequestReads.graphql.ts"; @@ -1658,6 +1660,36 @@ export function toLabelCandidates( }; } +export function toRepositoryLabels( + result: RepositoryLabels.Result, +): { readonly items: ReadonlyArray; readonly truncated: boolean } | null { + const repository = result.repository; + if (repository == null) return null; + return { + items: nodesOf(repository.labels).flatMap((node) => { + const name = trimmed(node.name); + return name === null ? [] : [{ name, color: trimmed(node.color) }]; + }), + truncated: repository.labels?.pageInfo.hasNextPage === true, + }; +} + +export function toRepositoryAuthors( + result: RepositoryAuthors.Result, +): { readonly items: ReadonlyArray; readonly truncated: boolean } | null { + const repository = result.repository; + if (repository == null) return null; + return { + items: nodesOf(repository.mentionableUsers).flatMap((node) => { + const login = trimmed(node.login); + return login === null + ? [] + : [{ login, name: trimmed(node.name), avatarUrl: trimmed(node.avatarUrl) }]; + }), + truncated: repository.mentionableUsers.pageInfo.hasNextPage, + }; +} + export function buildLabelRequest(labels: ReadonlyArray): { readonly labels: ReadonlyArray; } { diff --git a/packages/source-control-github/src/server/pullRequestReads.graphql b/packages/source-control-github/src/server/pullRequestReads.graphql index 7281af8c898a..ba103d44ad82 100644 --- a/packages/source-control-github/src/server/pullRequestReads.graphql +++ b/packages/source-control-github/src/server/pullRequestReads.graphql @@ -455,6 +455,37 @@ query LabelCandidates($owner: String!, $name: String!, $number: Int!) { } } +query RepositoryLabels($owner: String!, $name: String!, $query: String) { + repository(owner: $owner, name: $name) { + labels(first: 100, query: $query, orderBy: { field: NAME, direction: ASC }) { + pageInfo { + hasNextPage + } + nodes { + name + color + } + } + } +} + +# Mentionable rather than assignable users: a pull request from a fork is opened by someone with +# no access to the repository, and a listing is still narrowed by them. +query RepositoryAuthors($owner: String!, $name: String!, $query: String) { + repository(owner: $owner, name: $name) { + mentionableUsers(first: 50, query: $query) { + pageInfo { + hasNextPage + } + nodes { + login + name + avatarUrl + } + } + } +} + # Which files of a pull request the signed-in account has cleared. GraphQL only, since the REST # files endpoint the patch is read from carries no viewed state, so this is a second read rather # than a wider version of the first. diff --git a/packages/source-control-github/src/server/pullRequestReads.graphql.ts b/packages/source-control-github/src/server/pullRequestReads.graphql.ts index fd0fef547ea9..b6a2524468dc 100644 --- a/packages/source-control-github/src/server/pullRequestReads.graphql.ts +++ b/packages/source-control-github/src/server/pullRequestReads.graphql.ts @@ -1170,13 +1170,13 @@ export const RepositoryLabelsNodesLabel = Schema.Struct({ export interface RepositoryLabelsNodesLabel extends Schema.Struct.Type {} /** The connection type for Label. */ -export const RepositoryLabelsLabelConnection = Schema.Struct({ +export const LabelCandidatesRepositoryLabelsLabelConnection = Schema.Struct({ /** Information to aid in pagination. */ pageInfo: PullRequestCoreRepositoryPullRequestCommitsNodesCommitStatusCheckRollupContextsPageInfoPageInfo, /** A list of nodes. */ nodes: Schema.NullOr(Schema.Array(Schema.NullOr(RepositoryLabelsNodesLabel))) }) -export interface RepositoryLabelsLabelConnection extends Schema.Struct.Type {} +export interface LabelCandidatesRepositoryLabelsLabelConnection extends Schema.Struct.Type {} /** A label for categorizing Issues, Pull Requests, Milestones, or Discussions with a given Repository. */ export const LabelCandidatesRepositoryPullRequestLabelsNodesLabel = Schema.Struct({ @@ -1202,7 +1202,7 @@ export interface LabelCandidatesRepositoryPullRequestPullRequest extends Schema. /** A repository contains the content for a project. */ export const LabelCandidatesRepositoryRepository = Schema.Struct({ /** A list of labels associated with the repository. */ - labels: Schema.NullOr(RepositoryLabelsLabelConnection), + labels: Schema.NullOr(LabelCandidatesRepositoryLabelsLabelConnection), /** Returns a single pull request from the current repository by number. */ pullRequest: Schema.NullOr(LabelCandidatesRepositoryPullRequestPullRequest) }) @@ -1236,6 +1236,85 @@ export declare namespace LabelCandidates { export type Result = typeof LabelCandidates.result.Type } +/** The connection type for Label. */ +export const RepositoryLabelsRepositoryLabelsLabelConnection = Schema.Struct({ + /** Information to aid in pagination. */ + pageInfo: PullRequestCoreRepositoryPullRequestCommitsNodesCommitStatusCheckRollupContextsPageInfoPageInfo, + /** A list of nodes. */ + nodes: Schema.NullOr(Schema.Array(Schema.NullOr(PullRequestCoreRepositoryPullRequestLabelsNodesLabel))) +}) +export interface RepositoryLabelsRepositoryLabelsLabelConnection extends Schema.Struct.Type {} + +/** A repository contains the content for a project. */ +export const RepositoryLabelsRepositoryRepository = Schema.Struct({ + /** A list of labels associated with the repository. */ + labels: Schema.NullOr(RepositoryLabelsRepositoryLabelsLabelConnection) +}) +export interface RepositoryLabelsRepositoryRepository extends Schema.Struct.Type {} + +/** `query RepositoryLabels` */ +export const RepositoryLabels = GraphQL.query("RepositoryLabels", { + document: { + variables: [ + { name: "owner", body: "String!" }, + { name: "name", body: "String!" }, + { name: "query", body: "String" } + ], + selections: [ + { name: "repository", body: ["(owner:$", "owner", " name:$", "name", "){labels(first:100 query:$", "query", " orderBy:{field:NAME direction:ASC}){pageInfo{hasNextPage}nodes{name color}}}"] } + ], + fragments: [] + }, + variables: { + owner: Schema.String, + name: Schema.String, + query: Schema.optional(Schema.NullOr(Schema.String)) + }, + result: Schema.Struct({ + /** Lookup a given repository by the owner and repository name. */ + repository: Schema.NullOr(RepositoryLabelsRepositoryRepository) + }) +}) +export declare namespace RepositoryLabels { + export type Variables = typeof RepositoryLabels.variables.Type + export type Result = typeof RepositoryLabels.result.Type +} + +/** A repository contains the content for a project. */ +export const RepositoryAuthorsRepositoryRepository = Schema.Struct({ + /** A list of Users that can be mentioned in the context of the repository. */ + mentionableUsers: UserConnection +}) +export interface RepositoryAuthorsRepositoryRepository extends Schema.Struct.Type {} + +/** `query RepositoryAuthors` */ +export const RepositoryAuthors = GraphQL.query("RepositoryAuthors", { + document: { + variables: [ + { name: "owner", body: "String!" }, + { name: "name", body: "String!" }, + { name: "query", body: "String" } + ], + selections: [ + { name: "repository", body: ["(owner:$", "owner", " name:$", "name", "){mentionableUsers(first:50 query:$", "query", "){pageInfo{hasNextPage}nodes{login name avatarUrl}}}"] } + ], + fragments: [] + }, + variables: { + owner: Schema.String, + name: Schema.String, + query: Schema.optional(Schema.NullOr(Schema.String)) + }, + result: Schema.Struct({ + /** Lookup a given repository by the owner and repository name. */ + repository: Schema.NullOr(RepositoryAuthorsRepositoryRepository) + }) +}) +export declare namespace RepositoryAuthors { + export type Variables = typeof RepositoryAuthors.variables.Type + export type Result = typeof RepositoryAuthors.result.Type +} + /** A file changed in a pull request. */ export const PullRequestFilesViewedRepositoryPullRequestFilesNodesPullRequestChangedFile = Schema.Struct({ /** The path of the file. */ @@ -1587,4 +1666,4 @@ export declare namespace PullRequestSourceBranch { } /** Every operation in pullRequestReads.graphql. */ -export const PullRequestReadsGraphQLGroup = GraphQLGroup.make(RoutingIdentityViewer, ActorAvatars, PullRequestCore, PullRequestCheckContexts, PullRequestPreview, PullRequestActivity, PullRequestHeads, PullRequestNodeId, PullRequestSubject, ViewerPermissions, ReviewerCandidates, LabelCandidates, PullRequestFilesViewed, PullRequestFileStats, PullRequestMergeMessage, PullRequestActionState, PullRequestBranchToDelete, PullRequestSourceBranch) +export const PullRequestReadsGraphQLGroup = GraphQLGroup.make(RoutingIdentityViewer, ActorAvatars, PullRequestCore, PullRequestCheckContexts, PullRequestPreview, PullRequestActivity, PullRequestHeads, PullRequestNodeId, PullRequestSubject, ViewerPermissions, ReviewerCandidates, LabelCandidates, RepositoryLabels, RepositoryAuthors, PullRequestFilesViewed, PullRequestFileStats, PullRequestMergeMessage, PullRequestActionState, PullRequestBranchToDelete, PullRequestSourceBranch) diff --git a/packages/source-control-gitlab/src/server/GitLabPullRequestCli.ts b/packages/source-control-gitlab/src/server/GitLabPullRequestCli.ts index e4a7937de5a2..04fe88d03be6 100644 --- a/packages/source-control-gitlab/src/server/GitLabPullRequestCli.ts +++ b/packages/source-control-gitlab/src/server/GitLabPullRequestCli.ts @@ -6,9 +6,11 @@ import * as Schema from "effect/Schema"; import { resolvePullRequestAuthorFilter, VcsProcessExitError } from "@t3tools/contracts"; import type { PullRequestAction, + PullRequestActor, PullRequestComment, PullRequestCommit, PullRequestInvolvement, + PullRequestLabel, PullRequestListFilters, PullRequestListState, PullRequestMergeCapabilities, @@ -37,6 +39,7 @@ import { decodeNotesJson, decodeOwnAwardIdJson, decodeProjectMergeCapabilitiesJson, + decodeProjectLabelsJson, decodeProjectUsersJson, decodeRepositoryBlobsJson, decodeViewerJson, @@ -287,6 +290,24 @@ export class GitLabPullRequestCli extends Context.Service< readonly number: number; }) => Effect.Effect; + readonly listProjectLabels: (input: { + readonly cwd: string; + readonly repository: string; + readonly search?: string | undefined; + }) => Effect.Effect< + { readonly items: ReadonlyArray; readonly truncated: boolean }, + GitLabPullRequestCliError + >; + + readonly listProjectUsers: (input: { + readonly cwd: string; + readonly repository: string; + readonly search?: string | undefined; + }) => Effect.Effect< + { readonly items: ReadonlyArray; readonly truncated: boolean }, + GitLabPullRequestCliError + >; + readonly setReviewerRequest: (input: { readonly cwd: string; readonly repository: string; @@ -919,11 +940,13 @@ export const make = Effect.gen(function* () { const projectUsers = (input: { readonly cwd: string; readonly repository: string; + readonly search?: string | undefined; }): Effect.Effect => api({ cwd: input.cwd, path: `projects/${projectPath(input.repository)}/users?${query([ ["per_page", String(MAX_PAGE_SIZE)], + ...searchParams(input.search), ])}`, }).pipe( Effect.flatMap((result) => { @@ -1277,6 +1300,40 @@ export const make = Effect.gen(function* () { }), ), + listProjectLabels: (input) => + api({ + cwd: input.cwd, + path: `projects/${projectPath(input.repository)}/labels?${query([ + ["per_page", String(MAX_PAGE_SIZE)], + ...searchParams(input.search), + ])}`, + }).pipe( + Effect.flatMap((result) => { + const decoded = decodeProjectLabelsJson(result.stdout.trim()); + return Result.isSuccess(decoded) + ? Effect.succeed({ + items: decoded.success.labels, + truncated: decoded.success.rawCount >= MAX_PAGE_SIZE, + }) + : Effect.fail( + new GitLabMergeRequestReadError({ + command: "glab", + cwd: input.cwd, + operation: "listProjectLabels", + cause: decoded.failure, + }), + ); + }), + ), + + listProjectUsers: (input) => + projectUsers(input).pipe( + Effect.map(({ candidates, rawCount }) => ({ + items: candidates.map(({ login, name, avatarUrl }) => ({ login, name, avatarUrl })), + truncated: rawCount >= MAX_PAGE_SIZE, + })), + ), + listReviewerCandidates: (input) => Effect.all([mergeRequestDetail(input), projectUsers(input)], { concurrency: 2 }).pipe( Effect.map(([mergeRequest, users]) => { diff --git a/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.ts b/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.ts index 295960aa4072..297343fb9bef 100644 --- a/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.ts +++ b/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.ts @@ -232,6 +232,16 @@ export const make = Effect.gen(function* () { }) .pipe(Effect.mapError(fail("listReviewerCandidates"))), + listRepositoryLabels: (input) => + cli + .listProjectLabels({ cwd: input.cwd, repository: input.repository, search: input.query }) + .pipe(Effect.mapError(fail("listRepositoryLabels"))), + + searchRepositoryAuthors: (input) => + cli + .listProjectUsers({ cwd: input.cwd, repository: input.repository, search: input.query }) + .pipe(Effect.mapError(fail("searchRepositoryAuthors"))), + setReviewerRequest: (input) => cli .setReviewerRequest({ diff --git a/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.ts b/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.ts index 249283b61f1f..4edfbbdab185 100644 --- a/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.ts +++ b/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.ts @@ -441,6 +441,34 @@ export function decodeProjectUsersJson( return Result.succeed({ candidates, rawCount: decoded.success.length }); } +const RawLabelSchema = Schema.Struct({ + name: Schema.String, + color: Schema.optional(Schema.NullOr(Schema.String)), +}); +const decodeLabelEntry = Schema.decodeUnknownExit(RawLabelSchema); + +export interface GitLabProjectLabels { + readonly labels: ReadonlyArray; + readonly rawCount: number; +} + +export function decodeProjectLabelsJson( + raw: string, +): Result.Result { + const decoded = decodeUnknownList(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const labels: PullRequestLabel[] = []; + for (const entry of decoded.success) { + const label = decodeLabelEntry(entry); + if (Exit.isFailure(label)) continue; + const name = trimmed(label.value.name); + if (name !== null) labels.push({ name, color: trimmed(label.value.color) }); + } + return Result.succeed({ labels, rawCount: decoded.success.length }); +} + export function decodeProjectMergeCapabilitiesJson( raw: string, ): Result.Result { From e01c39969f2346d8354d6240754c0a1e312b43f2 Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:09:36 -0700 Subject: [PATCH 52/59] fix(mobile): chat and composer chips stay readable in light mode (#22585) Co-authored-by: Julius Marminge Co-authored-by: Claude Opus 5.5 --- apps/mobile/src/App.tsx | 2 + apps/mobile/src/components/ProjectFavicon.tsx | 8 +- .../src/features/keyboard/CommandPalette.tsx | 26 ++- .../features/keyboard/commandPaletteItems.ts | 3 + .../src/features/threads/ThreadFeed.tsx | 29 +++- .../src/lib/composerChipPresentation.test.ts | 109 +++++++++++++ .../src/lib/composerChipPresentation.ts | 42 +++++ .../src/lib/enrichedLinkPresentation.test.ts | 49 ++++++ .../src/lib/enrichedLinkPresentation.ts | 51 +++++- apps/mobile/src/lib/mobileTheme.ts | 112 +++++++++++-- apps/mobile/src/lib/projectIcon.test.ts | 81 ++++++++++ apps/mobile/src/lib/projectIcon.ts | 41 ++++- .../src/native/ProjectChipIconImages.tsx | 150 ++++++++++++++++++ .../src/native/T3ComposerEditor.ios.tsx | 6 +- .../src/native/T3ComposerEditor.native.tsx | 6 +- .../src/native/useEnrichedLinkVariants.ts | 47 ++++-- apps/mobile/src/state/entities.ts | 104 +++++++++++- vite.config.ts | 1 + 18 files changed, 816 insertions(+), 51 deletions(-) create mode 100644 apps/mobile/src/lib/composerChipPresentation.test.ts create mode 100644 apps/mobile/src/lib/projectIcon.test.ts create mode 100644 apps/mobile/src/native/ProjectChipIconImages.tsx diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index 819f780ed7d3..2ab9a1a191b2 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -9,6 +9,7 @@ import { SafeAreaProvider } from "react-native-safe-area-context"; import { createStaticNavigation, getStateFromPath } from "@react-navigation/native"; import { RegistryContext } from "@effect/atom-react"; +import { ProjectChipIconRasterizer } from "./native/ProjectChipIconImages"; import { ThreadArrangementHost } from "./features/threads/ThreadArrangementSheet"; import * as Dialog from "./components/ui/Dialog"; import { CloudAuthProvider } from "./features/cloud/CloudAuthProvider"; @@ -110,6 +111,7 @@ function AppContent() { + {/* Anchored-menu overlays render here — in-window, so the diff --git a/apps/mobile/src/components/ProjectFavicon.tsx b/apps/mobile/src/components/ProjectFavicon.tsx index 625e3d3893e8..1dd939b36cb6 100644 --- a/apps/mobile/src/components/ProjectFavicon.tsx +++ b/apps/mobile/src/components/ProjectFavicon.tsx @@ -14,6 +14,7 @@ import { Atom } from "effect/reactivity"; import { projectFaviconUrlAtom } from "../state/assets"; import { countGlyphs, + projectAutoMonogram, projectIconColorClassNames, resolveProjectIconGlyph, type ProjectIconGlyph, @@ -167,6 +168,7 @@ function ProjectFaviconImage(props: { const requestIsActive = faviconRequest !== null && activeFaviconRequest === faviconRequest; const showImage = requestIsActive && status === "loaded"; + const fallbackGlyph = showImage ? null : projectAutoMonogram(props.projectTitle); return ( - {!showImage ? ( + {showImage ? null : fallbackGlyph ? ( + + ) : ( - ) : null} + )} {requestIsActive ? ( = { }; function itemIcon(item: CommandPaletteItem): AppSymbolName { - if (item.kind === "project") return "folder"; if (item.kind === "thread") return "text.bubble"; return ACTION_ICONS[item.key] ?? "ellipsis"; } @@ -101,11 +101,24 @@ function PaletteRow(props: { style={{ height: ROW_HEIGHT }} > - + {props.item.project ? ( + + ) : ( + + )} @@ -310,6 +323,7 @@ export function CommandPalette(props: { const projectItems: CommandPaletteItem[] = projects.map((project) => ({ key: `project:${scopedProjectKey(project.environmentId, project.id)}`, kind: "project", + project, title: project.title, detail: `New thread · ${savedConnectionsById[project.environmentId]?.environmentLabel ?? project.environmentId}`, searchTerms: [project.workspaceRoot, "new thread", "project"], diff --git a/apps/mobile/src/features/keyboard/commandPaletteItems.ts b/apps/mobile/src/features/keyboard/commandPaletteItems.ts index 2e7c2399ae49..035d23e89d7d 100644 --- a/apps/mobile/src/features/keyboard/commandPaletteItems.ts +++ b/apps/mobile/src/features/keyboard/commandPaletteItems.ts @@ -1,6 +1,9 @@ +import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; + export interface CommandPaletteItem { readonly key: string; readonly kind: "action" | "project" | "thread"; + readonly project?: EnvironmentProject; readonly title: string; readonly detail?: string; readonly searchTerms: ReadonlyArray; diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 63526a732bcc..92c67fba2971 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -4,7 +4,11 @@ import { useOpenPullRequest } from "../../native/useOpenPullRequest"; import { useWorkspaceSettledContentWidth } from "../layout/workspace-content-width"; import { ThreadMarkdownVideo, ThreadMediaVisibleContext } from "./ThreadMarkdownVideo"; import { useMarkdownImageSource } from "../../native/useMarkdownImageSource"; -import { enrichedContextLinkCustomization } from "../../native/useEnrichedLinkVariants"; +import { + enrichedContextLinkCustomization, + enrichedThreadLinkCustomization, + withChipIcon, +} from "../../native/useEnrichedLinkVariants"; import { enrichedSkillDisplayName, enrichedSkillInstructionsPath, @@ -242,7 +246,11 @@ import { pickWorkspaceBasenameMatch, WORKSPACE_BASENAME_LOOKUP_LIMIT, } from "@t3tools/client-runtime/workspace-basename-lookup"; -import { useLiveThreadLinkLabels, useQuietThreadShell } from "../../state/entities"; +import { + useLiveThreadLinkLabels, + useQuietThreadShell, + useThreadChipIcons, +} from "../../state/entities"; import { useThreadSelection } from "../../state/use-thread-selection"; import { useEnvironmentQuery } from "../../state/query"; import { serverEnvironment } from "../../state/server"; @@ -858,6 +866,12 @@ const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: { }) { const liveMarkdown = useLiveThreadLinkLabels(props.markdown, props.environmentId); const segments = useMemo(() => splitCodexArtifactTemplateMarkdown(liveMarkdown), [liveMarkdown]); + const threadIcons = useThreadChipIcons(props.environmentId, liveMarkdown); + const linkHandlers: MarkdownLinkHandlers = { + ...props.linkHandlers, + linkCustomization: (href) => + withChipIcon(props.linkHandlers.linkCustomization?.(href), threadIcons.get(href)), + }; return segments.map((segment) => { if (segment.kind === "artifact-template") { @@ -878,7 +892,7 @@ const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: { markdown={markdown} skills={props.skills} textStyle={props.markdownStyles.nativeTextStyle} - {...props.linkHandlers} + {...linkHandlers} renderImage={props.renderImage} /> ); @@ -1781,6 +1795,7 @@ function UserMessageContent(props: UserMessageContentProps) { const navigation = useNavigation(); const { selectedThread } = useThreadSelection(); const liveText = useLiveThreadLinkLabels(props.text, props.environmentId); + const threadIcons = useThreadChipIcons(props.environmentId, liveText, props.context?.records); const text = replaceComposerContextReferences(liveText, (ref) => { const available = props.context?.records.some((record) => record.contextId === ref.contextId); return `[${ref.label}${available ? "" : " (unavailable)"}](t3-context://v1/${ref.kind}/${ref.contextId})`; @@ -1864,8 +1879,11 @@ function UserMessageContent(props: UserMessageContentProps) { } }, linkCustomization: (href) => - enrichedContextLinkCustomization(href, props.context?.records ?? []) ?? - props.linkHandlers.linkCustomization?.(href), + withChipIcon( + enrichedContextLinkCustomization(href, props.context?.records ?? []) ?? + props.linkHandlers.linkCustomization?.(href), + threadIcons.get(href), + ), }} /> {selected ? ( @@ -2323,6 +2341,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { }); const markdownLinkHandlers: MarkdownLinkHandlers = { onLinkPress: onMarkdownLinkPress, + linkCustomization: enrichedThreadLinkCustomization, onImagePress: (href) => onMarkdownLinkPress(href, true), resolveImageSource, fileContextMenu: (href) => { diff --git a/apps/mobile/src/lib/composerChipPresentation.test.ts b/apps/mobile/src/lib/composerChipPresentation.test.ts new file mode 100644 index 000000000000..fa578904dcb5 --- /dev/null +++ b/apps/mobile/src/lib/composerChipPresentation.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { contextChipAccent, contextChipPresentation } from "./composerChipPresentation"; +import { + flattenThemeColor, + getMobileThemeVariables, + MOBILE_THEME_IDS, + type MobileThemeVariables, +} from "./mobileTheme"; + +const CHIP_ACCENTS = [ + ...new Set( + [ + ...[ + "image", + "video", + "file", + "mention", + "terminal", + "element", + "review-comment", + "pull-request", + "skill", + "thread", + ].map((kind) => contextChipPresentation(kind)), + ...[ + { state: "open", isDraft: false }, + { state: "open", isDraft: true }, + { state: "merged", isDraft: false }, + { state: "closed", isDraft: false }, + ].map((pullRequest) => contextChipPresentation("pull-request", { pullRequest })), + ].flatMap(({ accent }) => (accent ? [accent] : [])), + ), +]; + +function relativeLuminance(hex: string): number { + const [red = 0, green = 0, blue = 0] = [1, 3, 5].map((offset) => { + const value = Number.parseInt(hex.slice(offset, offset + 2), 16) / 255; + return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * red + 0.7152 * green + 0.0722 * blue; +} + +function contrastRatio(first: string, second: string): number { + const [darker = 0, lighter = 0] = [relativeLuminance(first), relativeLuminance(second)].sort( + (a, b) => a - b, + ); + return (lighter + 0.05) / (darker + 0.05); +} + +function chipSurfaces(theme: MobileThemeVariables) { + const screen = theme["--color-screen"]; + return ( + [ + ["--color-user-bubble", "--color-user-bubble-foreground"], + ["--color-thread-canvas", "--color-md-body"], + ["--color-composer-surface", "--color-foreground"], + ] as const + ).map(([surface, text]) => ({ + surface: flattenThemeColor(theme[surface], screen).slice(0, 7), + text: theme[text], + })); +} + +const BUILT_IN_THEMES = MOBILE_THEME_IDS.filter((id) => id !== "material-you").flatMap((id) => + (["light", "dark"] as const).map((appearance) => ({ + name: `${id} ${appearance}`, + theme: getMobileThemeVariables(id, appearance), + })), +); + +describe("contextChipAccent", () => { + it("draws one color per theme that reads at 5:1 in messages, replies and the composer", () => { + for (const { name, theme } of BUILT_IN_THEMES) { + for (const accent of CHIP_ACCENTS) { + const shown = contextChipAccent(accent, theme, theme["--color-md-body"])!; + for (const { surface, text } of chipSurfaces(theme)) { + expect(contextChipAccent(accent, theme, text)).toBe(shown); + expect( + contrastRatio(shown, surface), + `${name}: ${accent} → ${shown} on ${surface}`, + ).toBeGreaterThanOrEqual(5); + } + } + } + }); + + it("reads on both a saturated Material You bubble and the light canvas beside it", () => { + const theme = { + ...getMobileThemeVariables("t3-code", "light"), + "--color-user-bubble": "#6750a4", + "--color-user-bubble-foreground": "#ffffff", + }; + for (const { surface, text } of chipSurfaces(theme)) { + for (const accent of CHIP_ACCENTS) { + const shown = contextChipAccent(accent, theme, text)!; + expect( + contrastRatio(shown, surface), + `${accent} → ${shown} on ${surface}`, + ).toBeGreaterThanOrEqual(5); + } + } + }); + + it("keeps an accent that already reads everywhere", () => { + const theme = getMobileThemeVariables("t3-code", "dark"); + expect(contextChipAccent("#009f6e", theme, theme["--color-md-body"])).toBe("#009f6e"); + }); +}); diff --git a/apps/mobile/src/lib/composerChipPresentation.ts b/apps/mobile/src/lib/composerChipPresentation.ts index 257234fd02b4..34e70e94197b 100644 --- a/apps/mobile/src/lib/composerChipPresentation.ts +++ b/apps/mobile/src/lib/composerChipPresentation.ts @@ -4,6 +4,12 @@ import { videoMimeType } from "@t3tools/shared/video"; import type { CONTEXT_CHIP_ICON_SOURCES } from "./contextChipIcons.generated"; import { resolveMarkdownFileIcon, type MarkdownFileIcon } from "./markdownLinks"; +import { + flattenThemeColor, + isDarkThemeColor, + themeAccentWithContrast, + type MobileThemeVariables, +} from "./mobileTheme"; export type ContextChipIcon = keyof typeof CONTEXT_CHIP_ICON_SOURCES; @@ -39,6 +45,42 @@ const CONTEXT_CHIP_PRESENTATIONS = { }, } as const satisfies Record; +const CHIP_CONTRAST = 5; + +const readableChipAccents = new Map(); + +/** + * A chip keeps one color per theme so it looks the same in a message, a reply and the composer, + * which means it must read on all of them; the user bubble is usually the hardest. A surface + * whose text is the opposite shade, like Material You's primary-colored light bubble, can't share + * a color with the canvas, so chips are matched only against surfaces like the one they sit on. + */ +export function contextChipAccent( + accent: string | undefined, + theme: MobileThemeVariables, + textColor: string, +): string | undefined { + if (!accent) return undefined; + const screen = theme["--color-screen"]; + const darkText = isDarkThemeColor(textColor); + const surfaces = ( + [ + ["--color-user-bubble", "--color-user-bubble-foreground"], + ["--color-thread-canvas", "--color-md-body"], + ["--color-composer-surface", "--color-foreground"], + ] as const + ) + .filter(([, text]) => isDarkThemeColor(theme[text]) === darkText) + .map(([surface]) => flattenThemeColor(theme[surface], screen)); + const key = [accent, ...surfaces].join(); + let readable = readableChipAccents.get(key); + if (readable === undefined) { + readable = themeAccentWithContrast(accent, surfaces, CHIP_CONTRAST); + readableChipAccents.set(key, readable); + } + return readable; +} + export const UNAVAILABLE_CONTEXT_CHIP_PRESENTATION: ContextChipPresentation = { icon: "circle-dashed", }; diff --git a/apps/mobile/src/lib/enrichedLinkPresentation.test.ts b/apps/mobile/src/lib/enrichedLinkPresentation.test.ts index 3ceb536926df..89155b11a3cc 100644 --- a/apps/mobile/src/lib/enrichedLinkPresentation.test.ts +++ b/apps/mobile/src/lib/enrichedLinkPresentation.test.ts @@ -1,8 +1,11 @@ import { describe, expect, it } from "vite-plus/test"; +import type { ComposerContextId, EnvironmentId, ThreadId } from "@t3tools/contracts"; import { enrichedLinkVariantPattern, enrichedSkillLinkRegex, + enrichedThreadLinkPresentation, ENRICHED_INLINE_FILE_LINK_REGEX, + threadChipTargets, } from "./enrichedLinkPresentation"; describe("Enriched link presentation", () => { @@ -46,4 +49,50 @@ describe("Enriched link presentation", () => { expect(pattern.test(`${url}/child`)).toBe(false); expect(pattern.test("file:///workspace/app1Xtsx?line=34")).toBe(false); }); + + it("presents thread links as thread chips and leaves other links alone", () => { + expect(enrichedThreadLinkPresentation("t3-thread://v1/thread%3Aproject%3A1")).toEqual({ + accent: "#009c96", + icon: { kind: "chip", name: "messages-square" }, + }); + for (const href of [ + "https://github.com/example/repo/pull/7", + "https://example.com", + "t3-context://v1/thread/thread_1", + ]) { + expect(enrichedThreadLinkPresentation(href), href).toBeUndefined(); + } + }); + + it("finds each linked thread once and resolves thread records to their own environment", () => { + const here = "env-here" as EnvironmentId; + const there = "env-there" as EnvironmentId; + expect( + threadChipTargets( + here, + "See [A](t3-thread://v1/thread_a), [A again](t3-thread://v1/thread_a) and [B](t3-thread://v1/thread_b).", + [ + { + version: 1, + contextId: "thr1" as ComposerContextId, + kind: "thread", + label: "C", + environmentId: there, + threadId: "thread_c" as ThreadId, + title: "C", + }, + ], + ), + ).toEqual([ + { href: "t3-thread://v1/thread_a", environmentId: here, threadId: "thread_a", link: true }, + { href: "t3-thread://v1/thread_b", environmentId: here, threadId: "thread_b", link: true }, + { + href: "t3-context://v1/thread/thr1", + environmentId: there, + threadId: "thread_c", + link: false, + }, + ]); + expect(threadChipTargets(here, "No links here.")).toEqual([]); + }); }); diff --git a/apps/mobile/src/lib/enrichedLinkPresentation.ts b/apps/mobile/src/lib/enrichedLinkPresentation.ts index db16d0181591..b93841ad8f8e 100644 --- a/apps/mobile/src/lib/enrichedLinkPresentation.ts +++ b/apps/mobile/src/lib/enrichedLinkPresentation.ts @@ -1,4 +1,4 @@ -import type { ComposerContextRecord } from "@t3tools/contracts"; +import type { ComposerContextRecord, EnvironmentId, ThreadId } from "@t3tools/contracts"; import { composerChipSizeSuffix, contextChipIcon, @@ -8,7 +8,11 @@ import { UNAVAILABLE_CONTEXT_CHIP_PRESENTATION, } from "./composerChipPresentation"; import type { SelectableMarkdownSkill } from "../native/SelectableMarkdownText.types"; -import { parseComposerContextHref } from "@t3tools/shared/composerContextReferences"; +import { + formatComposerContextHref, + parseComposerContextHref, +} from "@t3tools/shared/composerContextReferences"; +import { hasThreadLinks, parseThreadLinkHref } from "@t3tools/shared/threadLinks"; export function enrichedContextLinkPresentation( href: string, @@ -29,6 +33,49 @@ export function enrichedContextLinkPresentation( }; } +export function enrichedThreadLinkPresentation( + href: string, +): { accent?: string; icon: ContextChipIconRef } | undefined { + if (!parseThreadLinkHref(href)) return undefined; + const presentation = contextChipPresentation("thread"); + return { + ...(presentation.accent ? { accent: presentation.accent } : {}), + icon: contextChipIcon(presentation, ""), + }; +} + +export interface ThreadChipTarget { + readonly href: string; + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly link: boolean; +} + +export function threadChipTargets( + environmentId: EnvironmentId, + markdown: string, + records?: ReadonlyArray, +): ReadonlyArray { + const targets = new Map(); + if (hasThreadLinks(markdown)) { + for (const [, href = ""] of markdown.matchAll(/\]\((t3-thread:\/\/v1\/[^\s)]+)\)/g)) { + const threadId = parseThreadLinkHref(href); + if (threadId) targets.set(href, { href, environmentId, threadId, link: true }); + } + } + for (const record of records ?? []) { + if (record.kind !== "thread" || !("threadId" in record)) continue; + const href = formatComposerContextHref("thread", record.contextId); + targets.set(href, { + href, + environmentId: record.environmentId, + threadId: record.threadId, + link: false, + }); + } + return [...targets.values()]; +} + export function enrichedLinkVariantPattern(url: string) { return `^${url.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`; } diff --git a/apps/mobile/src/lib/mobileTheme.ts b/apps/mobile/src/lib/mobileTheme.ts index 52b8ff3b4ff6..02fe07f45d27 100644 --- a/apps/mobile/src/lib/mobileTheme.ts +++ b/apps/mobile/src/lib/mobileTheme.ts @@ -94,6 +94,22 @@ function linearToSrgb(value: number): number { return Math.round(Math.min(1, Math.max(0, converted)) * 255); } +function oklabToLinearSrgb(lightness: number, a: number, b: number) { + const l = (lightness + 0.3963377774 * a + 0.2158037573 * b) ** 3; + const m = (lightness - 0.1055613458 * a - 0.0638541728 * b) ** 3; + const s = (lightness - 0.0894841775 * a - 1.291485548 * b) ** 3; + return [ + 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, + -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, + -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s, + ] as const; +} + +function oklabToSrgb(lightness: number, a: number, b: number) { + const [red, green, blue] = oklabToLinearSrgb(lightness, a, b); + return [linearToSrgb(red), linearToSrgb(green), linearToSrgb(blue)] as const; +} + /** React Native does not accept OKLCH ColorValues, so palettes cross the app boundary as sRGB. */ export function themeColorToNativeColor(value: string): string { const match = OKLCH_PATTERN.exec(value); @@ -103,17 +119,7 @@ export function themeColorToNativeColor(value: string): string { const chroma = Number(match[2]); const hue = (Number(match[3]) * Math.PI) / 180; const alpha = match[4] === undefined ? 1 : Number(match[4]); - const a = chroma * Math.cos(hue); - const b = chroma * Math.sin(hue); - const lPrime = lightness + 0.3963377774 * a + 0.2158037573 * b; - const mPrime = lightness - 0.1055613458 * a - 0.0638541728 * b; - const sPrime = lightness - 0.0894841775 * a - 1.291485548 * b; - const l = lPrime ** 3; - const m = mPrime ** 3; - const s = sPrime ** 3; - const red = linearToSrgb(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s); - const green = linearToSrgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s); - const blue = linearToSrgb(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s); + const [red, green, blue] = oklabToSrgb(lightness, chroma * Math.cos(hue), chroma * Math.sin(hue)); return alpha < 1 ? `rgba(${red}, ${green}, ${blue}, ${Number(alpha.toFixed(4))})` @@ -168,14 +174,33 @@ export function flattenThemeColor(color: string, surface: string): string { return `#${channels.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`; } +function srgbToLinear(channel: number): number { + const value = channel / 255; + return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; +} + +function srgbToOklab(channels: readonly [number, number, number]) { + const [red = 0, green = 0, blue = 0] = channels.map(srgbToLinear); + const l = Math.cbrt(0.4122214708 * red + 0.5363325363 * green + 0.0514459929 * blue); + const m = Math.cbrt(0.2119034982 * red + 0.6806995451 * green + 0.1073969566 * blue); + const s = Math.cbrt(0.0883024619 * red + 0.2817188376 * green + 0.6299787005 * blue); + return [ + 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s, + 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s, + 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s, + ] as const; +} + function relativeLuminance(channels: readonly [number, number, number]): number { - const [red, green, blue] = channels.map((channel) => { - const value = channel / 255; - return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; - }); + const [red, green, blue] = channels.map(srgbToLinear); return 0.2126 * red! + 0.7152 * green! + 0.0722 * blue!; } +export function isDarkThemeColor(color: string): boolean { + const channels = rgbChannels(color); + return channels !== null && relativeLuminance(channels) < 0.18; +} + function contrastRatio( first: readonly [number, number, number], second: readonly [number, number, number], @@ -230,6 +255,63 @@ export function themeColorWithContrast( return `#${readable.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`; } +/** + * Unlike `themeColorWithContrast`, which blends toward black or white and greys the color out, + * this moves only OKLCH lightness and takes the most chroma sRGB allows at each step, so a vivid + * accent stays vivid at the contrast it needs. + */ +export function themeAccentWithContrast( + accent: string, + surfaces: ReadonlyArray, + minimum = 4.5, +): string { + const accentChannels = rgbChannels(accent); + const surfaceChannels = surfaces.map(rgbChannels).filter((channels) => channels !== null); + const minimumContrast = (channels: readonly [number, number, number]) => + Math.min(...surfaceChannels.map((surface) => contrastRatio(channels, surface))); + if ( + !accentChannels || + surfaceChannels.length === 0 || + minimumContrast(accentChannels) >= minimum + ) { + return accent; + } + + const [lightness, a, b] = srgbToOklab(accentChannels); + const hue = Math.atan2(b, a); + const inGamut = (channels: ReadonlyArray) => + channels.every((channel) => channel >= -1e-4 && channel <= 1 + 1e-4); + const mostVivid = (stepLightness: number) => { + let chroma = 0; + let lower = 0; + let upper = Math.max(Math.hypot(a, b), 0.2); + for (let index = 0; index < 20; index += 1) { + const candidate = (lower + upper) / 2; + if ( + inGamut( + oklabToLinearSrgb(stepLightness, candidate * Math.cos(hue), candidate * Math.sin(hue)), + ) + ) { + chroma = candidate; + lower = candidate; + } else { + upper = candidate; + } + } + return oklabToSrgb(stepLightness, chroma * Math.cos(hue), chroma * Math.sin(hue)); + }; + + const lighten = minimumContrast([255, 255, 255]) >= minimumContrast([0, 0, 0]); + const step = lighten ? 0.002 : -0.002; + for (let next = lightness; lighten ? next <= 1 : next >= 0; next += step) { + const candidate = mostVivid(next); + if (minimumContrast(candidate) >= minimum) { + return `#${candidate.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`; + } + } + return lighten ? "#ffffff" : "#000000"; +} + export function themeColorWithAlpha(color: string, alpha: number): string { const channels = rgbChannels(color); if (channels) { diff --git a/apps/mobile/src/lib/projectIcon.test.ts b/apps/mobile/src/lib/projectIcon.test.ts new file mode 100644 index 000000000000..8897712da3de --- /dev/null +++ b/apps/mobile/src/lib/projectIcon.test.ts @@ -0,0 +1,81 @@ +import type { ProjectIconOverride } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { projectAutoMonogram, resolveProjectChipIcon } from "./projectIcon"; + +const project = (title: string, projectIcon?: ProjectIconOverride) => ({ + title, + workspaceRoot: `/work/${title}`, + projectIcon, +}); + +describe("resolveProjectChipIcon", () => { + const favicon = () => "https://env.example/favicon.png"; + const noFavicon = () => null; + + it("shows the chat bubble for unknown threads and the no-project folder", () => { + expect(resolveProjectChipIcon(null, "/scratch", favicon)).toBeNull(); + expect(resolveProjectChipIcon(project("scratch"), "/work/scratch", favicon)).toBeNull(); + }); + + it("prefers a custom icon over the favicon", () => { + expect( + resolveProjectChipIcon( + project("t3code", { kind: "monogram", text: "Q7", color: "violet" }), + null, + favicon, + ), + ).toEqual({ kind: "monogram", text: "Q7", color: "violet" }); + expect( + resolveProjectChipIcon(project("t3code", { kind: "emoji", emoji: "🚀" }), null, favicon), + ).toEqual({ kind: "emoji", emoji: "🚀" }); + expect( + resolveProjectChipIcon( + project("lucide proj", { kind: "lucide", name: "rocket", color: "emerald" }), + null, + favicon, + ), + ).toEqual({ kind: "monogram", text: "LP", color: "emerald" }); + }); + + it("uses the favicon, then a monogram of the title", () => { + expect(resolveProjectChipIcon(project("t3code"), null, favicon)).toEqual({ + kind: "favicon", + uri: "https://env.example/favicon.png", + }); + expect(resolveProjectChipIcon(project("Auto Monogram"), null, noFavicon)).toEqual({ + kind: "monogram", + text: "AM", + color: "lime", + }); + expect( + resolveProjectChipIcon( + project("t3code"), + null, + () => "https://env.example/api/assets/project-favicon-missing", + ), + ).toMatchObject({ kind: "monogram" }); + }); +}); + +describe("projectAutoMonogram", () => { + it("matches desktop's deriveProjectIdentity", () => { + const desktop = [ + ["t3code", "T3", "teal"], + ["Auto Monogram", "AM", "lime"], + ["blitzdiff", "BF", "pink"], + ["uoalbumdotclub", "UB", "gray"], + [" Tagium ", "TM", "lime"], + ["Café Ünïcode", "CÜ", "fuchsia"], + ["web2 app", "W2", "emerald"], + ] as const; + for (const [title, text, color] of desktop) { + expect(projectAutoMonogram(title)).toEqual({ kind: "monogram", text, color }); + } + }); + + it("leaves untitled projects to the folder icon, like desktop", () => { + expect(projectAutoMonogram("")).toBeNull(); + expect(projectAutoMonogram(" ")).toBeNull(); + }); +}); diff --git a/apps/mobile/src/lib/projectIcon.ts b/apps/mobile/src/lib/projectIcon.ts index 525e0d253e6d..c4a186880ce7 100644 --- a/apps/mobile/src/lib/projectIcon.ts +++ b/apps/mobile/src/lib/projectIcon.ts @@ -1,4 +1,6 @@ -import type { ProjectIconColor, ProjectIconOverride } from "@t3tools/contracts"; +import { isScratchProject } from "@t3tools/client-runtime/state/projects"; +import { ProjectIconColor, type ProjectIconOverride } from "@t3tools/contracts"; +import { isProjectFaviconFallbackUrl } from "@t3tools/shared/projectFavicon"; export type ProjectIconGlyph = | { readonly kind: "emoji"; readonly emoji: string } @@ -30,6 +32,24 @@ export function projectMonogram(projectName: string): string { return Array.from(`${first}${second}`.toUpperCase()).slice(0, 2).join(""); } +export function projectMonogramColor(projectName: string): ProjectIconColor { + const seed = projectName.normalize("NFKC").trim().toLocaleLowerCase("en-US") || "project"; + let index = 0; + for (const glyph of seed) { + index = (index * 31 + (glyph.codePointAt(0) ?? 0)) % ProjectIconColor.literals.length; + } + return ProjectIconColor.literals[index] ?? "blue"; +} + +export function projectAutoMonogram(projectTitle: string): ProjectIconGlyph | null { + if (!projectTitle.trim()) return null; + return { + kind: "monogram", + text: projectMonogram(projectTitle), + color: projectMonogramColor(projectTitle), + }; +} + export function resolveProjectIconGlyph( projectIcon: ProjectIconOverride | null | undefined, projectTitle: string, @@ -46,6 +66,25 @@ export function resolveProjectIconGlyph( } } +export type ProjectChipIcon = { readonly kind: "favicon"; readonly uri: string } | ProjectIconGlyph; + +export function resolveProjectChipIcon( + project: { + readonly title: string; + readonly workspaceRoot: string; + readonly projectIcon?: ProjectIconOverride | null; + } | null, + scratchWorkspaceRoot: string | null | undefined, + faviconUrl: () => string | null, +): ProjectChipIcon | null { + if (!project || isScratchProject(project, scratchWorkspaceRoot)) return null; + const glyph = resolveProjectIconGlyph(project.projectIcon, project.title); + if (glyph) return glyph; + const url = faviconUrl(); + if (url && !isProjectFaviconFallbackUrl(url)) return { kind: "favicon", uri: url }; + return projectAutoMonogram(project.title); +} + const PROJECT_ICON_COLOR_CLASSES: Record< ProjectIconColor, { readonly text: string; readonly background: string } diff --git a/apps/mobile/src/native/ProjectChipIconImages.tsx b/apps/mobile/src/native/ProjectChipIconImages.tsx new file mode 100644 index 000000000000..25038801bf2f --- /dev/null +++ b/apps/mobile/src/native/ProjectChipIconImages.tsx @@ -0,0 +1,150 @@ +import type { ProjectIconColor } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import { Atom } from "effect/reactivity"; +import { useRef, useSyncExternalStore } from "react"; +import { Platform, View } from "react-native"; +import Svg, { Rect, Text } from "react-native-svg"; +import { useResolveClassNames } from "uniwind"; + +import { countGlyphs, projectIconColorClassNames, type ProjectIconGlyph } from "../lib/projectIcon"; + +const TILE_SIZE = 48; + +interface PendingTile { + readonly key: string; + readonly glyph: ProjectIconGlyph; + readonly promise: Promise; + readonly resolve: (uri: string) => void; + readonly reject: () => void; +} + +const tiles = new Map(); +let pendingTiles: ReadonlyArray = []; +const listeners = new Set<() => void>(); + +export function projectGlyphKey(glyph: ProjectIconGlyph): string { + return JSON.stringify( + glyph.kind === "emoji" ? ["emoji", glyph.emoji] : ["monogram", glyph.text, glyph.color], + ); +} + +function decodeGlyphKey(key: string): ProjectIconGlyph { + const [kind, text, color] = JSON.parse(key) as [string, string, ProjectIconColor]; + return kind === "emoji" ? { kind: "emoji", emoji: text } : { kind: "monogram", text, color }; +} + +function rasterize(key: string): Promise { + const existing = tiles.get(key); + if (existing) return existing.promise; + let resolve!: (uri: string) => void; + let reject!: () => void; + const promise = new Promise((done, fail) => { + resolve = done; + reject = fail; + }); + const tile = { key, glyph: decodeGlyphKey(key), promise, resolve, reject }; + tiles.set(key, tile); + pendingTiles = [...pendingTiles, tile]; + queueMicrotask(notify); + return promise; +} + +function finish(tile: PendingTile, base64: string | undefined) { + if (base64) { + tile.resolve(`data:image/png;base64,${base64.replace(/\s/g, "")}`); + } else { + tile.reject(); + } + pendingTiles = pendingTiles.filter((pending) => pending !== tile); + notify(); +} + +function notify() { + for (const listener of listeners) listener(); +} + +function subscribe(listener: () => void) { + listeners.add(listener); + return () => listeners.delete(listener); +} + +export const projectGlyphImageAtom = Atom.family((key: string) => + Atom.make(Effect.tryPromise(() => rasterize(key))).pipe( + Atom.keepAlive, + Atom.withLabel(`mobile-project-glyph-image:${key}`), + ), +); + +export function ProjectChipIconRasterizer() { + const pending = useSyncExternalStore(subscribe, () => pendingTiles); + return pending.map((tile) => ); +} + +function captureTile(svg: Svg, tile: PendingTile) { + try { + svg.toDataURL( + (base64?: string) => finish(tile, base64), + Platform.OS === "ios" ? { width: TILE_SIZE, height: TILE_SIZE } : undefined, + ); + } catch { + finish(tile, undefined); + } +} + +function GlyphCapture(props: { readonly tile: PendingTile }) { + const started = useRef(false); + const capture = (svg: Svg | null) => { + if (!svg || started.current) return; + started.current = true; + requestAnimationFrame(() => captureTile(svg, props.tile)); + }; + return ( + + + {props.tile.glyph.kind === "emoji" ? ( + + {props.tile.glyph.emoji} + + ) : ( + + )} + + + ); +} + +function MonogramTile(props: { readonly text: string; readonly color: ProjectIconColor }) { + const classNames = projectIconColorClassNames(props.color); + const background = useResolveClassNames(classNames.background); + const foreground = useResolveClassNames(`font-mono ${classNames.text}`); + const fontSize = TILE_SIZE * (countGlyphs(props.text) === 1 ? 0.6 : 0.515625); + return ( + <> + + + {props.text} + + + ); +} diff --git a/apps/mobile/src/native/T3ComposerEditor.ios.tsx b/apps/mobile/src/native/T3ComposerEditor.ios.tsx index 519de1993194..08208c50f725 100644 --- a/apps/mobile/src/native/T3ComposerEditor.ios.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.ios.tsx @@ -18,6 +18,7 @@ import { StyleSheet } from "react-native"; import { composerChipSizeSuffix, contextChipIcon, + contextChipAccent, contextChipPresentation, contextRecordFileName, } from "../lib/composerChipPresentation"; @@ -157,6 +158,7 @@ export function ComposerEditor({ ); const skillLabels = useMemo(() => composerSkillLabels(skills), [skills]); + const chipTextColor = createNativeComposerTheme(theme).text; const tokensJson = useMemo(() => { const tokens = collectComposerInlineTokens(props.value, { preserveTrailingFrom: confirmedTokensRef.current, @@ -181,7 +183,7 @@ export function ComposerEditor({ source: token.source, start: token.start, end: token.end, - accent: presentation.accent, + accent: contextChipAccent(presentation.accent, theme, chipTextColor), label: token.type === "skill" ? (skillLabels.get(token.value) ?? token.value) @@ -194,7 +196,7 @@ export function ComposerEditor({ }; }), ); - }, [props.value, props.context, skillLabels]); + }, [props.value, props.context, skillLabels, theme, chipTextColor]); // Every render resolves against the snapshot history, so a render whose // (value, selection) lags the acknowledged native state is stamped behind // the native revision and rejected by the editor instead of re-applying a diff --git a/apps/mobile/src/native/T3ComposerEditor.native.tsx b/apps/mobile/src/native/T3ComposerEditor.native.tsx index 89fd665ab1df..1b2f49dc6bc3 100644 --- a/apps/mobile/src/native/T3ComposerEditor.native.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.native.tsx @@ -19,6 +19,7 @@ import { Image, Platform, StyleSheet } from "react-native"; import { composerChipSizeSuffix, contextChipIcon, + contextChipAccent, contextChipPresentation, contextRecordFileName, } from "../lib/composerChipPresentation"; @@ -155,6 +156,7 @@ export function ComposerEditor({ ); const skillLabels = useMemo(() => composerSkillLabels(skills), [skills]); + const chipTextColor = createNativeComposerTheme(theme).text; const tokensJson = useMemo(() => { const tokens = collectComposerInlineTokens(props.value, { preserveTrailingFrom: confirmedTokensRef.current, @@ -179,7 +181,7 @@ export function ComposerEditor({ source: token.source, start: token.start, end: token.end, - accent: presentation.accent, + accent: contextChipAccent(presentation.accent, theme, chipTextColor), label: token.type === "skill" ? (skillLabels.get(token.value) ?? token.value) @@ -192,7 +194,7 @@ export function ComposerEditor({ }; }), ); - }, [props.value, props.context, skillLabels]); + }, [props.value, props.context, skillLabels, theme, chipTextColor]); // Every render resolves against the snapshot history, so a render whose // (value, selection) lags the acknowledged native state is stamped behind // the native revision and rejected by the editor instead of re-applying a diff --git a/apps/mobile/src/native/useEnrichedLinkVariants.ts b/apps/mobile/src/native/useEnrichedLinkVariants.ts index 3e4a0f92b7cf..2b32956f5e03 100644 --- a/apps/mobile/src/native/useEnrichedLinkVariants.ts +++ b/apps/mobile/src/native/useEnrichedLinkVariants.ts @@ -6,12 +6,13 @@ import type { MarkdownStyle, } from "react-native-enriched-markdown"; import type { ComposerContextRecord } from "@t3tools/contracts"; -import { contextChipPresentation } from "../lib/composerChipPresentation"; +import { contextChipAccent, contextChipPresentation } from "../lib/composerChipPresentation"; import { CONTEXT_CHIP_ICON_SOURCES } from "../lib/contextChipIcons.generated"; import { FONT_FAMILIES } from "../lib/fontFamilies"; import { contextChipIconSource } from "../lib/contextChipIconSource"; import { markdownFileIconSource } from "../lib/markdownFileIcons"; import { markdownLinkIconSource } from "../lib/markdownLinkIcons"; +import { useUniwindTheme } from "../lib/useUniwindTheme"; import { resolveMarkdownLinkIcon, resolveMarkdownLinkPresentation } from "../lib/markdownLinks"; import type { MarkdownLinkCustomization, @@ -22,19 +23,42 @@ import { enrichedLinkVariantPattern, enrichedSkillDisplayName, enrichedSkillReference, + enrichedThreadLinkPresentation, } from "../lib/enrichedLinkPresentation"; +function chipCustomization( + chip: ReturnType, +): MarkdownLinkCustomization | undefined { + if (!chip) return undefined; + return { + label: chip.label, + icon: contextChipIconSource(chip.icon), + chip: { accent: chip.accent, tintsIcon: chip.icon.kind === "chip" }, + }; +} + export function enrichedContextLinkCustomization( href: string, records?: ReadonlyArray, +) { + return chipCustomization(enrichedContextLinkPresentation(href, records)); +} + +export function enrichedThreadLinkCustomization(href: string) { + return chipCustomization(enrichedThreadLinkPresentation(href)); +} + +export function withChipIcon( + customization: MarkdownLinkCustomization | undefined, + iconUri: string | undefined, ): MarkdownLinkCustomization | undefined { - const context = enrichedContextLinkPresentation(href, records); - if (!context) return undefined; - return { - label: context.label, - icon: contextChipIconSource(context.icon), - chip: { accent: context.accent, tintsIcon: context.icon.kind === "chip" }, - }; + return iconUri && customization?.chip + ? { + ...customization, + icon: { uri: iconUri }, + chip: { ...customization.chip, tintsIcon: false }, + } + : customization; } function presentation( @@ -74,6 +98,7 @@ export function useEnrichedLinkVariants( props: SelectableMarkdownTextProps, ) { const { linkCustomization, skills, textStyle } = props; + const theme = useUniwindTheme(); const links = useMemo(() => { const byUrl = new Map(); for (const asset of assets) { @@ -88,10 +113,12 @@ export function useEnrichedLinkVariants( new Map( [...links].map(([url, link]) => [ url, - link.chip ? (link.chip.accent ?? textStyle.color) : undefined, + link.chip + ? (contextChipAccent(link.chip.accent, theme, textStyle.color) ?? textStyle.color) + : undefined, ]), ), - [links, textStyle], + [links, textStyle, theme], ); const linkVariants = useMemo( () => diff --git a/apps/mobile/src/state/entities.ts b/apps/mobile/src/state/entities.ts index a85a6fa09ded..a50cb9e50200 100644 --- a/apps/mobile/src/state/entities.ts +++ b/apps/mobile/src/state/entities.ts @@ -7,17 +7,29 @@ import type { EnvironmentThread, EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; -import type { +import { EnvironmentId, - ScopedProjectRef, - ScopedThreadRef, - ServerConfig, ThreadId, + type ComposerContextRecord, + type ScopedProjectRef, + type ScopedThreadRef, + type ServerConfig, } from "@t3tools/contracts"; -import { hasThreadLinks, relabelThreadLinks } from "@t3tools/shared/threadLinks"; -import { Atom } from "effect/reactivity"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { + hasThreadLinks, + relabelThreadLinks, + resolveThreadLinkRef, +} from "@t3tools/shared/threadLinks"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { AsyncResult, Atom } from "effect/reactivity"; import { useMemo } from "react"; +import { threadChipTargets } from "../lib/enrichedLinkPresentation"; +import { resolveProjectChipIcon } from "../lib/projectIcon"; +import { projectGlyphImageAtom, projectGlyphKey } from "../native/ProjectChipIconImages"; +import { projectFaviconUrlAtom } from "./assets"; import { environmentProjects } from "./projects"; import { environmentServerConfigsAtom, serverEnvironment } from "./server"; import { environmentThreadDetails, environmentThreadShells } from "./threads"; @@ -53,6 +65,73 @@ const threadTitlesAtom = Atom.family((environmentId: EnvironmentId) => { }).pipe(Atom.withLabel(`mobile-thread-titles:${environmentId}`)); }); +const decodeThreadKey = Schema.decodeUnknownSync(Schema.Tuple([EnvironmentId, ThreadId])); +const decodeThreadChipTargets = Schema.decodeUnknownSync( + Schema.Array( + Schema.Struct({ + href: Schema.String, + environmentId: EnvironmentId, + threadId: ThreadId, + link: Schema.Boolean, + }), + ), +); + +const threadProjectChipIconAtom = Atom.family((key: string) => { + const ref = scopeThreadRef(...decodeThreadKey(JSON.parse(key))); + return Atom.make((get): string | null => { + const shell = get(environmentThreadShells.threadShellAtom(ref)); + const project = shell + ? get( + environmentProjects.projectAtom({ + environmentId: ref.environmentId, + projectId: shell.projectId, + }), + ) + : null; + const icon = resolveProjectChipIcon( + project, + get(serverEnvironment.configValueAtom(ref.environmentId))?.scratchWorkspaceRoot, + () => + project + ? get( + projectFaviconUrlAtom({ + environmentId: ref.environmentId, + cwd: project.workspaceRoot, + faviconPath: project.faviconPath, + }), + ) + : null, + ); + if (!icon) return null; + if (icon.kind === "favicon") return icon.uri; + return Option.getOrNull(AsyncResult.value(get(projectGlyphImageAtom(projectGlyphKey(icon))))); + }).pipe(Atom.withLabel(`mobile-thread-project-chip-icon:${key}`)); +}); + +const EMPTY_THREAD_CHIP_ICONS: ReadonlyMap = new Map(); +const EMPTY_THREAD_CHIP_ICONS_ATOM = Atom.make(EMPTY_THREAD_CHIP_ICONS).pipe( + Atom.withLabel("mobile-thread-chip-icons:empty"), +); + +const threadChipIconsAtom = Atom.family((key: string) => { + const targets = decodeThreadChipTargets(JSON.parse(key)); + return Atom.make((get) => { + const icons = new Map(); + for (const target of targets) { + const source = scopeThreadRef(target.environmentId, target.threadId); + const ref = target.link + ? (resolveThreadLinkRef(source, get(environmentThreadShells.threadRefsAtom)) ?? source) + : source; + const icon = get( + threadProjectChipIconAtom(JSON.stringify([ref.environmentId, ref.threadId])), + ); + if (icon) icons.set(target.href, icon); + } + return icons; + }).pipe(Atom.withLabel(`mobile-thread-chip-icons:${key}`)); +}); + export function waitForProject( ref: ScopedProjectRef, timeoutMs = 10_000, @@ -123,6 +202,19 @@ export function useLiveThreadLinkLabels(markdown: string, environmentId: Environ ); } +export function useThreadChipIcons( + environmentId: EnvironmentId, + markdown: string, + records?: ReadonlyArray, +): ReadonlyMap { + const targets = threadChipTargets(environmentId, markdown, records); + return useAtomValue( + targets.length === 0 + ? EMPTY_THREAD_CHIP_ICONS_ATOM + : threadChipIconsAtom(JSON.stringify(targets)), + ); +} + export function useEnvironmentServerConfig( environmentId: EnvironmentId | null, ): ServerConfig | null { diff --git a/vite.config.ts b/vite.config.ts index c3f5e39500ae..92da9f57481f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -537,6 +537,7 @@ export default defineConfig({ "apps/mobile/src/native/T3ComposerEditor.ios.tsx", "apps/mobile/src/native/T3ComposerEditor.native.tsx", "apps/mobile/src/native/SelectableMarkdownText.tsx", + "apps/mobile/src/native/useEnrichedLinkVariants.ts", ], rules: { "t3code/no-mobile-uniwind-theme-escape-hatches": ["error", { allowUniwindTheme: true }], From 5e3ad56b8f459380ef1f2e2d4572fbf80f30bc30 Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:15:47 -0700 Subject: [PATCH 53/59] fix(web): review thread avatars no longer clip in collapsed activity rows (#23545) --- apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx b/apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx index 11397826a649..24f1fda059a5 100644 --- a/apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx @@ -389,7 +389,7 @@ function ReviewThreadEvent({
} muted={!expanded} /> From 52628d863e977e1a8b16644141fd00ba96640b16 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 23 Oct 2026 17:18:05 -0700 Subject: [PATCH 54/59] ci: close "needs more info" issues after 7 days without a reply (#22628) Co-authored-by: Claude Fable 5.1 --- .github/workflows/stale-needs-more-info.yml | 30 +++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 .github/workflows/stale-needs-more-info.yml diff --git a/.github/workflows/stale-needs-more-info.yml b/.github/workflows/stale-needs-more-info.yml new file mode 100644 index 000000000000..b7b475a6117d --- /dev/null +++ b/.github/workflows/stale-needs-more-info.yml @@ -0,0 +1,30 @@ +name: Close stale "needs more info" issues + +on: + schedule: + - cron: "17 6 * * *" + workflow_dispatch: + +permissions: + actions: write + issues: write + +jobs: + stale: + runs-on: ubuntu-latest + steps: + - uses: actions/stale@v9 + with: + only-issue-labels: "needs more info" + # The label is applied by the triage flow, so never auto-mark; close 7 days after it was added. + days-before-issue-stale: -1 + days-before-issue-close: 7 + days-before-pr-stale: -1 + days-before-pr-close: -1 + remove-issue-stale-when-updated: true + stale-issue-label: "needs more info" + close-issue-reason: not_planned + close-issue-message: >- + Closing: no reply in 7 days after we asked for more information. + Comment with the missing details and we will reopen. + operations-per-run: 200 From 33f1fe5ebe53fdd8d18b6a5d63dfd29c336ba5a9 Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:39:38 -0700 Subject: [PATCH 55/59] fix(server): Codex accounts show ChatGPT's current plan names (#24200) --- apps/server/src/provider/CodexProvider.ts | 10 +++++----- apps/server/src/provider/ProviderRegistry.test.ts | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/server/src/provider/CodexProvider.ts b/apps/server/src/provider/CodexProvider.ts index 81d6842692e2..d064dd708ad9 100644 --- a/apps/server/src/provider/CodexProvider.ts +++ b/apps/server/src/provider/CodexProvider.ts @@ -131,14 +131,14 @@ export function codexPlanLabel(planType: string | null | undefined): string | un case "plus": return "ChatGPT Plus Subscription"; case "pro": - return "ChatGPT Pro 20x Subscription"; + return "ChatGPT Pro 200 Subscription"; case "prolite": - return "ChatGPT Pro 5x Subscription"; + return "ChatGPT Pro 100 Subscription"; case "promax": - return "ChatGPT Pro Max Subscription"; - case "team": - return "ChatGPT Team Subscription"; + return "ChatGPT Pro 500 Subscription"; case "self_serve_business_prolite": + return "ChatGPT Business Premium Subscription"; + case "team": case "self_serve_business_usage_based": case "business": return "ChatGPT Business Subscription"; diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index 65ceac5012ab..4aeaddff8f4d 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -346,7 +346,7 @@ it.layer( assert.strictEqual(status.version, "1.0.0"); assert.strictEqual(status.auth.status, "authenticated"); assert.strictEqual(status.auth.type, "chatgpt"); - assert.strictEqual(status.auth.label, "ChatGPT Pro 20x Subscription"); + assert.strictEqual(status.auth.label, "ChatGPT Pro 200 Subscription"); assert.strictEqual(status.auth.email, "test@example.com"); assert.deepStrictEqual(status.setup, { canAuthenticate: false, canInstall: false }); assert.deepStrictEqual(status.models, [ From bf5ffe84482ef295dd274914b9ed5cabd4cfc819 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Fri, 23 Oct 2026 20:56:00 -0400 Subject: [PATCH 56/59] fix(observability): desktop dev version and web environment match the other services (#23975) Signed-off-by: Yordis Prieto --- .../src/app/DesktopObservability.test.ts | 30 +++++++++++++++++++ apps/desktop/src/app/DesktopObservability.ts | 5 +++- apps/web/src/observability/clientTracing.ts | 1 + docs/operations/observability.md | 2 ++ 4 files changed, 37 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/app/DesktopObservability.test.ts b/apps/desktop/src/app/DesktopObservability.test.ts index f865ed312063..6ddb31cb6eaa 100644 --- a/apps/desktop/src/app/DesktopObservability.test.ts +++ b/apps/desktop/src/app/DesktopObservability.test.ts @@ -1,4 +1,5 @@ import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; +import desktopPackageJson from "../../package.json" with { type: "json" }; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import * as ConfigProvider from "effect/ConfigProvider"; @@ -312,4 +313,33 @@ describe("DesktopObservability", () => { ), ); }); + + it.effect("reports the app's own version for an unpackaged desktop run", () => { + const requests: Array = []; + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-observability-test-", + }); + const layerEnvironment = layerEnvironmentFor(baseDir); + yield* writeObservabilitySettings(layerEnvironment, { + otlpLogsUrl: "https://settings.example.com/v1/logs", + }); + + yield* Effect.scoped( + Effect.logInfo("desktop resource under test").pipe( + Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(layerEnvironment))), + ), + ); + + const body = requests.map((request) => request.body).join("\n"); + assert.include( + body, + `"key":"service.version","value":{"stringValue":"${desktopPackageJson.version}"}`, + ); + }).pipe( + Effect.scoped, + Effect.provide(Layer.mergeAll(NodeServices.layer, layerCollector(requests), layerEmptyEnv)), + ); + }); }); diff --git a/apps/desktop/src/app/DesktopObservability.ts b/apps/desktop/src/app/DesktopObservability.ts index e35b14cc5760..55d200a15917 100644 --- a/apps/desktop/src/app/DesktopObservability.ts +++ b/apps/desktop/src/app/DesktopObservability.ts @@ -29,6 +29,8 @@ import * as SynchronizedRef from "effect/SynchronizedRef"; import * as Tracer from "effect/Tracer"; import { OtlpExporter, OtlpLogger, OtlpTracer } from "effect/observability"; +import desktopPackageJson from "../../package.json" with { type: "json" }; + import * as DesktopEnvironment from "./DesktopEnvironment.ts"; const DESKTOP_LOG_FILE_MAX_BYTES = 10 * 1024 * 1024; @@ -597,7 +599,8 @@ const layerTelemetry = Layer.unwrap( const endpoints = yield* resolveOtlpEndpoints; const resource = { serviceName: "t3code-desktop", - serviceVersion: environment.appVersion, + // Unpackaged runs launch a script, so Electron reports its own version. + serviceVersion: environment.isPackaged ? environment.appVersion : desktopPackageJson.version, attributes: { "service.namespace": "t3code", "service.runtime": "desktop", diff --git a/apps/web/src/observability/clientTracing.ts b/apps/web/src/observability/clientTracing.ts index 8dbdbe55d891..94e06c11bf2c 100644 --- a/apps/web/src/observability/clientTracing.ts +++ b/apps/web/src/observability/clientTracing.ts @@ -18,6 +18,7 @@ const CLIENT_TRACING_RESOURCE = { serviceName: "t3code-web", attributes: { "service.namespace": "t3code", + "deployment.environment.name": import.meta.env.DEV ? "development" : "production", "service.runtime": "t3-web", "service.mode": isElectron ? "electron" : "browser", "service.version": APP_VERSION, diff --git a/docs/operations/observability.md b/docs/operations/observability.md index eedb8732ea27..59eb410ab081 100644 --- a/docs/operations/observability.md +++ b/docs/operations/observability.md @@ -626,6 +626,8 @@ Service names are fixed: `t3code-server` for the backend and `t3code-desktop` fo process, both in `service.namespace` `t3code`. `OTEL_SERVICE_NAME` and a `service.name` or `service.namespace` in `OTEL_RESOURCE_ATTRIBUTES` are ignored. Tell installations apart with other resource attributes, such as `OTEL_RESOURCE_ATTRIBUTES=deployment.environment.name=development`. +The UI reports `deployment.environment.name` from its build: `development` under the Vite dev server +and `production` otherwise. If the OTLP URLs are unset, local tracing still works, metrics stay in-process only, and logs stay on stdout only. From f252ed0cdde04978eb163aa9c614c3277b4a1b60 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Fri, 23 Oct 2026 20:57:05 -0400 Subject: [PATCH 57/59] fix(observability): custom resource attributes use the t3code prefix (#23974) Signed-off-by: Yordis Prieto --- apps/server/src/config.ts | 2 +- apps/server/src/serverLogger.test.ts | 13 +++++++++++++ apps/web/src/observability/clientTracing.ts | 2 +- docs/operations/observability.md | 5 +++++ packages/shared/src/relayTracing.ts | 4 ++-- 5 files changed, 22 insertions(+), 4 deletions(-) diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index c5178547b43d..d0bc87cf0ff6 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -111,7 +111,7 @@ export const otlpResource = ( attributes: { "service.namespace": "t3code", "service.runtime": "t3-server", - "service.mode": config.mode, + "t3code.server.managed_by": config.mode === "desktop" ? "desktop" : "standalone", "host.name": config.otelEnvironment.resourceAttributes["host.name"] || hostName, }, }); diff --git a/apps/server/src/serverLogger.test.ts b/apps/server/src/serverLogger.test.ts index 75c35a61b03a..2191b072b323 100644 --- a/apps/server/src/serverLogger.test.ts +++ b/apps/server/src/serverLogger.test.ts @@ -152,6 +152,19 @@ const printThroughConsole = (fields: object) => }); describe("ServerLoggerLive", () => { + it.effect("exports a resource that names whether the desktop app launched the server", () => + Effect.gen(function* () { + const { requests } = yield* logInSpanThrough({ + otlpLogsUrl: "https://collector.example.com/v1/logs", + mode: "desktop", + }); + + const body = requests.map((request) => request.body).join("\n"); + assert.include(body, '"key":"t3code.server.managed_by","value":{"stringValue":"desktop"}'); + assert.notInclude(body, "service.mode"); + }), + ); + it.effect("prints a cause passed as a log field instead of collapsing it", () => Effect.gen(function* () { const output = yield* printThroughConsole({ diff --git a/apps/web/src/observability/clientTracing.ts b/apps/web/src/observability/clientTracing.ts index 94e06c11bf2c..73a2611c7d4d 100644 --- a/apps/web/src/observability/clientTracing.ts +++ b/apps/web/src/observability/clientTracing.ts @@ -20,7 +20,7 @@ const CLIENT_TRACING_RESOURCE = { "service.namespace": "t3code", "deployment.environment.name": import.meta.env.DEV ? "development" : "production", "service.runtime": "t3-web", - "service.mode": isElectron ? "electron" : "browser", + "t3code.client.surface": isElectron ? "desktop" : "web", "service.version": APP_VERSION, }, } as const; diff --git a/docs/operations/observability.md b/docs/operations/observability.md index 59eb410ab081..a9d03758ebfd 100644 --- a/docs/operations/observability.md +++ b/docs/operations/observability.md @@ -629,6 +629,11 @@ resource attributes, such as `OTEL_RESOURCE_ATTRIBUTES=deployment.environment.na The UI reports `deployment.environment.name` from its build: `development` under the Vite dev server and `production` otherwise. +Attributes specific to T3 Code use the `t3code.` prefix. The UI runs as `t3code-web` in both a +browser and the desktop window, since it is the same code, and `t3code.client.surface` is `desktop` +or `web`. The server's `t3code.server.managed_by` is `desktop` when the desktop app launched it and +`standalone` otherwise. + If the OTLP URLs are unset, local tracing still works, metrics stay in-process only, and logs stay on stdout only. diff --git a/packages/shared/src/relayTracing.ts b/packages/shared/src/relayTracing.ts index 2d726760d103..66f7e79665d6 100644 --- a/packages/shared/src/relayTracing.ts +++ b/packages/shared/src/relayTracing.ts @@ -173,8 +173,8 @@ export function layer( attributes: { "service.namespace": "t3code", "service.runtime": resource.runtime, - "service.component": resource.component ?? "relay-client", - "t3.client.surface": resource.client, + "t3code.component": resource.component ?? "relay-client", + "t3code.client.surface": resource.client, }, }, }).pipe(Layer.provide(OtlpSerialization.layerJson)); From 6c217984850438bd1b9b1f70e9409a4ea36f9564 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 23 Oct 2026 18:06:36 -0700 Subject: [PATCH 58/59] fix(mobile): Home and pull request lists let UIKit own their top inset (#24284) Co-authored-by: Claude Opus 5.5 --- apps/mobile/src/features/home/HomeScreen.tsx | 16 +--- .../PullRequestReviewersScreen.tsx | 11 +-- .../pull-requests/PullRequestRouteScreen.tsx | 17 +---- .../pull-requests/PullRequestsScreen.tsx | 19 ++--- .../src/native/useRestingTopOffset.test.tsx | 73 ------------------- apps/mobile/src/native/useRestingTopOffset.ts | 29 -------- 6 files changed, 16 insertions(+), 149 deletions(-) delete mode 100644 apps/mobile/src/native/useRestingTopOffset.test.tsx delete mode 100644 apps/mobile/src/native/useRestingTopOffset.ts diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 389054aa1035..2de56b7c391c 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -45,7 +45,6 @@ import type { SavedRemoteConnection } from "../../lib/connection"; import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; import { NativePrimaryColumnContext } from "../../native/v5-workspace-context"; import { nativeHeaderScrollEdgeEffects } from "../../native/scrollEdgeEffects"; -import { useRestingTopOffset } from "../../native/useRestingTopOffset"; import { useNativeColumnLayoutMetrics, useNativeLayoutMetrics, @@ -218,9 +217,7 @@ function HomeTopContentSpacer() { } function HomeScrollView(props: ComponentProps) { - const insets = useSafeAreaInsets(); const primaryColumn = use(NativePrimaryColumnContext); - const restingOffset = useRestingTopOffset(insets.top, props); if (Platform.OS !== "ios") return ; // v5 needs the actual content scroll view registered with its screen controller. @@ -235,14 +232,8 @@ function HomeScrollView(props: ComponentProps) { > ); @@ -885,8 +876,9 @@ export function HomeScreen(props: HomeScreenProps) { ListEmptyComponent={v2ListEmpty} style={{ flex: 1 }} automaticallyAdjustsScrollIndicatorInsets={Platform.OS === "ios"} - contentInsetAdjustmentBehavior="never" - contentInset={Platform.OS === "ios" ? { top: insets.top } : undefined} + // UIKit owns the bar's inset, so a minimizing bar and a resized column move + // the list in the same frame as the bar. + contentInsetAdjustmentBehavior="automatic" contentInsetStartAdjustment={Platform.OS === "ios" ? insets.top : 0} initialScrollOffset={ initialScrollOffset > 0 @@ -905,7 +897,7 @@ export function HomeScreen(props: HomeScreenProps) { (contentSideInsets?.right ?? 0) + (primaryColumn ? 8 : 0) + androidListGutter, paddingBottom: Platform.OS === "ios" - ? iosBottomClearance + ? iosBottomClearance - (columnMetrics?.safeArea.bottom ?? insets.bottom) : (workspace?.bottomClearance ?? Math.max(insets.bottom, 16) + (Platform.OS === "android" ? fabClearance : 88)), diff --git a/apps/mobile/src/features/pull-requests/PullRequestReviewersScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestReviewersScreen.tsx index af471abf2c86..ab609d42456b 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestReviewersScreen.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestReviewersScreen.tsx @@ -20,7 +20,6 @@ import { ScrollViewMarker } from "react-native-screens"; import { AppText as Text, AppTextInput as TextInput } from "../../components/ui/AppText"; import { EmptyState } from "../../components/ui/EmptyState"; import { Button } from "../../components/ui/Button"; -import { useRestingTopOffset } from "../../native/useRestingTopOffset"; import { SymbolView } from "../../components/ui/AppSymbol"; import { NativeStackScreenOptions } from "../../native/StackHeader"; import { useNativeLayoutMetrics } from "../../native/native-layout-metrics"; @@ -35,17 +34,9 @@ import { PullRequestAvatar } from "./PullRequestPresentation"; import { pullRequestTarget, type PullRequestParams } from "./usePullRequestDiff"; function ReviewerScrollView(props: ScrollViewProps) { - const insets = useSafeAreaInsets(); - const restingOffset = useRestingTopOffset(insets.top, props); return ( - + ); } diff --git a/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx index a513fd424ced..84f9ea16f4bb 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx @@ -56,7 +56,6 @@ import { View, } from "react-native"; import { NativeScrollViewMarker as ScrollViewMarker } from "../../native/NativeScrollViewMarker"; -import { useRestingTopOffset } from "../../native/useRestingTopOffset"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/ui/AppText"; @@ -117,16 +116,9 @@ import { usePullRequestEnvironmentFallback } from "./usePullRequestEnvironmentFa const EMPTY_IDS: readonly string[] = []; function PullRequestTabScrollView(props: ScrollViewProps) { - const insets = useSafeAreaInsets(); - const restingOffset = useRestingTopOffset(insets.top, props); return ( - + ); } @@ -572,10 +564,9 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps - + ); } @@ -266,8 +258,9 @@ export function PullRequestsScreen( showsVerticalScrollIndicator={Platform.OS !== "android"} recycleItems renderScrollComponent={Platform.OS === "ios" ? PullRequestScrollView : undefined} - contentInsetAdjustmentBehavior="never" - contentInset={Platform.OS === "ios" ? { top: insets.top } : undefined} + // UIKit owns the bar's inset, so a minimizing bar and a resized column move + // the list in the same frame as the bar. + contentInsetAdjustmentBehavior="automatic" // A plain offset: LegendList adds the list header to an index-based restore, which // landed every return one header lower. initialScrollOffset={ @@ -275,7 +268,9 @@ export function PullRequestsScreen( } contentInsetStartAdjustment={Platform.OS === "ios" ? insets.top : 0} contentContainerStyle={{ - paddingBottom: Math.max(insets.bottom, workspace.bottomClearance), + paddingBottom: + Math.max(insets.bottom, workspace.bottomClearance) - + (Platform.OS === "ios" ? insets.bottom : 0), }} onScroll={(event) => { workspace.recordScrollOffset( diff --git a/apps/mobile/src/native/useRestingTopOffset.test.tsx b/apps/mobile/src/native/useRestingTopOffset.test.tsx deleted file mode 100644 index 7be7e1d82c3b..000000000000 --- a/apps/mobile/src/native/useRestingTopOffset.test.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { act, useLayoutEffect } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, describe, expect, it } from "vite-plus/test"; - -import { useRestingTopOffset } from "./useRestingTopOffset"; - -let root: Root | null = null; -let latest: ReturnType | null = null; - -function Probe(props: { readonly topInset: number; readonly restoredOffset?: number }) { - const restingOffset = useRestingTopOffset( - props.topInset, - props.restoredOffset === undefined ? {} : { contentOffset: { x: 0, y: props.restoredOffset } }, - ); - useLayoutEffect(() => { - latest = restingOffset; - }); - return null; -} - -function render(topInset: number, restoredOffset?: number) { - (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; - root ??= createRoot(document.createElement("div")); - act(() => root!.render()); -} - -function scrollTo(y: number) { - const event = { nativeEvent: { contentOffset: { x: 0, y } } } as Parameters< - ReturnType["onScroll"] - >[0]; - act(() => latest!.onScroll(event)); -} - -function offset() { - return latest!.contentOffset.y; -} - -afterEach(() => { - act(() => root?.unmount()); - root = null; - latest = null; -}); - -describe("useRestingTopOffset", () => { - it("keeps an untouched list below the header once the column's inset arrives", () => { - // The folded iPhone Duo's window has no top inset; the header's arrives after mount. - render(0); - render(54); - expect(offset()).toBe(-54); - }); - - it("does not move a list the reader scrolled when the inset changes", () => { - render(54); - scrollTo(300); - render(90); - expect(offset()).toBe(-54); - }); - - it("follows the inset again once the list is back at the top", () => { - render(54); - scrollTo(300); - render(90); - scrollTo(-90); - render(54); - expect(offset()).toBe(-54); - }); - - it("holds a restored position through an inset change", () => { - render(0, 1_200); - render(54); - expect(offset()).toBe(1_200); - }); -}); diff --git a/apps/mobile/src/native/useRestingTopOffset.ts b/apps/mobile/src/native/useRestingTopOffset.ts deleted file mode 100644 index 33fc6bd09b47..000000000000 --- a/apps/mobile/src/native/useRestingTopOffset.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { useState } from "react"; -import type { NativeScrollEvent, NativeSyntheticEvent, ScrollViewProps } from "react-native"; - -/** - * A scroll view with a manual top inset rests at `-topInset`, just below the header. The inset - * can change after mount: a column's safe area starts as the window's until UIKit measures it, - * and rotation or folding changes it again. While the view rests below the header it follows the - * inset; once scrolled away it holds the last resting offset, because RN re-applies a changed - * `contentOffset` prop and would move the reader. - */ -export function holdRestingTopOffset(held: number | null, scrollY: number, topInset: number) { - return Math.abs(scrollY + topInset) <= 1 ? null : (held ?? -topInset); -} - -export function useRestingTopOffset( - topInset: number, - props: Pick, -) { - // A list restoring a saved position mounts away from its resting offset. - const [held, setHeld] = useState(props.contentOffset?.y ?? null); - return { - contentOffset: { x: 0, y: held ?? -topInset }, - onScroll: (event: NativeSyntheticEvent) => { - const scrollY = event.nativeEvent.contentOffset.y; - setHeld((current) => holdRestingTopOffset(current, scrollY, topInset)); - props.onScroll?.(event); - }, - }; -} From 82c149bf58ed77a9ca0578a2341ea5c6b7678017 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Fri, 23 Oct 2026 21:30:55 -0400 Subject: [PATCH 59/59] docs(fork): narrow the telemetry divergence to what upstream has not taken Signed-off-by: Yordis Prieto --- docs/fork/0026-telemetry-says-which-app-sent-it.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/fork/0026-telemetry-says-which-app-sent-it.md b/docs/fork/0026-telemetry-says-which-app-sent-it.md index fd6cafdf73d1..96ba82edf6de 100644 --- a/docs/fork/0026-telemetry-says-which-app-sent-it.md +++ b/docs/fork/0026-telemetry-says-which-app-sent-it.md @@ -36,10 +36,14 @@ else. ## Upstream considerations -A plausible upstream submission. The attributes came from upstream, and the -change carries no fork-specific intent. Dashboards or saved queries that -filter on `service.mode`, `service.runtime`, `service.component`, or the -relay's `t3.client.surface` have to move to the new keys, which is the part upstream would want to weigh. +Upstream has taken the `t3code.`-prefixed keys (`t3code.client.surface`, +`t3code.server.managed_by`, `t3code.component`) and the web UI's +`deployment.environment.name`. What the fork still carries is dropping +`service.runtime`, plus `service.instance.id`, the server and desktop +`deployment.environment.name`, and the web UI's `process.runtime.*`, +`user_agent.original`, and `browser.*`. Dashboards or +saved queries that filter on `service.runtime` have to move to the new keys, +which is the part upstream would want to weigh. Mobile relay traces now report `t3code.client.surface` as `mobile` and carry the build variant in `deployment.environment.name`, so a query on `mobile-production` becomes two filters.