From d56b82c7084cc4b5e5015c2f9ff1a5279bda74b7 Mon Sep 17 00:00:00 2001 From: maco Date: Wed, 11 Nov 2026 13:00:25 +0100 Subject: [PATCH 0001/1165] fix(web): keep saved default model when changing effort (#22193) Co-authored-by: macodev00 Co-authored-by: Julius Marminge Co-authored-by: Claude Opus 5.5 --- .../chat/ProviderModelPicker.logic.test.ts | 20 ++++ .../chat/ProviderModelPicker.logic.ts | 18 +++ .../components/chat/ProviderModelPicker.tsx | 2 + .../ProjectDefaultsSettings.logic.test.ts | 112 ++++++++++++++++++ .../settings/ProjectDefaultsSettings.logic.ts | 58 +++++++++ .../settings/ProjectDefaultsSettings.tsx | 28 ++--- 6 files changed, 222 insertions(+), 16 deletions(-) create mode 100644 apps/web/src/components/chat/ProviderModelPicker.logic.test.ts create mode 100644 apps/web/src/components/chat/ProviderModelPicker.logic.ts create mode 100644 apps/web/src/components/settings/ProjectDefaultsSettings.logic.test.ts create mode 100644 apps/web/src/components/settings/ProjectDefaultsSettings.logic.ts diff --git a/apps/web/src/components/chat/ProviderModelPicker.logic.test.ts b/apps/web/src/components/chat/ProviderModelPicker.logic.test.ts new file mode 100644 index 0000000000000..7602a362d30c4 --- /dev/null +++ b/apps/web/src/components/chat/ProviderModelPicker.logic.test.ts @@ -0,0 +1,20 @@ +import { ProviderDriverKind } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { retainedModelWhileProviderLocked } from "./ProviderModelPicker.logic"; + +const claude = ProviderDriverKind.make("claudeAgent"); +const opus = { slug: "claude-opus-5-5", name: "Claude Opus 5.5", shortName: "Opus 5.5" }; +const sonnet = { slug: "claude-sonnet-5-5", name: "Claude Sonnet 5.5", shortName: "Sonnet 5.5" }; +const retained = (enabled: boolean, isAvailable: boolean, models = [sonnet, opus]) => + retainedModelWhileProviderLocked({ driverKind: claude, enabled, isAvailable, models }, opus.slug); + +describe("retainedModelWhileProviderLocked", () => { + it("shows a hidden saved model while its provider cannot run", () => { + expect(retained(false, true)).toBe(opus); + expect(retained(true, false)).toBe(opus); + expect(retained(false, true, [sonnet])).toEqual({ slug: opus.slug, name: opus.slug }); + expect(retained(true, true)).toBeUndefined(); + expect(retainedModelWhileProviderLocked(null, opus.slug)).toBeUndefined(); + }); +}); diff --git a/apps/web/src/components/chat/ProviderModelPicker.logic.ts b/apps/web/src/components/chat/ProviderModelPicker.logic.ts new file mode 100644 index 0000000000000..dd3d6871e7c2a --- /dev/null +++ b/apps/web/src/components/chat/ProviderModelPicker.logic.ts @@ -0,0 +1,18 @@ +import type { ProviderDriverKind } from "@t3tools/contracts"; + +import type { ModelEsque } from "./providerIconUtils"; + +/** Keeps a hidden model visible while its provider is locked or turned off. */ +export function retainedModelWhileProviderLocked( + entry: { + readonly driverKind: ProviderDriverKind; + readonly enabled: boolean; + readonly isAvailable: boolean; + readonly models: ReadonlyArray>; + } | null, + model: string, +): ModelEsque | undefined { + if (!entry || (entry.enabled && entry.isAvailable) || !model) return undefined; + if (entry.driverKind === "opencode" || entry.driverKind === "antigravity") return undefined; + return entry.models.find((candidate) => candidate.slug === model) ?? { slug: model, name: model }; +} diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index 6f9b518075c79..e05736b0d412d 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -21,6 +21,7 @@ import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { cn } from "~/lib/utils"; import { ModelPickerContent, resolveModelPickerSelectedModel } from "./ModelPickerContent"; +import { retainedModelWhileProviderLocked } from "./ProviderModelPicker.logic"; import { ChatGptSharingControl } from "./ChatGptSharingControl"; import { ProviderInstanceIcon } from "./ProviderInstanceIcon"; import { ModelEsque, getDisplayModelName, getTriggerDisplayModelName } from "./providerIconUtils"; @@ -161,6 +162,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { model: props.model, options: selectedInstanceOptions, }) ?? + retainedModelWhileProviderLocked(activeEntry, props.model) ?? (activeEntry?.driverKind === "opencode" || activeEntry?.driverKind === "antigravity" ? undefined : selectedInstanceOptions[0]); diff --git a/apps/web/src/components/settings/ProjectDefaultsSettings.logic.test.ts b/apps/web/src/components/settings/ProjectDefaultsSettings.logic.test.ts new file mode 100644 index 0000000000000..a14288ce90b14 --- /dev/null +++ b/apps/web/src/components/settings/ProjectDefaultsSettings.logic.test.ts @@ -0,0 +1,112 @@ +import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; +import { DEFAULT_UNIFIED_SETTINGS, type UnifiedSettings } from "@t3tools/contracts/settings"; +import { createModelSelection } from "@t3tools/shared/model"; +import { describe, expect, it } from "vite-plus/test"; + +import { providerInstanceEntriesFor } from "../../providerInstances"; +import { resolveDefaultModelSettingsPresentation } from "./ProjectDefaultsSettings.logic"; + +const claudeId = ProviderInstanceId.make("claudeAgent"); +const high = [{ id: "effort", value: "high" }]; +const savedClaude = createModelSelection(claudeId, "claude-opus-4-8", high); +function provider(input: { + instanceId: string; + driver: string; + enabled?: boolean; + availability?: ServerProvider["availability"]; + models: ReadonlyArray; +}): ServerProvider { + return { + instanceId: ProviderInstanceId.make(input.instanceId), + driver: ProviderDriverKind.make(input.driver), + enabled: input.enabled ?? true, + installed: true, + version: null, + status: "ready", + ...(input.availability ? { availability: input.availability } : {}), + auth: { status: "authenticated" }, + checkedAt: "2026-01-01T00:00:00.000Z", + models: input.models.map((slug, index) => ({ + slug, + name: slug, + isCustom: false, + ...(index === 0 ? { isDefault: true } : {}), + capabilities: {}, + })), + slashCommands: [], + skills: [], + }; +} + +const claudeAndCodex = [ + provider({ instanceId: "codex", driver: "codex", models: ["gpt-5.6"] }), + provider({ instanceId: "claudeAgent", driver: "claudeAgent", models: ["claude-opus-4-8"] }), +]; + +function present( + providers: ReadonlyArray, + overrides: Partial = {}, +) { + const settings = { + ...DEFAULT_UNIFIED_SETTINGS, + defaultModelSelection: savedClaude, + ...overrides, + }; + return resolveDefaultModelSettingsPresentation({ + entries: providerInstanceEntriesFor(providers, settings, null), + providers, + settings, + projectId: null, + }); +} + +const claudeOff: Partial = { + providerInstances: { + [claudeId]: { driver: ProviderDriverKind.make("claudeAgent"), enabled: false }, + }, +}; + +describe("resolveDefaultModelSettingsPresentation", () => { + it("keeps the saved model and explains why when its provider cannot run", () => { + const withoutClaude = claudeAndCodex.filter((entry) => entry.instanceId !== claudeId); + const claudeUnavailable = claudeAndCodex.map((entry) => + entry.instanceId === claudeId ? { ...entry, availability: "unavailable" as const } : entry, + ); + // Settings turn Claude off before the server snapshot reflects it. + expect(present(claudeAndCodex, claudeOff)).toEqual({ + selection: savedClaude, + providerNote: "Claude is turned off.", + }); + expect(present(claudeUnavailable)).toEqual({ + selection: savedClaude, + providerNote: "Claude is unavailable.", + }); + expect(present(withoutClaude)).toEqual({ + selection: savedClaude, + providerNote: "This provider is not available on this environment.", + }); + }); + + it("leaves effort editable when the saved provider can run", () => { + expect(present(claudeAndCodex)).toEqual({ selection: savedClaude, providerNote: null }); + }); + + it("still follows a hidden model on the same provider", () => { + const row = present( + [ + provider({ + instanceId: "claudeAgent", + driver: "claudeAgent", + models: ["claude-sonnet-5", "claude-opus-4-8"], + }), + ], + { + providerModelPreferences: { + [claudeId]: { hiddenModels: ["claude-opus-4-8"], modelOrder: [] }, + }, + }, + ); + expect(row.providerNote).toBeNull(); + expect(row.selection).toMatchObject({ instanceId: claudeId, model: "claude-sonnet-5" }); + }); +}); diff --git a/apps/web/src/components/settings/ProjectDefaultsSettings.logic.ts b/apps/web/src/components/settings/ProjectDefaultsSettings.logic.ts new file mode 100644 index 0000000000000..68bd7bd81fc6a --- /dev/null +++ b/apps/web/src/components/settings/ProjectDefaultsSettings.logic.ts @@ -0,0 +1,58 @@ +import type { ModelSelection, ProjectId, ServerProvider } from "@t3tools/contracts"; +import type { UnifiedSettings } from "@t3tools/contracts/settings"; + +import { resolveEffectiveDefaultModelSelection } from "../../modelSelection"; +import { + resolveDefaultProviderModelSelection, + resolveProjectDefaultProviderModelSelection, + type ProviderInstanceEntry, +} from "../../providerInstances"; + +export interface DefaultModelSettingsPresentation { + readonly selection: ModelSelection | null; + /** Set when the saved model's provider cannot run; effort stays hidden until it can. */ + readonly providerNote: string | null; +} + +export function resolveDefaultModelSettingsPresentation(input: { + readonly entries: ReadonlyArray; + readonly providers: ReadonlyArray; + readonly settings: UnifiedSettings; + readonly projectId: ProjectId | null; +}): DefaultModelSettingsPresentation { + const saved = input.settings.defaultModelSelection; + if (saved === null) { + const automatic = resolveProjectDefaultProviderModelSelection( + input.providers, + input.settings, + input.projectId, + ); + return { + selection: automatic + ? resolveEffectiveDefaultModelSelection(input.settings, input.providers, automatic) + : null, + providerNote: null, + }; + } + + const savedEntry = input.entries.find((entry) => entry.instanceId === saved.instanceId); + const resolved = resolveDefaultProviderModelSelection(input.providers, saved); + // Settings can turn a provider off before the server snapshot catches up, so check both. + if ( + !savedEntry?.enabled || + !savedEntry.isAvailable || + resolved?.instanceId !== saved.instanceId + ) { + return { selection: saved, providerNote: providerNote(savedEntry) }; + } + return { + selection: resolveEffectiveDefaultModelSelection(input.settings, input.providers, resolved), + providerNote: null, + }; +} + +function providerNote(entry: ProviderInstanceEntry | undefined): string { + if (!entry) return "This provider is not available on this environment."; + if (!entry.enabled) return `${entry.displayName} is turned off.`; + return `${entry.displayName} is unavailable.`; +} diff --git a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx index b091d0d58f3fa..182b8bc5b1fdf 100644 --- a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx +++ b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx @@ -12,13 +12,8 @@ import { useClientSettings } from "../../hooks/useSettings"; import { defaultModelSelectionsAreMixed, getCustomModelOptionsByInstance, - resolveEffectiveDefaultModelSelection, } from "../../modelSelection"; -import { - providerInstanceEntriesFor, - resolveDefaultProviderModelSelection, - resolveProjectDefaultProviderModelSelection, -} from "../../providerInstances"; +import { providerInstanceEntriesFor } from "../../providerInstances"; import { useEnvironmentProviders, useEnvironments } from "../../state/environments"; import { firstManagedDenial } from "../../state/managedPolicy"; import { EMPTY_SERVER_PROVIDERS } from "../../state/server"; @@ -30,6 +25,7 @@ import { TraitsPicker } from "../chat/TraitsPicker"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { toastManager } from "../ui/toast"; import { Switch } from "../ui/switch"; +import { resolveDefaultModelSettingsPresentation } from "./ProjectDefaultsSettings.logic"; import type { ProjectSettingsCategory } from "./ProjectSettingsPanel"; import { searchableSetting } from "./settingsSearch"; import { useSettingsScope } from "./SettingsScopeContext"; @@ -67,14 +63,14 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting (representative?.serverConfig ? providersByEnvironment.get(representative.environmentId) : undefined) ?? EMPTY_SERVER_PROVIDERS; - const storedSelection = - settings.defaultModelSelection === null - ? resolveProjectDefaultProviderModelSelection(providers, settings, target?.projectId ?? null) - : resolveDefaultProviderModelSelection(providers, settings.defaultModelSelection); - const selection = storedSelection - ? resolveEffectiveDefaultModelSelection(settings, providers, storedSelection) - : null; const entries = providerInstanceEntriesFor(providers, settings, target?.projectId ?? null); + const presentation = resolveDefaultModelSettingsPresentation({ + entries, + providers, + settings, + projectId: target?.projectId ?? null, + }); + const selection = presentation.selection; const modelOptions = getCustomModelOptionsByInstance( settings, providers, @@ -146,7 +142,7 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting status={ mixedModel ? `${automaticModel ? "Automatic defaults differ." : "Defaults differ."} Select one environment or choose a shared model to edit effort.` - : effortDisabledReason + : (presentation.providerNote ?? effortDisabledReason) } resetAction={ settings.defaultModelSelection !== null ? ( @@ -154,7 +150,7 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting ) : null } control={ - selection && activeEntry ? ( + selection ? (
- {!mixedModel && !effortDisabledReason ? ( + {!mixedModel && !presentation.providerNote && !effortDisabledReason && activeEntry ? ( Date: Wed, 11 Nov 2026 04:05:45 -0800 Subject: [PATCH 0002/1165] fix(web): reveal expanded work rows hidden behind the composer (#21968) Co-authored-by: maria-rcks Co-authored-by: Julius Marminge --- .../components/chat/CommandOutputPanel.tsx | 4 +- .../chat/MessagesTimeline.browser.test.tsx | 220 +++++++++++++++++- .../src/components/chat/MessagesTimeline.tsx | 99 +++++++- .../components/chat/TimelineWorkEntryRow.tsx | 10 +- .../components/chat/TimelineWorkGroupRows.tsx | 26 ++- .../src/components/chat/V2ItemInspector.tsx | 7 +- .../src/components/chat/timelineRowContext.ts | 19 +- .../chat/timelineScrollAnchoring.test.tsx | 35 ++- .../chat/timelineScrollAnchoring.ts | 21 ++ 9 files changed, 422 insertions(+), 19 deletions(-) diff --git a/apps/web/src/components/chat/CommandOutputPanel.tsx b/apps/web/src/components/chat/CommandOutputPanel.tsx index fd3c9e2bd5be1..e9078c7ac54c0 100644 --- a/apps/web/src/components/chat/CommandOutputPanel.tsx +++ b/apps/web/src/components/chat/CommandOutputPanel.tsx @@ -7,7 +7,7 @@ import { useTheme } from "../../hooks/useTheme"; import { useServerConfigs } from "../../state/entities"; import { orchestrationEnvironment } from "../../state/orchestration"; import { useEnvironmentQuery } from "../../state/query"; -import type { TurnItemSource } from "./timelineRowContext"; +import { useInitialOutputReady, type TurnItemSource } from "./timelineRowContext"; const FOLLOW_SLACK_PX = 8; @@ -24,6 +24,7 @@ export function useCommandOutputStreaming(environmentId: EnvironmentId): boolean * It subscribes only while mounted, so collapsed rows and other threads receive nothing. */ export function CommandOutputPanel(props: { + readonly onInitialOutputReady?: (() => void) | undefined; readonly environmentId: EnvironmentId; readonly threadId: ThreadId; readonly itemId: TurnItemId; @@ -35,6 +36,7 @@ export function CommandOutputPanel(props: { ? props.source(input) : orchestrationEnvironment.v2.commandOutput({ environmentId: props.environmentId, input }), ); + useInitialOutputReady(data === null && !error, props.onInitialOutputReady); const scrollRef = useRef(null); const followRef = useRef(true); const text = data?.output.text ?? ""; diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx index 0ca1f46f5d7ea..9db177808409d 100644 --- a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx @@ -28,7 +28,7 @@ import { type ReactElement, } from "react"; import * as DateTime from "effect/DateTime"; -import { Atom } from "effect/reactivity"; +import { Atom, AsyncResult } from "effect/reactivity"; import { describe, expect, it, onTestFinished, vi } from "vite-plus/test"; import "~/index.css"; @@ -37,6 +37,7 @@ import { environmentThreadDetails } from "../../state/threads"; import { act, fakeTimers, render, stubGlobal } from "../../../test/render"; import { ChangedFilesCard } from "./ChangedFilesTree"; import { MessagesTimeline } from "./MessagesTimeline"; +import { resolveTimelineIsAtEnd } from "./MessagesTimeline.logic"; import { makeStreamingTimelineFixture } from "../../test-fixtures"; import { type PendingApproval, @@ -56,7 +57,7 @@ import { import { collectComposerContextReferences } from "@t3tools/shared/composerContextReferences"; const environmentQuery = vi.hoisted(() => ({ - error: "Pull request not found", + error: "Pull request not found" as string | null, data: null as unknown, })); vi.mock(import("~/state/query"), async (original) => ({ @@ -212,6 +213,7 @@ async function click(element: HTMLElement) { } function setup({ scheduledTasks = [] as ReadonlyArray<{ id: string; title: string }> } = {}) { + environmentQuery.error = "Pull request not found"; environmentQuery.data = { tasks: scheduledTasks }; } @@ -773,6 +775,220 @@ describe("MessagesTimeline messages", () => { await click(screen.button("Safe details")); expect((globalThis as { __t3Xss?: number }).__t3Xss).toBeUndefined(); }); + + const revealModes = ["inline", "at-end", "delayed", "grouped-delayed", "grouped-end"]; + it.each(revealModes)("reveals %s output", async (mode) => { + const inline = mode === "inline" || mode === "at-end"; + setup(); + environmentQuery.data = + mode === "grouped-end" + ? { + output: { text: `${"output line\n".repeat(10)}TOOL_INPUT_END`, truncated: false }, + running: false, + } + : null; + environmentQuery.error = null; + const props = baseProps(); + const composerOverlayHeight = mode === "grouped-end" ? 140 : 200; + const tallEntries = Array.from({ length: 12 }, (_, index) => + message( + `Message ${index}\n\n${"Long paragraph. ".repeat(80)}`, + { role: "assistant" }, + `tall-${index}`, + ), + ); + const projectedItem = makeStreamingTimelineFixture().visibleTurnItems[1]!; + const source = { + detail: () => Atom.make(AsyncResult.initial()), + commandOutput: () => Atom.make(AsyncResult.initial()), + }; + let outerGrowth = false; + let routeThreadKey = props.routeThreadKey; + const timeline = () => ( + source.detail(), + commandOutput: () => source.commandOutput(), + }, + })} + contentInsetEndAdjustment={composerOverlayHeight} + timelineEntries={[ + ...tallEntries.slice(0, 6), + { + id: "tool-near-composer", + kind: "work", + createdAt: CREATED_AT, + entry: { + id: "tool-near-composer", + createdAt: CREATED_AT, + label: "Example tool", + toolTitle: "Example tool", + tone: "tool", + itemType: "dynamic_tool", + toolLifecycleStatus: "completed", + toolData: { input: { query: `${"x".repeat(1200)} TOOL_INPUT_END` } }, + ...(inline + ? {} + : { + itemType: "command_execution", + projectedItem, + structuredPayload: projectedItem.item, + }), + }, + }, + ...(mode.startsWith("grouped-") + ? [ + { + id: "another-tool", + kind: "work" as const, + createdAt: CREATED_AT, + entry: { + id: "another-tool", + createdAt: CREATED_AT, + label: "Other tool", + tone: "tool" as const, + itemType: "dynamic_tool" as const, + toolLifecycleStatus: "completed" as const, + toolData: { input: { query: "other" } }, + }, + }, + ] + : []), + ...(mode === "grouped-end" + ? [message("Completed.", { role: "assistant" }, "final-message")] + : mode === "at-end" + ? [message("Done.", { role: "assistant" }, "final-message")] + : tallEntries.slice(6)), + ...(outerGrowth + ? [message("Later output\n\n".repeat(20), { role: "assistant" }, "outer-growth")] + : []), + ]} + /> + ); + await using screen = await renderElement( + mode === "grouped-delayed" ? ( + + ) : ( + timeline() + ), + ); + if (mode === "grouped-end") screen.container.style.height = "648px"; + await vi.waitFor(() => expect(props.listRef.current?.getScrollableNode()).toBeTruthy()); + const viewport = props.listRef.current!.getScrollableNode()!; + if (mode === "grouped-delayed") { + routeThreadKey = `${routeThreadKey}-switched`; + await screen.rerender(timeline()); + await vi.waitFor(() => expect(viewport.scrollHeight).toBeGreaterThan(3000)); + await act(() => viewport.dispatchEvent(new WheelEvent("wheel", { deltaY: -1 }))); + } + if (mode.startsWith("grouped-")) { + await act(() => { + void props.listRef.current!.scrollToIndex({ index: 6, animated: false }); + }); + await vi.waitFor(() => + expect( + screen.container.querySelector('[data-timeline-row-kind="work-toggle"] button'), + ).not.toBeNull(), + ); + const group = () => + screen.container.querySelector( + '[data-timeline-row-kind="work-toggle"] button', + )!; + await click(group()); + await vi.waitFor(() => + expect( + screen.container.querySelector('[data-work-entry-id="tool-near-composer"]'), + ).not.toBeNull(), + ); + if (mode === "grouped-delayed") { + await click( + screen.container.querySelector('[data-work-entry-id="tool-near-composer"]')!, + ); + await vi.waitFor(() => expect(screen.text()).toContain("Loading output")); + await click(group()); + } + } + const header = () => { + if (mode === "grouped-end") + return screen.container.querySelector( + '[data-work-entry-id="tool-near-composer"]', + )!; + if (mode === "grouped-delayed") + return screen.container.querySelector( + '[data-timeline-row-kind="work-toggle"] button', + )!; + const rows = screen.container.querySelectorAll( + '[data-timeline-row-kind="work"] [role="button"]', + ); + if (!rows[0]) throw new Error(screen.text()); + return rows[0]; + }; + const visibleBottom = () => viewport.getBoundingClientRect().bottom - composerOverlayHeight; + await act(() => { + void props.listRef.current!.scrollToIndex({ index: 6, animated: false }); + }); + if (mode === "at-end") { + await act(() => { + void props.listRef.current!.scrollToEnd({ animated: false }); + }); + await vi.waitFor(() => + expect(resolveTimelineIsAtEnd(props.listRef.current!.getState())).toBe(true), + ); + } else { + await vi.waitFor(async () => { + await act(() => { + viewport.scrollTop += header().getBoundingClientRect().bottom - (visibleBottom() - 8); + viewport.dispatchEvent(new Event("scroll")); + }); + expect(resolveTimelineIsAtEnd(props.listRef.current!.getState())).toBe(false); + expect(header().getBoundingClientRect().bottom).toBeLessThanOrEqual(visibleBottom()); + expect(header().getBoundingClientRect().top).toBeGreaterThan( + viewport.getBoundingClientRect().top, + ); + }); + } + + const collapsedScrollTop = viewport.scrollTop; + using scrollBy = vi.spyOn(viewport, "scrollBy"); + // The nested row can measure before the outer scroller gains its new range. + if (mode === "grouped-delayed") scrollBy.mockImplementationOnce(() => {}); + await click(header()); + if (mode === "grouped-delayed") { + await vi.waitFor(() => expect(scrollBy).toHaveBeenCalled()); + outerGrowth = true; + await screen.rerender(timeline()); + await vi.waitFor(() => expect(viewport.scrollTop).toBeGreaterThan(collapsedScrollTop)); + } + if (!inline && mode !== "grouped-end") { + await vi.waitFor(() => + expect(header().getBoundingClientRect().bottom).toBeLessThanOrEqual(visibleBottom() + 1), + ); + await vi.waitFor(() => expect(screen.text()).toContain("Loading output")); + environmentQuery.data = { + output: { text: `${"output line\n".repeat(20)}TOOL_INPUT_END`, truncated: false }, + running: false, + }; + await screen.rerender(timeline()); + } + const inputEnd = () => + Array.from(screen.container.querySelectorAll("*")).findLast((element) => + element.textContent?.includes("TOOL_INPUT_END"), + )!; + await vi.waitFor(() => + expect(inputEnd().getBoundingClientRect().bottom).toBeLessThanOrEqual(visibleBottom() + 1), + ); + await vi.waitFor(() => + expect(header().getBoundingClientRect().top).toBeGreaterThanOrEqual( + viewport.getBoundingClientRect().top, + ), + ); + }); }); function usageLimitEntries(resetAt: string) { diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index a2b39ffd143f9..f53012b2199e7 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -69,6 +69,7 @@ import { readTimelinePosition, type RememberedTimelinePosition, rememberTimelinePosition, + resolveDisclosureRevealScrollDelta, } from "./timelineScrollAnchoring"; import { AssistantSelectionToolbar } from "./AssistantSelectionToolbar"; import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; @@ -470,14 +471,80 @@ const ConversationTimeline = memo(function ConversationTimeline({ const normalizedFindQuery = findQuery.trim(); const findActive = findExpanded; - const preserveDisclosurePosition = (anchorKey: string) => { + const disclosureRevealRef = useRef<{ + key: string; + identity: string; + entryId: string | undefined; + contentKey: string | undefined; + ready: boolean; + } | null>(null); + const disclosureRevealFrameRef = useRef(null); + + // Anchoring keeps the toggled row still, so content that expands below it + // near the bottom would otherwise land under the composer. + const revealExpandedDisclosure = useStableCallback(() => { + const pending = disclosureRevealRef.current; + if (!pending || pending.identity !== listIdentityKey) return; + const state = listRef.current?.getState(); + const element = listRef.current?.getScrollableNode(); + const index = state?.indexByKey(pending.key); + const row = index === undefined ? undefined : state?.elementAtIndex(index); + if (!element || !(row instanceof HTMLElement)) return; + const viewport = element.getBoundingClientRect(); + const entry = pending.entryId + ? row.querySelector(`[data-work-entry-id="${CSS.escape(pending.entryId)}"]`) + : null; + const contentIndex = pending.contentKey ? state?.indexByKey(pending.contentKey) : undefined; + const content = contentIndex === undefined ? null : state?.elementAtIndex(contentIndex); + const bounds = (entry ?? row).getBoundingClientRect(); + const delta = resolveDisclosureRevealScrollDelta({ + rowTop: bounds.top, + rowBottom: content?.getBoundingClientRect().bottom ?? bounds.bottom, + viewportTop: viewport.top, + viewportBottom: viewport.bottom, + composerOverlayHeight: contentInsetEndAdjustment, + anchorOffset: CHAT_TIMELINE_ANCHOR_OFFSET, + }); + // Nested lists can finish measuring before their height reaches the outer scroller. + if (pending.ready && element.scrollTop + delta <= element.scrollHeight - element.clientHeight) { + disclosureRevealRef.current = null; + } + if (delta > 0) { + element.scrollBy({ top: delta, behavior: prefersReducedMotion ? "auto" : "smooth" }); + } + }); + const scheduleDisclosureReveal = () => { + if (disclosureRevealFrameRef.current !== null) { + cancelAnimationFrame(disclosureRevealFrameRef.current); + } + disclosureRevealFrameRef.current = requestAnimationFrame(() => { + disclosureRevealFrameRef.current = null; + revealExpandedDisclosure(); + }); + }; + + const preserveDisclosurePosition = ( + anchorKey: string, + reveal?: { readonly entryId?: string | undefined; readonly contentKey?: string | undefined }, + ) => { const list = listRef.current; + disclosureRevealRef.current = null; // An anchor ends end-follow until the reader scrolls, so a streaming turn keeps the live end. if (!list || (isWorking && list.getState().isFollowingEnd)) return; + if (reveal) { + disclosureRevealRef.current = { + key: anchorKey, + identity: listIdentityKey, + entryId: reveal.entryId, + contentKey: reveal.contentKey, + ready: false, + }; + } list.cancelScroll(); list.anchorVisibleContent({ key: anchorKey }); }; + // Turn and attempt folds reveal later rows rather than growing their own row. const onToggleTurnFold = (runId: RunId) => { preserveDisclosurePosition(`turn-fold:${runId}`); setExpandedRunIds((existing) => { @@ -491,7 +558,10 @@ const ConversationTimeline = memo(function ConversationTimeline({ }); }; const onToggleWorkGroup = (groupId: string, anchorKey: string) => { - preserveDisclosurePosition(anchorKey); + preserveDisclosurePosition( + anchorKey, + expandedWorkGroupIds.has(groupId) ? undefined : { contentKey: `${groupId}:details` }, + ); setExpandedWorkGroupIds((existing) => { const next = new Set(existing); if (next.has(groupId)) { @@ -1039,7 +1109,21 @@ const ConversationTimeline = memo(function ConversationTimeline({ onToggleTurnFold, onToggleAttemptFold, onToggleWorkGroup, - onToggleWorkEntry: preserveDisclosurePosition, + onToggleWorkEntry: (anchorKey, collapsed, entryId) => + preserveDisclosurePosition(anchorKey, collapsed ? undefined : { entryId }), + onWorkEntryContentReady: (anchorKey, entryId) => { + const pending = disclosureRevealRef.current; + if ( + !pending || + pending.identity !== listIdentityKey || + (pending.contentKey === undefined + ? pending.key !== anchorKey || pending.entryId !== entryId + : pending.contentKey !== anchorKey) + ) + return; + pending.ready = pending.contentKey === undefined; + scheduleDisclosureReveal(); + }, onCancelWorktreeSetup: onCancelWorktreeSetup ?? null, retryableWorkspacePreparationRunIds, onRetryWorkspacePreparation: onRetryWorkspacePreparation ?? null, @@ -1139,6 +1223,9 @@ const ConversationTimeline = memo(function ConversationTimeline({ viewportRef.current = viewport; const detachViewport = viewport ? onViewportAttach?.(viewport) : undefined; const stopRememberingRelease = viewport?.onFollowReleased(rememberReleasedPosition); + const stopDisclosureReveal = viewport?.subscribe(() => { + disclosureRevealRef.current = null; + }); let contentSizeFrame: number | null = null; // LegendList lays rows out from layout effects; the next frame sees them settled. let scheduledData: unknown = null; @@ -1153,6 +1240,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ contentSizeFrame = requestAnimationFrame(() => { contentSizeFrame = null; handleContentSizeChanged(laidOutData); + revealExpandedDisclosure(); }); }; const stopListeningToContentSize = list @@ -1162,7 +1250,12 @@ const ConversationTimeline = memo(function ConversationTimeline({ return () => { stopListeningToContentSize?.(); stopRememberingRelease?.(); + stopDisclosureReveal?.(); if (contentSizeFrame !== null) cancelAnimationFrame(contentSizeFrame); + if (disclosureRevealFrameRef.current !== null) { + cancelAnimationFrame(disclosureRevealFrameRef.current); + disclosureRevealFrameRef.current = null; + } detachViewport?.(); viewport?.dispose(); if (viewportRef.current === viewport) viewportRef.current = null; diff --git a/apps/web/src/components/chat/TimelineWorkEntryRow.tsx b/apps/web/src/components/chat/TimelineWorkEntryRow.tsx index 5f63142a8e29f..c1745f146bf29 100644 --- a/apps/web/src/components/chat/TimelineWorkEntryRow.tsx +++ b/apps/web/src/components/chat/TimelineWorkEntryRow.tsx @@ -148,6 +148,7 @@ type WorkEntryRowProps = { workspaceRoot: string | undefined; displayLabel?: string | undefined; onToggleEntry?: ((collapsed: boolean) => void) | undefined; + onContentReady?: (() => void) | undefined; }; export const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: WorkEntryRowProps) { @@ -221,10 +222,14 @@ function WorkEntryLogRow(props: WorkEntryRowProps) { const [expanded, setExpanded] = useState( () => groupView?.state.expandedEntries.has(workEntry.id) ?? false, ); + const onInitialOutputReady = () => { + if (groupView) groupView.onContentReady?.(workEntry.id); + else props.onContentReady?.(); + }; const toggleExpanded = () => { const next = !expanded; if (groupView) { - groupView.onToggleEntry(!next); + groupView.onToggleEntry(!next, workEntry.id); if (next) groupView.state.expandedEntries.add(workEntry.id); else groupView.state.expandedEntries.delete(workEntry.id); } else { @@ -430,6 +435,7 @@ function WorkEntryLogRow(props: WorkEntryRowProps) { return ( {workEntry.projectedItem && plainOutput === undefined ? ( - onToggleWorkEntry(disclosureAnchorKey, collapsed); + const { + workspaceRoot, + routeThreadKey, + onToggleWorkEntry, + onWorkEntryContentReady, + pendingApprovals, + } = use(TimelineRowCtx); const nonEmptyEntries = groupedEntries.filter((entry) => { const item = entry.projectedItem?.item; return ( @@ -104,7 +107,10 @@ export const WorkGroupSection = memo(function WorkGroupSection({ workEntry={workEntry} workspaceRoot={workspaceRoot} displayLabel={displayLabel} - onToggleEntry={onToggleStandaloneEntry} + onToggleEntry={(collapsed) => + onToggleWorkEntry(disclosureAnchorKey, collapsed, workEntry.id) + } + onContentReady={() => onWorkEntryContentReady?.(disclosureAnchorKey, workEntry.id)} /> ))} @@ -133,7 +139,11 @@ function ExpandedWorkGroupEntries({ entries: TimelineWorkEntry[]; workspaceRoot: string | undefined; }) { - const { workGroupViewState: viewState, onToggleWorkEntry } = use(TimelineRowCtx); + const { + workGroupViewState: viewState, + onToggleWorkEntry, + onWorkEntryContentReady, + } = use(TimelineRowCtx); const [initialScrollIndex] = useState(() => resolveWorkGroupScrollIndex(entries, viewState.scrollPositions.get(anchorKey)), ); @@ -155,7 +165,9 @@ function ExpandedWorkGroupEntries({ const groupView = { state: viewState, - onToggleEntry: (collapsed: boolean) => onToggleWorkEntry(disclosureAnchorKey, collapsed), + onToggleEntry: (collapsed: boolean, entryId: string) => + onToggleWorkEntry(disclosureAnchorKey, collapsed, entryId), + onContentReady: (entryId: string) => onWorkEntryContentReady?.(disclosureAnchorKey, entryId), }; const updateScrollFades = () => { const element = listRef.current?.getScrollableNode(); diff --git a/apps/web/src/components/chat/V2ItemInspector.tsx b/apps/web/src/components/chat/V2ItemInspector.tsx index 8d314a1bd1b62..8ff833524fccb 100644 --- a/apps/web/src/components/chat/V2ItemInspector.tsx +++ b/apps/web/src/components/chat/V2ItemInspector.tsx @@ -27,9 +27,10 @@ import { CommandOutputPanel, useCommandOutputStreaming } from "./CommandOutputPa import { resolveExternalWebLinkHref } from "./externalLinkContextMenu"; import type { ExpandedImagePreview } from "./ExpandedImagePreview"; import { ShellCommandBlock } from "./ShellCommandBlock"; -import type { TurnItemSource } from "./timelineRowContext"; +import { useInitialOutputReady, type TurnItemSource } from "./timelineRowContext"; interface V2ItemInspectorProps { + readonly onInitialOutputReady?: (() => void) | undefined; readonly projectedItem: OrchestrationV2ProjectedTurnItem; readonly environmentId: EnvironmentId; readonly cwd?: string | undefined; @@ -167,12 +168,14 @@ function ToolOutput(props: ToolOutputState) { } export function FetchedToolOutput(props: { + readonly onInitialOutputReady?: (() => void) | undefined; readonly projectedItem: OrchestrationV2ProjectedTurnItem; readonly environmentId: EnvironmentId; readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; readonly source?: TurnItemSource | undefined; }) { const { output } = useFetchedTurnItem(props.projectedItem, props.environmentId, props.source); + useInitialOutputReady(output.pending, props.onInitialOutputReady); return (
@@ -237,6 +240,7 @@ export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspec ); const item = fetched.item; const outputState = fetched.output; + useInitialOutputReady(streamsOutput || outputState.pending, props.onInitialOutputReady); return (
{item.type === "reasoning" && item.text ? ( @@ -262,6 +266,7 @@ export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspec outputSlot={ streamsOutput ? ( void; openPullRequest: (event: MouseEvent, url: string) => void; onToggleWorkGroup: (groupId: string, anchorKey: string) => void; - onToggleWorkEntry: (anchorKey: string, collapsed: boolean) => void; + onToggleWorkEntry: (anchorKey: string, collapsed: boolean, entryId?: string) => void; + onWorkEntryContentReady?: (anchorKey: string, entryId: string) => void; onCancelWorktreeSetup: (() => void) | null; retryableWorkspacePreparationRunIds: ReadonlySet; onRetryWorkspacePreparation: ((runId: RunId) => void) | null; @@ -121,7 +122,8 @@ export interface WorkGroupViewState { export const WorkGroupViewCtx = createContext<{ state: WorkGroupViewState; - onToggleEntry: (collapsed: boolean) => void; + onToggleEntry: (collapsed: boolean, entryId: string) => void; + onContentReady?: ((entryId: string) => void) | undefined; } | null>(null); export type TimelineWorkEntry = Extract< @@ -130,3 +132,14 @@ export type TimelineWorkEntry = Extract< >["groupedEntries"][number]; export type TimelineRow = MessagesTimelineRow; + +export function useInitialOutputReady(pending: boolean, onReady: (() => void) | undefined) { + const notified = useRef(false); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- reveals a disclosure after its first async output commits + useEffect(() => { + if (!pending && !notified.current) { + notified.current = true; + onReady?.(); + } + }, [pending, onReady]); +} diff --git a/apps/web/src/components/chat/timelineScrollAnchoring.test.tsx b/apps/web/src/components/chat/timelineScrollAnchoring.test.tsx index 69ab8e0bcf5f4..8d6fd885094c2 100644 --- a/apps/web/src/components/chat/timelineScrollAnchoring.test.tsx +++ b/apps/web/src/components/chat/timelineScrollAnchoring.test.tsx @@ -1,6 +1,6 @@ import { MessageId, RunId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { observeTimelineRun } from "./timelineScrollAnchoring"; +import { observeTimelineRun, resolveDisclosureRevealScrollDelta } from "./timelineScrollAnchoring"; describe("observeTimelineRun", () => { const existing = { @@ -67,3 +67,36 @@ describe("observeTimelineRun", () => { ); }); }); + +describe("resolveDisclosureRevealScrollDelta", () => { + const viewport = { + viewportTop: 0, + viewportBottom: 800, + composerOverlayHeight: 150, + anchorOffset: 24, + }; + + it("does not scroll when the expanded row ends above the composer", () => { + expect(resolveDisclosureRevealScrollDelta({ ...viewport, rowTop: 300, rowBottom: 650 })).toBe( + 0, + ); + }); + + it("scrolls content hidden behind the composer into view", () => { + expect(resolveDisclosureRevealScrollDelta({ ...viewport, rowTop: 560, rowBottom: 900 })).toBe( + 250, + ); + }); + + it("keeps the header of a row taller than the usable viewport visible", () => { + expect(resolveDisclosureRevealScrollDelta({ ...viewport, rowTop: 600, rowBottom: 2000 })).toBe( + 576, + ); + }); + + it("never scrolls backwards when the row header is already above the anchor inset", () => { + expect(resolveDisclosureRevealScrollDelta({ ...viewport, rowTop: -40, rowBottom: 1200 })).toBe( + 0, + ); + }); +}); diff --git a/apps/web/src/components/chat/timelineScrollAnchoring.ts b/apps/web/src/components/chat/timelineScrollAnchoring.ts index 70f4e639293b0..50ac2cbe7d24d 100644 --- a/apps/web/src/components/chat/timelineScrollAnchoring.ts +++ b/apps/web/src/components/chat/timelineScrollAnchoring.ts @@ -91,6 +91,27 @@ export function timelineContentOverflowsViewport( return lastBottom > visibleScrollLength; } +export function resolveDisclosureRevealScrollDelta({ + rowTop, + rowBottom, + viewportTop, + viewportBottom, + composerOverlayHeight, + anchorOffset, +}: { + readonly rowTop: number; + readonly rowBottom: number; + readonly viewportTop: number; + readonly viewportBottom: number; + readonly composerOverlayHeight: number; + readonly anchorOffset: number; +}): number { + const hiddenBelow = rowBottom - (viewportBottom - composerOverlayHeight); + if (!(hiddenBelow > 0)) return 0; + const roomAbove = rowTop - (viewportTop + anchorOffset); + return Math.max(0, Math.min(hiddenBelow, roomAbove)); +} + export interface RememberedTimelinePosition { readonly rowId: string; readonly offsetWithinRow: number; From b3f5d52701430c8458ee4893c6d523bc9d59e85b Mon Sep 17 00:00:00 2001 From: Nikita Maximilian Grimm Date: Wed, 11 Nov 2026 13:11:05 +0100 Subject: [PATCH 0003/1165] fix(composer): steer instead of queueing during automatic compaction (#24138) Co-authored-by: maria-rcks Co-authored-by: Julius Marminge --- .../src/state/use-thread-composer-state.ts | 36 +++++++++----- .../Adapters/CodexAdapterV2.ts | 1 + apps/web/src/components/ChatView.tsx | 14 +++++- apps/web/src/components/chat/ChatComposer.tsx | 16 +++--- .../src/components/chat/composerCommandKey.ts | 6 +-- .../src/state/composerDispatch.test.ts | 49 +++++++++++++++++++ .../src/state/composerDispatch.ts | 27 +++++++++- .../src/state/threadWorkflows.ts | 8 +++ packages/contracts/src/orchestrationV2.ts | 2 + 9 files changed, 132 insertions(+), 27 deletions(-) create mode 100644 packages/client-runtime/src/state/composerDispatch.test.ts diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 61a72f1af65dc..702aa53f90f0f 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -6,6 +6,7 @@ import { useAtomValue } from "@effect/atom-react"; import { shouldCompactBeforeSend } from "@t3tools/client-runtime/state/context-window"; import { threadRuntimeIsActive } from "@t3tools/client-runtime/state/shell"; import type { ThreadCheckpointSummary } from "@t3tools/client-runtime/state/thread-checkpoints"; +import { threadSteersDuringCompaction } from "@t3tools/client-runtime/state/thread-workflows"; import { resolvePendingHandoff } from "@t3tools/client-runtime/handoff"; import { deriveProviderSubagentStatus, @@ -84,6 +85,7 @@ import { } from "./use-composer-drafts"; import { isClearComposerCommand, parseSideChatCommand } from "@t3tools/shared/composerTrigger"; import { + compactionBlocksSteering, resolveComposerDispatchMode, type ActiveTurnComposerAction, } from "@t3tools/client-runtime/state/composer-dispatch"; @@ -322,7 +324,11 @@ export function useThreadComposerState(input: { ? deriveThreadActivityRun(selectedThreadProjection.projection) : (selectedThreadShell?.latestRun ?? null); - const isCompacting = (() => { + const activeRunId = + selectedThreadRuntime?.activeRunId && threadRuntimeIsActive(selectedThreadRuntime) + ? selectedThreadRuntime.activeRunId + : null; + const compactCommandIsActive = (() => { const queuedCompact = selectedThreadQueuedMessages.some( (message) => message.messageId === dispatchingQueuedMessageId && @@ -330,16 +336,7 @@ export function useThreadComposerState(input: { message.attachments.length === 0, ); if (queuedCompact) return true; - const activeRunId = selectedThreadRuntime?.activeRunId; - if (!activeRunId || !threadRuntimeIsActive(selectedThreadRuntime)) return false; - if ( - selectedThreadVisibleTurnItems.some( - ({ item }) => - item.runId === activeRunId && item.type === "compaction" && item.status === "running", - ) - ) { - return true; - } + if (!activeRunId) return false; const compactMessage = selectedThreadVisibleTurnItems.findLast( ({ item }) => item.runId === activeRunId && @@ -358,8 +355,21 @@ export function useThreadComposerState(input: { item.status === "cancelled"), ); })(); - // A compacting turn cannot take steering input, so follow-ups queue meanwhile. - const canSteerActiveTurn = queueWorkflow?.canPromoteToSteer === true && !isCompacting; + const compactionRunning = + activeRunId !== null && + selectedThreadVisibleTurnItems.some( + ({ item }) => + item.runId === activeRunId && item.type === "compaction" && item.status === "running", + ); + const isCompacting = compactCommandIsActive || compactionRunning; + const compactionBlocksSteer = compactionBlocksSteering({ + compactCommandIsActive, + compactionRunning, + steersDuringCompaction: threadSteersDuringCompaction(selectedThreadProjection?.projection), + run: selectedThreadProjection?.projection.runs.find((run) => run.id === activeRunId), + messages: selectedThreadMessages, + }); + const canSteerActiveTurn = queueWorkflow?.canPromoteToSteer === true && !compactionBlocksSteer; // A stop or a restart holds the queue, completion wakes included, until it // is resumed; the queue sheet lists only the user's own messages. const queueHeld = queueWorkflow?.isHeld === true; diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 6953636a8dcf5..4a5985f6c2ef5 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -265,6 +265,7 @@ export const CodexProviderCapabilitiesV2 = { emitsTurnCompleted: true, supportsInterrupt: true, supportsActiveSteering: true, + activeSteeringDuringCompaction: true, supportsSteeringByInterruptRestart: true, supportsQueuedMessages: true, terminalStatusQuality: "strong", diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 055e1a23f6354..54e7d6eda4311 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -90,8 +90,10 @@ import { } from "@t3tools/client-runtime/state/subagentRuntime"; import { getUserQueuedThreadRuns, + threadSteersDuringCompaction, threadSupportsProviderHandoff, } from "@t3tools/client-runtime/state/thread-workflows"; +import { compactionBlocksSteering } from "@t3tools/client-runtime/state/composer-dispatch"; import { resolvePendingHandoff } from "@t3tools/client-runtime/handoff"; import { HandoffRow } from "./chat/V2LifecycleRow"; import { @@ -1419,9 +1421,17 @@ export default function ChatView(props: ChatViewProps) { item.runId === activeRuntime?.activeRunId && item.status === "running", ); + const compactCommandIsActive = compactRequestIsActive && !compactionSettled; const isCompacting = (isSendBusy || phase === "connecting" || phase === "running") && - (compactionRunning || (compactRequestIsActive && !compactionSettled)); + (compactionRunning || compactCommandIsActive); + const compactionBlocksSteer = compactionBlocksSteering({ + compactCommandIsActive, + compactionRunning, + steersDuringCompaction: threadSteersDuringCompaction(serverProjection), + run: serverProjection?.runs.find((run) => run.id === activeRuntime?.activeRunId), + messages: serverProjection?.messages, + }); const isWorking = phase === "running" || isSendBusy || isCompacting || runlessWorkStartedAt !== null; const activeContextWindow = deriveLatestContextWindowSnapshot( @@ -3576,7 +3586,7 @@ export default function ChatView(props: ChatViewProps) { isLocalDraftThread && activeProject === null } phase={phase} - isCompacting={isCompacting} + canSteer={!compactionBlocksSteer} canInterrupt={canInterruptRunningThread} isSendBusy={ (isSendBusy && !sendQueuesBehindSetup) || diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index c66f05ff7903a..8ccd172dc0d90 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -370,7 +370,7 @@ export interface ChatComposerProps { isRevertingCheckpoint?: boolean; sendDisabledReason: string | null; isPreparingWorktree: boolean; - isCompacting: boolean; + canSteer: boolean; bannerItems: readonly ComposerBannerStackItem[]; resumeCompactionTokens: number | null; keepFullHistory: boolean; @@ -516,7 +516,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isRevertingCheckpoint = false, sendDisabledReason: externalSendDisabledReason, isPreparingWorktree, - isCompacting, + canSteer, environmentUnavailable, activePendingApproval: incomingPendingApproval, pendingApprovals, @@ -2144,7 +2144,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) running: phase === "running", alternateModifier: false, activeTurnDefault: followUpBehavior, - canSteer: !isCompacting, + canSteer, }), submissionIntent, ); @@ -2185,7 +2185,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) resolveComposerDispatchMode({ running: phase === "running", alternateModifier: intent === "alternate", - canSteer: !isCompacting, + canSteer, activeTurnDefault: followUpBehavior, }), intent, @@ -2200,7 +2200,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) running: phase === "running", alternateModifier: event.metaKey || event.ctrlKey, activeTurnDefault: followUpBehavior, - canSteer: !isCompacting, + canSteer, }), ); }; @@ -2212,7 +2212,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) running: phase === "running", alternateModifier: false, activeTurnDefault: followUpBehavior, - canSteer: !isCompacting, + canSteer, }), ); }; @@ -2293,7 +2293,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) sendShortcut: settings.sendShortcut, routeKind, phase, - isCompacting, + canSteer, followUpBehavior, isSoftwareKeyboardOpen, isLiteralPendingAnswer, @@ -3584,7 +3584,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isRunning={phase === "running"} canInterrupt={canInterrupt} followUpBehavior={followUpBehavior} - canSteer={!isCompacting} + canSteer={canSteer} alternateShortcutLabel={shortcutLabelForCommand( keybindings, "composer.sendAlternate", diff --git a/apps/web/src/components/chat/composerCommandKey.ts b/apps/web/src/components/chat/composerCommandKey.ts index ac5bba3475542..beaab007028ae 100644 --- a/apps/web/src/components/chat/composerCommandKey.ts +++ b/apps/web/src/components/chat/composerCommandKey.ts @@ -38,7 +38,7 @@ export function composerCommandKeyHandler(input: { readonly sendShortcut: ChatComposerProps["settings"]["sendShortcut"]; readonly routeKind: ChatComposerProps["routeKind"]; readonly phase: ChatComposerProps["phase"]; - readonly isCompacting: boolean; + readonly canSteer: boolean; readonly followUpBehavior: ChatComposerProps["settings"]["followUpBehavior"]; readonly isSoftwareKeyboardOpen: boolean; readonly isLiteralPendingAnswer: boolean; @@ -91,7 +91,7 @@ export function composerCommandKeyHandler(input: { sendShortcut, routeKind, phase, - isCompacting, + canSteer, followUpBehavior, isSoftwareKeyboardOpen, isLiteralPendingAnswer, @@ -267,7 +267,7 @@ export function composerCommandKeyHandler(input: { running: phase === "running", alternateModifier: submissionIntent === "alternate", activeTurnDefault: followUpBehavior, - canSteer: !isCompacting, + canSteer, }), submissionIntent, ); diff --git a/packages/client-runtime/src/state/composerDispatch.test.ts b/packages/client-runtime/src/state/composerDispatch.test.ts new file mode 100644 index 0000000000000..936ae33111ef4 --- /dev/null +++ b/packages/client-runtime/src/state/composerDispatch.test.ts @@ -0,0 +1,49 @@ +import { expect, it } from "vite-plus/test"; + +import { compactionBlocksSteering } from "./composerDispatch.ts"; + +it("blocks steering for explicit compaction, and for automatic compaction unless the provider takes steers then", () => { + const during = ( + text: string | undefined, + attachments: ReadonlyArray = [], + steersDuringCompaction = true, + ) => + compactionBlocksSteering({ + compactCommandIsActive: false, + compactionRunning: true, + steersDuringCompaction, + run: { userMessageId: "user" }, + messages: [ + ...(text === undefined ? [] : [{ id: "user", text, attachments }]), + { id: "later", text: "/compact" }, + ], + }); + + for (const text of ["/compact", " /COMPACT ", "/compact keep the debugging notes"]) { + expect(during(text)).toBe(true); + } + for (const text of ["Read the code", "/compactify", "please /compact", undefined]) { + expect(during(text)).toBe(false); + } + expect(during("/compact", [{}])).toBe(false); + expect(during("Read the code", [], false)).toBe(true); + + expect( + compactionBlocksSteering({ + compactCommandIsActive: true, + compactionRunning: false, + steersDuringCompaction: true, + run: undefined, + messages: undefined, + }), + ).toBe(true); + expect( + compactionBlocksSteering({ + compactCommandIsActive: false, + compactionRunning: false, + steersDuringCompaction: false, + run: { userMessageId: "user" }, + messages: [{ id: "user", text: "/compact" }], + }), + ).toBe(false); +}); diff --git a/packages/client-runtime/src/state/composerDispatch.ts b/packages/client-runtime/src/state/composerDispatch.ts index 145b1ba508147..c24bb95628d97 100644 --- a/packages/client-runtime/src/state/composerDispatch.ts +++ b/packages/client-runtime/src/state/composerDispatch.ts @@ -5,7 +5,7 @@ export function resolveComposerDispatchMode(input: { readonly running: boolean; readonly alternateModifier: boolean; readonly activeTurnDefault?: ActiveTurnComposerAction; - /** False while the active turn cannot take steering input, such as during compaction. */ + /** False while the active turn cannot take steering input, such as an explicit /compact. */ readonly canSteer?: boolean; }): ComposerDispatchMode { if (!input.running) return "auto"; @@ -15,6 +15,31 @@ export function resolveComposerDispatchMode(input: { return defaultAction; } +// Automatic compaction takes steers only where the provider says a steer leaves it running. +export function compactionBlocksSteering(input: { + readonly compactCommandIsActive: boolean; + readonly compactionRunning: boolean; + readonly steersDuringCompaction: boolean; + readonly run: { readonly userMessageId: string } | undefined; + readonly messages: + | ReadonlyArray<{ + readonly id: string; + readonly text: string; + readonly attachments?: ReadonlyArray; + }> + | undefined; +}): boolean { + if (input.compactCommandIsActive) return true; + if (!input.compactionRunning) return false; + if (!input.steersDuringCompaction) return true; + if (input.run === undefined) return false; + const message = input.messages?.find((message) => message.id === input.run?.userMessageId); + return ( + (message?.attachments?.length ?? 0) === 0 && + /^\/compact(?:\s|$)/i.test(message?.text.trim() ?? "") + ); +} + export function alternateComposerDispatchAction( activeTurnDefault?: ActiveTurnComposerAction, ): ActiveTurnComposerAction { diff --git a/packages/client-runtime/src/state/threadWorkflows.ts b/packages/client-runtime/src/state/threadWorkflows.ts index 9e13b669a1da5..0aae5835eff03 100644 --- a/packages/client-runtime/src/state/threadWorkflows.ts +++ b/packages/client-runtime/src/state/threadWorkflows.ts @@ -92,6 +92,14 @@ function resolveThreadProviderSession(projection: Projection): ProviderSession | ); } +export function threadSteersDuringCompaction(projection: Projection | null | undefined): boolean { + if (projection == null) return false; + return ( + resolveThreadProviderSession(projection)?.capabilities.turns.activeSteeringDuringCompaction === + true + ); +} + export function threadSupportsProviderHandoff(projection: Projection | null | undefined): boolean { if (projection == null) return false; const session = resolveThreadProviderSession(projection); diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index ba5bcdea0e391..e54e1eb447f5b 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -288,6 +288,8 @@ export const OrchestrationV2TurnCapabilities = Schema.Struct({ activeSteeringInterruptsTools: Schema.optional(Schema.Boolean), // Some take text only, so a steer with attachments restarts the turn instead. activeSteeringAcceptsAttachments: Schema.optional(Schema.Boolean), + // Only where a steer leaves a running automatic compaction intact; otherwise clients queue. + activeSteeringDuringCompaction: Schema.optional(Schema.Boolean), supportsSteeringByInterruptRestart: Schema.Boolean, supportsQueuedMessages: Schema.Boolean, terminalStatusQuality: Schema.Literals(["strong", "weak", "none"]), From f3da8ff63b734917b6a9875b24ef1197707de6ec Mon Sep 17 00:00:00 2001 From: Antony <97451137+TonybynMp4@users.noreply.github.com> Date: Wed, 11 Nov 2026 13:16:26 +0100 Subject: [PATCH 0004/1165] feat(gitlab): resolve overview threads from Summary and Timeline (#30575) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks Co-authored-by: Julius Marminge --- .../PullRequestDiscussionScreen.tsx | 33 ++++--- .../pull-requests/PullRequestRouteScreen.tsx | 4 +- .../pull-requests/PullRequestTimeline.tsx | 29 ++++-- .../pullRequestActivityFilter.ts | 37 ++++---- .../src/pullRequest/PullRequestService.ts | 3 + .../PullRequestResolveThreadButton.tsx | 66 ++++++++++++++ .../PullRequestSummaryTab.browser.test.tsx | 46 ++++++++++ .../pullRequest/PullRequestSummaryTab.tsx | 51 ++++++++--- .../pullRequest/PullRequestTimelineTab.tsx | 90 ++++++++++++------- .../pullRequest/pullRequestDetail.logic.ts | 21 +++-- .../pullRequest/usePullRequestDetail.ts | 3 + packages/contracts/src/pullRequest.ts | 19 ++++ .../src/server/PullRequestProvider.ts | 2 + .../src/server/GitLabPullRequestCli.test.ts | 7 +- .../src/server/GitLabPullRequestCli.ts | 6 +- .../server/GitLabPullRequestProvider.test.ts | 71 +++++++++------ .../src/server/GitLabPullRequestProvider.ts | 41 ++++++--- .../src/server/gitLabMergeRequestJson.ts | 41 ++++++--- 18 files changed, 421 insertions(+), 149 deletions(-) create mode 100644 apps/web/src/components/pullRequest/PullRequestResolveThreadButton.tsx diff --git a/apps/mobile/src/features/pull-requests/PullRequestDiscussionScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestDiscussionScreen.tsx index 40a7d0b675af4..e92debeea0f1d 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestDiscussionScreen.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestDiscussionScreen.tsx @@ -11,7 +11,7 @@ import { squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import { AsyncResult } from "effect/reactivity"; -import type { PullRequestThreadComment } from "@t3tools/contracts"; +import { pullRequestConversationThreads, type PullRequestThreadComment } from "@t3tools/contracts"; import { useRef, useState } from "react"; import { ScrollView, View, type ScrollViewInstance } from "react-native"; import { KeyboardAvoidingView, useKeyboardState } from "react-native-keyboard-controller"; @@ -60,7 +60,12 @@ function Discussion({ params }: { params: PullRequestParams & { readonly discuss reportFailure: false, }); const loadMore = useAtomCommand(composerPullRequests.threadComments, { reportFailure: false }); - const thread = activity.data?.reviewThreads.find((thread) => thread.id === params.discussionId); + const thread = + activity.data && + pullRequestConversationThreads(activity.data).find( + (thread) => thread.id === params.discussionId, + ); + const filePath = thread?.path ?? null; const [body, setBody] = useState(""); const completions = usePullRequestCompletions({ environmentId: params.environmentId, @@ -200,20 +205,24 @@ function Discussion({ params }: { params: PullRequestParams & { readonly discuss {activity.isPending && !thread ? : null} {thread ? ( - - {thread.path} - {thread.line ? `:${thread.line}` : ""} - + {thread.path === null ? null : ( + + {thread.path} + {thread.line ? `:${thread.line}` : ""} + + )} {thread.isResolved ? "Resolved" : "Unresolved"} {thread.isOutdated ? " · Outdated" : ""} - + ); +} diff --git a/apps/web/src/components/pullRequest/PullRequestSummaryTab.browser.test.tsx b/apps/web/src/components/pullRequest/PullRequestSummaryTab.browser.test.tsx index 98db15d1429c7..19c6cb75c5ac5 100644 --- a/apps/web/src/components/pullRequest/PullRequestSummaryTab.browser.test.tsx +++ b/apps/web/src/components/pullRequest/PullRequestSummaryTab.browser.test.tsx @@ -185,3 +185,49 @@ it("leaves description checkboxes read-only without the right to edit it", async true, ); }); + +it("fixes an overview thread as its opening remark, since it has no line to attach", async () => { + setup(); + const author = { login: "reviewer", name: null, avatarUrl: null }; + const note = { author, createdAt: "2026-09-01T00:00:00Z", url: null }; + const remark = { + ...note, + id: "10", + kind: "review-comment" as const, + body: "Mention the flag in the README", + path: null, + reviewState: null, + }; + const onFixFinding = vi.fn(); + const value: PullRequestDetailView = { + ...detail, + comments: [remark], + overviewThreads: [ + { + id: "overview", + path: null, + line: null, + side: "right", + isResolved: false, + isOutdated: false, + comments: [{ ...note, id: "10", body: remark.body }], + }, + ], + }; + await using screen = await renderUi( + {}} + environmentId={EnvironmentId.make("environment")} + threadRef={null} + reference={value} + detail={value} + activityPending={false} + activityError={null} + onRefresh={() => {}} + onFixFinding={onFixFinding} + />, + ); + + await userEvent.click(screen.getByRole("button", { name: "Fix in a thread" })); + expect(onFixFinding).toHaveBeenCalledWith({ kind: "comment", comment: remark }); +}); diff --git a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx index 58be7cfb29485..a78cdfbd6f06f 100644 --- a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx @@ -6,13 +6,14 @@ import { sourceControlClients, } from "@t3tools/client-runtime/source-control-clients"; import { useAtomCommand } from "~/state/use-atom-command"; -import type { - EnvironmentId, - PullRequestComment, - PullRequestDetailView, - PullRequestRef, - PullRequestReviewThread, - ScopedThreadRef, +import { + type EnvironmentId, + type PullRequestComment, + type PullRequestDetailView, + type PullRequestRef, + type PullRequestConversationThread, + type ScopedThreadRef, + pullRequestConversationThreads, } from "@t3tools/contracts"; import { ArrowDownUpIcon, @@ -93,6 +94,7 @@ import { pullRequestCompletionScope, } from "./PullRequestMarkdownTextarea"; import { PullRequestReactionBar } from "./PullRequestReactions"; +import { PullRequestResolveThreadButton } from "./PullRequestResolveThreadButton"; import { PullRequestConversationGhost } from "./PullRequestGhosts"; import { sectionCollapseAnchorScrollTop } from "./pullRequestSummaryScroll.logic"; @@ -147,7 +149,7 @@ function CommentLocation({ thread, }: { comment: PullRequestComment; - thread: PullRequestReviewThread | undefined; + thread: PullRequestConversationThread | undefined; }) { const path = thread?.path ?? comment.path; if (!path) return null; @@ -228,9 +230,10 @@ function CommentBody({ */ function chatSubject( comment: PullRequestComment, - thread: PullRequestReviewThread | undefined, + thread: PullRequestConversationThread | undefined, ): PullRequestChatSubject | null { - if (thread !== undefined) { + // An overview thread has no line to attach, so it goes as its opening remark, as fixing it does. + if (thread !== undefined && thread.path !== null) { return thread.comments.some((entry) => visibleBody(entry.body) !== null) ? { kind: "thread", thread } : null; @@ -256,6 +259,7 @@ function CollapsedComment({ label, body, reactionBar, + resolveButton, detail, thread, replyCount = 0, @@ -268,8 +272,9 @@ function CollapsedComment({ label: string; body: string | null; reactionBar: ReactNode; + resolveButton: ReactNode; detail: PullRequestDetailView; - thread: PullRequestReviewThread | undefined; + thread: PullRequestConversationThread | undefined; replyCount?: number; replies?: ReactNode; moreReplies?: ReactNode; @@ -298,6 +303,8 @@ function CollapsedComment({ className={cn("size-3.5 transition-transform", open && "rotate-180")} /> + {/* Collapsed, the status label already says "Resolved". */} + {open ? resolveButton : null} {onAddToChat ? : null} {reactionBar}
@@ -481,7 +488,10 @@ export function PullRequestSummaryTab({ DEFAULT_COMMENT_FILTER, CommentFilterSchema, ); - const items = pullRequestConversationItems(detail.comments, detail.reviewThreads); + const items = pullRequestConversationItems( + detail.comments, + pullRequestConversationThreads(detail), + ); const filteredItems = filterPullRequestConversationItems(items, commentFilter); const commentCount = items.reduce( (count, item) => count + pullRequestConversationItemComments(item).length, @@ -696,9 +706,20 @@ export function PullRequestSummaryTab({ /> ); + const resolveButtonFor = (thread: PullRequestConversationThread | undefined) => + thread === undefined ? null : ( + + ); + const renderCommentCard = ( comment: PullRequestComment, - thread: PullRequestReviewThread | undefined, + thread: PullRequestConversationThread | undefined, { reply = false }: { reply?: boolean } = {}, ) => { const body = visibleBody(comment.body); @@ -708,7 +729,7 @@ export function PullRequestSummaryTab({ (comment.kind !== "review" && comment.kind !== "review-comment") || outcome === "approved" ? null - : thread === undefined + : thread === undefined || thread.path === null ? body === null ? null : { kind: "comment", comment } @@ -747,6 +768,7 @@ export function PullRequestSummaryTab({ ) : null} + {resolveButtonFor(thread)} {onAddToChat && chat !== null ? ( onAddToChat(chat)} /> ) : null} @@ -791,6 +813,7 @@ export function PullRequestSummaryTab({ moreReplies={moreReplies} onAddToChat={onAddToChat && chat !== null ? () => onAddToChat(chat) : undefined} reactionBar={reactionBarFor(comment)} + resolveButton={resolveButtonFor(thread)} /> ); } diff --git a/apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx b/apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx index df0e9c260d88b..abefb7e1d7e87 100644 --- a/apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx @@ -56,6 +56,7 @@ import { PullRequestMarkdown } from "./PullRequestMarkdown"; import { PullRequestMoreReplies } from "./PullRequestMoreReplies"; import { PullRequestMarkdownEditor } from "./PullRequestMarkdownEditor"; import { PullRequestReactionBar } from "./PullRequestReactions"; +import { PullRequestResolveThreadButton } from "./PullRequestResolveThreadButton"; import { PullRequestActorAvatar, PullRequestDiffStat, @@ -443,10 +444,12 @@ function ReviewThreadEvent({ cwd, onOpen, reactions, + detail, }: { revealAnchor?: string | null; event: PullRequestTimelineEvent; thread: PullRequestTimelineThread; + detail: PullRequestDetailView; editable: ReadonlyMap; cwd: string; onOpen: (url: string) => void; @@ -471,40 +474,60 @@ function ReviewThreadEvent({ fallback={} muted={!expanded} /> - + {expanded ? ( + ) : null} - {dismissed ? Review dismissed : null} - {thread.isOutdated ? Outdated : null} - {expanded ? null : ( - - · {event.actor?.login ?? "ghost"} - {replyCount > 0 ? ( - <> - {" "} - · {replyCount.toLocaleString()} {replyCount === 1 ? "reply" : "replies"} - - ) : null} - - )} - - +
{expanded ? ( <> , + Pick< + PullRequestConversationThread, + "id" | "path" | "line" | "isResolved" | "isOutdated" | "canResolve" + >, PullRequestReplyAvailability { readonly replies: ReadonlyArray; } @@ -557,12 +561,12 @@ export type PullRequestConversationItem = readonly createdAt: string; readonly root: PullRequestComment; readonly replies: ReadonlyArray; - readonly thread: PullRequestReviewThread; + readonly thread: PullRequestConversationThread; } & PullRequestReplyAvailability); export function pullRequestConversationItems( comments: ReadonlyArray, - reviewThreads: ReadonlyArray, + reviewThreads: ReadonlyArray, ): ReadonlyArray { const byId = new Map(comments.map((comment) => [comment.id, comment])); const claimed = new Set(); @@ -623,7 +627,7 @@ export function pullRequestConversationItemComments( export function isPullRequestCommentFinished( comment: PullRequestComment, - thread?: PullRequestReviewThread, + thread?: PullRequestConversationThread, ): boolean { return ( thread?.isResolved === true || pullRequestReviewOutcome(comment.reviewState) === "dismissed" @@ -780,7 +784,7 @@ export function buildPullRequestTimeline( PullRequestDetailView, "provider" | "createdAt" | "author" | "commits" | "comments" | "mergedAt" | "closedAt" > & - Partial>, + Partial>, ): ReadonlyArray { const term = sourceControlClients.get(detail.provider).changeRequest.singular; return [ @@ -816,11 +820,16 @@ export function buildPullRequestTimeline( reviewState: null, reactions: [], })), - ...pullRequestConversationItems(detail.comments, detail.reviewThreads ?? []).map((item) => { + ...pullRequestConversationItems(detail.comments, [ + ...(detail.reviewThreads ?? []), + ...(detail.overviewThreads ?? []), + ]).map((item) => { if (item.kind === "comment") return commentTimelineEvent(item.comment); return { ...commentTimelineEvent(item.root), thread: { + id: item.thread.id, + ...(item.thread.canResolve === undefined ? {} : { canResolve: item.thread.canResolve }), path: item.thread.path, line: item.thread.line, isResolved: item.thread.isResolved, diff --git a/apps/web/src/components/pullRequest/usePullRequestDetail.ts b/apps/web/src/components/pullRequest/usePullRequestDetail.ts index 7d6ab0c7a6154..fb7515c646009 100644 --- a/apps/web/src/components/pullRequest/usePullRequestDetail.ts +++ b/apps/web/src/components/pullRequest/usePullRequestDetail.ts @@ -182,6 +182,9 @@ export function usePullRequestDetail( ), commentsTruncated: activity?.commentsTruncated ?? false, reviewThreads: activity?.reviewThreads ?? [], + ...(activity?.overviewThreads === undefined + ? {} + : { overviewThreads: activity.overviewThreads }), commits: activity?.commits ?? [], reactions: activity?.reactions ?? [], }; diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index 53043895b8477..014b26272af73 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -196,6 +196,16 @@ export const PullRequestReviewThread = Schema.Struct({ }); export type PullRequestReviewThread = typeof PullRequestReviewThread.Type; +/** A thread on the change request as a whole, such as a GitLab overview thread. */ +export const PullRequestOverviewThread = Schema.Struct({ + ...PullRequestReviewThread.fields, + path: Schema.Null, + line: Schema.Null, +}); +export type PullRequestOverviewThread = typeof PullRequestOverviewThread.Type; + +export type PullRequestConversationThread = PullRequestReviewThread | PullRequestOverviewThread; + export const PullRequestReviewerKind = Schema.Literals(["user", "team"]); export type PullRequestReviewerKind = typeof PullRequestReviewerKind.Type; @@ -667,11 +677,20 @@ export const PullRequestActivity = Schema.Struct({ commentsTruncated: Schema.Boolean, reviewThreadsTruncated: Schema.optional(Schema.Boolean), reviewThreads: Schema.Array(PullRequestReviewThread), + // Apart from reviewThreads so clients that require a thread path still decode the activity. + overviewThreads: Schema.optional(Schema.Array(PullRequestOverviewThread)), commits: Schema.Array(PullRequestCommit), reactions: Schema.optional(Schema.Array(PullRequestReaction)), }); export type PullRequestActivity = typeof PullRequestActivity.Type; +export const pullRequestConversationThreads = ( + activity: Pick, +): ReadonlyArray => + activity.overviewThreads === undefined || activity.overviewThreads.length === 0 + ? activity.reviewThreads + : [...activity.reviewThreads, ...activity.overviewThreads]; + export const PullRequestDetailView = Schema.Struct({ ...PullRequestDetail.fields, ...PullRequestActivity.fields, diff --git a/packages/source-control-core/src/server/PullRequestProvider.ts b/packages/source-control-core/src/server/PullRequestProvider.ts index be60f64c9c9a7..d5aab49d2cc0a 100644 --- a/packages/source-control-core/src/server/PullRequestProvider.ts +++ b/packages/source-control-core/src/server/PullRequestProvider.ts @@ -28,6 +28,7 @@ import type { PullRequestReactionContent, PullRequestReviewCommentDraft, PullRequestReviewDecision, + PullRequestOverviewThread, PullRequestReviewThread, PullRequestThreadCommentsResult, PullRequestReviewVerdict, @@ -221,6 +222,7 @@ export interface ProviderChangeRequestActivity { readonly commentsTruncated: boolean; readonly reviewThreadsTruncated?: boolean; readonly reviewThreads: ReadonlyArray; + readonly overviewThreads?: ReadonlyArray; readonly commits: ReadonlyArray; readonly reactions?: ReadonlyArray; } diff --git a/packages/source-control-gitlab/src/server/GitLabPullRequestCli.test.ts b/packages/source-control-gitlab/src/server/GitLabPullRequestCli.test.ts index 550fb3cac9d4e..4cdc6d1f15b6b 100644 --- a/packages/source-control-gitlab/src/server/GitLabPullRequestCli.test.ts +++ b/packages/source-control-gitlab/src/server/GitLabPullRequestCli.test.ts @@ -1417,7 +1417,7 @@ layer("GitLabPullRequestCli.layer", (it) => { ); it.effect( - "reads a positioned discussion as a thread and an open overview discussion as a finding", + "reads a positioned discussion at its line and an overview discussion as a thread and a finding", () => Effect.gen(function* () { mockedExecute.mockReturnValueOnce( @@ -1534,9 +1534,14 @@ layer("GitLabPullRequestCli.layer", (it) => { expect(overviewFindingIds).toEqual(["4"]); expect(threads.map(({ id, path, line, side }) => ({ id, path, line, side }))).toEqual([ { id: "abc123", path: "src/a.ts", line: 12, side: "right" }, + { id: "ghi789", path: null, line: null, side: "right" }, + { id: "jkl012", path: null, line: null, side: "right" }, { id: "file789", path: "src/b.ts", line: null, side: "right" }, { id: "img012", path: "logo.png", line: null, side: "right" }, ]); + expect(threads[1]).toMatchObject({ isResolved: false }); + expect(threads[1]?.comments.map(({ id }) => id)).toEqual(["4", "5"]); + expect(threads[2]).toMatchObject({ isResolved: true }); expect([...diffRefsByThreadId]).toEqual([ ["abc123", { baseSha: "base", startSha: "start", headSha: "head" }], ]); diff --git a/packages/source-control-gitlab/src/server/GitLabPullRequestCli.ts b/packages/source-control-gitlab/src/server/GitLabPullRequestCli.ts index ebd506df2a653..1ee2d355d3fa3 100644 --- a/packages/source-control-gitlab/src/server/GitLabPullRequestCli.ts +++ b/packages/source-control-gitlab/src/server/GitLabPullRequestCli.ts @@ -29,7 +29,7 @@ import type { PullRequestReactionContent, PullRequestReviewCommentDraft, PullRequestReviewPosition, - PullRequestReviewThread, + PullRequestConversationThread, PullRequestReviewVerdict, PullRequestReviewerCandidateList, } from "@t3tools/contracts"; @@ -345,7 +345,7 @@ const isVcsProcessExitError = Schema.is(VcsProcessExitError); const DIFF_FILE_MAX_OUTPUT_BYTES = 1024 * 1024; export interface GitLabDiscussionThreads { - readonly threads: ReadonlyArray; + readonly threads: ReadonlyArray; readonly diffRefsByThreadId: ReadonlyMap; readonly overviewFindingIds: ReadonlyArray; readonly truncated: boolean; @@ -1310,7 +1310,7 @@ export const make = Effect.gen(function* () { readonly repository: string; readonly number: number; readonly page: number; - readonly collected: ReadonlyArray; + readonly collected: ReadonlyArray; readonly collectedDiffRefs: ReadonlyMap; readonly collectedFindingIds: ReadonlyArray; }): Effect.Effect => diff --git a/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.test.ts b/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.test.ts index 5beb59cb3a8cb..c8f684ff8999c 100644 --- a/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.test.ts +++ b/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.test.ts @@ -605,35 +605,51 @@ describe("optional activity reads", () => { }), ); - it.effect("turns the first note of an open overview discussion into a review finding", () => - Effect.gen(function* () { - const finding = { ...comment, id: "10", body: "Mention the flag in the README" }; - const reply = { ...comment, id: "11", body: "Will do" }; - const provider = yield* make.pipe( - Effect.provide( - Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({ - ...reads, - listNotes: () => - Effect.succeed({ comments: [comment, finding, reply], truncated: false }), - listDiscussions: () => - Effect.succeed({ - threads: [], - diffRefsByThreadId: new Map(), - overviewFindingIds: ["10"], - truncated: false, - }), - }), - ), - ); + it.effect( + "turns an open overview discussion into a finding and a thread apart from file threads", + () => + Effect.gen(function* () { + const finding = { ...comment, id: "10", body: "Mention the flag in the README" }; + const reply = { ...comment, id: "11", body: "Will do" }; + const thread = { + side: "right", + isResolved: false, + isOutdated: false, + comments: [], + } as const; + const provider = yield* make.pipe( + Effect.provide( + Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({ + ...reads, + listNotes: () => + Effect.succeed({ comments: [comment, finding, reply], truncated: false }), + listDiscussions: () => + Effect.succeed({ + threads: [ + { ...thread, id: "overview", path: null, line: null }, + { ...thread, id: "file", path: "src/a.ts", line: 3 }, + ], + diffRefsByThreadId: new Map(), + overviewFindingIds: ["10"], + truncated: false, + }), + }), + ), + ); - const { comments } = yield* provider.getChangeRequestActivity(reference); + const { comments, reviewThreads, overviewThreads } = + yield* provider.getChangeRequestActivity(reference); - expect(comments.map((item) => [item.id, item.kind])).toEqual([ - ["42", "issue-comment"], - ["10", "review-comment"], - ["11", "issue-comment"], - ]); - }), + // Clients that require a thread path must never receive one without it. + expect(reviewThreads.map(({ id }) => id)).toEqual(["file"]); + expect(overviewThreads?.map(({ id }) => id)).toEqual(["overview"]); + + expect(comments.map((item) => [item.id, item.kind])).toEqual([ + ["42", "issue-comment"], + ["10", "review-comment"], + ["11", "issue-comment"], + ]); + }), ); it.effect("dates edited comments in the conversation and in threads", () => @@ -785,6 +801,7 @@ describe("optional activity reads", () => { commentCount: 1, commentsTruncated: true, reviewThreads: [], + overviewThreads: [], reactions: [], commits: [commit], }); diff --git a/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.ts b/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.ts index 7334d93a67385..df9210fb24124 100644 --- a/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.ts +++ b/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.ts @@ -8,6 +8,7 @@ import type { PullRequestActor, PullRequestCapabilities, PullRequestReaction, + PullRequestThreadComment, PullRequestViewerPermissions, } from "@t3tools/contracts"; @@ -423,6 +424,13 @@ export const make = Effect.gen(function* () { author !== null && botLogins.has(author.login.toLowerCase()) ? { ...author, isBot: true } : author; + const withNoteDetails = (comment: PullRequestThreadComment) => ({ + ...comment, + ...edited(comment.id), + author: withBotFlag(comment.author), + url: noteUrl(comment.id), + reactions: awards.reactionsByNoteId.get(comment.id) ?? [], + }); return { reactions: awards.reactions, comments: notes.comments.map((comment) => ({ @@ -435,20 +443,25 @@ export const make = Effect.gen(function* () { })), commentCount: notes.comments.length, commentsTruncated: notes.truncated || discussions.truncated, - reviewThreads: discussions.threads.map((thread) => ({ - ...thread, - isOutdated: isOutdated( - discussions.diffRefsByThreadId.get(thread.id), - detail?.diffRefs, - ), - comments: thread.comments.map((comment) => ({ - ...comment, - ...edited(comment.id), - author: withBotFlag(comment.author), - url: noteUrl(comment.id), - reactions: awards.reactionsByNoteId.get(comment.id) ?? [], - })), - })), + reviewThreads: discussions.threads.flatMap((thread) => + thread.path === null + ? [] + : [ + { + ...thread, + isOutdated: isOutdated( + discussions.diffRefsByThreadId.get(thread.id), + detail?.diffRefs, + ), + comments: thread.comments.map(withNoteDetails), + }, + ], + ), + overviewThreads: discussions.threads.flatMap((thread) => + thread.path === null + ? [{ ...thread, comments: thread.comments.map(withNoteDetails) }] + : [], + ), commits, }; }, diff --git a/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.ts b/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.ts index 2b9d2d22f07a3..44b87436d63bd 100644 --- a/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.ts +++ b/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.ts @@ -18,7 +18,7 @@ import type { PullRequestReactionContent, PullRequestReferenceCandidate, PullRequestReviewDecision, - PullRequestReviewThread, + PullRequestConversationThread, PullRequestReviewerCandidate, PullRequestState, } from "@t3tools/contracts"; @@ -789,7 +789,7 @@ export interface GitLabDiffRefs { } export interface GitLabDiscussions { - readonly threads: ReadonlyArray; + readonly threads: ReadonlyArray; /** GitLab moves a thread onto each new diff that still has its line; this is the last one. */ readonly diffRefsByThreadId: ReadonlyMap; /** First notes of unresolved discussions on the MR itself rather than a diff line. */ @@ -808,7 +808,7 @@ export function decodeDiscussionsJson( if (!Result.isSuccess(decoded)) { return Result.fail(decoded.failure); } - const threads: PullRequestReviewThread[] = []; + const threads: PullRequestConversationThread[] = []; const diffRefsByThreadId = new Map(); const overviewFindingIds: string[] = []; for (const entry of decoded.success) { @@ -816,11 +816,30 @@ export function decodeDiscussionsJson( if (!Exit.isSuccess(discussion)) continue; const notes = (discussion.value.notes ?? []).filter((note) => note.system !== true); const root = notes[0]; - const position = root?.position; - if (root !== undefined && !position && root.resolvable === true && root.resolved !== true) { - overviewFindingIds.push(String(root.id)); + if (root === undefined) continue; + const comments = notes.map((note) => ({ + id: String(note.id), + author: toActor(note.author), + body: note.body ?? "", + createdAt: note.created_at, + url: null, + })); + const position = root.position; + if (!position) { + // A thread started on the overview: resolvable, unlike a single comment. + if (root.resolvable !== true) continue; + if (root.resolved !== true) overviewFindingIds.push(String(root.id)); + threads.push({ + id: discussion.value.id, + path: null, + line: null, + side: "right", + isResolved: root.resolved === true, + isOutdated: false, + comments, + }); + continue; } - if (root === undefined || !position) continue; let side: "left" | "right"; let path: string | null; let line: number | null | undefined; @@ -850,13 +869,7 @@ export function decodeDiscussionsJson( side, isResolved: root.resolved === true, isOutdated: false, - comments: notes.map((note) => ({ - id: String(note.id), - author: toActor(note.author), - body: note.body ?? "", - createdAt: note.created_at, - url: null, - })), + comments, }); } return Result.succeed({ From 27188274e7513babfa5ee1112a5d3032ff299584 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 04:21:45 -0800 Subject: [PATCH 0005/1165] fix(web): pull request search keeps partial-word matches after GitHub answers (#26992) Co-authored-by: Claude Opus 5.5 --- .../pullRequest/PullRequestsPage.tsx | 2 + .../pullRequest/pullRequestList.logic.test.ts | 38 +++++++++++++++++++ .../pullRequestListGroups.logic.ts | 27 ++++++++++--- 3 files changed, 61 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestsPage.tsx b/apps/web/src/components/pullRequest/PullRequestsPage.tsx index 0a020466a83a8..9b1ad1a59950e 100644 --- a/apps/web/src/components/pullRequest/PullRequestsPage.tsx +++ b/apps/web/src/components/pullRequest/PullRequestsPage.tsx @@ -712,6 +712,8 @@ export function PullRequestsPage() { heldPullRequestsBySurface, } = pullRequestGroups({ known: ordered?.key === filterKey ? ordered.entries : (listData?.entries ?? []), + // The baseline was read without typed qualifiers, and rows cannot be checked for `checks:`. + baseline: typedParsed.filters.checks === undefined ? baselineQuery.data?.entries : undefined, providers: baselineQuery.data?.providers ?? listData?.providers ?? [], viewers, involvement: search.involvement, diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts index aae1914f63491..6a38bddfc8d68 100644 --- a/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts @@ -38,6 +38,7 @@ import { reusePullRequestEntries, settlePullRequestOverrides, } from "./pullRequestList.logic"; +import { pullRequestGroups } from "./pullRequestListGroups.logic"; import { readPullRequestListPreferences, writePullRequestListPreferences, @@ -177,6 +178,43 @@ describe("ranking what a search found", () => { rankPullRequestMatches([mentioned, titled], "welcome wizard").map((entry) => entry.number), ).toEqual([1, 2]); }); + + it("keeps loaded partial-word matches the host's whole-word search missed, after its answer", () => { + const bilal = { login: "bilal", name: null, avatarUrl: null }; + const hosted = row({ number: 1, title: "Notifications match the body text", author: bilal }); + const toast = row({ number: 2, title: "Threads show a completed toast", author: bilal }); + const toaster = row({ number: 3, title: "Toaster fixtures cover retries", author: bilal }); + const theirs = row({ number: 4, title: "Toasted sidebar" }); + const unrelated = row({ number: 5, title: "Cursor no longer adds attribution", author: bilal }); + + const { entries } = pullRequestGroups({ + known: [hosted, toast], + baseline: [theirs, unrelated, toaster, toast], + providers: [ + { + host: "github.com", + kind: SourceControlProviderKind.make("github"), + searchesOnHost: true, + projectCount: 1, + configured: true, + detail: null, + }, + ], + viewers: VIEWERS, + involvement: "all", + localFilters: { author: "bilal" }, + hasLocalFilters: true, + text: "toa", + answeredLocally: true, + baselineEmpty: false, + held: undefined, + authored: undefined, + reviewing: undefined, + partitionsWanted: false, + }); + + expect(entries.map((entry) => entry.number)).toEqual([1, 2, 3]); + }); }); describe("default merge-readiness ranking", () => { diff --git a/apps/web/src/components/pullRequest/pullRequestListGroups.logic.ts b/apps/web/src/components/pullRequest/pullRequestListGroups.logic.ts index 9a3bda738a287..31f02978c918d 100644 --- a/apps/web/src/components/pullRequest/pullRequestListGroups.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestListGroups.logic.ts @@ -15,6 +15,7 @@ import { partitionPullRequestsWithPriority, pullRequestEntryKey, pullRequestEntryViewer, + rankPullRequestMatches, type EnvironmentPullRequestEntry, type PullRequestPartitionsSnapshot, } from "./pullRequestList.logic"; @@ -25,6 +26,7 @@ export const pullRequestListEntryId = (target: Parameters; + /** The text-free rows already loaded, or undefined when the typed filters cannot vet them. */ + baseline: ReadonlyArray | undefined; providers: PullRequestListResult["providers"]; viewers: PullRequestListResult["viewers"]; involvement: PullRequestInvolvement; @@ -56,22 +60,33 @@ export function pullRequestGroups({ providers.flatMap((provider) => (provider.searchesOnHost ? [provider.host] : [])), ); - const entries = (() => { - const involvementEntries = filterPullRequestsByInvolvement(known, viewers, involvement); - // Host text searches cover fields absent from rows, so do not narrow their matches locally. - // Filter text locally while results are pending or when the host has no text search. - const narrowedEntries = hasLocalFilters + const narrowRows = (rows: ReadonlyArray) => { + const involvementEntries = filterPullRequestsByInvolvement(rows, viewers, involvement); + return hasLocalFilters ? involvementEntries.filter((entry) => matchesPullRequestFilters(entry, localFilters, pullRequestEntryViewer(entry, viewers)), ) : involvementEntries; + }; + + const entries = (() => { + const narrowedEntries = narrowRows(known); if (text.length === 0) { return narrowedEntries.toSorted(comparePullRequestsByRecency); } - return narrowedEntries.filter( + // Host text searches cover fields absent from rows, so do not narrow their matches locally. + // Filter text locally while results are pending or when the host has no text search. + const matched = narrowedEntries.filter( (entry) => (answeredLocally && searchingHosts.has(entry.host)) || matchesPullRequestQuery(entry, text), ); + // Host search matches whole words, so it can miss loaded rows a partial word matches here. + // Those follow the host's ranked answer rather than reshuffling it. + const listed = new Set(matched.map(pullRequestEntryKey)); + const missed = narrowRows(baseline ?? []).filter( + (entry) => !listed.has(pullRequestEntryKey(entry)) && matchesPullRequestQuery(entry, text), + ); + return missed.length === 0 ? matched : [...matched, ...rankPullRequestMatches(missed, text)]; })(); const listedPullRequestTabStatuses: Record = Object.fromEntries( From 835e72683ba6d7eb5bac3fef7db0990438ae1f37 Mon Sep 17 00:00:00 2001 From: Juan Suaya <142438538+juansuaya17@users.noreply.github.com> Date: Wed, 11 Nov 2026 09:27:05 -0300 Subject: [PATCH 0006/1165] fix(server): Claude subagents no longer show the parent's effort (#17042) Co-authored-by: Claude Opus 5.5 Co-authored-by: Julius Marminge --- .../Adapters/ClaudeAdapterV2.test.ts | 12 ++++++------ .../src/orchestration-v2/Adapters/ClaudeAdapterV2.ts | 9 ++------- .../OrchestratorReplayRecovery.integration.test.ts | 1 - 3 files changed, 8 insertions(+), 14 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 4550692f3e18a..6b3ffd78063fa 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -15819,11 +15819,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); - it.effect("gives a nested subagent its own effort, else the one its owner runs at", () => + it.effect("records only explicit effort for nested subagents", () => Effect.scoped( Effect.gen(function* () { const OUTER_TOOL_USE_ID = "toolu-effort-outer"; - const INHERITING_TOOL_USE_ID = "toolu-effort-nested-inheriting"; + const UNSPECIFIED_TOOL_USE_ID = "toolu-effort-nested-unspecified"; const OVERRIDING_TOOL_USE_ID = "toolu-effort-nested-overriding"; const harness = yield* makeWakeHarness; const now = yield* DateTime.now; @@ -15889,14 +15889,14 @@ describe("ClaudeAdapterV2 background wake turns", () => { type: "message", role: "assistant", content: [ - agentCall(INHERITING_TOOL_USE_ID), + agentCall(UNSPECIFIED_TOOL_USE_ID), agentCall(OVERRIDING_TOOL_USE_ID, "max"), ], }, uuid: "00000000-0000-4000-8000-000000000223", session_id: WAKE_NATIVE_SESSION, }), - taskStarted(INHERITING_TOOL_USE_ID, "00000000-0000-4000-8000-000000000224"), + taskStarted(UNSPECIFIED_TOOL_USE_ID, "00000000-0000-4000-8000-000000000224"), taskStarted(OVERRIDING_TOOL_USE_ID, "00000000-0000-4000-8000-000000000225"), makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000226", @@ -15921,7 +15921,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); assert.deepEqual(Object.fromEntries(efforts), { [`task-${OUTER_TOOL_USE_ID}`]: "low", - [`task-${INHERITING_TOOL_USE_ID}`]: "low", + [`task-${UNSPECIFIED_TOOL_USE_ID}`]: undefined, [`task-${OVERRIDING_TOOL_USE_ID}`]: "max", }); const childEfforts = harness.events.flatMap((event) => @@ -15929,7 +15929,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { ? [getModelSelectionStringOptionValue(event.appThread.modelSelection, "effort")] : [], ); - assert.deepEqual(childEfforts, ["low", "low", "max"]); + assert.deepEqual(childEfforts, ["low", undefined, "max"]); }).pipe( Effect.provide( Layer.mergeAll( diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 4a252d6092edb..08e7a1f8882ff 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -6886,13 +6886,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( workflow === undefined ? undefined : withWorkflowMemberThreadIds(workflow, childThreadId); - const effort = - existingSubagent === undefined - ? (input.effort ?? - input.owner?.effort ?? - compileClaudeModelSelection(input.context.input.modelSelection, modelCatalog) - .effort) - : existingSubagent.effort; + // Unstated effort stays unknown: a custom agent definition may override the owner's. + const effort = existingSubagent === undefined ? input.effort : existingSubagent.effort; const model = input.model ?? priorTask?.model ?? null; const task = { ...(priorTask ?? { diff --git a/apps/server/src/orchestration-v2/testkit/OrchestratorReplayRecovery.integration.test.ts b/apps/server/src/orchestration-v2/testkit/OrchestratorReplayRecovery.integration.test.ts index 6521447763951..233139235a705 100644 --- a/apps/server/src/orchestration-v2/testkit/OrchestratorReplayRecovery.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/OrchestratorReplayRecovery.integration.test.ts @@ -463,7 +463,6 @@ describe("orchestrator replay recovery", () => { assert.deepEqual(child?.thread.modelSelection, { instanceId: CLAUDE_MODEL_SELECTION.instanceId, model: "claude-sonnet-5", - options: [{ id: "effort", value: "high" }], }); const conversation = (child?.turnItems ?? []) From d806251188fed764490359bd22bd2d0111a8681f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 04:32:26 -0800 Subject: [PATCH 0007/1165] fix(server): runs cancelled by a server restart say why in the thread (#27192) Co-authored-by: Claude Opus 5.5 --- .../ProviderRuntimeRecoveryService.test.ts | 1 + .../ProviderRuntimeRecoveryService.ts | 30 +++++++++++++++++++ .../src/orchestration-v2/runtimeLayer.test.ts | 20 ++++++++++--- 3 files changed, 47 insertions(+), 4 deletions(-) diff --git a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.test.ts b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.test.ts index 642a642a80df1..e628fc5bcc4bd 100644 --- a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.test.ts @@ -793,6 +793,7 @@ it.effect( ["turn-item.updated", "cancelled"], ["turn-item.updated", "cancelled"], ["turn-item.updated", "cancelled"], + ["turn-item.updated", "completed"], ["provider-thread.updated", "idle"], ["provider-session.updated", "stopped"], ], diff --git a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts index 89e43e5b1ede4..46b739bec5732 100644 --- a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts +++ b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts @@ -634,6 +634,36 @@ export const make = Effect.gen(function* () { payload: cancelledItem(item, now), }); } + // Queued runs and unsent resumes leave no visible turn behind, so there is nothing to explain. + if (run.status === "queued" || unsentResumeRunIds.includes(run.id)) continue; + events.push({ + id: yield* allocateEventId(), + type: "turn-item.updated", + threadId: projection.thread.id, + runId: run.id, + ...(run.rootNodeId === null ? {} : { nodeId: run.rootNodeId }), + providerInstanceId: run.providerInstanceId, + occurredAt: now, + payload: { + id: ids.derive.runSignalTurnItem({ runId: run.id, signal: "server-recovery" }), + threadId: projection.thread.id, + runId: run.id, + nodeId: run.rootNodeId, + providerThreadId: run.providerThreadId, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + // EventSink allocates the real position, after the run's existing items. + ordinal: 0, + status: "completed", + title: trigger === "startup" ? "Server restarted" : "Server shut down", + startedAt: now, + completedAt: now, + updatedAt: now, + type: "system_notice", + message: detail, + }, + }); } // Blocking waits die with their turn; let child results wake the parent continuation as async tasks. const cutRunIds = new Set(runs.map((run) => run.id)); diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index a31a96cf5b463..6d6ccc63909b2 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -1591,10 +1591,16 @@ it.layer(layerTest)("OrchestrationV2LayerLive", (it) => { assert.equal(native.runtimeRequests[0]?.responseCapability.type, "not_resumable"); yield* recovery.reconcile("shutdown"); yield* recovery.reconcile("startup"); - assert.deepEqual( - (yield* orchestrator.getThreadProjection(threadId)).runtimeRequests, - recovered.runtimeRequests, - ); + const rerecovered = yield* orchestrator.getThreadProjection(threadId); + assert.deepEqual(rerecovered.runtimeRequests, recovered.runtimeRequests); + const runItems = rerecovered.turnItems.filter((item) => item.runId === runId); + const notices = runItems.filter((item) => item.type === "system_notice"); + assert.lengthOf(notices, scope === "run" ? 1 : 0); + if (scope === "run") { + assert.equal(notices[0]?.title, "Server restarted"); + assert.equal(notices[0]?.nodeId, recovered.runs[0]?.rootNodeId); + assert.equal(notices[0]?.ordinal, Math.max(...runItems.map((item) => item.ordinal))); + } yield* orchestrator.dispatch({ type: "runtime-request.respond", commandId: CommandId.make(`recovery-answer-${trigger}-${scope}`), @@ -9416,6 +9422,12 @@ it.layer(layerTest)("usage-limit recovery", (it) => { ), [run.id, resumed.runs.at(-1)!.id], ); + assert.deepEqual( + resumed.turnItems.flatMap((item) => + item.type === "system_notice" && item.title === "Server restarted" ? [item.runId] : [], + ), + scenario === "resume" ? [] : [continuation.id], + ); assert.deepEqual( resumed.runs.map((row) => row.status), scenario === "resume" ? ["failed", "cancelled", "starting"] : ["failed", "cancelled"], From a74ee4cc7c0ee9729f58dfff6cc2a118692cf8ea Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 04:37:45 -0800 Subject: [PATCH 0008/1165] fix(client-runtime): server updates stop waiting 32 minutes when the host returns without this update's outcome (#27275) Co-authored-by: Claude Opus 5.5 --- packages/client-runtime/src/rpc/client.ts | 31 +- .../client-runtime/src/state/server.test.ts | 310 ++++++++++++++++-- packages/client-runtime/src/state/server.ts | 76 ++++- 3 files changed, 378 insertions(+), 39 deletions(-) diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index 0d8c9e1351d0d..dd435d820d641 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -136,7 +136,7 @@ export type EnvironmentRpcStreamFailure = ? E : never; -const currentSession = Effect.fn("EnvironmentRpc.currentSession")(function* () { +export const currentSession = Effect.fn("EnvironmentRpc.currentSession")(function* () { const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor; return yield* SubscriptionRef.get(supervisor.session).pipe( Effect.flatMap( @@ -194,7 +194,7 @@ const authorizeRequest = Effect.fn("EnvironmentRpc.authorize")(function* ( yield* guard.authorize(supervisor.target.environmentId, method, input); }); -const requestInSession = Effect.fn("EnvironmentRpc.request")(function* < +export const requestInSession = Effect.fn("EnvironmentRpc.request")(function* < TTag extends EnvironmentUnaryRpcTag, >(session: RpcSession, tag: TTag, input: EnvironmentRpcInput) { const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor; @@ -234,16 +234,23 @@ export function runStreamGuarded( EnvironmentSupervisor.EnvironmentSupervisor > { return Stream.unwrap( - authorizeRequest(tag, input).pipe( - Effect.andThen(currentSession()), - Effect.map((session) => { - const method = session.client[tag] as ( - input: EnvironmentRpcInput, - ) => Stream.Stream, EnvironmentRpcStreamFailure>; - return method(input); - }), - ), - ).pipe( + currentSession().pipe(Effect.map((session) => runStreamInSession(session, tag, input))), + ); +} + +export function runStreamInSession( + session: RpcSession, + tag: TTag, + input: EnvironmentRpcInput, +): Stream.Stream< + EnvironmentRpcStreamValue, + EnvironmentRpcStreamFailure | EnvironmentAuthorizationError, + EnvironmentSupervisor.EnvironmentSupervisor +> { + const method = session.client[tag] as ( + input: EnvironmentRpcInput, + ) => Stream.Stream, EnvironmentRpcStreamFailure>; + return Stream.unwrap(authorizeRequest(tag, input).pipe(Effect.map(() => method(input)))).pipe( Stream.withSpan("EnvironmentRpc.runStream", { attributes: { "rpc.method": tag }, }), diff --git a/packages/client-runtime/src/state/server.test.ts b/packages/client-runtime/src/state/server.test.ts index f3c282d8bd312..b01cd406d053a 100644 --- a/packages/client-runtime/src/state/server.test.ts +++ b/packages/client-runtime/src/state/server.test.ts @@ -10,12 +10,15 @@ import { UsageLimitSourceId, type ServerConfig, type ServerConfigStreamEvent, + type ServerLifecycleStreamEvent, type ServerLifecycleWelcomePayload, + type ServerSelfUpdateOutcome, WS_METHODS, } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import { vi } from "vite-plus/test"; import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -25,6 +28,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; +import * as Scheduler from "effect/Scheduler"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; @@ -48,7 +52,6 @@ import type { RpcSession } from "../rpc/session.ts"; import { applyServerWelcomeEvent, createServerEnvironmentAtoms, - serverUpdateStateAtom, makeEnvironmentServerWelcomeState, makeEnvironmentServerConfigState, isLegacyUpdateHandoffLoss, @@ -61,6 +64,7 @@ import { resolveServerWelcomeState, resolveServerUpdateProgressResult, serverUpdateStateForProgressEvent, + serverUpdateStateAtom, serverUpdateStateForServerVersion, validateServerUpdateReadyEvent, waitForNextEnvironmentReconnect, @@ -254,22 +258,37 @@ describe("accepted server update recovery", () => { capabilities: { serverSelfUpdate: method, serverSelfUpdateProgress: true }, }, } as ServerConfig; + const ready = (serverVersion: string) => + ({ + version: 1, + sequence: 1, + type: "ready", + payload: { at: "2026-09-01T00:00:00.000Z", environment: { serverVersion } }, + }) as Parameters[1]; + let requested = false; const client = { [WS_METHODS.subscribeServerConfig]: () => Stream.never, + // Before the request, a boot-service update reads the accepting server's ready. [WS_METHODS.subscribeServerLifecycle]: () => - Stream.fromEffect(Deferred.succeed(observing, undefined)).pipe( - Stream.drain, - Stream.concat(Stream.fromQueue(readyEvents)), - Stream.tap((event) => - event.payload.environment.serverVersion === "0.0.30" - ? Deferred.succeed(oldReadyObserved, undefined) - : Effect.void, - ), - ), + requested + ? Stream.fromEffect(Deferred.succeed(observing, undefined)).pipe( + Stream.drain, + Stream.concat(Stream.fromQueue(readyEvents)), + Stream.tap((event) => + event.payload.environment.serverVersion === "0.0.30" + ? Deferred.succeed(oldReadyObserved, undefined) + : Effect.void, + ), + ) + : Stream.make(ready("0.0.30")).pipe(Stream.concat(Stream.never)), [WS_METHODS.serverUpdateServerWithProgress]: () => - (loss === "no_progress" - ? Stream.empty - : Stream.make({ type: "progress" as const, stage: "downloading" as const }) + Stream.unwrap( + Effect.sync(() => { + requested = true; + return loss === "no_progress" + ? Stream.empty + : Stream.make({ type: "progress" as const, stage: "downloading" as const }); + }), ).pipe( Stream.concat( Stream.fromEffect( @@ -363,13 +382,6 @@ describe("accepted server update recovery", () => { status: "running", stage: "resuming", }); - const ready = (serverVersion: string) => - ({ - version: 1, - sequence: 1, - type: "ready", - payload: { at: "2026-09-01T00:00:00.000Z", environment: { serverVersion } }, - }) as Parameters[1]; yield* Queue.offer(readyEvents, ready("0.0.30")); yield* Deferred.await(oldReadyObserved); expect(registry.get(serverUpdateStateAtom(TARGET.environmentId))).toMatchObject({ @@ -778,6 +790,20 @@ describe("server state projection", () => { validateServerUpdateReadyEvent(result, ready("rolled-back")), ); expect(rollback.message).toBe("prepared-timeout"); + const foreign = ready("rolled-back"); + const foreignRollback = yield* Effect.flip( + validateServerUpdateReadyEvent(result, { + ...foreign, + payload: { + ...foreign.payload, + updateOutcome: { ...foreign.payload.updateOutcome!, id: "manual-1" }, + }, + }), + ); + expect(foreignRollback).toMatchObject({ + status: "failed", + reason: "The service launcher resumed without committing the requested server version.", + }); }), ); @@ -1315,6 +1341,250 @@ describe("server state projection", () => { ); }); +describe("launcher update resume", () => { + const UPDATE_CONFIG = { + ...CONFIG, + environment: { + serverVersion: "0.0.30", + capabilities: { serverSelfUpdate: "boot-service", serverSelfUpdateProgress: true }, + }, + } as unknown as ServerConfig; + + const ready = (at: string, serverVersion: string, updateOutcome?: ServerSelfUpdateOutcome) => + ({ + version: 1, + sequence: 1, + type: "ready", + payload: { at, environment: { serverVersion }, ...(updateOutcome ? { updateOutcome } : {}) }, + }) as ServerLifecycleStreamEvent; + const after = (delay: Duration.Input, event: ServerLifecycleStreamEvent) => + Stream.fromEffect(Effect.as(Effect.sleep(delay), event)); + const committed = (id: string, fromVersion: string, targetVersion: string) => ({ + id, + fromVersion, + targetVersion, + status: "committed" as const, + }); + const ACCEPTING = ready("2026-08-01T00:00:00.000Z", "0.0.30"); + const REPLACEMENT = ready("2026-08-01T00:05:00.000Z", "0.0.31"); + + // Lifecycle subscriptions opened before the update is acknowledged reach the accepting + // process; later ones reach whatever the host has become. + const launchUpdate = Effect.fn("ServerUpdateTest.launchUpdate")(function* (host: { + readonly accepting: Stream.Stream; + readonly resumed: Stream.Stream; + readonly reconnectAfterBaseline?: boolean; + }) { + const started = yield* Deferred.make(); + const resumeSubscribed = yield* Deferred.make(); + let acknowledged = false; + const client = { + [WS_METHODS.subscribeServerConfig]: () => Stream.never, + [WS_METHODS.serverUpdateServerWithProgress]: () => + Stream.fromEffect( + Deferred.succeed(started, undefined).pipe( + Effect.andThen( + Effect.sync(() => { + acknowledged = true; + return { + type: "complete" as const, + result: { + targetVersion: "0.0.31", + method: "boot-service" as const, + updateId: "update-1", + }, + }; + }), + ), + ), + ), + [WS_METHODS.subscribeServerLifecycle]: () => { + const events = acknowledged + ? Deferred.succeed(resumeSubscribed, undefined).pipe(Effect.as(host.resumed)) + : Effect.succeed(host.accepting); + const reconnect = + !acknowledged && host.reconnectAfterBaseline === true + ? SubscriptionRef.set(supervisor.session, Option.some(updateSession())) + : Effect.void; + return Stream.unwrap( + Deferred.succeed(started, undefined).pipe(Effect.andThen(events)), + ).pipe(Stream.concat(Stream.never), Stream.ensuring(reconnect)); + }, + } as unknown as WsRpcProtocolClient; + const updateSession = (): RpcSession => ({ + ...session(client), + initialConfig: Effect.succeed(UPDATE_CONFIG), + }); + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), + session: yield* SubscriptionRef.make(Option.some(updateSession())), + prepared: yield* SubscriptionRef.make(Option.none()), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + type Environments = EnvironmentRegistry.EnvironmentRegistry["Service"]; + const run: Environments["run"] = (_environmentId, effect) => + Effect.provideService(effect, EnvironmentSupervisor.EnvironmentSupervisor, supervisor); + const runStream: Environments["runStream"] = (_environmentId, stream) => + Stream.provideService(stream, EnvironmentSupervisor.EnvironmentSupervisor, supervisor); + const environments = EnvironmentRegistry.EnvironmentRegistry.of({ + run, + runStream, + followStream: runStream, + stateChanges: () => Stream.never, + retryNow: () => Effect.void, + } as unknown as Environments); + const cache = Persistence.EnvironmentCacheStore.of({ + loadShell: () => Effect.succeedNone, + saveShell: () => Effect.void, + loadThread: () => Effect.succeedNone, + saveThread: () => Effect.void, + removeThread: () => Effect.void, + loadServerConfig: () => Effect.succeedNone, + saveServerConfig: () => Effect.void, + loadVcsRefs: () => Effect.succeedNone, + saveVcsRefs: () => Effect.void, + removeVcsRefs: () => Effect.void, + clearVcsRefs: () => Effect.void, + clear: () => Effect.void, + }); + // Share the test clock so the command's timers and the host's restart run on TestClock. + const runtime = Atom.runtime( + Layer.mergeAll( + Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, environments), + Layer.succeed(Persistence.EnvironmentCacheStore, cache), + Layer.succeed(Clock.Clock, yield* Clock.Clock), + Layer.succeed(Scheduler.Scheduler, yield* Scheduler.Scheduler), + ), + ); + const initialConfigValueAtom = Atom.make(UPDATE_CONFIG); + const atoms = createServerEnvironmentAtoms(runtime, { + initialConfigValueAtom: () => initialConfigValueAtom, + }); + const registry = yield* Effect.acquireRelease(Effect.sync(AtomRegistry.make), (registry) => + Effect.sync(() => registry.dispose()), + ); + const updateStateAtom = serverUpdateStateAtom(TARGET.environmentId); + yield* AtomRegistry.mount(registry, updateStateAtom); + + yield* Effect.promise(() => + atoms.updateServer.run(registry, { + environmentId: TARGET.environmentId, + input: { targetVersion: "0.0.31" }, + }), + ).pipe(Effect.forkScoped); + return { + // The host saw its first call: the update request or a lifecycle subscription. + started: Deferred.await(started), + resumeSubscribed: Deferred.await(resumeSubscribed), + state: () => registry.get(updateStateAtom), + }; + }); + + const UNCOMMITTED_RESUME = { + status: "failed", + stage: "resuming", + fromVersion: "0.0.30", + targetVersion: "0.0.31", + message: "The service launcher resumed without committing the requested server version.", + }; + + it.effect("fails promptly when a server the launcher did not start comes back", () => + Effect.scoped( + Effect.gen(function* () { + const update = yield* launchUpdate({ + accepting: Stream.make(ACCEPTING), + resumed: after("1 second", ACCEPTING).pipe(Stream.concat(after("1 minute", REPLACEMENT))), + }); + yield* update.resumeSubscribed; + + yield* TestClock.adjust("1 second"); + expect(update.state()).toMatchObject({ status: "running", stage: "resuming" }); + yield* TestClock.adjust("1 minute"); + expect(update.state()).toEqual(UNCOMMITTED_RESUME); + }), + ), + ); + + it.effect( + "fails promptly when the accepting server's replay arrives after a quick acknowledgement", + () => + Effect.scoped( + Effect.gen(function* () { + const update = yield* launchUpdate({ + accepting: after("3 seconds", ACCEPTING), + resumed: after("1 second", REPLACEMENT), + }); + yield* update.started; + yield* TestClock.adjust("3 seconds"); + yield* update.resumeSubscribed; + + yield* TestClock.adjust("1 minute"); + expect(update.state()).toEqual(UNCOMMITTED_RESUME); + }), + ), + ); + + it.effect( + "waits for this update's outcome when a reconnect lands between the baseline and the request", + () => + Effect.scoped( + Effect.gen(function* () { + const update = yield* launchUpdate({ + reconnectAfterBaseline: true, + accepting: Stream.make(ACCEPTING), + // The process that accepted the update over the new session replays its own ready first. + resumed: after("1 second", ready("2026-08-01T00:01:00.000Z", "0.0.30")).pipe( + Stream.concat( + after( + "1 minute", + ready( + "2026-08-01T00:05:00.000Z", + "0.0.31", + committed("update-1", "0.0.30", "0.0.31"), + ), + ), + ), + ), + }); + yield* update.resumeSubscribed; + + yield* TestClock.adjust("1 second"); + expect(update.state()).toMatchObject({ status: "running", stage: "resuming" }); + yield* TestClock.adjust("1 minute"); + expect(update.state()).toMatchObject({ status: "idle" }); + }), + ), + ); + + it.effect( + "sends the update without a baseline when the accepting server never replays ready", + () => + Effect.scoped( + Effect.gen(function* () { + const update = yield* launchUpdate({ + accepting: Stream.never, + resumed: after( + "1 second", + ready( + "2026-08-01T00:05:00.000Z", + "0.0.31", + committed("update-1", "0.0.30", "0.0.31"), + ), + ), + }); + yield* update.started; + + yield* TestClock.adjust("5 seconds"); + yield* TestClock.adjust("1 second"); + expect(update.state()).toMatchObject({ status: "idle" }); + }), + ), + ); +}); + describe("provider setup commands", () => { it.effect("starts installs for two instances in one environment separately", () => Effect.gen(function* () { diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 6b757ba8d15cf..40256c327b29e 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -43,10 +43,13 @@ import { safeErrorLogAttributes } from "../errors/safeLog.ts"; import * as Persistence from "../platform/persistence.ts"; import { runCachePersistence } from "./cachePersistence.ts"; import { + currentSession, isRpcClientError, request, requestGuarded, + requestInSession, runStream, + runStreamInSession, subscribe, subscribeDynamicWithSession, type EnvironmentRpcInput, @@ -134,6 +137,8 @@ export class ServerUpdateTerminalError extends Schema.TaggedError { if (result.updateId === undefined) return Effect.void; - const outcome = event.payload.updateOutcome; + const reported = event.payload.updateOutcome; + const outcome = reported?.id === result.updateId ? reported : undefined; if ( - outcome?.id === result.updateId && - outcome.status === "committed" && + outcome?.status === "committed" && outcome.targetVersion === result.targetVersion && event.payload.environment.serverVersion === result.targetVersion ) { @@ -859,6 +871,17 @@ export function createServerEnvironmentAtoms( const desktopCommitStarting = yield* Deferred.make(); const desktopReconnectObserverArmed = yield* Deferred.make(); const desktopReconnected = yield* Deferred.make(); + let accepting: { readonly session: RpcSession; readonly at: string } | undefined; + let acceptedAt: string | undefined; + // The baseline only describes the accepting process if the request rides its session, + // so the session read here is the one the request is sent on. + const acceptingSession = currentSession().pipe( + Effect.tap((session) => + Effect.sync(() => { + acceptedAt = accepting?.session === session ? accepting.at : undefined; + }), + ), + ); yield* Deferred.await(desktopCommitStarting).pipe( Effect.andThen( environmentRegistry.stateChanges(target.environmentId).pipe( @@ -885,6 +908,28 @@ export function createServerEnvironmentAtoms( fromVersion, targetVersion, }); + // Only launcher updates carry an updateId. Read before the request so a ready from + // after it can never pose as the accepting process. + if (currentConfig?.environment.capabilities.serverSelfUpdate === "boot-service") { + accepting = yield* environmentRegistry + .followStream( + target.environmentId, + subscribeDynamicWithSession(WS_METHODS.subscribeServerLifecycle, () => + Effect.succeed({}), + ), + ) + .pipe( + Stream.filter( + (entry): entry is readonly [RpcSession, ServerLifecycleStreamReadyEvent] => + entry[1].type === "ready", + ), + Stream.map(([session, ready]) => ({ session, at: ready.payload.at })), + Stream.runHead, + Effect.timeoutOption(SERVER_UPDATE_ACCEPTING_READY_TIMEOUT), + Effect.map((head) => Option.getOrUndefined(Option.flatten(head))), + Effect.orElseSucceed(() => undefined), + ); + } const supportsProgress = currentConfig?.environment.capabilities.serverSelfUpdateProgress === true; @@ -897,7 +942,17 @@ export function createServerEnvironmentAtoms( const streamExit = yield* environmentRegistry .runStream( target.environmentId, - runStream(WS_METHODS.serverUpdateServerWithProgress, target.input), + Stream.unwrap( + acceptingSession.pipe( + Effect.map((session) => + runStreamInSession( + session, + WS_METHODS.serverUpdateServerWithProgress, + target.input, + ), + ), + ), + ), ) .pipe( Stream.runForEach((event) => @@ -937,7 +992,14 @@ export function createServerEnvironmentAtoms( : yield* Effect.gen(function* () { const selfUpdateMethod = currentConfig?.environment.capabilities.serverSelfUpdate; const exit = yield* environmentRegistry - .run(target.environmentId, request(WS_METHODS.serverUpdateServer, target.input)) + .run( + target.environmentId, + acceptingSession.pipe( + Effect.flatMap((session) => + requestInSession(session, WS_METHODS.serverUpdateServer, target.input), + ), + ), + ) .pipe(Effect.exit); if (Exit.isSuccess(exit)) { return exit.value; @@ -1014,7 +1076,7 @@ export function createServerEnvironmentAtoms( .pipe( Stream.filter( (event): event is ServerLifecycleStreamReadyEvent => - event.type === "ready" && matchesServerUpdateResumeEvent(result, event), + event.type === "ready" && matchesServerUpdateResumeEvent(result, event, acceptedAt), ), Stream.runHead, Effect.timeoutOption(SERVER_UPDATE_RESUME_TIMEOUT), From cbc68c9dc1de5d13fadc204188a431a61e157da4 Mon Sep 17 00:00:00 2001 From: voida6 Date: Wed, 11 Nov 2026 23:43:00 +1100 Subject: [PATCH 0009/1165] fix(web): Stop shows a pending state and repeat clicks no longer resume (#20809) Co-authored-by: Yannic Charlon <52761674+JustYannicc@users.noreply.github.com> Co-authored-by: Julius Marminge Co-authored-by: maria-rcks Co-authored-by: Claude Sonnet 5.5 --- apps/web/src/components/chat/ChatComposer.tsx | 3 + .../chat/ComposerFooterControls.tsx | 2 + .../ComposerPrimaryActions.browser.test.tsx | 144 ++++++++++++++++++ .../chat/ComposerPrimaryActions.tsx | 71 +++++++-- 4 files changed, 208 insertions(+), 12 deletions(-) create mode 100644 apps/web/src/components/chat/ComposerPrimaryActions.browser.test.tsx diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 8ccd172dc0d90..04385a5a6b873 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -3578,11 +3578,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) {composerControls}
({ + useEnvironmentIdentificationMode: () => "none", + useClientSettings: () => false, +})); +vi.mock("../SidebarStageBackdrop", () => ({ + StageBackdropButtonArt: () => null, + useSidebarStageBackdropVariant: () => null, +})); + +import { ComposerPrimaryActions } from "./ComposerPrimaryActions"; + +function setup() { + return { + compact: true, + canOperateThread: true, + pendingAction: null, + isRunning: true, + canInterrupt: true, + canResume: false, + isSendBusy: false, + sendDisabledReason: null, + isEnvironmentUnavailable: false, + isPreparingWorktree: false, + hasSendableContent: false, + onPreviousPendingQuestion: vi.fn(), + onInterrupt: vi.fn(), + onResume: vi.fn(), + onSubmitMessage: vi.fn(), + } satisfies ComponentProps; +} + +it.each([false, true])( + "keeps repeat clicks away from Resume and Send (draft: %s)", + async (draft) => { + const props = setup(); + const container = document.createElement("div"); + Object.assign(container.style, { display: "flex", justifyContent: "flex-end", width: "400px" }); + await using screen = await render(, { container }); + const stop = screen + .getByRole("button", { name: "Stop generation" }) + .element() as HTMLButtonElement; + const { x, y, width, height } = stop.getBoundingClientRect(); + const target = () => + document.elementFromPoint(x + width / 2, y + height / 2)?.closest("button"); + await act(() => stop.click()); + await act(() => + ( + screen.getByRole("button", { name: "Stopping", exact: true }).element() as HTMLButtonElement + ).click(), + ); + await expect + .element(screen.getByRole("button", { name: "Stopping", exact: true })) + .toBeDisabled(); + expect(props.onInterrupt).toHaveBeenCalledTimes(1); + await screen.rerender( + , + ); + await expect + .element(screen.getByRole("button", { name: "Stopped", exact: true })) + .toBeDisabled(); + expect(target()).toHaveAttribute("aria-label", "Stopped"); + await act(() => { + for (let i = 0; i < 20; i++) target()?.click(); + }); + expect(props.onResume).not.toHaveBeenCalled(); + expect(props.onSubmitMessage).not.toHaveBeenCalled(); + const action = screen + .getByRole("button", { name: draft ? "Submit message" : "Resume thread" }) + .element() as HTMLButtonElement; + await act(() => action.click()); + expect(draft ? props.onSubmitMessage : props.onResume).toHaveBeenCalledTimes(1); + await screen.rerender(); + await expect.element(screen.getByRole("button", { name: "Stop generation" })).toBeEnabled(); + }, +); + +it("allows retry after ten seconds and protects a late stop", async () => { + const props = setup(); + await using screen = await render(); + using _timers = fakeTimers(); + await act(() => + ( + screen.getByRole("button", { name: "Stop generation" }).element() as HTMLButtonElement + ).click(), + ); + await act(() => vi.advanceTimersByTime(9_999)); + expect(screen.getByRole("button", { name: "Stopping", exact: true }).element()).toBeDisabled(); + await act(() => vi.advanceTimersByTime(1)); + await act(() => + ( + screen.getByRole("button", { name: "Stop generation" }).element() as HTMLButtonElement + ).click(), + ); + await screen.rerender( + , + ); + expect(screen.getByRole("button", { name: "Stopped", exact: true }).element()).toBeDisabled(); + expect(props.onInterrupt).toHaveBeenCalledTimes(2); + expect(props.onResume).not.toHaveBeenCalled(); +}); + +it("keeps Stop pending across a permission failure", async () => { + const props = setup(); + await using screen = await render(); + await act(() => + ( + screen.getByRole("button", { name: "Stop generation" }).element() as HTMLButtonElement + ).click(), + ); + await screen.rerender( + , + ); + await screen.rerender(); + await expect + .element(screen.getByRole("button", { name: "Stopping", exact: true })) + .toBeDisabled(); + expect(props.onInterrupt).toHaveBeenCalledTimes(1); +}); + +it("keeps a pending stop when the run id clears and resets it for a different run", async () => { + const props = setup(); + await using screen = await render(); + await act(() => + ( + screen.getByRole("button", { name: "Stop generation" }).element() as HTMLButtonElement + ).click(), + ); + await screen.rerender(); + expect(screen.getByRole("button", { name: "Stopping", exact: true }).element()).toBeDisabled(); + await screen.rerender(); + expect(screen.getByRole("button", { name: "Stop generation" }).element()).toBeEnabled(); +}); diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.tsx index ec9ec10332b7a..8e28cc1dd6af6 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.tsx @@ -1,4 +1,11 @@ -import { memo, type MouseEventHandler, type PointerEventHandler, type ReactNode } from "react"; +import { + memo, + useEffect, + useState, + type MouseEventHandler, + type PointerEventHandler, + type ReactNode, +} from "react"; import { CheckIcon, ChevronLeftIcon, @@ -36,6 +43,7 @@ interface ComposerPrimaryActionsProps { pendingAction: PendingActionState | null; isRunning: boolean; canInterrupt: boolean; + runId?: string | null; followUpBehavior?: "queue" | "steer"; canSteer?: boolean; alternateShortcutLabel?: string | null; @@ -76,6 +84,8 @@ const formatPendingPrimaryActionLabel = (input: { return input.questionIndex > 0 ? "Submit answers" : "Submit answer"; }; +const STOP_PENDING_MAX_MS = 10_000; + const messageActionPillClassName = "inline-flex shrink-0 cursor-pointer items-center justify-center gap-1.5 whitespace-nowrap rounded-full bg-message-action font-medium text-base text-message-action-foreground shadow-xs shadow-message-action/24 outline-none hover:bg-message-action-hover focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-64 disabled:shadow-none sm:text-sm"; @@ -90,6 +100,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ pendingAction, isRunning, canInterrupt, + runId = null, followUpBehavior = "steer", canSteer = true, alternateShortcutLabel = null, @@ -110,6 +121,26 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ keepFullHistory = false, onToggleKeepFullHistory, }: ComposerPrimaryActionsProps) { + const [stopPhase, setStopPhase] = useState<"pending" | "timedOut" | "landed" | null>(null); + const [stopRunId, setStopRunId] = useState(runId); + if (runId !== null && stopRunId !== runId) { + setStopRunId(runId); + setStopPhase(null); + } else if (canOperateThread) { + // Losing permission also clears canInterrupt; it does not acknowledge the stop. + if ((stopPhase === "pending" || stopPhase === "timedOut") && !canInterrupt) + setStopPhase("landed"); + else if (stopPhase === "landed" && canInterrupt) setStopPhase(null); + } + const isStopDisabled = stopPhase === "pending" || stopPhase === "landed"; + const stopLabel = + stopPhase === "landed" ? "Stopped" : stopPhase === "pending" ? "Stopping" : "Stop generation"; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- timer that re-enables Stop if the interrupt never settles + useEffect(() => { + if (stopPhase !== "pending") return; + const timeout = setTimeout(() => setStopPhase("timedOut"), STOP_PENDING_MAX_MS); + return () => clearTimeout(timeout); + }, [stopPhase]); const pointerFocusProps = preserveComposerFocusOnPointerDown ? { onPointerDown: preventPointerFocus } : undefined; @@ -134,15 +165,17 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx index 0d10d6513cb17..3bd6d9c88fb8d 100644 --- a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx @@ -1234,7 +1234,7 @@ describe("MessagesTimeline automation prompts", () => { ], { supportsConversationRollback: true, - threadStartCheckpointReady: true, + threadStartCheckpointAvailable: true, turnDiffSummaries: [ { runId, diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 5d242f1efc20e..6b7d785d387e3 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -1026,7 +1026,7 @@ describe("deriveMessagesTimelineRows", () => { activeTurnStartedAt: null, turnDiffSummaries: [assistantTurnDiffSummary], supportsConversationRollback: true, - threadStartCheckpointReady: true, + threadStartCheckpointAvailable: true, }); const userRow = rows.find( diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 9e9d82a11fcbd..787fc18db675f 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -1527,7 +1527,7 @@ export function deriveMessagesTimelineRows(input: { activeTurnStartedAt?: string | null; turnDiffSummaries: ReadonlyArray; supportsConversationRollback: boolean; - threadStartCheckpointReady?: boolean; + threadStartCheckpointAvailable?: boolean; availableRevertTurnCounts?: ReadonlySet | undefined; worktreeSetup?: WorktreeSetupSnapshot | null; toolCallVisibility?: ToolCallVisibility; @@ -1558,7 +1558,7 @@ export function deriveMessagesTimelineRows(input: { ? deriveRevertTurnCountByUserMessageId({ timelineEntries: input.timelineEntries, checkpoints: input.turnDiffSummaries, - threadStartCheckpointReady: input.threadStartCheckpointReady === true, + threadStartCheckpointAvailable: input.threadStartCheckpointAvailable === true, unsettledRunId, availableRevertTurnCounts: input.availableRevertTurnCounts, }) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index f53012b2199e7..308d6bc03d6d9 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -280,7 +280,7 @@ interface MessagesTimelineProps { readonly runId: RunId; }) => Promise; supportsConversationRollback: boolean; - threadStartCheckpointReady?: boolean; + threadStartCheckpointAvailable?: boolean; availableRevertTurnCounts?: ReadonlySet | undefined; onRevertToTurnCount: (targetTurnCount: number, messageId: MessageId) => void; onUseArtifactTemplate?: (template: CodexArtifactTemplate) => void; @@ -379,7 +379,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ workflowOpening = null, onForkFromRun, supportsConversationRollback, - threadStartCheckpointReady = false, + threadStartCheckpointAvailable = false, availableRevertTurnCounts, onRevertToTurnCount, onUseArtifactTemplate = NOOP_USE_ARTIFACT_TEMPLATE, @@ -737,7 +737,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ activeTurnStartedAt, turnDiffSummaries, supportsConversationRollback, - threadStartCheckpointReady, + threadStartCheckpointAvailable, availableRevertTurnCounts, worktreeSetup, toolCallVisibility, diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index df3eb85c3db38..be6b8f33be266 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -226,7 +226,7 @@ describe("V2 session presentation", () => { const targets = deriveRevertTurnCountByUserMessageId({ timelineEntries, - threadStartCheckpointReady: true, + threadStartCheckpointAvailable: true, checkpoints: [ { runId, @@ -266,7 +266,7 @@ describe("V2 session presentation", () => { }, }; }); - const checkpoint = (ordinal: number, status: "ready" | "stale" = "ready") => ({ + const checkpoint = (ordinal: number, status: "ready" | "missing" | "stale" = "ready") => ({ runId: runId(ordinal), checkpointTurnCount: ordinal, checkpointRef: `checkpoint-${ordinal}` as never, @@ -278,13 +278,13 @@ describe("V2 session presentation", () => { const targets = ( ordinals: ReadonlyArray, checkpoints: ReadonlyArray>, - options: { threadStartCheckpointReady?: boolean; unsettledRunId?: RunId } = {}, + options: { threadStartCheckpointAvailable?: boolean; unsettledRunId?: RunId } = {}, ) => Object.fromEntries( deriveRevertTurnCountByUserMessageId({ timelineEntries: ordinals.flatMap(turn), checkpoints, - threadStartCheckpointReady: options.threadStartCheckpointReady ?? true, + threadStartCheckpointAvailable: options.threadStartCheckpointAvailable ?? true, unsettledRunId: options.unsettledRunId ?? null, }), ); @@ -297,6 +297,11 @@ describe("V2 session presentation", () => { "user-5": 4, }); expect(targets([2], [checkpoint(1, "stale")])).toEqual({ "user-2": 0 }); + // Without file snapshots, missing checkpoints still pin the conversation. + expect(targets([1, 2], [checkpoint(1, "missing"), checkpoint(2, "missing")])).toEqual({ + "user-1": 0, + "user-2": 1, + }); }); it("preserves a notification-only wake run while ignoring inherited and unrelated work", () => { @@ -360,7 +365,7 @@ describe("V2 session presentation", () => { checkpoint(7), checkpoint(8), ], - threadStartCheckpointReady: true, + threadStartCheckpointAvailable: true, }), ), ).toEqual({ "user-1": 0, "user-3": 2 }); @@ -371,7 +376,7 @@ describe("V2 session presentation", () => { expect(targets([1, 2], [checkpoint(1, "stale"), checkpoint(2)])).toEqual({ "user-1": 0 }); // Earlier history is paged out, so the first loaded run's predecessor is unknown. expect( - targets([4, 5], [checkpoint(4), checkpoint(5)], { threadStartCheckpointReady: false }), + targets([4, 5], [checkpoint(4), checkpoint(5)], { threadStartCheckpointAvailable: false }), ).toEqual({ "user-5": 4 }); expect(targets([1, 2], [checkpoint(1), checkpoint(2)], { unsettledRunId: runId(2) })).toEqual( { "user-1": 0 }, @@ -384,7 +389,7 @@ describe("V2 session presentation", () => { deriveRevertTurnCountByUserMessageId({ timelineEntries: [1, 2, 3].flatMap(turn), checkpoints: [checkpoint(1), checkpoint(2), checkpoint(3)], - threadStartCheckpointReady: true, + threadStartCheckpointAvailable: true, availableRevertTurnCounts: new Set([0, 1]), }), ), @@ -404,7 +409,7 @@ describe("V2 session presentation", () => { deriveRevertTurnCountByUserMessageId({ timelineEntries: [...inheritedEntries, ...turn(2)], checkpoints: [{ ...checkpoint(7), runId: fixture.historyRunId }, checkpoint(2)], - threadStartCheckpointReady: true, + threadStartCheckpointAvailable: true, }), ), ).toEqual({ "user-2": 0 }); diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 4b0c18b8411a3..904f6510e6e53 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -992,14 +992,14 @@ export function deriveTimelineEntriesFromVisibleTurnItemsWithState( export function deriveRevertTurnCountByUserMessageId(input: { readonly timelineEntries: ReadonlyArray; readonly checkpoints: ReadonlyArray; - readonly threadStartCheckpointReady: boolean; + readonly threadStartCheckpointAvailable: boolean; readonly availableRevertTurnCounts?: ReadonlySet | undefined; readonly unsettledRunId?: RunId | null; }): Map { - const readyTurnCountByRunId = new Map(); + const turnCountByRunId = new Map(); for (const checkpoint of input.checkpoints) { - if (checkpoint.status === "ready") { - readyTurnCountByRunId.set(checkpoint.runId, checkpoint.checkpointTurnCount); + if (isRewindableCheckpoint(checkpoint)) { + turnCountByRunId.set(checkpoint.runId, checkpoint.checkpointTurnCount); } } const targetByRunId = new Map(); @@ -1022,10 +1022,10 @@ export function deriveRevertTurnCountByUserMessageId(input: { targetByRunId.set( runId, previousRunId === null - ? input.threadStartCheckpointReady + ? input.threadStartCheckpointAvailable ? 0 : undefined - : readyTurnCountByRunId.get(previousRunId), + : turnCountByRunId.get(previousRunId), ); previousRunId = runId; } @@ -1043,13 +1043,19 @@ export function deriveRevertTurnCountByUserMessageId(input: { return byUserMessageId; } +// A missing snapshot (no git, or a side chat) still pins a conversation position; only +// restoring files needs a ready one. +function isRewindableCheckpoint(checkpoint: Pick) { + return checkpoint.status === "ready" || checkpoint.status === "missing"; +} + export function findRevertCheckpoint( checkpoints: ReadonlyArray, turnCount: number, ): OrchestrationV2Checkpoint | undefined { return checkpoints.findLast( (checkpoint) => - checkpoint.status === "ready" && + isRewindableCheckpoint(checkpoint) && (turnCount === 0 ? checkpoint.ordinalWithinScope === 0 && checkpoint.appRunOrdinal === null : checkpoint.appRunOrdinal === turnCount), diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index f813ece95dc45..f4ece3888ec98 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -941,7 +941,12 @@ export const revertThreadCheckpoint = Effect.fn("EnvironmentCommands.revertThrea ? candidate.ordinalWithinScope === 0 && candidate.appRunOrdinal === null : candidate.appRunOrdinal === input.turnCount, ); - if (checkpoint === undefined || checkpoint.status !== "ready") { + if ( + checkpoint === undefined || + // Missing snapshots support conversation rewind only. + (checkpoint.status !== "ready" && + !(input.restoreFiles === false && checkpoint.status === "missing")) + ) { const target = input.checkpointId === undefined ? `run ordinal ${input.turnCount ?? "unknown"}` From f5fe0790e78127372eb746d6513e46f68302ddb7 Mon Sep 17 00:00:00 2001 From: spoukyii <61633921+spoukyii@users.noreply.github.com> Date: Wed, 11 Nov 2026 11:18:33 -0800 Subject: [PATCH 0036/1165] fix(web): Office previews say "1 page", "1 sheet", "1 slide" and "1 row" (#31868) --- .../components/files/AttachmentFilePreview.browser.test.tsx | 2 +- apps/web/src/components/files/OfficeViewer.tsx | 5 +++-- apps/web/src/components/files/office/Presentation.tsx | 2 +- apps/web/src/components/files/office/Spreadsheet.tsx | 2 +- apps/web/src/components/files/office/WordDocument.tsx | 6 ++++-- 5 files changed, 10 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx index 2835e4ab678b3..6cfd5a8758540 100644 --- a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx +++ b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx @@ -2762,7 +2762,7 @@ it.each(["rtf", "epub", "ods"] as const)( expect(screen.container.textContent).toContain("North"); expect(screen.container.textContent).toContain("150"); expect(screen.container.textContent).not.toContain("private note"); - expect(screen.container.textContent).toContain("1 rows · 2 columns"); + expect(screen.container.textContent).toContain("1 row · 2 columns"); expect(screen.container.textContent).not.toContain("Limited to 500"); } else { const frame = screen.container.querySelector("iframe"); diff --git a/apps/web/src/components/files/OfficeViewer.tsx b/apps/web/src/components/files/OfficeViewer.tsx index 1af5f122a83af..76298b545d66b 100644 --- a/apps/web/src/components/files/OfficeViewer.tsx +++ b/apps/web/src/components/files/OfficeViewer.tsx @@ -250,8 +250,9 @@ function ExtendedOfficeViewer(props: { className="flex min-h-10 shrink-0 flex-wrap items-center gap-x-1 border-b border-border/60 px-3 py-2 text-xs text-muted-foreground tabular-nums" role="status" > - Sheet {page + 1} of {count} · {sheet.rowCount.toLocaleString()} rows ·{" "} - {sheet.columns.toLocaleString()} columns + Sheet {page + 1} of {count} · {sheet.rowCount.toLocaleString()} row + {sheet.rowCount === 1 ? "" : "s"} · {sheet.columns.toLocaleString()} column + {sheet.columns === 1 ? "" : "s"} {fileSize !== undefined ? ` · ${fileSize === 0 ? "0 bytes" : formatAttachmentSize(fileSize)}` : ""} diff --git a/apps/web/src/components/files/office/Presentation.tsx b/apps/web/src/components/files/office/Presentation.tsx index 891e40f91ad56..db28aeb3ae2df 100644 --- a/apps/web/src/components/files/office/Presentation.tsx +++ b/apps/web/src/components/files/office/Presentation.tsx @@ -83,7 +83,7 @@ export default function Presentation(props: { if (slides.length === 0 || slides.length > 500) throw new Error("Preview limited to presentations with 1 to 500 slides."); setOpened({ slides, paint }); - onDetails(`${slides.length} slides`); + onDetails(`${slides.length} slide${slides.length === 1 ? "" : "s"}`); }) .catch(fail); return () => { diff --git a/apps/web/src/components/files/office/Spreadsheet.tsx b/apps/web/src/components/files/office/Spreadsheet.tsx index 7bea259b9f07b..ed2274025fbf2 100644 --- a/apps/web/src/components/files/office/Spreadsheet.tsx +++ b/apps/web/src/components/files/office/Spreadsheet.tsx @@ -70,7 +70,7 @@ export default function Spreadsheet(props: { .then((value) => { if (!value) throw new Error("This workbook has no visible sheets."); setOpened({ ...value, engine }); - onDetails(`${value.tabs.length} sheets`); + onDetails(`${value.tabs.length} sheet${value.tabs.length === 1 ? "" : "s"}`); }) .catch(fail); return () => { diff --git a/apps/web/src/components/files/office/WordDocument.tsx b/apps/web/src/components/files/office/WordDocument.tsx index 39ec9e982d215..f7a0bb0f50d1b 100644 --- a/apps/web/src/components/files/office/WordDocument.tsx +++ b/apps/web/src/components/files/office/WordDocument.tsx @@ -63,7 +63,9 @@ export default function WordDocument(props: { .then(async (first) => { if (controller.signal.aborted) return; setOpened({ pages: first.pages, paint, engine }); - onDetails(`${first.pages.length}${first.loading ? "+" : ""} pages`); + onDetails( + `${first.pages.length}${first.loading ? "+" : ""} page${first.pages.length === 1 && !first.loading ? "" : "s"}`, + ); if (!first.loading) return; await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); while (!controller.signal.aborted) { @@ -71,7 +73,7 @@ export default function WordDocument(props: { if (!step.done) continue; if (step.pages.length > 1000) throw new Error("Preview limited to 1,000 pages."); setOpened({ pages: step.pages, paint, engine }); - onDetails(`${step.pages.length} pages`); + onDetails(`${step.pages.length} page${step.pages.length === 1 ? "" : "s"}`); break; } }) From 5ee7f6fee8d1c52c17eb4abb6eec58985a1ae0fd Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 11:18:40 -0800 Subject: [PATCH 0037/1165] fix(chat): show late async answers in their own runs (#23249) Co-authored-by: Claude Opus 5.5 --- apps/mobile/src/lib/threadActivity.test.ts | 42 ++++++++++ apps/mobile/src/lib/threadActivity.ts | 27 +++++-- .../src/orchestration-v2/ThreadFind.test.ts | 44 +++++++++++ .../server/src/orchestration-v2/ThreadFind.ts | 10 ++- apps/web/src/session-logic.test.ts | 76 +++++++++++++++++++ apps/web/src/session-logic.ts | 16 +++- 6 files changed, 201 insertions(+), 14 deletions(-) diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index f5b4d0232a902..9205e26835dca 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -393,6 +393,48 @@ describe("buildThreadFeed", () => { expect(buildThreadFeed([projected(reply, 0)])[0]?.type).toBe("message"); }); + it.each([false, true])( + "shows an async answer that started a later run where that run begins (cache: %s)", + (cached) => { + const requestId = RuntimeRequestId.make("late-question"); + const inRun = (item: OrchestrationV2TurnItem, run: string, position: number) => + projected({ ...item, runId: RunId.make(run) }, position); + const message = (id: string, role: "user" | "assistant"): OrchestrationV2TurnItem => ({ + ...(role === "user" ? userMessage() : assistantMessage()), + id: TurnItemId.make(id), + messageId: MessageId.make(id), + }); + const answerMessageId = `async-answer:${requestId}`; + const settledPrefix = [ + inRun( + { + ...base("late-question", "2026-06-20T00:00:01.000Z", 0), + type: "user_input_request", + requestId, + questions: [], + questionAnswer: { requestId, answers: { color: "Blue" }, attachmentsByQuestionId: {} }, + }, + "run-8", + 0, + ), + inRun(message("queued", "user"), "run-9", 1), + inRun(message("reply-9", "assistant"), "run-9", 2), + ]; + const rows = [ + ...settledPrefix, + inRun(message(answerMessageId, "user"), "run-10", 3), + inRun(message("reply-10", "assistant"), "run-10", 4), + ]; + const cache = cached ? createThreadFeedCache() : undefined; + if (cache) buildThreadFeed(settledPrefix, { cache }); + expect( + buildThreadFeed(rows, { cache }).flatMap((entry) => + entry.type === "message" ? [entry.id] : [], + ), + ).toEqual(["queued", "reply-9", answerMessageId, "reply-10"]); + }, + ); + it.each([false, true])( "keeps answered questions outside settled work (attachments only: %s)", (attachmentsOnly) => { diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index cd2313b6dccb1..251a0e1186ca8 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -2114,7 +2114,7 @@ interface ThreadFeedBuild { readonly attempts: ReadonlyArray | undefined; readonly nodes: ReadonlyArray | undefined; readonly checkpointSummaries: ReadonlyArray | undefined; - readonly foldedAnswerMessageIds: ReadonlySet; + readonly foldedAnswerRunIdByMessageId: ReadonlyMap; readonly delegatedChildrenByRun: ReadonlyMap>; readonly feed: ReadonlyArray; /** Grouping is empty before `row`: the rows before it produced `feed.slice(0, feedLength)`. */ @@ -2164,6 +2164,14 @@ function checkpointSummariesEqual( }); } +function mapsEqual(left: ReadonlyMap, right: ReadonlyMap): boolean { + if (left.size !== right.size) return false; + for (const [key, value] of left) { + if (!right.has(key) || right.get(key) !== value) return false; + } + return true; +} + function setMapsEqual( left: ReadonlyMap>, right: ReadonlyMap>, @@ -2217,11 +2225,12 @@ export function buildThreadFeed( }; // Thread-wide facts that change rows anywhere in the feed. A cached prefix // is reused only while these match the build that produced it. - const foldedAnswerMessageIds = new Set(); + // An answer that started a later run stays a normal message at that run's position. + const foldedAnswerRunIdByMessageId = new Map(); const delegatedChildrenByRun = new Map>(); for (const { item } of visibleTurnItems) { if (item.type === "user_input_request" && item.questionAnswer) { - foldedAnswerMessageIds.add(`async-answer:${item.questionAnswer.requestId}`); + foldedAnswerRunIdByMessageId.set(`async-answer:${item.questionAnswer.requestId}`, item.runId); } else if (item.type === "subagent" && item.origin === "app_owned" && item.runId !== null) { const children = delegatedChildrenByRun.get(item.runId) ?? new Set(); children.add(item.subagentId); @@ -2238,7 +2247,7 @@ export function buildThreadFeed( previous.attempts === options?.attempts && previous.nodes === options?.nodes && checkpointSummariesEqual(previous.checkpointSummaries, options?.checkpointSummaries) && - setsEqual(previous.foldedAnswerMessageIds, foldedAnswerMessageIds) && + mapsEqual(previous.foldedAnswerRunIdByMessageId, foldedAnswerRunIdByMessageId) && setMapsEqual(previous.delegatedChildrenByRun, delegatedChildrenByRun) ) { const firstChanged = firstChangedIndex(previous.rows, visibleTurnItems); @@ -2276,7 +2285,11 @@ export function buildThreadFeed( item.status === "pending" ) continue; - if (item.type === "user_message" && foldedAnswerMessageIds.has(item.messageId)) continue; + if ( + item.type === "user_message" && + foldedAnswerRunIdByMessageId.get(item.messageId) === item.runId + ) + continue; if ( item.type === "run_interrupt_request" || (item.type === "run_interrupt_result" && @@ -2377,7 +2390,7 @@ export function buildThreadFeed( entryRows.push(index); } const retainedMessageIds = new Set([ - ...foldedAnswerMessageIds, + ...foldedAnswerRunIdByMessageId.keys(), ...entries.flatMap((entry) => (entry.type === "message" ? [entry.id] : [])), ]); const appendLocalMessage = (message: LocalThreadMessage): RawThreadFeedEntry => { @@ -2446,7 +2459,7 @@ export function buildThreadFeed( attempts: options?.attempts, nodes: options?.nodes, checkpointSummaries: options?.checkpointSummaries, - foldedAnswerMessageIds, + foldedAnswerRunIdByMessageId, delegatedChildrenByRun, feed, restarts, diff --git a/apps/server/src/orchestration-v2/ThreadFind.test.ts b/apps/server/src/orchestration-v2/ThreadFind.test.ts index 9366fdf193dbf..c3464eecea6f9 100644 --- a/apps/server/src/orchestration-v2/ThreadFind.test.ts +++ b/apps/server/src/orchestration-v2/ThreadFind.test.ts @@ -563,6 +563,50 @@ describe("V2 thread find", () => { }).pipe(Effect.provide(layerTest)), ); + it.effect("finds an async answer that started a later run", () => + Effect.gen(function* () { + yield* createProject(projectId); + const projection = yield* ProjectionStore.ProjectionStoreV2; + const queuedRun = RunId.make("run:queued"); + const answerRun = RunId.make("run:answer"); + yield* commit([ + thread(threadId, projectId), + run(), + run(threadId, queuedRun, 2), + run(threadId, answerRun, 3), + ]); + const requestId = RuntimeRequestId.make("late"); + const question: OrchestrationV2TurnItem = { + ...item("question", 1, ""), + type: "user_input_request", + requestId, + questions: [], + questionAnswer: { requestId, answers: {}, attachmentsByQuestionId: {} }, + }; + const items = [ + question, + { ...item("queued", 2, "queued follow-up", "user_message"), runId: queuedRun }, + { ...item(`async-answer:${requestId}`, 3, "needle", "user_message"), runId: answerRun }, + ]; + yield* commit( + items.map((payload) => ({ + id: EventId.make(`${payload.id}:initial`), + threadId, + occurredAt: at(1), + type: "turn-item.updated" as const, + payload, + })), + ); + const result = yield* projection.searchThread({ threadId, query: "needle" }); + assert.equal(result.totalMatches, 1); + assert.deepEqual(result.match, { + entryId: `async-answer:${requestId}`, + runId: answerRun, + occurrence: 0, + }); + }).pipe(Effect.provide(layerTest)), + ); + it.effect( "searches inherited fork history through the fork boundary, using source item identities", () => diff --git a/apps/server/src/orchestration-v2/ThreadFind.ts b/apps/server/src/orchestration-v2/ThreadFind.ts index c6a2c012f758c..44099f4ebbc10 100644 --- a/apps/server/src/orchestration-v2/ThreadFind.ts +++ b/apps/server/src/orchestration-v2/ThreadFind.ts @@ -32,6 +32,7 @@ interface FindRow { interface FindPayload extends FindRow { readonly payload: string; readonly entryId: string; + readonly runId: OrchestrationV2TurnItem["runId"]; readonly type: OrchestrationV2TurnItem["type"]; } interface FindDocument extends FindRow { @@ -213,10 +214,12 @@ export const makeThreadFind = Effect.fn("makeThreadFind")(function* ( turn_item_id: string; payload_json: string; entry_id: string; + run_id: OrchestrationV2TurnItem["runId"]; item_type: OrchestrationV2TurnItem["type"]; }>` SELECT item.thread_id, item.turn_item_id, item.payload_json, COALESCE(json_extract(item.payload_json, '$.messageId'), item.turn_item_id) AS entry_id, + json_extract(item.payload_json, '$.runId') AS run_id, json_extract(item.payload_json, '$.type') AS item_type FROM orchestration_v2_projection_turn_items AS item JOIN json_each(${sources}) AS wanted @@ -237,6 +240,7 @@ export const makeThreadFind = Effect.fn("makeThreadFind")(function* ( ...row, payload: payload.payload_json, entryId: payload.entry_id, + runId: payload.run_id, type: payload.item_type, }); }); @@ -343,17 +347,17 @@ export const makeThreadFind = Effect.fn("makeThreadFind")(function* ( ), ); // Answers rendered inside question cards must not appear as separate matches. - const foldedAnswers = new Set(); + const foldedAnswerRunIds = new Map(); for (const payload of payloads) { if (payload.type !== "user_input_request") continue; const { item } = yield* decode(payload); if (item.type === "user_input_request" && item.questionAnswer) - foldedAnswers.add(`async-answer:${item.questionAnswer.requestId}`); + foldedAnswerRunIds.set(`async-answer:${item.questionAnswer.requestId}`, item.runId); } const rows = payloads.filter( (row) => row.type !== "user_input_request" && - !(row.type === "user_message" && foldedAnswers.has(row.entryId)), + !(row.type === "user_message" && foldedAnswerRunIds.get(row.entryId) === row.runId), ); const documents = new Map(); const count = Effect.fnUntraced(function* (row: FindPayload) { diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index be6b8f33be266..013e38c701ca1 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -1123,6 +1123,82 @@ describe("native provider presentation in the v2 timeline", () => { ).toBe("message"); }); + it("shows a late async answer that started a later run where that run begins", () => { + const requestId = RuntimeRequestId.make("late-question"); + const inRun = (item: OrchestrationV2TurnItem, runId: string, ordinal: number) => + visible({ ...item, runId: RunId.make(runId), ordinal }); + const userMessage = ( + id: string, + text: string, + ): Extract => ({ + ...base, + id: TurnItemId.make(id), + type: "user_message", + messageId: MessageId.make(id), + inputIntent: "queued_turn", + text, + createdBy: "user", + creationSource: "web", + attachments: [], + }); + const assistantMessage = (id: string, text: string): OrchestrationV2TurnItem => ({ + ...base, + id: TurnItemId.make(id), + type: "assistant_message", + messageId: MessageId.make(id), + text, + streaming: false, + }); + const question = inRun( + { + ...base, + id: TurnItemId.make("late-question"), + type: "user_input_request", + requestId, + questions: [], + questionAnswer: { requestId, answers: { color: "Blue" }, attachmentsByQuestionId: {} }, + }, + "run-8", + 8_000_070, + ); + const answerMessageId = MessageId.make(`async-answer:${requestId}`); + const settledPrefix = [ + question, + inRun(userMessage("queued", "Queued follow-up"), "run-9", 9_000_001), + inRun(assistantMessage("reply-9", "RUN 9 REPLY"), "run-9", 9_000_002), + ]; + const lateRun = [ + inRun( + { + ...userMessage(answerMessageId, "Which color?\nBlue"), + inputIntent: "steer", + creationSource: "server", + }, + "run-10", + 10_000_001, + ), + inRun(assistantMessage("reply-10", "RUN 10 ACKNOWLEDGED BLUE"), "run-10", 10_000_002), + ]; + const input = { visibleTurnItems: [...settledPrefix, ...lateRun], optimisticMessages: [] }; + const messageIds = (entries: ReadonlyArray) => + entries.flatMap((entry) => (entry.kind === "message" ? [entry.message.id] : [])); + const expectedMessageIds = ["queued", "reply-9", answerMessageId, "reply-10"]; + + expect + .soft(messageIds(deriveTimelineEntriesFromVisibleTurnItems(input)), "full projection") + .toEqual(expectedMessageIds); + const previous = deriveTimelineEntriesFromVisibleTurnItemsWithState({ + ...input, + visibleTurnItems: settledPrefix, + }); + expect + .soft( + messageIds(deriveTimelineEntriesFromVisibleTurnItemsWithState(input, previous).entries), + "incremental projection", + ) + .toEqual(expectedMessageIds); + }); + it("excludes checkpoint-only work from the timeline", () => { const message: OrchestrationV2TurnItem = { ...base, diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 904f6510e6e53..67d88025c0e35 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -527,10 +527,11 @@ export function deriveTimelineEntriesFromVisibleTurnItems( return undefined; }; - const foldedAnswerMessageIds = new Set( + // An answer that started a later run stays a normal message at that run's position. + const foldedAnswerRunIdByMessageId = new Map( input.visibleTurnItems.flatMap(({ item }) => item.type === "user_input_request" && item.questionAnswer - ? [`async-answer:${item.questionAnswer.requestId}`] + ? [[`async-answer:${item.questionAnswer.requestId}`, item.runId] as const] : [], ), ); @@ -544,7 +545,11 @@ export function deriveTimelineEntriesFromVisibleTurnItems( item.status === "pending" ) continue; - if (item.type === "user_message" && foldedAnswerMessageIds.has(item.messageId)) continue; + if ( + item.type === "user_message" && + foldedAnswerRunIdByMessageId.get(item.messageId) === item.runId + ) + continue; const createdAt = projectedItemCreatedAt(row); const attempt = resolveAttempt(item); const attemptMetadata = attempt === undefined ? {} : { attempt }; @@ -682,7 +687,10 @@ export function deriveTimelineEntriesFromVisibleTurnItems( }); } - const retainedMessageIds = new Set([...committedMessageIds, ...foldedAnswerMessageIds]); + const retainedMessageIds = new Set([ + ...committedMessageIds, + ...foldedAnswerRunIdByMessageId.keys(), + ]); for (const message of input.anchoredMessages ?? []) { if (retainedMessageIds.has(message.id)) continue; retainedMessageIds.add(message.id); From bc3eb6d2b2019ad4a77ae8c8efcdde8c41269e07 Mon Sep 17 00:00:00 2001 From: Moses <114490965+Natur7a@users.noreply.github.com> Date: Thu, 12 Nov 2026 02:19:13 +0700 Subject: [PATCH 0038/1165] fix(web): fix japanese text in file viewer not rendering (#29051) --- apps/web/package.json | 1 + .../src/components/files/office/fonts-cjk.ts | 4 -- .../files/office/fonts.browser.test.ts | 18 ++++++ apps/web/src/components/files/office/fonts.ts | 62 +++---------------- .../components/files/office/slide-fonts.ts | 5 +- apps/web/vite.config.ts | 6 -- pnpm-lock.yaml | 18 ++++-- third-party-licenses.config.json | 15 +++++ 8 files changed, 61 insertions(+), 68 deletions(-) delete mode 100644 apps/web/src/components/files/office/fonts-cjk.ts create mode 100644 apps/web/src/components/files/office/fonts.browser.test.ts diff --git a/apps/web/package.json b/apps/web/package.json index e353e21eb8df9..fc268cc492d28 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -15,6 +15,7 @@ "@base-ui/react": "^1.9.0", "@betteroffice/docx": "0.4.3", "@betteroffice/fonts": "0.4.0", + "@betteroffice/fonts-cjk": "0.4.0", "@betteroffice/pptx": "0.2.0", "@betteroffice/xlsx": "0.3.0", "@clerk/clerk-js": "catalog:", diff --git a/apps/web/src/components/files/office/fonts-cjk.ts b/apps/web/src/components/files/office/fonts-cjk.ts deleted file mode 100644 index d593dbb2bf3eb..0000000000000 --- a/apps/web/src/components/files/office/fonts-cjk.ts +++ /dev/null @@ -1,4 +0,0 @@ -// Adapted from opencode (MIT). Copyright (c) 2025 opencode. See LICENSE.opencode. -// `@betteroffice/fonts` imports its optional Chinese, Japanese and Korean add-on on demand. The add-on is about 33 MB, -// so the app aliases it to this module, which offers no faces: those scripts fall back to the bundled Latin faces. -export const CJK_FONT_ASSET_URLS = undefined; diff --git a/apps/web/src/components/files/office/fonts.browser.test.ts b/apps/web/src/components/files/office/fonts.browser.test.ts new file mode 100644 index 0000000000000..b9f67db4d7b6f --- /dev/null +++ b/apps/web/src/components/files/office/fonts.browser.test.ts @@ -0,0 +1,18 @@ +import { BUNDLED_FONTS } from "@betteroffice/fonts"; +import { describe, expect, it } from "vite-plus/test"; +import { wordFonts } from "./fonts"; + +const notoSansJp = BUNDLED_FONTS.find((face) => face.file === "NotoSansJP-Regular.otf")!; +const fonts = wordFonts(); + +describe("wordFonts", () => { + it.each([ + ["Yu Gothic", fonts.resolve("Yu Gothic", false, false)], + ["MS Gothic", fonts.resolve("MS Gothic", false, false)], + ["the Japanese fallback", fonts.resolveScriptFallback?.("cjk-jp", false, false)], + ])("draws %s with Noto Sans JP", async (_, load) => { + const bytes = await load?.(); + + expect(bytes?.byteLength).toBe(notoSansJp.byteLength); + }); +}); diff --git a/apps/web/src/components/files/office/fonts.ts b/apps/web/src/components/files/office/fonts.ts index cdac25d4bbe14..410b725103cf1 100644 --- a/apps/web/src/components/files/office/fonts.ts +++ b/apps/web/src/components/files/office/fonts.ts @@ -1,5 +1,4 @@ // Adapted from opencode (MIT). Copyright (c) 2025 opencode. See LICENSE.opencode. -import { pipe } from "effect/Function"; import type { BundledFontProvider } from "@betteroffice/docx/layout"; import { loadBundledFontBytes, @@ -8,75 +7,34 @@ import { resolveMetricCompatFace, resolveScriptFallbackFace, type BundledFontFace, - type BundledFontScript, } from "@betteroffice/fonts"; -// Every face loads from the app's own files, so no font request leaves the machine. The Chinese, Japanese and Korean -// faces are an add-on the app does not ship (see `fonts-cjk.ts`), yet the font package still maps East Asian families -// such as Microsoft YaHei or Yu Gothic Medium to them, so every lookup passes through `shippedFace`. -function shipped(script: BundledFontScript) { - return !script.startsWith("cjk-"); -} +// Every face loads from the app's own files, so no font request leaves the machine. The engines draw only with the +// faces they are handed, never the system's, so CJK text needs the `@betteroffice/fonts-cjk` faces to show at all. /** The faces Word substitutes for a document's fonts: metric-compatible ones first, then Word's own fallbacks. */ export function wordFonts(): BundledFontProvider { const loader = (face: BundledFontFace | undefined) => (face ? () => loadFace(face) : undefined); return { - resolve: (family, bold, italic) => - pipe(resolveMetricCompatFace(family, bold, italic), shippedFace, loader), - resolveFamily: (family, bold, italic) => - pipe(resolveBundledFamilyFace(family, bold, italic), shippedFace, loader), - resolveScriptFallback: (script, bold, italic) => loader(scriptFace(script, bold, italic)), + resolve: (family, bold, italic) => loader(resolveMetricCompatFace(family, bold, italic)), + resolveFamily: (family, bold, italic) => loader(resolveBundledFamilyFace(family, bold, italic)), + resolveScriptFallback: (script, bold, italic) => + loader(resolveScriptFallbackFace(script, bold, italic)), resolveLastResort: (family, bold, italic) => () => - loadFace(lastResortFace(family, bold, italic, "word")), + loadFace(resolveLastResortFace(family, bold, italic, "word")), }; } /** The face PowerPoint draws a family with: its metric-compatible or bundled face, else PowerPoint's substitute. */ export function presentationFace(family: string, bold: boolean, italic: boolean): BundledFontFace { return ( - shippedFace(resolveMetricCompatFace(family, bold, italic)) ?? - shippedFace(resolveBundledFamilyFace(family, bold, italic)) ?? - lastResortFace(family, bold, italic, "powerpoint") - ); -} - -function shippedFace(face: BundledFontFace | undefined) { - return face && (!face.script || shipped(face.script)) ? face : undefined; -} - -/** - * The substitute an application picks for a family it lacks. Only a weight variant of an East Asian family, such as - * Yu Mincho Light, resolves to a face the app does not ship; its name without the weight still tells serif from sans. - */ -function lastResortFace( - family: string, - bold: boolean, - italic: boolean, - office: "word" | "powerpoint", -) { - const face = resolveLastResortFace(family, bold, italic, office); - - if (shippedFace(face)) return face; - - const base = family - .trim() - .split(/[\s-]+/) - .slice(0, -1) - .join(" "); - - return ( - shippedFace(resolveLastResortFace(base, bold, italic, office)) ?? - resolveLastResortFace("", bold, italic, office) + resolveMetricCompatFace(family, bold, italic) ?? + resolveBundledFamilyFace(family, bold, italic) ?? + resolveLastResortFace(family, bold, italic, "powerpoint") ); } -/** The face that covers a script the Latin faces lack, or undefined when the app ships none. */ -export function scriptFace(script: BundledFontScript, bold: boolean, italic: boolean) { - return shipped(script) ? resolveScriptFallbackFace(script, bold, italic) : undefined; -} - /** * A bundled face's bytes. The font package caches them per worker, so a face loads once per open file; the browser's * caches, which every worker shares, serve the files to the next. diff --git a/apps/web/src/components/files/office/slide-fonts.ts b/apps/web/src/components/files/office/slide-fonts.ts index e4a8391a69026..37afc1e9b2fc1 100644 --- a/apps/web/src/components/files/office/slide-fonts.ts +++ b/apps/web/src/components/files/office/slide-fonts.ts @@ -5,7 +5,8 @@ import type { SlideDisplayList, SlidePrimitive, } from "@betteroffice/pptx"; -import { loadFace, presentationFace, scriptFace } from "./fonts"; +import { resolveScriptFallbackFace } from "@betteroffice/fonts"; +import { loadFace, presentationFace } from "./fonts"; import type { DeckText, FallbackScript, FontStyle } from "./slide-text"; /** A face the deck asks for: a family it names, or the fallback for a script the Latin faces lack. */ @@ -32,7 +33,7 @@ export function slideFonts(deck: PresentationHandle, fonts: FontFaceSet) { /** Loads a face's bytes and browser face; the returned function registers both, so callers choose the order. */ const prepare = async (want: Want, requested: FontStyle) => { const source = want.script - ? scriptFace(want.script, requested.bold, requested.italic) + ? resolveScriptFallbackFace(want.script, requested.bold, requested.italic) : presentationFace(want.family, requested.bold, requested.italic); if (!source) return; diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 217294de3762c..8a627a540a156 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -323,12 +323,6 @@ export default defineConfig(() => { }, resolve: { tsconfigPaths: true, - alias: { - "@betteroffice/fonts-cjk": new URL( - "./src/components/files/office/fonts-cjk.ts", - import.meta.url, - ).pathname, - }, dedupe: ["react", "react-dom"], }, experimental: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6451f3f97cb29..e4b4fd80906a8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1013,8 +1013,11 @@ importers: version: 1.9.0(@date-fns/tz@1.5.0)(@types/react@19.3.0)(date-fns@4.4.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@betteroffice/docx': specifier: 0.4.3 - version: 0.4.3(@betteroffice/fonts@0.4.0) + version: 0.4.3(@betteroffice/fonts@0.4.0(@betteroffice/fonts-cjk@0.4.0)) '@betteroffice/fonts': + specifier: 0.4.0 + version: 0.4.0(@betteroffice/fonts-cjk@0.4.0) + '@betteroffice/fonts-cjk': specifier: 0.4.0 version: 0.4.0 '@betteroffice/pptx': @@ -3252,6 +3255,9 @@ packages: '@betteroffice/fonts': optional: true + '@betteroffice/fonts-cjk@0.4.0': + resolution: {integrity: sha512-gLLqoIdVA02H5GQs+TpGrHWzxYEbpxj8a1Kzkf3cGQUea+cCpzoCuO38sV0zSDmBbub3gRldzf0N5fPpKCsS5g==} + '@betteroffice/fonts@0.4.0': resolution: {integrity: sha512-x1BVwYDAgY5R6TWI4t9NLguSiAEbH5GItN2ZlpBIFoRXTIJgjBsXd6sUxPGpexXOyY6KdhRjG8XvHjyAYxEGCw==} peerDependencies: @@ -13456,11 +13462,15 @@ snapshots: optionalDependencies: '@types/react': 19.3.0 - '@betteroffice/docx@0.4.3(@betteroffice/fonts@0.4.0)': + '@betteroffice/docx@0.4.3(@betteroffice/fonts@0.4.0(@betteroffice/fonts-cjk@0.4.0))': optionalDependencies: - '@betteroffice/fonts': 0.4.0 + '@betteroffice/fonts': 0.4.0(@betteroffice/fonts-cjk@0.4.0) - '@betteroffice/fonts@0.4.0': {} + '@betteroffice/fonts-cjk@0.4.0': {} + + '@betteroffice/fonts@0.4.0(@betteroffice/fonts-cjk@0.4.0)': + optionalDependencies: + '@betteroffice/fonts-cjk': 0.4.0 '@betteroffice/pptx@0.2.0': {} diff --git a/third-party-licenses.config.json b/third-party-licenses.config.json index 0ce031a30f359..c11d58d8ae4fd 100644 --- a/third-party-licenses.config.json +++ b/third-party-licenses.config.json @@ -330,6 +330,21 @@ } ] }, + { + "name": "@betteroffice/fonts-cjk", + "version": "0.4.0", + "license": "Apache-2.0 AND OFL-1.1", + "generatedNotices": [ + { "licenseId": "Apache-2.0" }, + { + "licenseId": "OFL-1.1", + "copyrights": [ + "Copyright 2014-2021 Adobe (https://adobe.com/), with Reserved Font Name 'Source'", + "Copyright 2017-2024 Adobe (https://adobe.com/), with Reserved Font Name 'Source'" + ] + } + ] + }, { "name": "remark-math", "version": "6.0.0", From bc9c84e4f64133a703ec118e0f650b358248c23b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 11:23:54 -0800 Subject: [PATCH 0039/1165] docs(mcp): t3_thread_update lists only its rename and title actions (#26130) Co-authored-by: Claude Opus 5.5 --- docs/orchestration-v2/orchestrator-mcp-server.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md index 732d8fed9516a..a28070cf1fc9c 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -427,10 +427,10 @@ runs, background wakes, and delegated completions keep it snoozed. ### `t3_thread_update` Updates metadata for the calling thread or any other thread in the environment. -The typed actions are `rename`, `regenerate_title`, `link_pull_request`, and -`unlink_pull_request`. A link input supplies the repository, number, and URL; -the server records the target thread's project ID. Branch and workspace changes -are outside this tool. +The typed actions are `rename`, which requires `title`, and `regenerate_title`, +which rejects one. Pull request links go through the separate +`link_pull_request` and `unlink_pull_request` tools. Branch and workspace +changes are outside this tool. The result includes the command ID and durable event sequence together with the resultant title, title-regeneration marker, and linked pull request. Reusing a From 60efd7c8697d409a629672966f523f33f51aac4f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 11:29:09 -0800 Subject: [PATCH 0040/1165] fix(server): t3_thread_launch refuses models the provider does not advertise (#26625) Co-authored-by: Claude Opus 5.5 --- .../src/mcp/OrchestratorMcpService.test.ts | 70 ++++++++++++++++++- apps/server/src/mcp/OrchestratorMcpService.ts | 37 ++++++---- .../src/mcp/toolkits/project/handlers.test.ts | 68 ++++++++++++++++++ .../src/mcp/toolkits/project/handlers.ts | 8 +++ 4 files changed, 168 insertions(+), 15 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index 1b733e7181c81..ed709bbfc4ecd 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -3181,6 +3181,17 @@ describe("OrchestratorMcpService provider resolution", () => { target: { options: {} }, expected: { ...inherited, options: [] }, }, + { + name: "same model by alias", + target: { model: "GPT" }, + expected: { ...inherited, model: "GPT" }, + }, + { + name: "parent named the same model by alias", + parent: { ...inherited, model: "gpt" }, + target: { model: inherited.model }, + expected: inherited, + }, { name: "change model", target: { model: "other-model" }, @@ -3225,7 +3236,10 @@ describe("OrchestratorMcpService provider resolution", () => { getThreadRecords: (threadId) => Effect.succeed( threadId === parentThreadId - ? parentProjection(commands.length > 0 ? [task] : [], inherited) + ? parentProjection( + commands.length > 0 ? [task] : [], + "parent" in testCase ? testCase.parent : inherited, + ) : childProjection, ), dispatch: (command) => @@ -3251,6 +3265,7 @@ describe("OrchestratorMcpService provider resolution", () => { name: model, isCustom: false, capabilities: null, + ...(model === inherited.model ? { aliases: ["gpt"] } : {}), })), { slug: "ultracode-model", @@ -3392,6 +3407,59 @@ describe("OrchestratorMcpService provider resolution", () => { }), ); + it.effect("validates options against the model a target names by alias", () => + Effect.gen(function* () { + const dependencies = Layer.mergeAll( + NodeServices.layer, + NodeCrypto.layer, + Layer.mock(ThreadManagementService.ThreadManagementService)({ + hasReceipt: () => Effect.succeed(false), + assertProviderInstanceEnabled: () => Effect.void, + getThreadRecords: (threadId) => + Effect.succeed(threadId === parentThreadId ? parentProjection([]) : childProjection), + dispatch: () => Effect.die("an invalid target must not dispatch"), + }), + ServerSettings.layerTest(), + providerRegistryLayer([ + { + ...providerSnapshot({ + instanceId: codexInstanceId, + driver: ProviderDriverKind.make("codex"), + }), + models: [ + { + slug: "gpt-5.4", + name: "gpt-5.4", + aliases: ["gpt"], + isCustom: false, + capabilities: { + optionDescriptors: [{ id: "fastMode", label: "Fast", type: "boolean" }], + }, + }, + ], + }, + ]), + adapterRegistryLayer([codexInstanceId]), + Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(RemoteDelegation.RemoteDelegation)({}), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + ); + const error = yield* Effect.gen(function* () { + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + return yield* service + .delegateTask(scope, { + task: "Review the change.", + target: yield* decodeTarget({ model: "GPT", options: { bogus: "x" } }), + mode: "async", + }) + .pipe(Effect.flip); + }).pipe(Effect.provide(layerOrchestratorService.pipe(Layer.provide(dependencies)))); + assert.equal(error.code, "invalid_request"); + assert.include(error.message, "Unknown option bogus"); + }), + ); + describe("resolveRuntimeMode", () => { const muse = { modelSelection: { instanceId: ProviderInstanceId.make("muse"), model: "muse-model" }, diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 79a244270b361..e32918f4709e9 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -350,6 +350,23 @@ function capabilityModelOption(descriptor: ProviderOptionDescriptor): Orchestrat }; } +const advertisedModel = (provider: ServerProvider, model: string) => + provider.models.find((candidate) => candidate.slug === model) ?? + provider.models.find((candidate) => + candidate.aliases?.some((alias) => alias.toLowerCase() === model.toLowerCase()), + ); + +/** A provider that lists no models accepts any slug. */ +export const requireAdvertisedModel = (provider: ServerProvider, model: string) => + provider.models.length > 0 && advertisedModel(provider, model) === undefined + ? Effect.fail( + failure( + "model_unavailable", + `Model ${model} is not advertised by provider ${provider.instanceId}.`, + ), + ) + : Effect.void; + function providerConstraints( provider: ServerProvider | undefined, supportsOrchestrationV2: boolean, @@ -1387,19 +1404,9 @@ const make = Effect.gen(function* () { `Provider ${instanceId} has no model available for inheritance.`, ); } - if ( - requestedModel !== undefined && - provider.models.length > 0 && - !provider.models.some((candidate) => candidate.slug === requestedModel) - ) { - return yield* failure( - "model_unavailable", - `Model ${requestedModel} is not advertised by provider ${instanceId}.`, - ); - } - - const descriptors = provider.models.find((candidate) => candidate.slug === model)?.capabilities - ?.optionDescriptors; + if (requestedModel !== undefined) yield* requireAdvertisedModel(provider, requestedModel); + const advertised = advertisedModel(provider, model); + const descriptors = advertised?.capabilities?.optionDescriptors; const requestedOptions = withLegacyUltracodeEffort(descriptors, input.target?.options); if (requestedOptions !== undefined) { const invalid = invalidOptionSelections(requestedOptions, descriptors); @@ -1412,7 +1419,9 @@ const make = Effect.gen(function* () { } const inheritsOptions = - instanceId === inheritedSelection.instanceId && model === inheritedSelection.model; + instanceId === inheritedSelection.instanceId && + (advertised?.slug ?? model) === + (advertisedModel(provider, inheritedSelection.model)?.slug ?? inheritedSelection.model); let options = inheritsOptions ? knownOptions( withLegacyUltracodeEffort(descriptors, inheritedSelection.options), diff --git a/apps/server/src/mcp/toolkits/project/handlers.test.ts b/apps/server/src/mcp/toolkits/project/handlers.test.ts index b77df3ac2075e..9b933de577159 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.test.ts @@ -332,6 +332,74 @@ it.effect("a launch steps an inherited mode down to the provider's, and refuses }), ); +it.effect("a launch with a model its provider does not advertise creates no thread", () => + Effect.gen(function* () { + const launched: Array = []; + const providers: ServerProvider[] = ["claudeAgent", "codex"].map((driver) => ({ + instanceId: ProviderInstanceId.make(driver), + driver: ProviderDriverKind.make(driver), + enabled: true, + installed: true, + status: "ready", + version: null, + auth: { status: "authenticated" }, + checkedAt: "2026-01-01T00:00:00Z", + models: [ + driver === "claudeAgent" + ? { + slug: "claude-opus", + name: "Opus", + aliases: ["opus"], + isCustom: false, + capabilities: null, + } + : { slug: "gpt-5.4", name: "GPT", isCustom: false, capabilities: null }, + ], + slashCommands: [], + skills: [], + })); + const { projectId, dependencies } = clientLaunchHarness({ + runtimeModeCeiling: "auto", + launched, + providers, + }); + const toolkit = yield* ProjectToolkit.pipe( + Effect.provide( + McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe(Layer.provide(dependencies)), + ), + ); + const launchWith = (instanceId: string, model: string) => + toolkit + .handle("t3_thread_launch", { + title: "Fix", + projectId, + modelSelection: { instanceId: ProviderInstanceId.make(instanceId), model }, + }) + .pipe( + Stream.unwrap, + Stream.runCollect, + Effect.map((result) => result.at(-1)?.result), + Effect.provide(dependencies), + ); + + for (const [instanceId, model] of [ + ["claudeAgent", "claude-unadvertised"], + ["codex", "opus"], + ] as const) { + expect(yield* launchWith(instanceId, model)).toMatchObject({ + code: "model_unavailable", + message: `Model ${model} is not advertised by provider ${instanceId}.`, + }); + } + expect(launched).toHaveLength(0); + + expect(yield* launchWith("claudeAgent", "Opus")).toMatchObject({ + modelSelection: { instanceId: "claudeAgent", model: "Opus" }, + }); + expect(launched).toHaveLength(1); + }), +); + it.effect("a launch retried with the same clientRequestId replays the first one", () => Effect.gen(function* () { const launched: Array = []; diff --git a/apps/server/src/mcp/toolkits/project/handlers.ts b/apps/server/src/mcp/toolkits/project/handlers.ts index ab27b6b5bc4b4..fd5772f6dc3bb 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.ts @@ -29,6 +29,7 @@ import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { resolveRuntimeMode } from "../../delegatedTaskModes.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; import * as McpToolAccess from "../../McpToolAccess.ts"; +import * as OrchestrationMcp from "../../OrchestratorMcpService.ts"; import { newCommandId, readCaller, resolveProjectId, unavailable } from "../../threadAccess.ts"; import { ProjectToolkit } from "./tools.ts"; @@ -152,6 +153,13 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { }); yield* assertProjectWorktree(project.workspaceRoot, input.workspaceStrategy.worktreePath); } + if (!replaying && input.modelSelection !== undefined) { + const { instanceId, model } = input.modelSelection; + const providers = yield* (yield* ProviderRegistry.ProviderRegistry).getProviders; + const provider = providers.find((candidate) => candidate.instanceId === instanceId); + if (provider !== undefined) + yield* OrchestrationMcp.requireAdvertisedModel(provider, model); + } // Reject unresolved base refs before accepting a launch; worktree provisioning happens later. const strategy = input.workspaceStrategy; if (!replaying && strategy?.type === "worktree") From 15695ee78f5515bdd36beb928a3c51554fcff3a5 Mon Sep 17 00:00:00 2001 From: Abdul Date: Wed, 11 Nov 2026 19:34:23 +0000 Subject: [PATCH 0041/1165] fix(opencode): omit unsupported reasoning options from catalog models (#19000) Co-authored-by: Julius Marminge --- .../src/server/OpenCodeRuntime.ts | 38 +++ .../src/server/adapter.test.ts | 264 +++++++++++++++++- .../provider-opencode/src/server/adapter.ts | 31 +- .../src/server/status.test.ts | 47 ++++ .../provider-opencode/src/server/status.ts | 4 +- .../src/server/textGeneration.test.ts | 39 ++- .../src/server/textGeneration.ts | 6 +- 7 files changed, 419 insertions(+), 10 deletions(-) diff --git a/packages/provider-opencode/src/server/OpenCodeRuntime.ts b/packages/provider-opencode/src/server/OpenCodeRuntime.ts index fffda5ff823b2..ec1fa4c7b318a 100644 --- a/packages/provider-opencode/src/server/OpenCodeRuntime.ts +++ b/packages/provider-opencode/src/server/OpenCodeRuntime.ts @@ -230,6 +230,44 @@ export interface ParsedOpenCodeModelSlug { readonly modelID: string; } +export type OpenCodeVariantCache = Map | undefined>; + +export const resolveOpenCodeVariant = Effect.fnUntraced(function* ( + client: OpencodeClient, + model: ParsedOpenCodeModelSlug | undefined, + variant: string | undefined, + abortSignal?: AbortSignal, + cache?: OpenCodeVariantCache, +) { + if (!variant || !model) return variant; + + const key = `${model.providerID}/${model.modelID}`; + const cached = cache?.get(key); + if (cache?.has(key)) { + return cached !== undefined && !cached.has(variant) ? undefined : variant; + } + + // Only a confirmed catalog mismatch can invalidate a persisted selection. + const response = yield* runOpenCodeSdk("provider.list", (signal) => + client.provider.list(undefined, { + signal: abortSignal ? AbortSignal.any([signal, abortSignal]) : signal, + }), + ).pipe( + Effect.timeout("10 seconds"), + Effect.orElseSucceed(() => undefined), + ); + if (response?.data === undefined) return variant; + + const catalogModel = response.data.all.find((provider) => provider.id === model.providerID) + ?.models[model.modelID]; + const variants = catalogModel ? new Set(Object.keys(catalogModel.variants ?? {})) : undefined; + if (cache !== undefined) { + // Keep only this model's names; provider.list can return several megabytes. + cache.set(key, variants); + } + return variants !== undefined && !variants.has(variant) ? undefined : variant; +}); + export interface OpenCodeSkill { readonly name?: string | null; readonly description?: string | null; diff --git a/packages/provider-opencode/src/server/adapter.test.ts b/packages/provider-opencode/src/server/adapter.test.ts index 90d99699b3082..91400661e6eed 100644 --- a/packages/provider-opencode/src/server/adapter.test.ts +++ b/packages/provider-opencode/src/server/adapter.test.ts @@ -35,6 +35,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost"; @@ -154,6 +155,7 @@ const makeOpenCodeRuntimeHarness = Effect.fn("makeOpenCodeRuntimeHarness")(funct client: object, host: Partial = {}, turnSettings: { + readonly model?: string; readonly options?: ReadonlyArray; readonly interactionMode?: ProviderAdapter.ProviderAdapterV2RuntimePolicy["interactionMode"]; } = {}, @@ -162,7 +164,7 @@ const makeOpenCodeRuntimeHarness = Effect.fn("makeOpenCodeRuntimeHarness")(funct const threadId = ThreadId.make(`thread-opencode-${suffix}`); const modelSelection = { instanceId, - model: "anthropic/claude-sonnet", + model: turnSettings.model ?? "anthropic/claude-sonnet", options: turnSettings.options ?? [], }; const policy = runtimePolicy("full-access", { @@ -255,6 +257,266 @@ const makeOpenCodeRuntimeHarness = Effect.fn("makeOpenCodeRuntimeHarness")(funct }); describe("OpenCodeAdapterV2", () => { + it.effect.each([ + ["empty", "prompt"], + ["unsupported", "command"], + ["supported", "command"], + ["unknown", "prompt"], + ["failure", "prompt"], + ["unset", "prompt"], + ] as const)("normalizes reasoning variants for %s inventory at %s dispatch", ([kind, request]) => + Effect.gen(function* () { + const suffix = `variant-${kind}-${request}`; + const nativeEvents = asyncEventStream(); + const inputs: Array> = []; + let inventoryCalls = 0; + const receive = async (input: Record) => { + inputs.push(input); + return { data: true }; + }; + const harness = yield* makeOpenCodeRuntimeHarness( + suffix, + suffix, + { + event: { + subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { + options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); + return { stream: nativeEvents.stream }; + }, + }, + provider: { + list: async () => { + inventoryCalls++; + if (kind === "failure") throw new Error("inventory unavailable"); + return { + data: { + all: [ + { + id: "opencode", + models: + kind === "unknown" + ? {} + : { + "big-pickle": { + id: "big-pickle", + variants: + kind === "supported" + ? { medium: {} } + : kind === "unsupported" + ? { high: {} } + : {}, + }, + }, + }, + ], + }, + }; + }, + }, + command: { list: async () => ({ data: [{ name: "review" }] }) }, + session: { + create: async () => ({ data: { id: suffix, time: { created: 1, updated: 1 } } }), + promptAsync: receive, + command: receive, + }, + }, + {}, + { + model: "opencode/big-pickle", + options: [ + ...(kind === "unset" ? [] : [{ id: "variant", value: "medium" }]), + { id: "agent", value: "plan" }, + ], + }, + ); + yield* harness.startTurn(request === "command" ? "/review staged changes" : "hello"); + assert.equal(inputs.length, 1); + const payload = inputs[0]!; + assert.equal(payload.agent, "plan"); + if (kind === "supported" || kind === "unknown" || kind === "failure") { + assert.equal(payload.variant, "medium"); + } else { + assert.equal(Object.hasOwn(payload, "variant"), false); + } + if (kind === "unset") assert.equal(inventoryCalls, 0); + }).pipe( + Effect.provide(Layer.merge(IdAllocator.layer, McpProviderSessions.layer)), + Effect.scoped, + ), + ); + + it.effect("reuses successful model variants for the session and retries failed inventories", () => + Effect.gen(function* () { + const sessionId = "variant-cache"; + const nativeEvents = asyncEventStream(); + const inputs: Array> = []; + let inventoryCalls = 0; + let variants: Record = {}; + const harness = yield* makeOpenCodeRuntimeHarness( + sessionId, + sessionId, + { + event: { + subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { + options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); + return { stream: nativeEvents.stream }; + }, + }, + provider: { + list: async () => { + inventoryCalls++; + if (inventoryCalls === 1) throw new Error("inventory unavailable"); + return { + data: { + all: [{ id: "opencode", models: { "big-pickle": { variants } } }], + }, + }; + }, + }, + session: { + create: async () => ({ data: { id: sessionId, time: { created: 1, updated: 1 } } }), + promptAsync: async (input: Record) => { + inputs.push(input); + return { data: true }; + }, + }, + }, + {}, + { model: "opencode/big-pickle", options: [{ id: "variant", value: "medium" }] }, + ); + const submit = Effect.fnUntraced(function* () { + yield* harness.startTurn(); + yield* Effect.promise(() => + nativeEvents.push({ + type: "message.updated", + properties: { + sessionID: sessionId, + info: { + id: inputs.at(-1)!.messageID, + sessionID: sessionId, + role: "user", + time: { created: 1 }, + }, + }, + }), + ); + yield* Effect.promise(() => + nativeEvents.push({ type: "session.idle", properties: { sessionID: sessionId } }), + ); + yield* harness.runtime.events.pipe( + Stream.filter((event) => event.type === "turn.terminal"), + Stream.take(1), + Stream.runDrain, + ); + return inputs.at(-1)!; + }); + + assert.equal((yield* submit()).variant, "medium"); + assert.equal(inventoryCalls, 1); + assert.isFalse(Object.hasOwn(yield* submit(), "variant")); + assert.equal(inventoryCalls, 2); + variants = { medium: {} }; + assert.isFalse(Object.hasOwn(yield* submit(), "variant")); + assert.equal(inventoryCalls, 2); + }).pipe( + Effect.provide(Layer.merge(IdAllocator.layer, McpProviderSessions.layer)), + Effect.scoped, + ), + ); + + it.effect("keeps queued steers behind a cold catalog lookup and cancels them on stop", () => + Effect.gen(function* () { + const sessionId = "catalog-admission"; + const nativeEvents = asyncEventStream(); + const catalogStarted = promiseGate(); + const calls: string[] = []; + const harness = yield* makeOpenCodeRuntimeHarness( + sessionId, + sessionId, + { + event: { + subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { + options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); + return { stream: nativeEvents.stream }; + }, + }, + provider: { + list: async (_input: unknown, { signal }: { signal: AbortSignal }) => { + calls.push("catalog"); + catalogStarted.resolve(); + await new Promise((_resolve, reject) => { + signal.addEventListener( + "abort", + () => { + calls.push("catalog-aborted"); + reject(signal.reason); + }, + { once: true }, + ); + }); + return { data: { all: [] } }; + }, + }, + session: { + create: async () => ({ data: { id: sessionId, time: { created: 1, updated: 1 } } }), + get: async () => ({ data: { id: sessionId, time: { created: 1, updated: 1 } } }), + messages: async () => ({ data: [] }), + children: async () => ({ data: [] }), + promptAsync: async (_input: unknown, { signal }: { signal: AbortSignal }) => { + signal.throwIfAborted(); + calls.push("prompt"); + return { data: true }; + }, + abort: async () => { + calls.push("abort"); + return { data: true }; + }, + }, + }, + {}, + { model: "opencode/big-pickle", options: [{ id: "variant", value: "medium" }] }, + ); + const start = yield* harness.startTurn().pipe(Effect.forkScoped); + yield* Effect.promise(() => catalogStarted.promise); + const snapshot = yield* harness.runtime.readThreadSnapshot({ + providerThread: harness.providerThread, + }); + const activeTurn = snapshot.providerTurns.at(-1)!; + const steer = (message: string) => + harness.runtime + .steerTurn({ + threadId: harness.threadId, + runId: harness.runId, + providerThread: harness.providerThread, + providerTurnId: activeTurn.id, + message: { + messageId: MessageId.make(message), + text: message, + attachments: [], + createdBy: "user", + creationSource: "web", + }, + }) + .pipe(Effect.exit, Effect.forkScoped({ startImmediately: true })); + const firstSteer = yield* steer("first follow-up"); + const secondSteer = yield* steer("second follow-up"); + yield* TestClock.adjust("5 seconds"); + assert.deepEqual(calls, ["catalog"]); + + yield* harness.runtime.interruptTurn({ + providerThread: harness.providerThread, + providerTurnId: activeTurn.id, + }); + yield* Fiber.join(start); + assert.isTrue(Exit.isFailure(yield* Fiber.join(firstSteer))); + assert.isTrue(Exit.isFailure(yield* Fiber.join(secondSteer))); + assert.deepEqual(calls, ["catalog", "catalog-aborted", "abort"]); + }).pipe( + Effect.provide(Layer.merge(IdAllocator.layer, McpProviderSessions.layer)), + Effect.scoped, + ), + ); + it.effect.each([ { message: "", diff --git a/packages/provider-opencode/src/server/adapter.ts b/packages/provider-opencode/src/server/adapter.ts index b9f2714fb9379..fc3ba8fe2b8f5 100644 --- a/packages/provider-opencode/src/server/adapter.ts +++ b/packages/provider-opencode/src/server/adapter.ts @@ -1003,6 +1003,7 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function const events = yield* Queue.unbounded(); let nativeStreamFailure: OrchestrationV2ProviderFailure | null = null; const threads = new Map(); + const variantCache: OpenCodeRuntime.OpenCodeVariantCache = new Map(); const commandReceipts = new Map>(); const commandControllers = new Map>(); const pendingRequests = new Map(); @@ -2999,12 +3000,24 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function const submitPrompt = Effect.fn("OpenCode.submitPrompt")(function* ( state: OpenCodeThreadState, turn: ActiveOpenCodeTurn, - payload: Parameters[0] & { + inputPayload: Parameters[0] & { messageID: string; sessionID: string; }, abortController: AbortController, ) { + const { variant, ...payloadWithoutVariant } = inputPayload; + const selectedVariant = yield* OpenCodeRuntime.resolveOpenCodeVariant( + client, + inputPayload.model, + variant, + abortController.signal, + variantCache, + ); + const payload = { + ...payloadWithoutVariant, + ...(selectedVariant === undefined ? {} : { variant: selectedVariant }), + }; const text = payload.parts ?.filter((part) => part.type === "text") .map((part) => part.text) @@ -3421,8 +3434,20 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function ...(text.length === 0 ? [] : [{ type: "text" as const, text }]), ...files, ]; + const admissionMessageId = yield* makeOpenCodeMessageId(messageIdClock); + while (turn.admissionAbortController !== null) { + const pendingAdmission = turn.admissionSettled; + yield* Deferred.await(pendingAdmission); + // Another waiting steer may have claimed admission first. + if (turn.admissionSettled === pendingAdmission) break; + } + if (state.activeTurn !== turn || turn.finalized || turn.interrupted) { + return yield* protocolError( + `OpenCode turn ${steerInput.providerTurnId} is not active`, + ); + } turn.admissionGeneration = state.nextAdmissionGeneration++; - turn.admissionMessageId = yield* makeOpenCodeMessageId(messageIdClock); + turn.admissionMessageId = admissionMessageId; turn.admissionPending = true; turn.admissionAccepted = false; turn.admissionMessageObserved = false; @@ -3457,7 +3482,7 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function ]).pipe(Effect.asVoid), ), ); - if (state.activeTurn === turn && !turn.finalized) { + if (state.activeTurn === turn && !turn.finalized && !turn.interrupted) { const admissionAction = advanceOpenCodePromptAdmission(turn, "accepted"); if (admissionAction === "reconcile-idle") { yield* reconcilePromptAdmission(state, turn); diff --git a/packages/provider-opencode/src/server/status.test.ts b/packages/provider-opencode/src/server/status.test.ts index b6560a2f4a184..c8d324f06012b 100644 --- a/packages/provider-opencode/src/server/status.test.ts +++ b/packages/provider-opencode/src/server/status.test.ts @@ -15,6 +15,7 @@ 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"; +import { buildProviderOptionSelectionsFromDescriptors } from "@t3tools/shared/model"; import { settingsSecretLiteral } from "@t3tools/contracts"; import { OpenCodeSettings } from "../settings.ts"; @@ -684,6 +685,52 @@ it.layer(layerTest)("checkOpenCodeProviderStatus", (it) => { }), ); + it.effect("keeps Agent without inventing Reasoning for models with no variants", () => + Effect.gen(function* () { + runtimeMock.state.inventory = { + providerList: { + connected: ["opencode"], + all: [ + { + id: "opencode", + name: "OpenCode Zen", + models: { + "big-pickle": { id: "big-pickle", name: "Big Pickle", variants: {} }, + "glm-5": { id: "glm-5", name: "GLM-5" }, + }, + }, + ], + default: {}, + }, + agents: [ + { name: "build", hidden: false, mode: "primary" }, + { name: "plan", hidden: false, mode: "primary" }, + ], + }; + const snapshot = yield* checkProvider(makeOpenCodeSettings()); + for (const slug of ["opencode/big-pickle", "opencode/glm-5"]) { + const model = snapshot.models.find((entry) => entry.slug === slug); + NodeAssert.ok(model); + const descriptors = model.capabilities?.optionDescriptors; + NodeAssert.deepEqual(descriptors, [ + { + id: "agent", + label: "Agent", + type: "select", + options: [ + { id: "build", label: "Build", isDefault: true }, + { id: "plan", label: "Plan" }, + ], + currentValue: "build", + }, + ]); + NodeAssert.deepEqual(buildProviderOptionSelectionsFromDescriptors(descriptors), [ + { id: "agent", value: "build" }, + ]); + } + }), + ); + it.effect("includes OpenCode skills in the provider snapshot", () => Effect.gen(function* () { runtimeMock.state.inventory = { diff --git a/packages/provider-opencode/src/server/status.ts b/packages/provider-opencode/src/server/status.ts index 0afd0dde1633d..0ba2f84ceb896 100644 --- a/packages/provider-opencode/src/server/status.ts +++ b/packages/provider-opencode/src/server/status.ts @@ -234,9 +234,7 @@ function openCodeCapabilitiesForModel(input: { readonly model: ProviderListResponse["all"][number]["models"][string]; readonly agents: ReadonlyArray; }): ModelCapabilities { - const rawVariantValues = Object.keys(input.model.variants ?? {}); - const variantValues = - rawVariantValues.length > 0 ? rawVariantValues : ["low", "medium", "high", "xhigh"]; + const variantValues = Object.keys(input.model.variants ?? {}); const defaultVariant = inferDefaultVariant(input.providerID, variantValues); const variantOptions = variantValues.map((value) => defaultVariant === value diff --git a/packages/provider-opencode/src/server/textGeneration.test.ts b/packages/provider-opencode/src/server/textGeneration.test.ts index c877bfdd4eabc..b29e2fb9f86d2 100644 --- a/packages/provider-opencode/src/server/textGeneration.test.ts +++ b/packages/provider-opencode/src/server/textGeneration.test.ts @@ -1,5 +1,6 @@ import { ProviderInstanceId, settingsSecretLiteral, TextGenerationError } from "@t3tools/contracts"; import { OpenCodeSettings } from "../settings.ts"; +import type { OpencodeClient } from "@opencode-ai/sdk/v2"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import * as Cause from "effect/Cause"; @@ -25,6 +26,7 @@ const runtimeMock = { startCalls: [] as string[], promptUrls: [] as string[], promptParts: [] as ReadonlyArray[], + promptInputs: [] as Array[0]>, authHeaders: [] as Array, closeCalls: [] as string[], sessionCreateCalls: 0, @@ -46,6 +48,7 @@ const runtimeMock = { this.state.startCalls.length = 0; this.state.promptUrls.length = 0; this.state.promptParts.length = 0; + this.state.promptInputs.length = 0; this.state.authHeaders.length = 0; this.state.closeCalls.length = 0; this.state.sessionCreateCalls = 0; @@ -106,6 +109,13 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntime["Service"] = { runOpenCodeCommand: () => Effect.succeed({ stdout: "", stderr: "", code: 0 }), createOpenCodeSdkClient: ({ baseUrl, serverPassword }) => ({ + provider: { + list: async () => ({ + data: { + all: [{ id: "openai", models: { "gpt-5": { id: "gpt-5" } } }], + }, + }), + }, session: { create: async (_input: unknown, options?: { readonly signal?: AbortSignal }) => { runtimeMock.state.sessionCreateCalls += 1; @@ -130,11 +140,12 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntime["Service"] = { return { data: true }; }, prompt: async ( - input: { readonly parts: ReadonlyArray }, + input: Parameters[0], options?: { readonly signal?: AbortSignal }, ) => { + runtimeMock.state.promptInputs.push(input); runtimeMock.state.promptUrls.push(baseUrl); - runtimeMock.state.promptParts.push(input.parts); + runtimeMock.state.promptParts.push(input.parts ?? []); runtimeMock.state.authHeaders.push( serverPassword ? `Basic ${btoa(`opencode:${serverPassword}`)}` : null, ); @@ -250,6 +261,30 @@ const advanceIdleClock = Effect.gen(function* () { }); it.layer(layerOpenCodeTextGenerationTest)("OpenCodeTextGeneration", (it) => { + it.effect("omits stale reasoning variants when generating commit messages", () => + withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + yield* textGeneration.generateCommitMessage({ + ...DEFAULT_COMMIT_MESSAGE_INPUT, + modelSelection: { + ...DEFAULT_TEST_MODEL_SELECTION, + options: [ + { id: "variant", value: "medium" }, + { id: "agent", value: "plan" }, + ], + }, + }); + + expect(runtimeMock.state.promptInputs).toHaveLength(1); + expect(runtimeMock.state.promptInputs[0]).toMatchObject({ + model: { providerID: "openai", modelID: "gpt-5" }, + agent: "plan", + }); + expect(runtimeMock.state.promptInputs[0]).not.toHaveProperty("variant"); + }), + ), + ); + it.effect("cancels or times out an in-flight session creation before prompting", () => withOpenCodeTextGeneration(EXISTING_SERVER_OPENCODE_SETTINGS, (textGeneration) => Effect.gen(function* () { diff --git a/packages/provider-opencode/src/server/textGeneration.ts b/packages/provider-opencode/src/server/textGeneration.ts index fe63a36886531..bbb02d36e6323 100644 --- a/packages/provider-opencode/src/server/textGeneration.ts +++ b/packages/provider-opencode/src/server/textGeneration.ts @@ -240,7 +240,11 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" ).pipe(Effect.timeout("5 seconds"), Effect.ignore({ log: "Warn" })), ); const selectedAgent = getModelSelectionStringOptionValue(input.modelSelection, "agent"); - const selectedVariant = getModelSelectionStringOptionValue(input.modelSelection, "variant"); + const selectedVariant = yield* OpenCodeRuntime.resolveOpenCodeVariant( + client, + parsedModel, + getModelSelectionStringOptionValue(input.modelSelection, "variant"), + ); const promptContext = { operation: input.operation, cwd: input.cwd, From 84478e09c1548e32cd2fee2960da36ced15193e9 Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Thu, 12 Nov 2026 01:06:34 +0530 Subject: [PATCH 0042/1165] fix(web): long merged spreadsheet selections no longer crash (#34331) --- .../files/office/sheet-cells.test.ts | 40 ++++++++++ .../components/files/office/sheet-cells.ts | 76 +++++++++++++++---- 2 files changed, 100 insertions(+), 16 deletions(-) create mode 100644 apps/web/src/components/files/office/sheet-cells.test.ts diff --git a/apps/web/src/components/files/office/sheet-cells.test.ts b/apps/web/src/components/files/office/sheet-cells.test.ts new file mode 100644 index 0000000000000..5ed46a3a7b65e --- /dev/null +++ b/apps/web/src/components/files/office/sheet-cells.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { coverMerges } from "./sheet-cells"; + +describe("coverMerges", () => { + it.each([false, true])("expands a long merge chain (reversed: %s)", (reversed) => { + const count = 15_000; + const merges = Array.from({ length: count }, (_, i) => ({ + top: i, + bottom: i + 1, + left: i % 2, + right: i % 2, + })); + if (reversed) merges.reverse(); + const range = { top: count, bottom: count, left: 0, right: 1 }; + + expect(coverMerges(range, merges)).toEqual({ ...range, top: 0 }); + expect(range.top).toBe(count); + }); + + it("revisits merges reached through expansion on every edge without including distant merges", () => { + const merges = [ + { top: 1, bottom: 1, left: 0, right: 2 }, + { top: 1, bottom: 2, left: 3, right: 3 }, + { top: 3, bottom: 3, left: 3, right: 5 }, + { top: 2, bottom: 5, left: 2, right: 2 }, + { top: 6, bottom: 7, left: 6, right: 7 }, + ]; + const range = { top: 3, bottom: 3, left: 2, right: 3 }; + + expect(coverMerges(range, merges)).toEqual({ top: 1, bottom: 5, left: 0, right: 5 }); + expect(coverMerges(range, [])).toEqual(range); + expect(coverMerges({ top: 6, bottom: 6, left: 0, right: 0 }, merges)).toEqual({ + top: 6, + bottom: 6, + left: 0, + right: 0, + }); + }); +}); diff --git a/apps/web/src/components/files/office/sheet-cells.ts b/apps/web/src/components/files/office/sheet-cells.ts index f61cad9981d88..458e3ba475e31 100644 --- a/apps/web/src/components/files/office/sheet-cells.ts +++ b/apps/web/src/components/files/office/sheet-cells.ts @@ -46,23 +46,67 @@ export function parseRange(a1: string): CellRange | undefined { /** Grows a range until it covers every merged cell it touches, as Excel selects. */ export function coverMerges(range: CellRange, merges: readonly CellRange[]): CellRange { - const grown = merges.reduce( - (current, merge) => - overlaps(current, merge) - ? { - top: Math.min(current.top, merge.top), - left: Math.min(current.left, merge.left), - bottom: Math.max(current.bottom, merge.bottom), - right: Math.max(current.right, merge.right), - } - : current, - range, - ); - - // A merge the range grew into can touch another one it did not reach before. - if (same(grown, range)) return grown; + let grown = range; + for (let pass = 0; pass < 2; pass++) { + const next = merges.reduce( + (current, merge) => + overlaps(current, merge) + ? { + top: Math.min(current.top, merge.top), + left: Math.min(current.left, merge.left), + bottom: Math.max(current.bottom, merge.bottom), + right: Math.max(current.right, merge.right), + } + : current, + grown, + ); + if (same(next, grown)) return next; + grown = next; + } - return coverMerges(grown, merges); + // Each overlap inequality becomes true only once as the selection grows. Sweep sorted + // edges so a chain of merges cannot force repeated full scans or recursive stack growth. + const sweeps = ( + [ + { mergeEdge: "top", rangeEdge: "bottom", direction: 1 }, + { mergeEdge: "bottom", rangeEdge: "top", direction: -1 }, + { mergeEdge: "left", rangeEdge: "right", direction: 1 }, + { mergeEdge: "right", rangeEdge: "left", direction: -1 }, + ] as const + ).map(({ mergeEdge, rangeEdge, direction }) => ({ + rangeEdge, + direction, + position: 0, + edges: merges + .map((merge, index) => ({ index, value: direction * merge[mergeEdge] })) + .sort((a, b) => a.value - b.value), + })); + const crossed = new Uint8Array(merges.length); + let changed; + do { + changed = false; + for (const sweep of sweeps) { + let edge = sweep.edges[sweep.position]; + while (edge && edge.value <= sweep.direction * grown[sweep.rangeEdge]) { + const index = edge.index; + crossed[index] = crossed[index]! + 1; + if (crossed[index] === 4) { + const merge = merges[index]!; + const next = { + top: Math.min(grown.top, merge.top), + left: Math.min(grown.left, merge.left), + bottom: Math.max(grown.bottom, merge.bottom), + right: Math.max(grown.right, merge.right), + }; + changed ||= !same(grown, next); + grown = next; + } + edge = sweep.edges[++sweep.position]; + } + } + } while (changed); + + return grown; } /** From d0c7147a8e1a382fc3bdf7cab291614623b31edb Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 11:39:45 -0800 Subject: [PATCH 0043/1165] fix(pi): rewinding works after the Pi session was released (#26322) Co-authored-by: Claude Opus 5.5 --- .../provider-pi/src/server/adapter.test.ts | 71 +++++++++++++++++++ packages/provider-pi/src/server/adapter.ts | 16 +++++ 2 files changed, 87 insertions(+) diff --git a/packages/provider-pi/src/server/adapter.test.ts b/packages/provider-pi/src/server/adapter.test.ts index 69f2287d8e52c..aa53098ff9432 100644 --- a/packages/provider-pi/src/server/adapter.test.ts +++ b/packages/provider-pi/src/server/adapter.test.ts @@ -3666,6 +3666,77 @@ describe("PiAdapterV2", () => { }).pipe(Effect.scoped, Effect.provide(layerTest)), ); + it.effect("rolls back a persisted thread on a fresh runtime after its session was released", () => + Effect.gen(function* () { + const previousFake = yield* makeFakePi; + const { runtime: previousRuntime } = yield* openRuntime(previousFake); + const providerThread = yield* previousRuntime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + const sessionFile = providerThread.nativeThreadRef?.nativeId; + assert.isDefined(sessionFile); + const turn = (ordinal: number, entryId: string): OrchestrationV2ProviderTurn => ({ + id: ProviderTurnId.make(`turn-${ordinal}`), + providerThreadId: providerThread.id, + nodeId: NodeId.make(`node-${ordinal}`), + runAttemptId: null, + nativeTurnRef: { driver: PI_PROVIDER, nativeId: entryId, strength: "strong" }, + ordinal, + status: "completed", + startedAt: null, + completedAt: null, + }); + const turns = [turn(1, "u1"), turn(2, "u2")]; + + // Rollback opens the session the way CheckpointRollbackService does, without resumeThread. + const fake = yield* makeFakePi; + const adapter = yield* makeAdapter(fake); + const runtime = yield* adapter.openSession({ + threadId: THREAD_ID, + providerSessionId: SESSION_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + initialNativeThreadId: sessionFile!, + }); + yield* runtime.events.pipe(Stream.runDrain, Effect.forkScoped); + fake.queueState({ sessionFile: sessionFile! }); + fake.queueState({ sessionFile: "/fake/rolled-back-session.jsonl" }); + const result = yield* runtime + .rollbackThread({ + providerThread, + target: { + type: "provider_turn", + checkpointId: CheckpointId.make("checkpoint-1"), + appRunOrdinal: 1, + providerTurn: turns[0]!, + }, + providerThreadTurns: turns, + }) + .pipe(Effect.result); + + assert.equal( + result._tag, + "Success", + `rollback on a fresh Pi runtime failed: ${result._tag === "Failure" ? String(result.failure.cause) : ""}`, + ); + const requests = fake.allRequests(); + const switchIndex = requests.findIndex( + (request) => request.type === "switch_session" && request.sessionPath === sessionFile, + ); + const forkIndex = requests.findIndex((request) => request.type === "fork"); + assert.isAtLeast(switchIndex, 0, "fresh runtime never switched to the persisted session"); + assert.isAbove(forkIndex, switchIndex, "fork ran before switching to the persisted session"); + assert.equal(requests[forkIndex]?.entryId, "u2"); + if (result._tag !== "Success") return; + assert.equal( + result.success.providerThread.nativeThreadRef?.nativeId, + "/fake/rolled-back-session.jsonl", + ); + }).pipe(Effect.scoped, Effect.provide(layerTest)), + ); + it.effect("settles a command-only prompt from its deferred ack and idle probe", () => 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 4b06ee16bfd85..c5a7e70b08c17 100644 --- a/packages/provider-pi/src/server/adapter.ts +++ b/packages/provider-pi/src/server/adapter.ts @@ -3182,6 +3182,22 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( ), rollbackThread: Effect.fn("PiAdapter.rollbackThread")( function* (rollbackInput) { + // A fresh runtime (restart or idle release) must load the persisted session before forking. + // Unpublished: the rollback service persists the returned snapshot. + if ( + threadState === null && + rollbackInput.providerThread.nativeThreadRef?.nativeId != null + ) { + yield* registerThread( + { + threadId: rollbackInput.providerThread.appThreadId ?? input.threadId, + modelSelection: input.modelSelection, + runtimePolicy: input.runtimePolicy, + existingProviderThread: rollbackInput.providerThread, + }, + false, + ); + } const state = threadState; if (state === null) { return yield* protocolError("Pi session has no registered thread"); From 787c59c802ff58274b06a2cdfb1a369bf7c04571 Mon Sep 17 00:00:00 2001 From: yaacovcorcos Date: Wed, 11 Nov 2026 21:44:59 +0200 Subject: [PATCH 0044/1165] fix(web): Word previews no longer cut ordinary documents short (#32040) Co-authored-by: Claude Opus 5.5 Co-authored-by: Julius Marminge --- .../AttachmentFilePreview.browser.test.tsx | 40 +++--- .../components/files/officeArchive.test.ts | 136 ++++++++++++++++++ .../web/src/components/files/officeArchive.ts | 88 +++++++++--- 3 files changed, 224 insertions(+), 40 deletions(-) create mode 100644 apps/web/src/components/files/officeArchive.test.ts diff --git a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx index 6cfd5a8758540..7eccb21c77814 100644 --- a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx +++ b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx @@ -482,6 +482,7 @@ describe("bounded binary previews", () => { }); }); import { OFFICE_PREVIEW_MAX_COLUMNS, type OfficeWorkerResult } from "./officePreviewProtocol"; +import { WORD_LIMITS } from "./officeArchive"; describe("large text preview recovery", () => { it("retains the workspace viewer across URL renewals and reloads revisions and manual refreshes", async () => { @@ -1190,7 +1191,7 @@ it.each([ : scenario === "runs" ? `${"x".repeat(50_001)}` : scenario === "tables" - ? "".repeat(50_001) + ? "".repeat(2 * WORD_LIMITS.bodyElements) : scenario === "XML bytes" ? `${"x".repeat(2 * 1024 * 1024)}` : ""; @@ -1203,7 +1204,7 @@ it.each([ if (scenario === "auxiliary XML" || scenario === "renamed XML") archive.file( scenario === "renamed XML" ? "word/footnotes.dat" : "word/footnotes.xml", - `${"".repeat(50_001)}`, + `${"".repeat(WORD_LIMITS.auxiliaryMarkup + 1)}`, ); const file = await archive.generateAsync({ type: "blob", compression: "DEFLATE" }); const src = URL.createObjectURL(file); @@ -1254,8 +1255,8 @@ it.each([ ); } expect(markup.length).toBeLessThan(body.length); - expect(markup.length).toBeLessThanOrEqual(3 * 1024 * 1024); - expect(markup.match(/<(?![!?/])/g)!.length).toBeLessThanOrEqual(100_000); + expect(markup.length).toBeLessThanOrEqual(WORD_LIMITS.bodyBytes); + expect(markup.match(/<(?![!?/])/g)!.length).toBeLessThanOrEqual(WORD_LIMITS.bodyTags); } } finally { worker.terminate(); @@ -1286,15 +1287,15 @@ it.each([ ); archive.file( "word/_rels/document.xml.rels", - `${scenario === "content type" ? "" : ``}${scenario === "metadata type" ? "".repeat(50_001) : ""}`, + `${scenario === "content type" ? "" : ``}${scenario === "metadata type" ? "".repeat(WORD_LIMITS.auxiliaryMarkup + 1) : ""}`, ); archive.file( path, scenario === "relationship bytes" - ? `${"x".repeat(1024 * 1024)}` + ? `${"x".repeat(WORD_LIMITS.auxiliaryPartBytes)}` : scenario === "deep XML" ? `${"".repeat(65)}${"".repeat(65)}` - : `${" - T3 Code (Beta) + + + T3 Code
diff --git a/apps/web/src/appInit.tsx b/apps/web/src/appInit.tsx index 3be9175c4f2e4..dd38a292718dc 100644 --- a/apps/web/src/appInit.tsx +++ b/apps/web/src/appInit.tsx @@ -1,4 +1,9 @@ -import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { + scopedThreadKey, + scopeProjectRef, + scopeThreadRef, +} from "@t3tools/client-runtime/environment"; +import type { ScopedThreadRef } from "@t3tools/contracts"; import type { ClientSettings } from "@t3tools/contracts/settings"; import { createBrowserHistory, createHashHistory } from "@tanstack/react-router"; import * as Schema from "effect/Schema"; @@ -22,7 +27,7 @@ import { startSlowRpcRequestToasts } from "./components/SlowRpcRequestToastCoord import { startThreadNotifications } from "./components/ThreadNotificationCoordinator"; import { startGuideRunNotifications } from "./components/pullRequest/guide/PullRequestGuideRunCoordinator"; import { APP_BASE_NAME, APP_DISPLAY_NAME, APP_STAGE_LABEL } from "./branding"; -import { resolveServerBackedAppDisplayName } from "./branding.logic"; +import { resolveServerBackedAppDisplayName, resolveWindowTitle } from "./branding.logic"; import { isElectron } from "./env"; import { subscribeComposerDraftModelReconciliation, @@ -48,6 +53,7 @@ import { import { startProviderAuthDelivery } from "./providerAuthDelivery"; import { getRouter, type AppRouter } from "./router"; import { appAtomRegistry } from "./rpc/atomRegistry"; +import { environmentProjects } from "./state/projects"; import { primaryServerConfigAtom } from "./state/server"; import { environmentShell } from "./state/shell"; import { installChatRouteCommands } from "./shortcuts/chatRouteCommands"; @@ -55,7 +61,7 @@ import { startWorkspaceFocus } from "./lib/workspaceFocus"; import { installCommandPaletteCommands } from "./shortcuts/commandPaletteCommands"; import { registerOpenFavoriteEditorCommand } from "./components/chat/OpenInPickerShortcut"; import { environmentThreadShells, threadEnvironment } from "./state/threads"; -import { resolveThreadRouteTarget } from "./threadRoutes"; +import { resolveActiveThreadRouteRef, resolveThreadRouteTarget } from "./threadRoutes"; /** Writes appearance settings onto the document, touching only the groups that changed. */ function startClientAppearance(): void { @@ -117,19 +123,82 @@ function startClientAppearance(): void { apply(); } -function startDocumentTitle(): void { - appAtomRegistry.subscribe( - primaryServerConfigAtom, - (config) => { - document.title = resolveServerBackedAppDisplayName({ +function readOpenThreadRef(router: AppRouter): ScopedThreadRef | null { + const target = resolveThreadRouteTarget(router.state.matches.at(-1)?.params ?? {}); + const draft = + target?.kind === "draft" + ? useComposerDraftStore.getState().getDraftSession(target.draftId) + : null; + return resolveActiveThreadRouteRef(target, draft); +} + +/** Names the open thread and its project in the window title, or the app when none is open. */ +function startDocumentTitle(router: AppRouter): void { + const readOnGuestPage = () => router.state.matches.some((match) => match.routeId === "/guest"); + let openThreadRef = readOpenThreadRef(router); + let onGuestPage = readOnGuestPage(); + const write = () => { + // GuestPage titles the tab after the shared thread. + if (onGuestPage) return; + const thread = + openThreadRef === null + ? null + : appAtomRegistry.get(environmentThreadShells.quietThreadShellAtom(openThreadRef)); + const project = + thread === null + ? null + : appAtomRegistry.get( + environmentProjects.projectAtom( + scopeProjectRef(thread.environmentId, thread.projectId), + ), + ); + const title = resolveWindowTitle({ + appDisplayName: resolveServerBackedAppDisplayName({ baseName: APP_BASE_NAME, fallbackDisplayName: APP_DISPLAY_NAME, fallbackStageLabel: APP_STAGE_LABEL, - primaryServerVersion: config?.environment.serverVersion ?? null, - }); - }, - { immediate: true }, - ); + primaryServerVersion: + appAtomRegistry.get(primaryServerConfigAtom)?.environment.serverVersion ?? null, + }), + projectTitle: project?.title ?? null, + threadTitle: thread?.title ?? null, + desktop: isElectron, + }); + // Projects change whenever any project does; most changes leave the title alone. + if (document.title !== title) document.title = title; + }; + // The open thread's own shell, since the shell lists skip threads hidden by a tag filter. + let unsubscribeOpenThread = () => {}; + const watchOpenThread = () => { + unsubscribeOpenThread(); + unsubscribeOpenThread = + openThreadRef === null + ? () => {} + : appAtomRegistry.subscribe( + environmentThreadShells.quietThreadShellAtom(openThreadRef), + write, + ); + }; + // The draft store changes on every keystroke; only a new open thread matters here. + const updateOpenThread = () => { + const next = readOpenThreadRef(router); + const nextOnGuestPage = readOnGuestPage(); + if ( + nextOnGuestPage === onGuestPage && + (next && scopedThreadKey(next)) === (openThreadRef && scopedThreadKey(openThreadRef)) + ) { + return; + } + openThreadRef = next; + onGuestPage = nextOnGuestPage; + watchOpenThread(); + write(); + }; + watchOpenThread(); + appAtomRegistry.subscribe(primaryServerConfigAtom, write, { immediate: true }); + appAtomRegistry.subscribe(environmentProjects.projectsAtom, write); + router.subscribe("onResolved", updateOpenThread); + useComposerDraftStore.subscribe(updateOpenThread); } /** @@ -180,7 +249,7 @@ export function startAppServices(): AppRouter { installDesktopPasteAsText(window.desktopBridge, window); startClientAppearance(); - startDocumentTitle(); + startDocumentTitle(router); startHostEnvironmentLookup(); startEnvironmentThemeRepaint(); startBrowserChromeThemeSync(router); diff --git a/apps/web/src/branding.logic.ts b/apps/web/src/branding.logic.ts index 126fb77062481..6490edbb4a05c 100644 --- a/apps/web/src/branding.logic.ts +++ b/apps/web/src/branding.logic.ts @@ -11,6 +11,21 @@ export function formatAppDisplayName(input: { return `${input.baseName} (${input.stageLabel})`; } +export function resolveHostedAppChannelLabel(channel: string | null | undefined): string | null { + const normalized = channel?.trim().toLowerCase(); + return normalized === "nightly" ? "Nightly" : normalized === "latest" ? "Latest" : null; +} + +/** Stage label of a web build. vite.config.ts stamps it into index.html's title too. */ +export function resolveWebAppStageLabel(input: { + readonly hostedAppChannel: string | null | undefined; + readonly development: boolean; +}): string { + return ( + resolveHostedAppChannelLabel(input.hostedAppChannel) ?? (input.development ? "Dev" : "Beta") + ); +} + export function resolveServerBackedAppStageLabel(input: { readonly primaryServerVersion: string | null | undefined; readonly fallbackStageLabel: string; @@ -36,3 +51,23 @@ export function resolveServerBackedAppDisplayName(input: { ? input.fallbackDisplayName : formatAppDisplayName({ baseName: input.baseName, stageLabel }); } + +/** + * Names the open thread: `project / thread` on desktop, where the OS already + * shows the app, with the app name appended in browser tabs. + */ +export function resolveWindowTitle(input: { + readonly appDisplayName: string; + readonly projectTitle: string | null; + readonly threadTitle: string | null; + readonly desktop: boolean; +}): string { + const threadTitle = input.threadTitle?.trim() ?? ""; + if (threadTitle === "") { + return input.appDisplayName; + } + + const projectTitle = input.projectTitle?.trim() ?? ""; + const context = projectTitle === "" ? threadTitle : `${projectTitle} / ${threadTitle}`; + return input.desktop ? context : `${context} - ${input.appDisplayName}`; +} diff --git a/apps/web/src/branding.ts b/apps/web/src/branding.ts index 6ac2d1f397a47..bf5b76f0dd1b7 100644 --- a/apps/web/src/branding.ts +++ b/apps/web/src/branding.ts @@ -1,5 +1,9 @@ import type { DesktopAppBranding } from "@t3tools/contracts"; -import { formatAppDisplayName } from "./branding.logic"; +import { + formatAppDisplayName, + resolveHostedAppChannelLabel, + resolveWebAppStageLabel, +} from "./branding.logic"; function readInjectedDesktopAppBranding(): DesktopAppBranding | null { if (typeof window === "undefined") { @@ -14,13 +18,14 @@ const hostedAppChannel = import.meta.env.VITE_HOSTED_APP_CHANNEL?.trim().toLower export const HOSTED_APP_CHANNEL = hostedAppChannel === "latest" || hostedAppChannel === "nightly" ? hostedAppChannel : null; -export const HOSTED_APP_CHANNEL_LABEL = - HOSTED_APP_CHANNEL === "nightly" ? "Nightly" : HOSTED_APP_CHANNEL === "latest" ? "Latest" : null; +export const HOSTED_APP_CHANNEL_LABEL = resolveHostedAppChannelLabel(HOSTED_APP_CHANNEL); export const APP_BASE_NAME = injectedDesktopAppBranding?.baseName ?? "T3 Code"; export const APP_STAGE_LABEL = injectedDesktopAppBranding?.stageLabel ?? - HOSTED_APP_CHANNEL_LABEL ?? - (import.meta.env.DEV ? "Dev" : "Beta"); + resolveWebAppStageLabel({ + hostedAppChannel: HOSTED_APP_CHANNEL, + development: import.meta.env.DEV, + }); export const APP_DISPLAY_NAME = injectedDesktopAppBranding?.displayName ?? formatAppDisplayName({ baseName: APP_BASE_NAME, stageLabel: APP_STAGE_LABEL }); diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 8a627a540a156..89c54e307d963 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -22,6 +22,7 @@ import { loadRepoEnv } from "../../scripts/lib/public-config"; import { thirdPartyLicensesPlugin } from "../../scripts/lib/third-party-licenses"; import { tailwindPlugins } from "./vite/tailwind"; import { warmBundledDevPlugin } from "./vite/warmBundledDev"; +import { formatAppDisplayName, resolveWebAppStageLabel } from "./src/branding.logic"; const repoEnv = loadRepoEnv(); Object.assign(process.env, repoEnv); @@ -246,6 +247,28 @@ function chunkCycleGuardPlugin(): Plugin { }; } +// Stamps index.html's title with the name branding.ts resolves once the app +// loads, so a tab opens on the right channel instead of flashing another's. +// Desktop swaps in its injected branding from an inline script in index.html. +function bootstrapTitlePlugin(): Plugin { + let development = false; + return { + name: "t3code:bootstrap-title", + configResolved(config) { + // The same flag Vite exposes to the app as import.meta.env.DEV. + development = !config.isProduction; + }, + transformIndexHtml(html) { + const stageLabel = resolveWebAppStageLabel({ + hostedAppChannel: configuredHostedAppChannel, + development, + }); + const title = formatAppDisplayName({ baseName: "T3 Code", stageLabel }); + return html.replace(/[^<]*<\/title>/, `<title>${title}`); + }, + }; +} + // Allow Tailnet hosts for sharing; Tailscale-controlled DNS prevents attacker rebinding. const configuredAllowedHosts = (process.env.T3CODE_DEV_ALLOWED_HOSTS ?? "") .split(",") @@ -266,6 +289,7 @@ export default defineConfig(() => { }, assetsInclude: ["**/*.wasm"], plugins: [ + bootstrapTitlePlugin(), devCompressionPlugin(), chunkCycleGuardPlugin(), builtDev From c4cf58511b1cc957ba3efa92563d0d187d0ff768 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 12:00:05 -0800 Subject: [PATCH 0094/1165] fix(server): long Claude sessions import instead of being skipped (#29183) Co-authored-by: Claude Opus 5.5 --- .../src/project/AgentSessionScanner.test.ts | 89 +++++++++++++++++++ .../server/src/project/AgentSessionScanner.ts | 47 ++++++++-- 2 files changed, 131 insertions(+), 5 deletions(-) diff --git a/apps/server/src/project/AgentSessionScanner.test.ts b/apps/server/src/project/AgentSessionScanner.test.ts index f672768e7ac58..956a65976369f 100644 --- a/apps/server/src/project/AgentSessionScanner.test.ts +++ b/apps/server/src/project/AgentSessionScanner.test.ts @@ -2034,6 +2034,95 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { }), ); + it.effect("imports long Claude history beyond the selected-history and record limits", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-long-conversation-claude-"); + const codexHomePath = yield* makeTempDir("t3code-long-conversation-codex-"); + const workspace = yield* makeTempDir("t3code-long-conversation-workspace-"); + const record = (fields: Record) => + encodeTranscriptRecord({ sessionId: "claude-long", ...fields }); + const longText = "x".repeat(64 * 1024); + const middleCount = 600; + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug", "claude-long.jsonl"), + contents: [ + record({ + type: "user", + cwd: workspace, + message: { role: "user", content: "First prompt" }, + }), + // 600 x 64 KiB exceeds the 32 MiB selected-history budget; the shown window fits. + ...Array.from({ length: middleCount }, (_, index) => { + const role = index % 2 === 0 ? "assistant" : "user"; + return record({ type: role, message: { role, content: longText } }); + }), + // Tool and progress records push the file past the 100,000-record limit. + ...Array.from({ length: 100_000 }, () => "{}"), + record({ type: "user", message: { role: "user", content: "Latest prompt" } }), + ].join("\n"), + mtimeMs: nowMs - 60_000, + }); + + const outcomes = yield* runRecentThreadOutcomes({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + }); + + expect(outcomes.map((outcome) => outcome._tag)).toEqual(["Importable"]); + const thread = outcomes[0]?._tag === "Importable" ? outcomes[0].thread : undefined; + expect(thread?.messages[0]).toMatchObject({ role: "user", text: "First prompt" }); + expect(thread?.messages.at(-1)).toMatchObject({ role: "user", text: "Latest prompt" }); + expect(thread?.messages).toHaveLength(200); + expect(thread?.omittedMessageCount).toBe(middleCount + 2 - 200); + }), + ); + + it.effect("imports a late first Claude prompt once when older replies are dropped", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-late-prompt-claude-"); + const codexHomePath = yield* makeTempDir("t3code-late-prompt-codex-"); + const workspace = yield* makeTempDir("t3code-late-prompt-workspace-"); + const contents = [ + encodeTranscriptRecord({ type: "attachment", sessionId: "late-prompt", cwd: workspace }), + // The first prompt is still among the latest 200 messages after older replies drop out. + ...Array.from({ length: 250 }, (_, index) => { + const role = index < 60 || index % 2 === 1 ? "assistant" : "user"; + return encodeTranscriptRecord({ + type: role, + sessionId: "late-prompt", + message: { role, content: `${role} ${index}` }, + }); + }), + ].join("\n"); + const filePath = path.join(claudeHomePath, "projects", "-slug", "late-prompt.jsonl"); + yield* writeTranscript({ filePath, contents, mtimeMs: nowMs - 60_000 }); + + const [thread] = yield* runRecentThreads({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + }); + + expect(thread?.messages[0]).toMatchObject({ role: "assistant", text: "assistant 50" }); + expect(thread).toEqual( + AgentSessionScanner.parseAgentSessionTranscript({ + source: "claudeAgent", + providerInstanceId: ProviderInstanceId.make("claudeAgent"), + fallbackSessionId: "late-prompt", + lastActiveAtMs: nowMs - 60_000, + contents, + }), + ); + }), + ); + it.effect("skips growth during reading without exceeding the reserved bytes", () => Effect.gen(function* () { const path = yield* Path.Path; diff --git a/apps/server/src/project/AgentSessionScanner.ts b/apps/server/src/project/AgentSessionScanner.ts index 1b32de22a8878..6c8410241a3f5 100644 --- a/apps/server/src/project/AgentSessionScanner.ts +++ b/apps/server/src/project/AgentSessionScanner.ts @@ -431,13 +431,14 @@ export function parseAgentSessionTranscript( input.source === "claudeAgent" ? records.reduce(foldClaudeFields, emptyClaudeFields) : emptyClaudeFields; - return parseAgentSessionRecords(input, records, claudeFields); + return parseAgentSessionRecords(input, records, claudeFields, 0); } function parseAgentSessionRecords( input: AgentSessionTranscriptMetadata, records: ReadonlyArray, claudeFields: ClaudeFields, + droppedMessageCount: number, ): AgentSessionThread | null { const fallbackTimestamp = DateTime.formatIso(DateTime.makeUnsafe(input.lastActiveAtMs)); // Claude filenames are session IDs. Codex rollout filenames include extra @@ -650,7 +651,7 @@ function parseAgentSessionRecords( createdAt: retainedMessages[0]?.createdAt ?? fallbackTimestamp, updatedAt: fallbackTimestamp, messages: retainedMessages, - omittedMessageCount: conversationMessageCount - retainedMessages.length, + omittedMessageCount: conversationMessageCount + droppedMessageCount - retainedMessages.length, }; } @@ -687,6 +688,31 @@ function shouldRetainDecodedRecord( ); } +// The import shows only the first user message and the latest messages, so a +// long Claude session holds just those and counts the rest as dropped. +function createClaudeHistoryWindow() { + const recent: Array<{ readonly record: DecodedTranscriptRecord; readonly bytes: number }> = []; + let firstUser: DecodedTranscriptRecord | undefined; + let droppedMessageCount = 0; + return { + // Takes a record shouldRetainDecodedRecord kept; returns the bytes it released. + push: (record: DecodedTranscriptRecord, bytes: number): number => { + if (firstUser === undefined && record.type === "user") firstUser = record; + recent.push({ record, bytes }); + if (recent.length <= MAX_IMPORTED_MESSAGES) return 0; + const evicted = recent.shift(); + if (evicted === undefined || evicted.record === firstUser) return 0; + droppedMessageCount += 1; + return evicted.bytes; + }, + records: () => { + const held = recent.map((entry) => entry.record); + return firstUser === undefined || held.includes(firstUser) ? held : [firstUser, ...held]; + }, + droppedMessageCount: () => droppedMessageCount, + }; +} + function normalizeForWorktreeMatch(value: string, caseFold: boolean): string { const normalized = `${value.replaceAll("\\", "/")}/`; return caseFold ? normalized.toLowerCase() : normalized; @@ -1056,6 +1082,7 @@ export const make = Effect.gen(function* () { return null; } const records: Array = []; + const claudeHistory = createClaudeHistoryWindow(); let cwd = Option.none(); let claudeFields = emptyClaudeFields; let historyBytes = prefix?.historyBytes ?? 0; @@ -1077,7 +1104,8 @@ export const make = Effect.gen(function* () { const finishRecord = () => { reader.write(decoder.decode()); recordCount += 1; - if (recordCount > MAX_IMPORT_RECORDS) return false; + // Claude keeps a bounded window, so only the byte budgets limit its length. + if (recordCount > MAX_IMPORT_RECORDS && source !== "claudeAgent") return false; const value = reader.finish(); const decoded = decodeTranscriptValue(value); if (Option.isNone(decoded) && source === "pi") { @@ -1089,7 +1117,9 @@ export const make = Effect.gen(function* () { claudeFields = foldClaudeFields(claudeFields, decoded.value); } if (shouldRetainDecodedRecord(source, decoded.value)) { - if ( + if (source === "claudeAgent") { + historyBytes += recordBytes - claudeHistory.push(decoded.value, recordBytes); + } else if ( source === "pi" && decoded.value.type === "message" && !isPiConversationRecord(decoded.value) @@ -1147,7 +1177,13 @@ export const make = Effect.gen(function* () { ) return null; return sameTranscriptIdentity(expected, transcriptIdentity(filePath, yield* file.stat)) - ? { records, cwd, claudeFields, historyBytes } + ? { + records: source === "claudeAgent" ? claudeHistory.records() : records, + droppedMessageCount: claudeHistory.droppedMessageCount(), + cwd, + claudeFields, + historyBytes, + } : null; }), ), @@ -1900,6 +1936,7 @@ export const make = Effect.gen(function* () { ? snapshot.records : [...inheritedRecords, ...snapshot.records], snapshot.claudeFields, + snapshot.droppedMessageCount, ); if (parsedThread === null) { return Option.some({ _tag: "Skipped" }); From 675d2bd3b23f315cbfa03d687df788b68b11a157 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 12:00:16 -0800 Subject: [PATCH 0095/1165] fix(server): closed PR watches end even when the activity read fails (#27953) Co-authored-by: Claude Opus 5.5 --- .../PullRequestWatchReactor.ts | 11 +-- .../src/orchestration-v2/runtimeLayer.test.ts | 69 +++++++++++++++++++ 2 files changed, 76 insertions(+), 4 deletions(-) diff --git a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts index 91452764deefa..6fdea57bf9e1e 100644 --- a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts +++ b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts @@ -308,7 +308,7 @@ export const make = Effect.gen(function* () { const giveUp = (target: WatchTarget) => record(target, null, { - text: `T3 Code stopped watching pull request #${target.link.number} (${target.link.url}) because it failed to read it from the host ${READ_FAILURE_LIMIT} times in a row. Check it yourself, and call watch_pull_request to watch it again.`, + text: `T3 Code stopped watching pull request #${target.link.number} (${target.link.url}) because it failed to read it from the host ${READ_FAILURE_LIMIT} times in a row. Check it yourself, and call watch_pull_request only if it is still open.`, notification: { source: { kind: "monitor" }, outcome: "failed", @@ -501,11 +501,14 @@ export const make = Effect.gen(function* () { { concurrency: 2 }, ); // Activity failures must not cancel or hide a permanent detail failure. + // Activity is irrelevant once the pull request is closed or merged, so it cannot block ending. const read = Exit.isFailure(detailRead) ? Exit.failCause(detailRead.cause) - : Exit.isFailure(activityRead) - ? Exit.failCause(activityRead.cause) - : Exit.succeed([detailRead.value, activityRead.value] as const); + : detailRead.value.state !== "open" + ? Exit.succeed([detailRead.value, Option.none()] as const) + : Exit.isFailure(activityRead) + ? Exit.failCause(activityRead.cause) + : Exit.succeed([detailRead.value, activityRead.value] as const); if (Exit.isFailure(read)) { if (Cause.hasInterruptsOnly(read.cause)) return yield* Effect.failCause(read.cause); lastReads.delete(group.key); diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 4b94f518d3aee..9cce2d21a85ff 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -3663,6 +3663,75 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); + it.effect("ends a watch on a closed pull request whose activity cannot be read", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const threadId = ThreadId.make("runtime-pull-request-watch-closed-unreadable"); + const projectId = ProjectId.make("pr-watch-closed-unreadable-project"); + yield* seedProject({ + projectId, + title: "Watch closed unreadable", + workspaceRoot: "/workspace/watch-closed-unreadable", + defaultModelSelection: null, + createdAt: "2026-10-01T00:00:00.000Z", + }); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("pr-watch-closed-unreadable-create"), + threadId, + projectId, + title: "Watch closed unreadable", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + yield* orchestrator.dispatch({ + type: "thread.pull-request.watch", + commandId: CommandId.make("pr-watch-closed-unreadable-start"), + threadId, + host: "github.com", + repository: "pingdotgg/t3code", + number: 8, + watching: true, + link: { url: "https://github.com/pingdotgg/t3code/pull/8", source: "agent" }, + }); + const reactor = yield* PullRequestWatchReactor.make.pipe( + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + NodeCrypto.layer, + Layer.mock(PullRequestService.PullRequestService)({ + watchFingerprint: () => Effect.succeed(null), + detail: () => + Effect.succeed({ + ...watchedPullRequestDetail({ + projectId, + number: 8, + at: "2026-10-02T12:00:00.000Z", + }), + state: "closed" as const, + }), + activity: () => Effect.die("host unreachable"), + }), + ), + ), + ); + yield* reactor.sweep; + + const thread = yield* orchestrator.getThreadShell(threadId); + assert.isUndefined(thread?.pullRequests?.[0]?.watch); + const { messages } = yield* orchestrator.getThreadRecords(threadId, ["messages"]); + assert.deepEqual( + messages.flatMap((message) => message.notification?.summary ?? []), + ["#8: closed, stopped watching"], + ); + }), + ); + it.effect("wakes once for a state it reported, across a restart and a new watch", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; From 731c87334c75705ee85cd657957ee93c5b946ba8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 12:00:26 -0800 Subject: [PATCH 0096/1165] fix(desktop): a hidden preview tab no longer keeps the display awake after a screenshot (#25978) Co-authored-by: Claude Opus 5.5 --- .../src/preview/DesktopBrowserHost.test.ts | 39 +++++++---- .../desktop/src/preview/DesktopBrowserHost.ts | 67 +++++++++++-------- 2 files changed, 63 insertions(+), 43 deletions(-) diff --git a/apps/desktop/src/preview/DesktopBrowserHost.test.ts b/apps/desktop/src/preview/DesktopBrowserHost.test.ts index 7b8729a11646c..a40450643215f 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.test.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.test.ts @@ -607,20 +607,21 @@ describe("DesktopBrowserHost", () => { params: { sessionId: "t3-preview-page-1" }, }); expect(yield* Queue.take(replies)).toBe(3); - expect(painting).toEqual([true, false]); + expect(painting).toEqual([true]); // The agent's screenshot waits its turn; painting lets the abandoned one finish first. yield* command(host, 4, "Page.captureScreenshot"); yield* settle; - expect(painting).toEqual([true, false, true]); + expect(painting).toEqual([true]); debuggee.release(); expect(yield* Queue.take(replies)).toBe(2); yield* settle; expect(debuggee.sent).toEqual([undefined, 4]); + expect(painting).toEqual([true]); debuggee.release(); expect(yield* Queue.take(replies)).toBe(4); yield* settle; - expect(painting).toEqual([true, false, true, false]); + expect(painting).toEqual([true, false]); }).pipe(Effect.scoped), ); @@ -645,14 +646,19 @@ describe("DesktopBrowserHost", () => { expect(yield* Queue.take(replies)).toBe(2); yield* raw({ id: 3, method: "Page.captureScreenshot", sessionId: "t3-preview-page-1" }); yield* settle; - expect(painting).toEqual([true, false, true]); + expect(painting).toEqual([true]); yield* raw({ id: 4, method: "Target.detachFromTarget", params: { sessionId: "t3-preview-page-1" }, }); expect(yield* Queue.take(replies)).toBe(4); - expect(painting).toEqual([true, false, true, false]); + + // The agent's answers late; the viewer's is dropped unsent, so nothing is left to paint. + debuggee.release(); + yield* settle; + expect(debuggee.sent).toEqual([1]); + expect(painting).toEqual([true, false]); yield* TestClock.adjust("1 minute"); expect(debuggee.focusEmulation).toEqual([true, false]); }).pipe(Effect.scoped), @@ -682,7 +688,7 @@ describe("DesktopBrowserHost", () => { }).pipe(Effect.scoped), ); - it.effect("stops painting for a screenshot still queued when the agent's turn ends", () => + it.effect("holds no focus for a screenshot still queued when the agent's turn ends", () => Effect.gen(function* () { const host = yield* DesktopBrowserHost.make; const debuggee = makeDebuggee(); @@ -697,15 +703,18 @@ describe("DesktopBrowserHost", () => { yield* settle; yield* turn(false); yield* settle; - expect(painting).toEqual([true, false]); + expect(painting).toEqual([true]); - // The first answers late; the second still goes in turn, but holds nothing. + // The first answers late; the second still goes in turn, painting but holding no focus. debuggee.releaseFirst(); yield* settle; expect(debuggee.sent).toEqual([1, 2]); - expect(painting).toEqual([true, false]); yield* TestClock.adjust("1 minute"); expect(debuggee.focusEmulation).toEqual([true, false]); + expect(painting).toEqual([true]); + debuggee.release(); + yield* settle; + expect(painting).toEqual([true, false]); }).pipe(Effect.scoped), ); @@ -952,7 +961,7 @@ describe("DesktopBrowserHost", () => { }).pipe(Effect.scoped), ); - it.effect("stops painting for a screenshot Chromium never answers", () => + it.effect("stops painting for a screenshot Chromium never answers once its document goes", () => Effect.gen(function* () { const host = yield* DesktopBrowserHost.make; const debuggee = makeDebuggee(); @@ -961,32 +970,34 @@ describe("DesktopBrowserHost", () => { const turn = (active: boolean) => host.handleCommandLine(encodeJson({ type: "turn", threadId: key.threadId, active })); - // The server gave up on it, and the agent's turn ends. + // The server gave up on it, and the agent's turn ends; Chromium still holds the capture. yield* turn(true); yield* command(host, 1, "Page.captureScreenshot"); yield* settle; yield* turn(false); yield* settle; - expect(painting).toEqual([true, false]); + expect(painting).toEqual([true]); // The page loads a new document, which also frees the screenshot turn. debuggee.navigate(); + expect(painting).toEqual([true, false]); yield* command(host, 2, "Page.captureScreenshot"); yield* settle; debuggee.navigate(); yield* settle; expect(painting).toEqual([true, false, true, false]); - // The server releases the relay. + // The server releases the relay; Chromium still holds the capture. yield* command(host, 3, "Page.captureScreenshot"); yield* settle; yield* host.handleCommandLine(encodeJson({ type: "release", ...key })); yield* settle; - expect(painting).toEqual([true, false, true, false, true, false]); + expect(painting).toEqual([true, false, true, false, true]); // The guest detaches; a replacement starts with no screenshot pending. const replacement = makeDebuggee(); host.attach(key, replacement.tab); + expect(painting).toEqual([true, false, true, false, true, false]); yield* command(host, 4, "Page.captureScreenshot"); yield* settle; host.detach(key); diff --git a/apps/desktop/src/preview/DesktopBrowserHost.ts b/apps/desktop/src/preview/DesktopBrowserHost.ts index d1f1ce9ac35a0..2cda717a891b7 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.ts @@ -49,7 +49,7 @@ interface AttachedTab { readonly debuggee: DesktopBrowserTabDebugger; relay: CdpRelayConnection | null; screenshotTurn: object | null; - /** Each holds focus and painting while `held` and whoever queued it still waits on it. */ + /** Each keeps the guest painting while whoever queued it still waits, and focused while `held`. */ readonly waitingScreenshots: Array<{ readonly start: () => void; readonly live: () => boolean; @@ -62,10 +62,9 @@ interface AttachedTab { focusChange: Promise | null; focusUses: number; /** Each pending command, by the extra session waiting on it; the main connection's have none. */ - readonly focusPending: Map< - object, - { readonly owner: string | undefined; readonly paints: boolean } - >; + readonly focusPending: Map; + /** Screenshots Chromium was sent and has not answered; each keeps its wake lock until then. */ + readonly capturing: Set; /** Whether the client keeps the guest painting, as published on `painting`. */ painting: boolean; /** @@ -150,7 +149,7 @@ export const make = Effect.gen(function* () { tab: AttachedTab, relay: CdpRelayConnection, ownerLive: () => boolean, - /** Sends only while `current` holds; holds focus and painting only while `held`. */ + /** Sends only while `current` holds; holds focus only while `held`. */ capture: (current: () => boolean, held: boolean) => Promise, ) => new Promise((resolve, reject) => { @@ -238,38 +237,42 @@ export const make = Effect.gen(function* () { const active = tabs.get(keyOf(tab.key)) === tab && (tab.screencasts.size > 0 || - waitingHolds(tab) || - [...tab.focusPending.values()].some((hold) => hold.paints)); + tab.capturing.size > 0 || + tab.waitingScreenshots.some((waiting) => waiting.live())); if (tab.painting === active) return; tab.painting = active; runFork(PubSub.publish(painting, { key: tab.key, active })); }; /** A command may wait on the page's next frame, so the guest stays focused until it settles. */ - const holdFocus = ( - tab: AttachedTab, - method: string, - owner: string | undefined, - work: () => Promise, - ) => { + const holdFocus = (tab: AttachedTab, owner: string | undefined, work: () => Promise) => { const pending = {}; - tab.focusPending.set(pending, { owner, paints: method === "Page.captureScreenshot" }); + tab.focusPending.set(pending, owner); tab.focusUses += 1; setFocusEmulation(tab, true); releaseFocusWhenIdle(tab); - syncPainting(tab); return work().finally(() => { if (!tab.focusPending.delete(pending)) return; tab.focusUses += 1; releaseFocusWhenIdle(tab); + }); + }; + + /** Chromium answers a capture, and frees its wake lock, only once the guest paints a frame. */ + const paintUntilAnswered = (tab: AttachedTab, send: () => Promise) => { + const sent = {}; + tab.capturing.add(sent); + syncPainting(tab); + return send().finally(() => { + tab.capturing.delete(sent); syncPainting(tab); }); }; /** * Chromium can leave a command unanswered, so the screenshot guard's release points - * also end every hold: a released relay has no one waiting, and a new document answers - * nothing pending from the old one. A released relay's screencast has no viewer to stop it. + * also end every focus hold: a released relay has no one waiting, and a new document + * answers nothing pending from the old one. A released relay's screencast has no viewer. */ const dropFocusHolds = (tab: AttachedTab) => { tab.focusPending.clear(); @@ -287,8 +290,8 @@ export const make = Effect.gen(function* () { /** A detached session, such as a closed viewer, waits on nothing it sent. */ const dropSessionHolds = (tab: AttachedTab, owner: string) => { tab.screencasts.delete(owner); - for (const [pending, hold] of tab.focusPending) { - if (hold.owner === owner) tab.focusPending.delete(pending); + for (const [pending, holder] of tab.focusPending) { + if (holder === owner) tab.focusPending.delete(pending); } releaseFocusWhenIdle(tab); syncPainting(tab); @@ -296,7 +299,7 @@ export const make = Effect.gen(function* () { /** * A command the server stopped waiting on, such as a timed-out screenshot or evaluation, - * may never settle; the turn's end ends every hold. The agent also sends on its own extra + * may never settle; the turn's end ends every focus hold. The agent also sends on its own extra * session, so this cannot spare other sessions; a viewer's screencast still holds. */ const endAgentTurn = (threadId: string) => { @@ -304,7 +307,7 @@ export const make = Effect.gen(function* () { for (const tab of tabs.values()) { if (tab.key.threadId !== threadId) continue; tab.agentUsed = false; - // Still sent in turn, so the queue keeps moving, but no longer kept painting. + // Still sent in turn, so the queue keeps moving, but no longer kept focused. for (const waiting of tab.waitingScreenshots) waiting.held = false; dropFocusHolds(tab); } @@ -325,8 +328,10 @@ export const make = Effect.gen(function* () { if (sessionId === undefined && method === "Page.startScreencast") { tab.screencasts.add(owner); if (owner !== undefined) streamingOwners.add(owner); + syncPainting(tab); } else if (sessionId === undefined && method === "Page.stopScreencast") { tab.screencasts.delete(owner); + syncPainting(tab); } // A person's input through a viewer, or Playwright's own housekeeping, is not the agent's. if ( @@ -339,9 +344,11 @@ export const make = Effect.gen(function* () { const forward = (current?: () => boolean) => { const change = tab.focusChange; const run = () => - tab.relay === relay && (current === undefined || current()) - ? send(method, params, sessionId) - : Promise.reject(new Error("The page this screenshot was for has moved on.")); + tab.relay !== relay || (current !== undefined && !current()) + ? Promise.reject(new Error("The page this screenshot was for has moved on.")) + : current === undefined + ? send(method, params, sessionId) + : paintUntilAnswered(tab, () => send(method, params, sessionId)); return change === null ? run() : change.then(run); }; return method === "Page.captureScreenshot" @@ -350,17 +357,17 @@ export const make = Effect.gen(function* () { relay, () => owner === undefined || !detachedOwners.has(owner), (current, held) => - held ? holdFocus(tab, method, owner, () => forward(current)) : forward(current), + held ? holdFocus(tab, owner, () => forward(current)) : forward(current), ) : method === "Page.startScreencast" - ? holdFocus(tab, method, owner, forward).catch((cause: unknown) => { + ? holdFocus(tab, owner, forward).catch((cause: unknown) => { // A viewer can outlive a refused stream; nothing is streaming for it. if (sessionId === undefined && tab.relay === relay) tab.screencasts.delete(owner); releaseFocusWhenIdle(tab); syncPainting(tab); throw cause; }) - : holdFocus(tab, method, owner, forward); + : holdFocus(tab, owner, forward); }, sessionDetached: (owner) => { detachedOwners.add(owner); @@ -447,6 +454,7 @@ export const make = Effect.gen(function* () { focusChange: null, focusUses: 0, focusPending: new Map(), + capturing: new Set(), painting: false, agentUsed: false, focusReleasing: false, @@ -461,7 +469,8 @@ export const make = Effect.gen(function* () { // 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: () => { - // Before the next screenshot starts, so its own hold survives. + // Before the next screenshot starts, so its own holds survive. + tab.capturing.clear(); dropFocusHolds(tab); if (tab.screenshotTurn !== null) nextScreenshot(tab); }, From 52c1491c8a301e79a25568bbbfdeb59317222fa0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 12:00:36 -0800 Subject: [PATCH 0097/1165] fix(server): successful worktree handoffs complete normally (#24545) Co-authored-by: Claude Opus 5.5 --- apps/server/src/orchestration-v2/EventSink.ts | 24 +++ .../FoundationPersistence.test.ts | 101 ++++++++++ .../ProviderTurnStartService.ts | 8 + .../RunExecutionService.test.ts | 172 ++++++++++++++++++ .../orchestration-v2/RunExecutionService.ts | 110 +++++++---- .../SelectionRestart.integration.test.ts | 113 ++++++++++++ 6 files changed, 491 insertions(+), 37 deletions(-) diff --git a/apps/server/src/orchestration-v2/EventSink.ts b/apps/server/src/orchestration-v2/EventSink.ts index 0c42ac73ff9cb..81ec4019f2c0f 100644 --- a/apps/server/src/orchestration-v2/EventSink.ts +++ b/apps/server/src/orchestration-v2/EventSink.ts @@ -3,6 +3,7 @@ import { type OrchestrationV2Run, OrchestrationV2DomainEvent, type OrchestrationV2ProviderThread, + type OrchestrationV2TurnItem, OrchestrationV2StoredEvent, ProviderThreadId, RunAttemptId, @@ -127,6 +128,11 @@ export interface EventSinkV2Shape { readonly runId: RunId; readonly activeAttemptId: RunAttemptId; readonly expectedStatus: OrchestrationV2Run["status"]; + /** Also refuse while this run interrupt request is stored without its result. */ + readonly unlessInterruptRequested?: { + readonly requestId: OrchestrationV2TurnItem["id"]; + readonly resultId: OrchestrationV2TurnItem["id"]; + }; readonly events: ReadonlyArray; readonly effects?: ReadonlyArray; }) => Effect.Effect< @@ -525,6 +531,24 @@ const layerBase: Layer.Layer< storedEvents: [] as ReadonlyArray, }; } + if (input.unlessInterruptRequested !== undefined) { + const { requestId, resultId } = input.unlessInterruptRequested; + const pending = yield* sql<{ readonly pending: number }>` + SELECT EXISTS ( + SELECT 1 FROM orchestration_v2_projection_turn_items + WHERE thread_id = ${input.threadId} AND turn_item_id = ${requestId} + ) AND NOT EXISTS ( + SELECT 1 FROM orchestration_v2_projection_turn_items + WHERE thread_id = ${input.threadId} AND turn_item_id = ${resultId} + ) AS pending + `; + if (pending[0]?.pending === 1) { + return { + committed: false as const, + storedEvents: [] as ReadonlyArray, + }; + } + } const normalized = yield* normalizeEvents( input.guardPendingUserInputCancellations === true diff --git a/apps/server/src/orchestration-v2/FoundationPersistence.test.ts b/apps/server/src/orchestration-v2/FoundationPersistence.test.ts index b8307ffc59953..796c10b411944 100644 --- a/apps/server/src/orchestration-v2/FoundationPersistence.test.ts +++ b/apps/server/src/orchestration-v2/FoundationPersistence.test.ts @@ -1984,6 +1984,107 @@ it.layer(layerTest)("orchestration V2 foundation persistence", (it) => { }).pipe(Effect.provide(Layer.fresh(layerTest))), ); + it.effect("refuses a run-current write once an unpaired interrupt request is stored", () => + Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make("thread:foundation-interrupt-guard"); + const runId = RunId.make("run:foundation-interrupt-guard"); + const attemptId = RunAttemptId.make("run-attempt:foundation-interrupt-guard"); + const requestId = TurnItemId.make("turn-item:foundation-interrupt-guard:request"); + const resultId = TurnItemId.make("turn-item:foundation-interrupt-guard:result"); + const run: OrchestrationV2Run = { + id: runId, + threadId, + ordinal: 1, + providerInstanceId, + modelSelection, + providerThreadId: null, + userMessageId: MessageId.make("message:foundation-interrupt-guard"), + rootNodeId: null, + activeAttemptId: attemptId, + status: "running", + queuePosition: null, + requestedAt: now, + startedAt: now, + completedAt: null, + checkpointId: null, + contextHandoffId: null, + }; + yield* eventSink.write({ + events: [ + threadCreatedEvent({ + id: "event:foundation-interrupt-guard:thread", + thread: makeThread(threadId, now), + now, + }), + { + id: EventId.make("event:foundation-interrupt-guard:run"), + type: "run.created", + threadId, + runId, + providerInstanceId, + occurredAt: now, + payload: run, + }, + ], + }); + const signalItem = (id: TurnItemId, type: "run_interrupt_request" | "run_interrupt_result") => + ({ + id: EventId.make(`event:${id}`), + type: "turn-item.updated", + threadId, + runId, + occurredAt: now, + payload: { + id, + threadId, + runId, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 1, + status: "completed", + title: "Interrupt", + startedAt: now, + completedAt: now, + updatedAt: now, + type, + message: "Stopped", + }, + }) as OrchestrationV2DomainEvent; + const completeRun = (suffix: string) => + eventSink.writeIfRunCurrent({ + threadId, + runId, + activeAttemptId: attemptId, + expectedStatus: "running", + unlessInterruptRequested: { requestId, resultId }, + events: [ + { + id: EventId.make(`event:foundation-interrupt-guard:waiting:${suffix}`), + type: "run.updated", + threadId, + runId, + providerInstanceId, + occurredAt: now, + payload: { ...run, status: "waiting" }, + }, + ], + }); + + yield* eventSink.write({ events: [signalItem(requestId, "run_interrupt_request")] }); + const refused = yield* completeRun("refused"); + assert.isFalse(refused.committed); + assert.deepEqual(refused.storedEvents, []); + + yield* eventSink.write({ events: [signalItem(resultId, "run_interrupt_result")] }); + assert.isTrue((yield* completeRun("paired")).committed); + }).pipe(Effect.provide(Layer.fresh(layerTest))), + ); + it.effect("guards post-terminal provider-thread writes by attempt and run ordinal", () => Effect.gen(function* () { const eventSink = yield* EventSink.EventSinkV2; diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index 0b94f9a104ee9..9880da3408a77 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -236,6 +236,7 @@ export const layer: Layer.Layer< readonly attemptId: OrchestrationV2RunAttempt["id"]; readonly providerThreadId: OrchestrationV2ProviderThread["id"]; readonly runOrdinal: number; + readonly worktreePath: string | null; readonly inheritedBackgroundTurnItems: ReturnType< typeof RunExecutionService.selectInheritedBackgroundTurnItems >; @@ -281,6 +282,11 @@ export const layer: Layer.Layer< }), ) .pipe(Effect.catchCause(() => Effect.succeed(false))), + hasWorkspaceMoved: () => + projectionStore.getRuntimeRecoveryProjection(input.threadId).pipe( + Effect.map((current) => current.thread.worktreePath !== input.worktreePath), + Effect.catchCause(() => Effect.succeed(false)), + ), loadRunInterruptReason: () => projectionStore .getThreadRecords(input.threadId, ["turnItems"], { @@ -692,6 +698,7 @@ export const layer: Layer.Layer< attemptId: attempt.id, providerThreadId: providerThread.id, runOrdinal: run.ordinal, + worktreePath: projection.thread.worktreePath, inheritedBackgroundTurnItems, }); const { isCurrentAttemptInStatus } = runControls; @@ -1766,6 +1773,7 @@ export const layer: Layer.Layer< shouldFinalizeRun: runControls.shouldFinalizeRun, hasUnpairedRunInterruptRequest: runControls.hasUnpairedRunInterruptRequest, loadRunInterruptReason: runControls.loadRunInterruptReason, + hasWorkspaceMoved: runControls.hasWorkspaceMoved, message: { messageId: message.id, text: userText, diff --git a/apps/server/src/orchestration-v2/RunExecutionService.test.ts b/apps/server/src/orchestration-v2/RunExecutionService.test.ts index e25b4c7ab556a..beba39a06a63e 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.test.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.test.ts @@ -2580,6 +2580,143 @@ it.effect( }), ); +it.effect("settles native subagents when a workspace handoff completes the root run", () => + Effect.gen(function* () { + const ids = backgroundScenarioIds("subagent-workspace-handoff"); + const providerInstanceId = ProviderInstanceId.make("codex"); + const written = yield* Ref.make>([]); + const ingestionDone = yield* Deferred.make(); + const layerTest = RunExecutionService.layer.pipe( + Layer.provide( + Layer.mergeAll( + McpAppModelContext.layerEmpty, + PreviewAgentActivity.layer, + Layer.mock(CheckpointService.CheckpointServiceV2)({ + captureBaseline: () => Effect.void, + }), + layerTurnItemPositionsTest, + mockEventSink({ + write: () => Effect.succeed([]), + writeWithEffects: (input) => + Ref.update(written, (current) => [...current, ...input.events]).pipe(Effect.as([])), + writeIfRunCurrent: (input) => + Ref.update(written, (current) => [...current, ...input.events]).pipe( + Effect.as({ committed: true, storedEvents: [] }), + ), + }), + IdAllocator.layer, + Layer.mock(ProviderEventIngestor.ProviderEventIngestorV2)({ + ingestNormalized: () => Effect.succeed([]), + }), + ServerSettings.layerTest(), + ), + ), + ); + const fixture = { + ids, + providerInstanceId, + childThreadId: ids.childThreadId, + driver, + status: "running", + } as const; + const runningSubagent = makeRunOwnedSubagentFixture(fixture); + const runningTurnItem = makeRunOwnedSubagentTurnItemFixture(fixture); + const runningNode = makeRunOwnedSubagentNodeFixture({ ids, status: "running" }); + const runningChildNode = makeRunOwnedSubagentChildNodeFixture({ ids, status: "running" }); + + yield* Effect.gen(function* () { + const runExecution = yield* RunExecutionService.RunExecutionServiceV2; + yield* runExecution.startRootRun({ + commandId: CommandId.make("command:subagent-workspace-handoff"), + appThread: rootAppThread(ids.threadId), + providerSessionId: ProviderSessionId.make("session:subagent-workspace-handoff"), + session: { + events: Stream.empty, + subscribeEvents: Effect.succeed({ + // The provider event stream stays open: only settled children may end ingestion. + events: Stream.concat( + Stream.fromIterable([ + childThreadCreatedEvent(ids), + { type: "subagent.updated", driver, subagent: runningSubagent }, + { type: "node.updated", driver, node: runningNode }, + { type: "node.updated", driver, node: runningChildNode }, + { type: "turn_item.updated", driver, turnItem: runningTurnItem }, + rootTerminalEvent(ids, "interrupted"), + ] satisfies ReadonlyArray), + Stream.never, + ), + close: Deferred.succeed(ingestionDone, undefined), + }), + startTurn: () => Effect.void, + } as unknown as ProviderAdapter.ProviderAdapterV2SessionRuntime, + run: { + id: ids.runId, + threadId: ids.threadId, + ordinal: 1, + providerInstanceId, + } as OrchestrationV2Run, + rootNode: { id: ids.rootNodeId } as OrchestrationV2ExecutionNode, + checkpointScope: { + id: CheckpointScopeId.make("checkpoint-scope:subagent-workspace-handoff"), + } as OrchestrationV2CheckpointScope, + providerThread: { id: ids.providerThreadId, driver } as OrchestrationV2ProviderThread, + attempt: { + id: ids.attemptId, + providerTurnId: ids.rootProviderTurnId, + } as OrchestrationV2RunAttempt, + attemptId: ids.attemptId, + providerTurnOrdinal: 1, + shouldFinalizeRun: () => Effect.succeed(true), + hasWorkspaceMoved: () => Effect.succeed(true), + message: { + messageId: MessageId.make("message:subagent-workspace-handoff:user"), + text: "Spawn a subagent then move to a worktree.", + attachments: [], + createdBy: "user", + creationSource: "web", + }, + modelSelection: { instanceId: providerInstanceId, model: "gpt-5.4" }, + runtimePolicy: { + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), + approvalPolicy: "never", + sandboxPolicy: { + type: "readOnly", + access: { type: "fullAccess" }, + networkAccess: false, + }, + }, + }); + }).pipe(Effect.provide(layerTest)); + + const closed = yield* Deferred.await(ingestionDone).pipe(Effect.timeoutOption("2 seconds")); + assert.isTrue(Option.isSome(closed), "settled children must end event ingestion"); + + const events = yield* Ref.get(written); + const statusOf = (type: OrchestrationV2DomainEvent["type"], id: string) => + events.flatMap((event) => { + if (event.type !== type) return []; + const payload = ( + event as { readonly payload: { readonly id: string; readonly status: string } } + ).payload; + return payload.id === id ? [payload.status] : []; + }); + assert.deepEqual(statusOf("subagent.updated", runningSubagent.id), ["interrupted"]); + assert.deepEqual(statusOf("turn-item.updated", runningTurnItem.id), ["interrupted"]); + assert.deepEqual(statusOf("node.updated", runningNode.id), ["interrupted"]); + assert.deepEqual(statusOf("node.updated", runningChildNode.id), ["interrupted"]); + assert.deepEqual(statusOf("run.updated", ids.runId), ["waiting"]); + assert.deepEqual(statusOf("run-attempt.updated", ids.attemptId), ["completed"]); + assert.isFalse( + events.some( + (event) => + event.type === "turn-item.updated" && event.payload.type === "run_interrupt_result", + ), + ); + }), +); + it.effect("omits interrupt results and subagent cascade for a superseded attempt", () => Effect.gen(function* () { const { written, observed } = yield* captureRootRunTermination({ @@ -2619,6 +2756,37 @@ it.effect("emits run_interrupt_result when superseded attempt still has a hard-s }), ); +it.effect("keeps a Stop that commits while a workspace handoff completes the run", () => + Effect.gen(function* () { + const stopCommitted = yield* Ref.make(false); + const { written, finalEvents, committedEffects } = yield* captureRootRunTermination({ + key: "stop-races-workspace-handoff", + shouldFinalizeRun: () => Effect.succeed(true), + // Stop commits after the handoff is detected and before the terminal write. + hasWorkspaceMoved: () => Ref.set(stopCommitted, true).pipe(Effect.as(true)), + // The sink refuses a guarded write once the Stop request is stored. + beforeTerminalWrite: (request) => + Ref.get(stopCommitted).pipe( + Effect.map((stopped) => !(stopped && request.unlessInterruptRequested !== undefined)), + ), + }); + const statuses = (type: OrchestrationV2DomainEvent["type"]) => + finalEvents.flatMap((event) => + event.type === type ? [(event.payload as { readonly status: string }).status] : [], + ); + assert.deepEqual(statuses("run.updated"), ["interrupted"]); + assert.deepEqual(statuses("run-attempt.updated"), ["interrupted"]); + assert.deepEqual( + written.map((item) => item.type), + ["run_interrupt_result"], + ); + assert.deepEqual( + committedEffects.map((effect) => effect.request.type), + ["checkpoint.capture"], + ); + }), +); + it.effect("does not overwrite Stop when ownership changes after the finalization read", () => Effect.gen(function* () { const { written, observed, submittedEffects, committedEffects } = @@ -3180,6 +3348,7 @@ function captureRootRunTermination(input: { readonly shouldFinalizeRun: () => Effect.Effect; readonly rejectTerminalWrite?: boolean; readonly hasUnpairedRunInterruptRequest?: () => Effect.Effect; + readonly hasWorkspaceMoved?: () => Effect.Effect; readonly loadRunInterruptReason?: () => Effect.Effect; readonly hasPendingBackgroundWorkForThread?: ProviderAdapter.ProviderAdapterV2SessionRuntime["hasPendingBackgroundWorkForThread"]; readonly seedOpenSubagent?: boolean; @@ -3376,6 +3545,9 @@ function captureRootRunTermination(input: { : { hasUnpairedRunInterruptRequest: input.hasUnpairedRunInterruptRequest, }), + ...(input.hasWorkspaceMoved === undefined + ? {} + : { hasWorkspaceMoved: input.hasWorkspaceMoved }), message: { messageId: MessageId.make(`message:${input.key}`), text: "interrupt projection", diff --git a/apps/server/src/orchestration-v2/RunExecutionService.ts b/apps/server/src/orchestration-v2/RunExecutionService.ts index e981ab7362d2d..c8fe7a6577d79 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.ts @@ -586,6 +586,7 @@ export interface RunExecutionServiceV2StartRootRunInput { readonly shouldFinalizeRun?: () => Effect.Effect; readonly hasUnpairedRunInterruptRequest?: () => Effect.Effect; readonly loadRunInterruptReason?: () => Effect.Effect; + readonly hasWorkspaceMoved?: () => Effect.Effect; readonly message: ProviderAdapter.ProviderAdapterV2TurnMessage; readonly modelSelection: ModelSelection; readonly runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy; @@ -639,6 +640,7 @@ export const layer: Layer.Layer< readonly hasUnpairedRunInterruptRequest?: () => Effect.Effect; readonly loadRunInterruptReason?: () => Effect.Effect; readonly openRunOwnedSubagents?: OpenRunOwnedSubagentProjection; + readonly openRunOwnedSubagentsTerminal?: ProviderTerminalEvent; // Root turn snapshot written with the run when T3 ends the run itself. readonly abandonedProviderTurn?: OrchestrationV2ProviderTurn; readonly terminal: ProviderTerminalEvent; @@ -648,6 +650,7 @@ export const layer: Layer.Layer< readonly writeIfRunCurrent?: { readonly activeAttemptId: RunAttemptId; readonly expectedStatus: OrchestrationV2Run["status"]; + readonly unlessInterruptRequested?: boolean; }; }) { const completedAt = input.completedAt ?? (yield* DateTime.now); @@ -694,7 +697,7 @@ export const layer: Layer.Layer< yield* input.refreshAfterTurn; } } - return; + return false; } const allocateEventId = () => idAllocator.allocate.event({ threadId: input.run.threadId }); const reason = @@ -709,14 +712,15 @@ export const layer: Layer.Layer< open.turnItems.size > 0 || open.childTurnItems.size > 0 || open.nodes.size > 0; + const subagentTerminal = input.openRunOwnedSubagentsTerminal ?? input.terminal; const cascadedSubagentEvents = - isRunOwnedSubagentTerminalStatus(input.terminal.status) && - !childTurnsContinue(input.terminal) && + isRunOwnedSubagentTerminalStatus(subagentTerminal.status) && + !childTurnsContinue(subagentTerminal) && hasOpenSubagentProjection ? yield* cascadeTerminalizeRunOwnedSubagents({ run: input.run, open, - status: input.terminal.status, + status: subagentTerminal.status, completedAt, allocateEventId, }) @@ -874,11 +878,25 @@ export const layer: Layer.Layer< runId: input.run.id, activeAttemptId: input.writeIfRunCurrent.activeAttemptId, expectedStatus: input.writeIfRunCurrent.expectedStatus, + ...(input.writeIfRunCurrent.unlessInterruptRequested === true + ? { + unlessInterruptRequested: { + requestId: idAllocator.derive.runSignalTurnItem({ + runId: input.run.id, + signal: "interrupt-request", + }), + resultId: idAllocator.derive.runSignalTurnItem({ + runId: input.run.id, + signal: "interrupt-result", + }), + }, + } + : {}), events: finalization.events, effects: finalization.effects, }); if (!result.committed) { - return; + return false; } } else { yield* eventSink.writeWithEffects(finalization); @@ -890,6 +908,7 @@ export const layer: Layer.Layer< childrenRunning: hasOpenSubagentProjection && cascadedSubagentEvents.length === 0, }); yield* input.refreshAfterTurn; + return true; }, ); @@ -1097,38 +1116,55 @@ export const layer: Layer.Layer< } const providerThread = yield* Ref.get(latestProviderThread); const openSubagents = yield* Ref.get(openRunOwnedSubagents); - yield* writeFinalRunEvents({ - run: input.run, - rootNode: input.rootNode, - checkpointScope: input.checkpointScope, - providerThread, - attempt: input.attempt, - ...(input.shouldFinalizeRun === undefined - ? {} - : { - shouldFinalizeRun: input.shouldFinalizeRun, - // Stop may commit between the ownership read and this - // terminal write. Gate the events and checkpoint together. - writeIfRunCurrent: { - activeAttemptId: input.attempt.id, - expectedStatus: "running" as const, - }, - }), - ...(input.hasUnpairedRunInterruptRequest === undefined - ? {} - : { - hasUnpairedRunInterruptRequest: input.hasUnpairedRunInterruptRequest, - }), - ...(input.loadRunInterruptReason === undefined - ? {} - : { loadRunInterruptReason: input.loadRunInterruptReason }), - openRunOwnedSubagents: openSubagents, - terminal, - failureItemPersisted: terminal.status === "failed", - refreshAfterTurn, - }).pipe( - Effect.mapError((cause) => new RunExecutionIngestError({ runId: input.run.id, cause })), - ); + const writeTerminal = (settledTerminal: ProviderTerminalEvent) => + writeFinalRunEvents({ + run: input.run, + rootNode: input.rootNode, + checkpointScope: input.checkpointScope, + providerThread, + attempt: input.attempt, + ...(input.shouldFinalizeRun === undefined + ? {} + : { + shouldFinalizeRun: input.shouldFinalizeRun, + // Stop may commit between the ownership read and this + // terminal write. Gate the events and checkpoint together. + writeIfRunCurrent: { + activeAttemptId: input.attempt.id, + expectedStatus: "running" as const, + unlessInterruptRequested: settledTerminal !== terminal, + }, + }), + ...(input.hasUnpairedRunInterruptRequest === undefined + ? {} + : { + hasUnpairedRunInterruptRequest: input.hasUnpairedRunInterruptRequest, + }), + ...(input.loadRunInterruptReason === undefined + ? {} + : { loadRunInterruptReason: input.loadRunInterruptReason }), + openRunOwnedSubagents: openSubagents, + openRunOwnedSubagentsTerminal: terminal, + terminal: settledTerminal, + failureItemPersisted: settledTerminal.status === "failed", + refreshAfterTurn, + }).pipe( + Effect.mapError( + (cause) => new RunExecutionIngestError({ runId: input.run.id, cause }), + ), + ); + // A workspace handoff detaches the session and stops the turn that asked + // for it: the run completes, while its native subagents still settle + // as interrupted. The completed write refuses in the same transaction + // as a stored Stop request, so a racing Stop stays authoritative. + const handedOff = + terminal.status === "interrupted" && + input.shouldFinalizeRun !== undefined && + input.hasWorkspaceMoved !== undefined && + (yield* input.hasWorkspaceMoved()); + if (!handedOff || !(yield* writeTerminal({ ...terminal, status: "completed" }))) { + yield* writeTerminal(terminal); + } if (isRunOwnedSubagentTerminalStatus(terminal.status) && !childTurnsContinue(terminal)) { yield* Ref.set(openRunOwnedSubagents, emptyOpenRunOwnedSubagentProjection()); idleFlushClosed = true; diff --git a/apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts b/apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts index 4352d8b2d6477..ba49f0bcfba53 100644 --- a/apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts +++ b/apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts @@ -446,6 +446,119 @@ it.live("retries a failed selection restart session open before completing", () ), ); +it.live("a workspace handoff during a run does not leave the run interrupted", () => + Effect.scoped( + Effect.gen(function* () { + const cwd = yield* checkpointWorkspace("workspace-handoff-lifecycle"); + const handoffCwd = yield* checkpointWorkspace("workspace-handoff-lifecycle-target"); + const threadId = ThreadId.make("thread:workspace-handoff-lifecycle"); + const state = yield* Ref.make({ + activeTurn: null, + opened: [], + started: [], + closedSessionCount: 0, + failedReplacementOpen: false, + }); + const layerRegistry = ProviderAdapterRegistry.layerSingle(makeRestartAdapter(state)); + + const projection = yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const eventSink = yield* EventSink.EventSinkV2; + + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("command:workspace-handoff:create"), + threadId, + projectId: ProjectId.make("project:workspace-handoff"), + title: "Workspace handoff", + modelSelection: initialSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: cwd, + }); + const running = yield* eventSink + .stream({ + threadId, + afterSequence: yield* eventSink.latestSequence({ threadId }), + }) + .pipe( + Stream.map((stored) => stored.event), + Stream.filter( + (event) => + event.type === "provider-turn.updated" && event.payload.status === "running", + ), + Stream.take(1), + Stream.runDrain, + Effect.forkScoped, + ); + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("command:workspace-handoff:first"), + threadId, + messageId: MessageId.make("message:workspace-handoff:first"), + text: "move to a worktree", + attachments: [], + modelSelection: initialSelection, + dispatchMode: { type: "start_immediately" }, + }); + yield* Fiber.join(running); + yield* worker.drain(); + + const settled = yield* eventSink + .stream({ + threadId, + afterSequence: yield* eventSink.latestSequence({ threadId }), + }) + .pipe( + Stream.map((stored) => stored.event), + Stream.filter( + (event) => + event.type === "run.updated" && + event.payload.status !== "running" && + event.payload.status !== "queued" && + event.payload.status !== "starting", + ), + Stream.take(1), + Stream.runDrain, + Effect.forkScoped, + ); + // What t3_worktree_handoff dispatches while the calling turn is still running. + yield* orchestrator.dispatch({ + type: "thread.metadata.update", + commandId: CommandId.make("command:workspace-handoff:rebind"), + threadId, + branch: "repro-handoff", + worktreePath: handoffCwd, + }); + yield* Fiber.join(settled); + yield* worker.drain(); + return yield* orchestrator.getThreadProjection(threadId); + }).pipe( + Effect.provide( + ProviderReplayHarness.layerWithRegistry( + { name: "workspace-handoff-lifecycle" }, + layerRegistry, + ), + ), + ); + + assert.equal(projection.thread.worktreePath, handoffCwd); + assert.equal(projection.runs[0]?.status, "completed"); + assert.isNotNull(projection.runs[0]?.checkpointId ?? null); + assert.isFalse( + projection.turnItems.some((item) => item.type === "run_interrupt_result"), + "an intentional workspace handoff must not project an interrupt result", + ); + }), + ), +); + it.live("Stop during a restart interrupts the turn the restart could not stop", () => Effect.scoped( Effect.gen(function* () { From 81e9368c4a7a3c4aa9a05d3460736775d89da511 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 12:00:46 -0800 Subject: [PATCH 0098/1165] fix(mobile): swipe action labels stay whole at larger Android text sizes (#27335) Co-authored-by: Claude Opus 5.5 --- .../features/home/thread-swipe-actions.tsx | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/features/home/thread-swipe-actions.tsx b/apps/mobile/src/features/home/thread-swipe-actions.tsx index 6f2e8a7a9020f..532badd9b35cb 100644 --- a/apps/mobile/src/features/home/thread-swipe-actions.tsx +++ b/apps/mobile/src/features/home/thread-swipe-actions.tsx @@ -21,7 +21,7 @@ import type { StyleProp, ViewStyle, } from "react-native"; -import { Pressable, View } from "react-native"; +import { Platform, Pressable, View, useWindowDimensions } from "react-native"; import ReanimatedSwipeable, { type SwipeableMethods, } from "react-native-gesture-handler/ReanimatedSwipeable"; @@ -41,6 +41,7 @@ import Animated, { } from "react-native-reanimated"; import { AppText as Text } from "../../components/ui/AppText"; +import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; import { SwipeRowActivationContext, type SwipeRowActivation } from "./swipe-row-activation"; import { registerThreadDismissal } from "./thread-dismissal"; @@ -49,6 +50,8 @@ const ACTION_CIRCLE_SIZE = 36; const ACTION_ICON_SIZE = 15; const COMPACT_ACTION_CIRCLE_SIZE = 28; const COMPACT_ACTION_ICON_SIZE = 13; +// At the default app text size, "Unarchive", the longest label, fills its slot at about 1.12x. +const ACTION_LABEL_MAX_FONT_SCALE = 1.1; const THREAD_SWIPE_ACTIONS_WIDTH = ACTION_ITEM_WIDTH * 2; const THREAD_SWIPE_SPRING = { @@ -545,6 +548,15 @@ function SwipeActionButton(props: { } = props; const circleSize = props.compact ? COMPACT_ACTION_CIRCLE_SIZE : ACTION_CIRCLE_SIZE; const iconSize = props.compact ? COMPACT_ACTION_ICON_SIZE : ACTION_ICON_SIZE; + const { fontScale } = useWindowDimensions(); + const labelLineHeight = useScaledTextRole("micro").lineHeight; + // Android scales lineHeight by the full font scale, past maxFontSizeMultiplier. + const labelTextStyle = { + lineHeight: + Platform.OS === "android" && fontScale > ACTION_LABEL_MAX_FONT_SCALE + ? (labelLineHeight * ACTION_LABEL_MAX_FONT_SCALE) / fontScale + : labelLineHeight, + }; const actionStyle = useAnimatedStyle(() => { const reveal = Math.max(-translation.value, 0); const entryProgress = interpolate( @@ -674,13 +686,19 @@ function SwipeActionButton(props: { /> + {/* An offset, not padding: padding would grow the box and move the circle at 1x. */} - + {props.label} From 173374c4a7cd4f3020785df316ca1238cef4e8d6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 12:00:56 -0800 Subject: [PATCH 0099/1165] fix(server): opening a pull request loads while its host is rate-limited (#27595) Co-authored-by: Claude Opus 5.5 --- .../pullRequest/PullRequestService.test.ts | 65 +++++++++++++++++++ .../src/pullRequest/PullRequestService.ts | 59 +++++++++-------- apps/server/src/ws.ts | 7 +- 3 files changed, 102 insertions(+), 29 deletions(-) diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 37f8915982df0..f54c77007c313 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -1891,6 +1891,71 @@ it.effect("stops new reads after a rate limit while leaving manual actions avail }), ); +it.effect( + "opening a pull request during a rate-limit pause still reads its detail and activity", + () => + Effect.gen(function* () { + let detailReads = 0; + let activityReads = 0; + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "cloud", workspaceRoot: "/cloud", repository: "acme/web" }), + ], + providers: [ + fakePullRequestProvider(SourceControlProviderKind.make("github"), { + getChangeRequest: ({ number }) => + Effect.suspend(() => { + detailReads += 1; + return number === 1 + ? Effect.fail( + new PullRequestProviderError({ + provider: SourceControlProviderKind.make("github"), + operation: "getChangeRequest", + reason: "rate-limited", + detail: "GitHub API rate limit exceeded.", + }), + ) + : Effect.succeed({ ...hostedChangeRequest("Body"), number }); + }), + getChangeRequestActivity: () => + Effect.sync(() => { + activityReads += 1; + return { + comments: [], + commentCount: 0, + commentsTruncated: false, + reviewThreads: [], + commits: [], + }; + }), + }), + ], + }); + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 2 }; + + yield* Effect.result(service.detail({ ...reference, number: 1 })); + const background = yield* Effect.result(service.detail(reference)); + const opened = yield* Effect.result( + service + .detail(reference) + .pipe(Effect.provideService(SourceControlRateLimit.Interactive, true)), + ); + const timeline = yield* Effect.result( + service + .activity(reference) + .pipe(Effect.provideService(SourceControlRateLimit.Interactive, true)), + ); + + expect(background).toMatchObject({ _tag: "Failure" }); + expect({ opened, timeline }).toMatchObject({ + opened: { _tag: "Success", success: { number: 2 } }, + timeline: { _tag: "Success", success: { commentCount: 0 } }, + }); + assert.strictEqual(detailReads, 2); + assert.strictEqual(activityReads, 1); + }), +); + it.effect("refuses a repository that does not belong to the requested project", () => Effect.gen(function* () { const service = yield* makeService({ diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 8efb747050178..9009e2d09e91f 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -471,32 +471,37 @@ function withRateLimitBackoff( const protect = ( operation: string, effect: Effect.Effect, - allowPaused: boolean, + alwaysInteractive: boolean, ) => - limits.check(key, allowPaused ? { allowPaused: true } : undefined).pipe( - Effect.mapError( - (error) => - new PullRequestProviderError({ - provider: api.kind, - operation, - reason: "rate-limited", - detail: error.detail, - retryAt: error.retryAt, - cause: error, - }), - ), - Effect.flatMap((lease) => - effect.pipe( - Effect.provideService(SourceControlRateLimit.Interactive, allowPaused), - Effect.tap(() => limits.recordSuccess({ ...key, lease })), - Effect.tapError((error) => - error.reason === "rate-limited" - ? limits.recordRateLimit({ - ...key, - lease, - ...(error.retryAt === undefined ? {} : { retryAt: error.retryAt }), - }) - : Effect.void, + SourceControlRateLimit.Interactive.pipe( + Effect.map((callerInteractive) => alwaysInteractive || callerInteractive), + Effect.flatMap((allowPaused) => + limits.check(key, allowPaused ? { allowPaused: true } : undefined).pipe( + Effect.mapError( + (error) => + new PullRequestProviderError({ + provider: api.kind, + operation, + reason: "rate-limited", + detail: error.detail, + retryAt: error.retryAt, + cause: error, + }), + ), + Effect.flatMap((lease) => + effect.pipe( + Effect.provideService(SourceControlRateLimit.Interactive, allowPaused), + Effect.tap(() => limits.recordSuccess({ ...key, lease })), + Effect.tapError((error) => + error.reason === "rate-limited" + ? limits.recordRateLimit({ + ...key, + lease, + ...(error.retryAt === undefined ? {} : { retryAt: error.retryAt }), + }) + : Effect.void, + ), + ), ), ), ), @@ -505,10 +510,10 @@ function withRateLimitBackoff( , A>( operation: string, call: (...args: Args) => Effect.Effect, - allowPaused = false, + alwaysInteractive = false, ) => (...args: Args) => - protect(operation, call(...args), allowPaused); + protect(operation, call(...args), alwaysInteractive); const interactive = , A>( operation: string, call: (...args: Args) => Effect.Effect, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 08a2756bfbe6c..f80a177e3191e 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -258,6 +258,7 @@ import * as VcsProjectConfig from "./vcs/VcsProjectConfig.ts"; import * as PairingGrantStore from "./auth/PairingGrantStore.ts"; import * as SessionStore from "./auth/SessionStore.ts"; import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http.ts"; +import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; import { isNetworkPath } from "@t3tools/shared/path"; import * as RelayClient from "@t3tools/shared/relayClient"; import { @@ -1540,6 +1541,8 @@ export const layerWsRpc = ( yield* SourceControlRepositoryService.SourceControlRepositoryService; const sourceControlSignIn = yield* SourceControlSignIn.SourceControlSignIn; const withPullRequestViewer = pullRequests.withRoutingCredential; + // The panel's reads for a pull request the user opened go out during a rate-limit pause. + const openedPullRequestRead = Effect.provideService(SourceControlRateLimit.Interactive, true); const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; const sessions = yield* SessionStore.SessionStore; const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics; @@ -2666,13 +2669,13 @@ export const layerWsRpc = ( ), ), [WS_METHODS.pullRequestsDetail]: (input) => - withPullRequestViewer(input, pullRequests.detail(input)), + withPullRequestViewer(input, openedPullRequestRead(pullRequests.detail(input))), [WS_METHODS.pullRequestsPreview]: (input) => withPullRequestViewer(input, pullRequests.preview(input)), [WS_METHODS.pullRequestsChecks]: (input) => withPullRequestViewer(input, pullRequests.checks(input)), [WS_METHODS.pullRequestsActivity]: (input) => - withPullRequestViewer(input, pullRequests.activity(input)), + withPullRequestViewer(input, openedPullRequestRead(pullRequests.activity(input))), [WS_METHODS.pullRequestsReviewGuide]: (input) => withPullRequestViewer(input, reviewGuides.get(input)), [WS_METHODS.pullRequestsGenerateReviewGuide]: (input) => From 8b21cbdf1e2dc29b01e952fd1b700f091ce1b884 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 12:01:11 -0800 Subject: [PATCH 0100/1165] fix(server): Codex turns that auto-compact end instead of looping on restored context (#25911) Co-authored-by: Claude Opus 5.5 --- .../Adapters/CodexAdapterV2.test.ts | 56 +++++++++++++++---- .../Adapters/CodexAdapterV2.ts | 21 +++++-- 2 files changed, 60 insertions(+), 17 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index d2cd67d627c88..1e57f4df277d3 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -1239,6 +1239,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { beforeEmitInbound?: CodexReplay.CodexAppServerReplayDriver["beforeEmitInbound"], onUsageLimits?: CodexAdapterV2.CodexAdapterV2Options["onUsageLimits"], promptSuggestions: Effect.Effect = Effect.succeed(false), + onNotification: (method: string) => Effect.Effect = () => Effect.void, ) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -1277,6 +1278,10 @@ describe("CodexAdapterV2 post-settle continuation", () => { onRequest(method, params).pipe( Effect.andThen(client.request(method, params)), ), + handleServerNotification: (method, handler) => + client.handleServerNotification(method, (payload) => + onNotification(method).pipe(Effect.andThen(handler(payload))), + ), raw: { ...client.raw, request: (method, params) => @@ -2509,6 +2514,17 @@ describe("CodexAdapterV2 post-settle continuation", () => { }, }, }, + { + type: "emit_inbound", + label: "done", + frame: { + method: "turn/completed", + params: { + threadId: nativeThreadId, + turn: makeCodexReplayTurn({ id: nativeTurnId, status: "completed" }), + }, + }, + }, { type: "expect_outbound", label: "restore context", @@ -2526,20 +2542,30 @@ describe("CodexAdapterV2 post-settle continuation", () => { }, }, { type: "emit_inbound", label: "restored", frame: { id: 4, result: {} } }, - { - type: "emit_inbound", - label: "done", - frame: { - method: "turn/completed", - params: { - threadId: nativeThreadId, - turn: makeCodexReplayTurn({ id: nativeTurnId, status: "completed" }), - }, - }, - }, ], }); - const harness = yield* makeCodexReplayHarness(transcript); + // Codex queues items injected mid-turn as pending input and samples again. + const wireOrder: Array = []; + const restoreRequested = yield* Deferred.make(); + const harness = yield* makeCodexReplayHarness( + transcript, + undefined, + (method) => + method === "thread/inject_items" + ? Effect.sync(() => wireOrder.push(method)).pipe( + Effect.andThen(Deferred.succeed(restoreRequested, undefined)), + ) + : Effect.void, + undefined, + undefined, + undefined, + undefined, + (method) => + Effect.sync(() => { + if (method === "item/completed" || method === "turn/completed") + wireOrder.push(method); + }), + ); yield* (yield* McpProviderSessions.McpProviderSessions).set({ environmentId: EnvironmentId.make("test"), threadId: harness.threadId, @@ -2559,7 +2585,13 @@ describe("CodexAdapterV2 post-settle continuation", () => { }), ); yield* harness.firstTerminal; + yield* Deferred.await(restoreRequested); assert.equal(harness.terminalEvents()[0]?.status, "completed"); + assert.deepEqual( + wireOrder, + ["item/completed", "turn/completed", "thread/inject_items"], + "restored T3 context while the compacting native turn was still running", + ); }).pipe( Effect.provide( Layer.mergeAll( diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 118ecb89bbf10..5221f339606de 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -1782,8 +1782,9 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( NonNullable >(), ); - // Codex drops client developer messages during compaction but only - // resends additionalContext when it changes. + const threadsAwaitingContextRestore = yield* Ref.make(new Set()); + // Codex drops client developer messages during compaction but only resends additionalContext + // when it changes. Restore after turn/completed: items injected mid-turn make the turn sample again. const restoreAdditionalContext = Effect.fn("CodexAdapterV2.restoreAdditionalContext")( function* (threadId: string) { const context = (yield* Ref.get(additionalContextByThread)).get(threadId); @@ -5148,9 +5149,8 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( yield* client.handleServerNotification("item/completed", (payload) => Effect.gen(function* () { if (payload.item.type === "contextCompaction") - // Fork requests off the notification reader so it can read their responses. - yield* restoreAdditionalContext(payload.threadId).pipe( - Effect.forkIn(scope, { startImmediately: true }), + yield* Ref.update(threadsAwaitingContextRestore, (current) => + new Set(current).add(payload.threadId), ); if ( (payload.item.type === "subAgentActivity" || @@ -6847,6 +6847,17 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( yield* client.handleServerNotification("turn/completed", (payload) => Effect.gen(function* () { + const compacted = yield* Ref.modify(threadsAwaitingContextRestore, (current) => { + if (!current.has(payload.threadId)) return [false, current] as const; + const next = new Set(current); + next.delete(payload.threadId); + return [true, next] as const; + }); + if (compacted) + // Fork requests off the notification reader so it can read their responses. + yield* restoreAdditionalContext(payload.threadId).pipe( + Effect.forkIn(scope, { startImmediately: true }), + ); const context = (yield* Ref.get(activeTurns)).get(payload.turn.id); if (context === undefined) { return; From 3cfbc3607b81035fa0873ec3c8ea5c3c8d659c48 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 12:01:29 -0800 Subject: [PATCH 0101/1165] fix(server): keep queued Claude workflow members pending (#25325) Co-authored-by: Claude Opus 5.5 --- .../Adapters/ClaudeAdapterV2.test.ts | 8 ++++- .../Adapters/claudeWorkflowProgress.test.ts | 29 +++++++++++++++++-- .../Adapters/claudeWorkflowProgress.ts | 13 ++++++--- 3 files changed, 42 insertions(+), 8 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 5c319d034ded2..a17d4d3d9f3ab 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -18217,7 +18217,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { phaseTitle: "Alpha", model: input.model ?? "claude-opus-5[1m]", promptPreview: input.prompt ?? "Reply with exactly: A1", - ...(input.startedAt === undefined ? {} : { startedAt: input.startedAt }), + // Claude reports a scheduled member as `start` and only sets startedAt once it runs. + ...(input.startedAt === undefined && input.state !== "start" + ? {} + : { startedAt: input.startedAt ?? 0 }), ...(input.lastProgressAt === undefined ? {} : { lastProgressAt: input.lastProgressAt }), ...(input.durationMs === undefined ? {} : { durationMs: input.durationMs }), ...(input.attempt === undefined ? {} : { attempt: input.attempt }), @@ -18234,6 +18237,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { phaseIndex: 1, phaseTitle: "Alpha", promptPreview: "Reply with exactly: A2", + ...((input.secondState ?? input.state) === "start" ? { startedAt: 0 } : {}), ...(input.state === "done" ? { resultPreview: "A2 excerpt" } : {}), }, ], @@ -18727,6 +18731,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { uuid: "00000000-0000-4000-8000-000000001005", state: "start", secondState: "queued", + startedAt: DateTime.toEpochMillis(memberStartedAt), }), ); yield* harness.until(() => coordinatorTurnItems().length > turnItemsBeforeRepeat); @@ -18750,6 +18755,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { uuid: "00000000-0000-4000-8000-000000001017", state: "start", secondState: "queued", + startedAt: DateTime.toEpochMillis(memberStartedAt), label: "alpha:review", prompt: "Review the change", model: "claude-sonnet-4-6", diff --git a/apps/server/src/orchestration-v2/Adapters/claudeWorkflowProgress.test.ts b/apps/server/src/orchestration-v2/Adapters/claudeWorkflowProgress.test.ts index 360a21c068f25..451035d669a3e 100644 --- a/apps/server/src/orchestration-v2/Adapters/claudeWorkflowProgress.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/claudeWorkflowProgress.test.ts @@ -74,6 +74,19 @@ describe("mergeClaudeWorkflowProgress", () => { expect(workflow?.agents[2]).not.toHaveProperty("completedAt"); }); + it("keeps a started member queued until the provider reports its startedAt", () => { + const scheduled = mergeClaudeWorkflowProgress({ + previous: undefined, + message: snapshotFrame([agent({ state: "start", queuedAt: 1000 })]), + }); + expect(scheduled?.agents[0]?.state).toBe("queued"); + const started = mergeClaudeWorkflowProgress({ + previous: scheduled, + message: snapshotFrame([agent({ state: "start", queuedAt: 1000, startedAt: 21_000 })]), + }); + expect(started?.agents[0]).toMatchObject({ state: "running", startedAt: 21_000 }); + }); + it.each(["something-new", "toString", "constructor", "__proto__"])( "treats unrecognised state %s as still running rather than settled", (state) => { @@ -133,7 +146,14 @@ describe("mergeClaudeWorkflowProgress", () => { agents: first.agents.map((member) => ({ ...member, effort: "max", speed: "standard" })), }, message: snapshotFrame([ - { type: "workflow_agent", index: 1, label: "alpha:one", state: "start", attempt: 2 }, + { + type: "workflow_agent", + index: 1, + label: "alpha:one", + state: "start", + attempt: 2, + startedAt: 3000, + }, ]), }); expect(second?.agents).toEqual([ @@ -142,6 +162,7 @@ describe("mergeClaudeWorkflowProgress", () => { label: "alpha:one", state: "running", attempt: 2, + startedAt: 3000, phaseIndex: 1, phaseTitle: "Alpha", }, @@ -171,12 +192,14 @@ describe("mergeClaudeWorkflowProgress", () => { ])("keeps member state when the same attempt regresses from %s to %s", (state, staleState) => { const first = mergeClaudeWorkflowProgress({ previous: undefined, - message: snapshotFrame([agent({ state, attempt: 2, resultPreview: "Current result" })]), + message: snapshotFrame([ + agent({ state, attempt: 2, startedAt: 1010, resultPreview: "Current result" }), + ]), }); const second = mergeClaudeWorkflowProgress({ previous: first, message: snapshotFrame([ - agent({ state: staleState, attempt: 2, resultPreview: "Old result" }), + agent({ state: staleState, attempt: 2, startedAt: 1010, resultPreview: "Old result" }), ]), }); expect(second?.agents).toEqual(first?.agents); diff --git a/apps/server/src/orchestration-v2/Adapters/claudeWorkflowProgress.ts b/apps/server/src/orchestration-v2/Adapters/claudeWorkflowProgress.ts index 1653e5b42f1d5..2db512e825016 100644 --- a/apps/server/src/orchestration-v2/Adapters/claudeWorkflowProgress.ts +++ b/apps/server/src/orchestration-v2/Adapters/claudeWorkflowProgress.ts @@ -53,9 +53,14 @@ function parseAgent(entry: unknown): OrchestrationV2WorkflowAgent | null { const label = text(field(entry, "label")); if (index === undefined || label === undefined) return null; const reportedState = text(field(entry, "state")) ?? ""; - const state = Object.hasOwn(AGENT_STATES, reportedState) - ? (AGENT_STATES[reportedState] ?? "running") - : "running"; + const startedAt = timestamp(field(entry, "startedAt")); + // Claude marks members `start` when scheduled; they only run once a concurrency slot sets startedAt. + const state = + reportedState === "start" && startedAt === undefined + ? "queued" + : Object.hasOwn(AGENT_STATES, reportedState) + ? (AGENT_STATES[reportedState] ?? "running") + : "running"; return { index, label, @@ -69,7 +74,7 @@ function parseAgent(entry: unknown): OrchestrationV2WorkflowAgent | null { ...optional("toolCalls", count(field(entry, "toolCalls"))), ...optional("durationMs", count(field(entry, "durationMs"))), ...optional("queuedAt", timestamp(field(entry, "queuedAt"))), - ...optional("startedAt", timestamp(field(entry, "startedAt"))), + ...optional("startedAt", startedAt), // Execution duration excludes startup waits; terminal progress carries the end instant. ...optional( "completedAt", From b264e118cf047560eb7aa6ac12f6baa7acce6497 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 12:01:50 -0800 Subject: [PATCH 0102/1165] fix(server): t3 doctor warns how long reclaiming takes and shows each step (#29956) Co-authored-by: Claude Opus 5.5 --- apps/server/src/cli/doctor.test.ts | 74 +++++++++++++++++++ apps/server/src/cli/doctor.ts | 34 +++++++-- .../persistence/DatabaseMaintenance.test.ts | 26 ++++--- .../src/persistence/DatabaseMaintenance.ts | 21 +++++- docs/user/background-service.md | 12 +-- 5 files changed, 143 insertions(+), 24 deletions(-) diff --git a/apps/server/src/cli/doctor.test.ts b/apps/server/src/cli/doctor.test.ts index 2b9abe89f26a5..87fccaeca5f50 100644 --- a/apps/server/src/cli/doctor.test.ts +++ b/apps/server/src/cli/doctor.test.ts @@ -1,7 +1,9 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; +import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as TestClock from "effect/testing/TestClock"; import * as BootService from "../cloud/bootService.ts"; import { runDoctor } from "./doctor.ts"; @@ -53,3 +55,75 @@ it.effect( assert.deepEqual(calls, ["pause", "compact", "resume"]); }), ); + +it.effect("warns how long reclaiming can take and reports each step with its elapsed time", () => + Effect.gen(function* () { + const output: string[] = []; + const steps = [ + ["prune-events", "65 seconds"], + ["vacuum", "701 seconds"], + ["rebuild-search", "1 second"], + ["checkpoint", "1 second"], + ] as const; + yield* runDoctor("/test/statev2.sqlite", { fix: true, dryRun: false, interactive: false }).pipe( + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + Layer.mock(BootService.BootService)({ + withStopped: (operation) => + Effect.sync(() => output.push("")).pipe( + Effect.andThen(operation), + Effect.ensuring(Effect.sync(() => output.push(""))), + ), + }), + Layer.succeed(DatabaseMaintenance, { + inspect: () => + Effect.succeed({ + databaseBytes: 12.8 * 1024 ** 3, + reclaimableBytes: 0, + incrementalVacuumEnabled: false, + legacyDatabaseBytes: null, + }), + compact: (_databasePath, onStep) => + Effect.gen(function* () { + for (const [step, duration] of steps) { + if (onStep) yield* onStep(step); + yield* TestClock.adjust(duration); + } + return { + beforeBytes: 12.8 * 1024 ** 3, + afterBytes: 12.8 * 1024 ** 3, + deletedEventCount: 0, + deletedReceiptCount: 0, + }; + }), + removeLegacyDatabase: () => Effect.void, + }), + ), + ), + Effect.provideService(Console.Console, { + ...globalThis.console, + log: (...args: ReadonlyArray) => { + output.push(...args.join(" ").split("\n")); + }, + }), + ); + + const paused = output.indexOf(""); + const resumed = output.indexOf(""); + assert.isAbove(paused, -1, "the repair must run with the service paused"); + const beforePause = output.slice(0, paused).join("\n"); + const description = beforePause.slice(beforePause.indexOf("Reclaim database space")); + assert.include(description, "12.80 GiB", "the repair must state the database size up front"); + assert.match(description, /hour/, "the repair must warn it can take over an hour"); + const progress = output + .slice(paused + 1, resumed) + .filter((line) => line.trim() !== "" && !line.trim().startsWith("Done")) + .map((line) => /\[(\d+:\d{2})\]/.exec(line)?.[1] ?? `unprefixed: ${line}`); + assert.deepEqual( + progress, + ["0:00", "1:05", "12:46", "12:47"], + "each compaction step must print one line with the elapsed time as it starts", + ); + }), +); diff --git a/apps/server/src/cli/doctor.ts b/apps/server/src/cli/doctor.ts index f7afaf9e4d0ca..6047f4cede24b 100644 --- a/apps/server/src/cli/doctor.ts +++ b/apps/server/src/cli/doctor.ts @@ -1,4 +1,5 @@ import { DATABASE_COMPACTION_RECOMMENDED_BYTES } from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -16,6 +17,18 @@ const formatBytes = (bytes: number) => ? `${(bytes / 1024 ** 2).toFixed(1)} MiB` : `${Math.ceil(bytes / 1024)} KiB`; +const formatElapsed = (nanos: bigint) => { + const seconds = Number(nanos / 1_000_000_000n); + return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`; +}; + +const COMPACT_STEP_LABELS: Record = { + "prune-events": "Removing superseded events", + vacuum: "Rewriting the database file", + "rebuild-search": "Rebuilding the search index", + checkpoint: "Flushing the write-ahead log", +}; + export const runDoctor = Effect.fn("runDoctor")(function* ( databasePath: string, options: { readonly fix: boolean; readonly dryRun: boolean; readonly interactive?: boolean }, @@ -41,7 +54,7 @@ export const runDoctor = Effect.fn("runDoctor")(function* ( status.incrementalVacuumEnabled ? "Returns unused space to disk." : "Returns unused space to disk and turns on automatic shrinking." - }\n Pauses the background service, if installed, while it runs and may need temporary disk space.`, + }\n Rewrites the whole ${formatBytes(status.databaseBytes)} database, which can take more than an hour on a large database or slow disk.\n Pauses the background service, if installed, while it runs and may need temporary disk space.`, recommended: !status.incrementalVacuumEnabled || status.reclaimableBytes >= DATABASE_COMPACTION_RECOMMENDED_BYTES, @@ -100,15 +113,26 @@ export const runDoctor = Effect.fn("runDoctor")(function* ( } if (selected.includes("database")) { const service = yield* BootService.BootService; - yield* Console.log("\nReclaiming database space. This can take several minutes..."); + yield* Console.log("\nReclaiming database space. Each step is shown as it starts."); + const startedAt = yield* Clock.monotonicTimeNanos; + const reportStep = (step: DatabaseMaintenance.DatabaseCompactStep) => + Clock.monotonicTimeNanos.pipe( + Effect.flatMap((now) => + Console.log(` [${formatElapsed(now - startedAt)}] ${COMPACT_STEP_LABELS[step]}`), + ), + ); // Report the repair before restarting, which can fail independently. yield* service.withStopped( maintenance - .compact(databasePath) + .compact(databasePath, reportStep) .pipe( Effect.tap((result) => - Console.log( - `Done: ${formatBytes(result.beforeBytes)} -> ${formatBytes(result.afterBytes)}, ${result.deletedEventCount} superseded events removed.`, + Clock.monotonicTimeNanos.pipe( + Effect.flatMap((now) => + Console.log( + `Done in ${formatElapsed(now - startedAt)}: ${formatBytes(result.beforeBytes)} -> ${formatBytes(result.afterBytes)}, ${result.deletedEventCount} superseded events removed.`, + ), + ), ), ), ), diff --git a/apps/server/src/persistence/DatabaseMaintenance.test.ts b/apps/server/src/persistence/DatabaseMaintenance.test.ts index 864d9f4da1bc7..533a31b40446e 100644 --- a/apps/server/src/persistence/DatabaseMaintenance.test.ts +++ b/apps/server/src/persistence/DatabaseMaintenance.test.ts @@ -227,18 +227,22 @@ it.layer(DatabaseMaintenance.layer.pipe(Layer.provideMerge(NodeServices.layer))) const clock = yield* Clock.Clock; const pauses: number[] = []; let activeNanos = 0n; - const result = yield* maintenance.compact(dbPath).pipe( - Effect.provideService(Clock.Clock, { - ...clock, - // Exceed the background budget whenever the compactor checks elapsed time. - monotonicTimeNanos: Effect.sync(() => (activeNanos += 30_000_000n)), - sleep: (duration) => - Effect.sync(() => { - pauses.push(Duration.toMillis(duration)); - }), - }), - ); + const steps: string[] = []; + const result = yield* maintenance + .compact(dbPath, (step) => Effect.sync(() => steps.push(step))) + .pipe( + Effect.provideService(Clock.Clock, { + ...clock, + // Exceed the background budget whenever the compactor checks elapsed time. + monotonicTimeNanos: Effect.sync(() => (activeNanos += 30_000_000n)), + sleep: (duration) => + Effect.sync(() => { + pauses.push(Duration.toMillis(duration)); + }), + }), + ); assert.deepEqual(pauses, [], "offline doctor must not throttle compaction"); + assert.deepEqual(steps, ["prune-events", "vacuum", "rebuild-search", "checkpoint"]); assert.equal(result.deletedReceiptCount, 51); assert.isBelow(result.afterBytes, result.beforeBytes - 3 * 1024 * 1024); yield* Effect.gen(function* () { diff --git a/apps/server/src/persistence/DatabaseMaintenance.ts b/apps/server/src/persistence/DatabaseMaintenance.ts index d192f6f445965..79580761090de 100644 --- a/apps/server/src/persistence/DatabaseMaintenance.ts +++ b/apps/server/src/persistence/DatabaseMaintenance.ts @@ -71,6 +71,8 @@ export class DiskSpaceCheckError extends Schema.TaggedError } } +export type DatabaseCompactStep = "prune-events" | "vacuum" | "rebuild-search" | "checkpoint"; + export class DatabaseMaintenance extends Context.Service< DatabaseMaintenance, { @@ -84,8 +86,14 @@ export class DatabaseMaintenance extends Context.Service< }, DatabaseMissingError | DatabaseInspectError >; - /** Compacts history, then VACUUMs into incremental auto_vacuum. Refuses while a server has it open. */ - readonly compact: (databasePath: string) => Effect.Effect< + /** + * Compacts history, then VACUUMs into incremental auto_vacuum. Refuses while a server has it open. + * Calls `onStep` as each step starts; VACUUM time grows with the whole database size. + */ + readonly compact: ( + databasePath: string, + onStep?: (step: DatabaseCompactStep) => Effect.Effect, + ) => Effect.Effect< { readonly beforeBytes: number; readonly afterBytes: number; @@ -171,18 +179,25 @@ const make = Effect.gen(function* () { ); const compact = Effect.fn("DatabaseMaintenance.compact")( - function* (databasePath: string) { + function* ( + databasePath: string, + onStep: (step: DatabaseCompactStep) => Effect.Effect = () => Effect.void, + ) { yield* locate(databasePath); const beforeBytes = Number((yield* fs.stat(databasePath)).size); const result = yield* Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; yield* lockExclusively(databasePath); const maintenance = yield* ProjectionMaintenance.ProjectionMaintenanceV2; + yield* onStep("prune-events"); const summary = yield* maintenance.pruneEventStore(); + yield* onStep("vacuum"); // VACUUM is required to enable incremental auto_vacuum on existing databases. yield* sql`PRAGMA auto_vacuum = INCREMENTAL`; yield* sql`VACUUM`; + yield* onStep("rebuild-search"); yield* rebuildMessageSearch(sql); + yield* onStep("checkpoint"); yield* sql`PRAGMA wal_checkpoint(TRUNCATE)`; return summary; }).pipe( diff --git a/docs/user/background-service.md b/docs/user/background-service.md index 51a16878d2bbc..7e22a8518490b 100644 --- a/docs/user/background-service.md +++ b/docs/user/background-service.md @@ -184,11 +184,13 @@ the repair fails. Quit a server you started by hand, or the desktop app, first: the repair refuses to touch a database that is in use. Use `--base-dir` for a different data directory. -The repair can take several minutes on a large database. It rebuilds the -database through a temporary copy, so it needs free space of about twice the -data still in use (the database size minus the reclaimable space): once in the -system temporary directory (on Linux and macOS, `TMPDIR`, or `/var/tmp` when it -is not set) and once next to the database for its write-ahead log. +The repair's time grows with the size of the whole database, not only the +space it reclaims, and can exceed an hour on a large database or a slow disk; +`t3 doctor` shows each step as it runs. It rebuilds the database through a +temporary copy, so it needs free space of about twice the data still in use +(the database size minus the reclaimable space): once in the system temporary +directory (on Linux and macOS, `TMPDIR`, or `/var/tmp` when it is not set) and +once next to the database for its write-ahead log. If you upgraded from a version before 0.1.0, its database (`state.sqlite`) is still kept next to the current one so you can roll back. `t3 doctor` offers to From 686121d470d9ebf10cab115dcecc91ea697e7803 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 12:02:01 -0800 Subject: [PATCH 0103/1165] fix(server): recover service PATH after a slow login shell probe (#23356) Co-authored-by: Claude Opus 5.5 --- apps/server/src/os-jank.test.ts | 53 +++++++++++++++++++++++++++++++++ apps/server/src/os-jank.ts | 40 ++++++++++++++++++++++--- 2 files changed, 89 insertions(+), 4 deletions(-) diff --git a/apps/server/src/os-jank.test.ts b/apps/server/src/os-jank.test.ts index aaac95bc3e880..346c711ba6346 100644 --- a/apps/server/src/os-jank.test.ts +++ b/apps/server/src/os-jank.test.ts @@ -64,6 +64,59 @@ effectIt.effect("hydrates PATH when the desktop handoff is absent", () => ), ); +const timedOut = (file: string) => + Object.assign(new Error(`spawnSync ${file} ETIMEDOUT`), { code: "ETIMEDOUT" }); + +effectIt.effect("waits longer for a login shell that times out at startup", () => + Effect.acquireUseRelease( + Effect.sync(() => + vi.spyOn(NodeChildProcess, "execFileSync").mockImplementation((file, _args, options) => { + // This profile takes 8 seconds while the machine is under load. + if ((options?.timeout ?? 0) < 8_000) throw timedOut(String(file)); + return "__T3CODE_ENV_PATH_START__\n/opt/tools/bin\n__T3CODE_ENV_PATH_END__\n"; + }), + ), + () => + Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { SHELL: "/bin/zsh", PATH: "/usr/bin" }; + + yield* fixPath().pipe( + Effect.provideService(HostProcess.Platform, "linux"), + Effect.provideService(HostProcess.Environment, env), + Effect.provide(NodeServices.layer), + ); + + assert.equal(env.PATH, "/opt/tools/bin:/usr/bin"); + }), + (probe) => Effect.sync(() => probe.mockRestore()), + ), +); + +effectIt.effect("waits longer for only one hung login shell", () => + Effect.acquireUseRelease( + Effect.sync(() => + vi.spyOn(NodeChildProcess, "execFileSync").mockImplementation((file) => { + throw timedOut(String(file)); + }), + ), + (probe) => + Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { SHELL: "/bin/zsh", PATH: "/usr/bin" }; + + yield* fixPath().pipe( + Effect.provideService(HostProcess.Platform, "linux"), + Effect.provideService(HostProcess.Environment, env), + Effect.provide(NodeServices.layer), + ); + + const timeouts = probe.mock.calls.map(([, , options]) => options?.timeout); + assert.equal(timeouts.filter((timeout) => (timeout ?? 0) > 5_000).length, 1); + assert.equal(env.PATH, "/usr/bin"); + }), + (probe) => Effect.sync(() => probe.mockRestore()), + ), +); + effectIt.effect("keeps mise's PATH record with the login shell PATH", () => Effect.acquireUseRelease( Effect.sync(() => diff --git a/apps/server/src/os-jank.ts b/apps/server/src/os-jank.ts index dece291aa1ca4..e7fcdcd68719e 100644 --- a/apps/server/src/os-jank.ts +++ b/apps/server/src/os-jank.ts @@ -10,6 +10,8 @@ import { import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; +// @effect-diagnostics-next-line nodeBuiltinImport:off -- the shared login shell probe runs synchronously through execFileSync. +import * as NodeChildProcess from "node:child_process"; import * as NodeOS from "node:os"; function logPathHydrationWarning(message: string, error?: unknown): void { @@ -18,16 +20,46 @@ function logPathHydrationWarning(message: string, error?: unknown): void { ); } +// mise's record of the PATH entries it added. It must travel with the +// shell's PATH, or `mise x` can rebuild PATH behind a same-named wrapper +// (`exec mise x claude -- claude`) that then re-runs itself forever. +const LOGIN_SHELL_ENVIRONMENT_NAMES = ["PATH", "__MISE_DIFF", "JAVA_HOME"]; +const SLOW_LOGIN_SHELL_TIMEOUT_MS = 20_000; + +function readSlowLoginShellEnvironment(shell: string): Partial> { + return readEnvironmentFromLoginShell( + shell, + LOGIN_SHELL_ENVIRONMENT_NAMES, + (file, args, options) => + NodeChildProcess.execFileSync(file, args, { + ...options, + timeout: SLOW_LOGIN_SHELL_TIMEOUT_MS, + }), + ); +} + function hydratePosixPath(env: NodeJS.ProcessEnv, platform: NodeJS.Platform): void { let shellEnvironment: Partial> = {}; + let waitedForSlowShell = false; for (const shell of listLoginShellCandidates(platform, env.SHELL)) { try { - // mise's record of the PATH entries it added. It must travel with the - // shell's PATH, or `mise x` can rebuild PATH behind a same-named wrapper - // (`exec mise x claude -- claude`) that then re-runs itself forever. - shellEnvironment = readEnvironmentFromLoginShell(shell, ["PATH", "__MISE_DIFF", "JAVA_HOME"]); + shellEnvironment = readEnvironmentFromLoginShell(shell, LOGIN_SHELL_ENVIRONMENT_NAMES); } catch (error) { logPathHydrationWarning(`Failed to read PATH from login shell ${shell}.`, error); + // A profile can be slow only while the machine boots. Wait for it once, + // here: providers copy this env when they are built, so a later read + // would never reach them. + if (!waitedForSlowShell && (error as NodeJS.ErrnoException | null)?.code === "ETIMEDOUT") { + waitedForSlowShell = true; + try { + shellEnvironment = readSlowLoginShellEnvironment(shell); + } catch (retryError) { + logPathHydrationWarning( + `Failed to read PATH from slow login shell ${shell}.`, + retryError, + ); + } + } } if (shellEnvironment.PATH) break; From 28bbc91c7c2b349d701fe10751e2ed4245171a69 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 12:02:10 -0800 Subject: [PATCH 0104/1165] fix(server): say Git isn't installed when a new project can't run git (#28219) Co-authored-by: Claude Opus 5.5 --- .../src/project/ManagedProjectFolders.test.ts | 38 ++++++++++++++++++- .../src/project/ManagedProjectFolders.ts | 13 ++++++- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/apps/server/src/project/ManagedProjectFolders.test.ts b/apps/server/src/project/ManagedProjectFolders.test.ts index f289a3ebe6186..c627758f40be4 100644 --- a/apps/server/src/project/ManagedProjectFolders.test.ts +++ b/apps/server/src/project/ManagedProjectFolders.test.ts @@ -12,6 +12,7 @@ import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; +import { ChildProcessSpawner } from "effect/process"; import * as Scope from "effect/Scope"; import * as ServerConfig from "../config.ts"; @@ -55,7 +56,7 @@ const layerRealGit = GitVcsDriver.layer.pipe(Layer.provide(VcsProcess.layer)); interface HarnessOptions { readonly vcsProcess?: Layer.Layer; - readonly git?: Layer.Layer; + readonly git?: typeof layerRealGit; readonly projects?: ( real: ProjectService.ProjectService["Service"], ) => ProjectService.ProjectService["Service"]; @@ -450,6 +451,41 @@ it.effect("keeps a named project and reports why when Git cannot commit", () => ), ); +it.effect("says Git is not installed when a named project cannot start Git", () => + withScratch( + () => + Effect.gen(function* () { + const folders = yield* ManagedProjectFolders.ManagedProjectFolders; + + const failure = yield* Effect.flip(folders.createNamedProject({ name: "T3Code Test" })); + + assert.strictEqual( + failure.message, + "Git isn't installed on this machine. Install Git to create a project.", + ); + }), + { + git: GitVcsDriver.layer.pipe( + Layer.provide( + Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.fail( + PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + syscall: "spawn git", + }), + ), + ), + ), + ), + ), + }, + ), +); + it.effect("keeps a folder that another project owns when the create conflicts", () => withScratch(({ baseDir }) => withGitEnv( diff --git a/apps/server/src/project/ManagedProjectFolders.ts b/apps/server/src/project/ManagedProjectFolders.ts index 92cc1dc0d69c1..7c1c5f9e53414 100644 --- a/apps/server/src/project/ManagedProjectFolders.ts +++ b/apps/server/src/project/ManagedProjectFolders.ts @@ -1,5 +1,6 @@ import { CommandId, + GitCommandError, type GitManagerServiceError, ProjectId, type ThreadId, @@ -67,6 +68,14 @@ export class ScratchProjectError extends Schema.TaggedError export type ScratchError = ScratchUnavailableError | ScratchFolderError | ScratchProjectError; +// The spawner checks cwd before spawning, so NotFound from spawn itself means a missing executable. +const isMissingExecutable = (cause: unknown) => + PlatformError.isPlatformError(cause) && + cause.reason._tag === "NotFound" && + cause.reason.method === "spawn"; + +const isGitCommandError = Schema.is(GitCommandError); + export class NamedProjectFolderError extends Schema.TaggedError()( "NamedProjectFolderError", { @@ -75,7 +84,9 @@ export class NamedProjectFolderError extends Schema.TaggedError Date: Wed, 11 Nov 2026 12:02:20 -0800 Subject: [PATCH 0105/1165] fix(server): agent clones are no longer cut off after 2 minutes (#27588) Co-authored-by: Claude Opus 5.5 --- .../src/mcp/toolkits/project/handlers.test.ts | 73 +++++++++++++++++++ .../src/mcp/toolkits/project/handlers.ts | 3 +- .../SourceControlRepositoryService.test.ts | 62 ++++++++++++++++ .../SourceControlRepositoryService.ts | 25 ++++++- 4 files changed, 159 insertions(+), 4 deletions(-) diff --git a/apps/server/src/mcp/toolkits/project/handlers.test.ts b/apps/server/src/mcp/toolkits/project/handlers.test.ts index 9b933de577159..58bd2a864488d 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.test.ts @@ -2,6 +2,7 @@ import * as ProviderRegistry from "../../../provider/ProviderRegistry.ts"; import { expect, it } from "@effect/vitest"; import { EnvironmentId, + GitCommandError, ProjectId, ProviderInstanceId, ProviderDriverKind, @@ -15,13 +16,17 @@ import { import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Cause from "effect/Cause"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Stream from "effect/Stream"; +import { ChildProcessSpawner } from "effect/process"; +import * as TestClock from "effect/testing/TestClock"; import * as DocumentText from "../../../provider/DocumentText.ts"; import { OrchestratorRuntimeModeAboveCeilingError } from "../../../orchestration-v2/Orchestrator.ts"; @@ -33,6 +38,8 @@ import * as Project from "../../../project/ProjectService.ts"; import * as ProjectCloneTracker from "../../../project/ProjectCloneTracker.ts"; import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.ts"; import * as PeerForwarding from "../../../peer/PeerForwarding.ts"; +import * as SourceControlProviderRegistry from "../../../sourceControl/SourceControlProviderRegistry.ts"; +import * as Repositories from "../../../sourceControl/SourceControlRepositoryService.ts"; import * as GitVcsDriver from "../../../vcs/GitVcsDriver.ts"; import * as VcsProcess from "../../../vcs/VcsProcess.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; @@ -689,6 +696,72 @@ it.effect("a client launches with the fallback for an exhausted default", () => }), ); +it.effect("an agent's clone is not cut off while git is still transferring", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const parent = yield* fs.makeTempDirectoryScoped({ prefix: "t3-mcp-clone-" }); + const destinationPath = path.join(parent, "repo"); + const started = yield* Deferred.make(); + const transferred = yield* Deferred.make(); + const { dependencies } = clientLaunchHarness({ + runtimeModeCeiling: "full-access", + launched: [], + }); + const repositories = Repositories.layer.pipe( + Layer.provide( + Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({ + resolveLink: () => undefined, + }), + ), + Layer.provide( + Layer.mock(GitVcsDriver.GitVcsDriver)({ + // Like the real driver, the clone fails once it outlives the deadline it was given. + execute: (input) => + Deferred.succeed(started, undefined).pipe( + Effect.andThen(Deferred.await(transferred)), + Effect.as({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: "", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }), + Effect.timeoutOrElse({ + duration: input.timeoutMs ?? Infinity, + orElse: () => + Effect.fail( + new GitCommandError({ + operation: input.operation, + command: "git", + cwd: input.cwd, + detail: "Git command timed out.", + }), + ), + }), + ), + }), + ), + Layer.provideMerge(dependencies), + ); + const toolkit = yield* ProjectToolkit.pipe( + Effect.provide( + McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe(Layer.provide(repositories)), + ), + ); + + const clone = yield* toolkit + .handle("t3_project_clone", { remoteUrl: "https://example.test/repo.git", destinationPath }) + .pipe(Stream.unwrap, Stream.runCollect, Effect.provide(repositories), Effect.forkChild); + yield* Deferred.await(started); + // A large repository over a slow link; agents' MCP calls get a far longer budget. + yield* TestClock.adjust("10 minutes"); + yield* Deferred.succeed(transferred, undefined); + + expect((yield* Fiber.join(clone)).at(-1)?.result).toMatchObject({ cwd: destinationPath }); + }).pipe(Effect.provide(NodeServices.layer)), +); + it.effect("deleting a project through MCP also discards its clone record", () => Effect.gen(function* () { const discarded: Array = []; diff --git a/apps/server/src/mcp/toolkits/project/handlers.ts b/apps/server/src/mcp/toolkits/project/handlers.ts index fd5772f6dc3bb..37ee975f915ef 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.ts @@ -387,7 +387,8 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { (input) => Effect.gen(function* () { const repositories = yield* Repositories.SourceControlRepositoryService; - return yield* repositories.cloneRepository(input).pipe( + // Agents' MCP calls have their own long budget, and an aborted call interrupts git. + return yield* repositories.cloneRepository(input, { timeoutMs: null }).pipe( Effect.mapError( (error) => new OrchestratorMcpFailure({ diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts index 326b7931da292..4a0533da0b86e 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts @@ -3,12 +3,15 @@ import * as NodePath from "@effect/platform-node/NodePath"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Layer from "effect/Layer"; import * as PlatformError from "effect/PlatformError"; import { ChildProcessSpawner } from "effect/process"; +import * as TestClock from "effect/testing/TestClock"; import { GitCommandError, @@ -292,6 +295,65 @@ it.effect("reports clone progress from git's stderr and keeps its error text on }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); +it.effect("says a clone that ran out of time timed out, not git's last remote line", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const parent = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-source-control-clone-timeout-", + }); + const started = yield* Deferred.make(); + const clone = yield* Effect.gen(function* () { + const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; + return yield* Effect.flip( + service.cloneRepository({ + remoteUrl: CLONE_URLS.url, + destinationPath: path.join(parent, "t3code"), + }), + ); + }).pipe( + Effect.provide( + layer({ + git: { + // Like the real driver: the transfer outlives its deadline and git's last line is + // a remote summary. + execute: (input) => + Effect.gen(function* () { + yield* ( + input.progress?.onStderrLine?.( + "remote: Total 4 (delta 0), reused 0 (delta 0), pack-reused 0", + ) ?? Effect.void + ); + yield* Deferred.succeed(started, undefined); + return yield* Effect.never; + }).pipe( + Effect.timeoutOrElse({ + duration: input.timeoutMs ?? Infinity, + orElse: () => + Effect.fail( + new GitCommandError({ + operation: input.operation, + command: "git", + cwd: input.cwd, + detail: "Git command timed out.", + }), + ), + }), + ), + }, + }), + ), + Effect.forkChild, + ); + + yield* Deferred.await(started); + yield* TestClock.adjust("2 minutes"); + const error = yield* Fiber.join(clone); + + assert.match(error.detail, /timed out/i); + }).pipe(Effect.provide(NodeServices.layer)), +); + function rejectingSsh( urls: Array, httpsFails: boolean, diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.ts index 07c7b28d82ff2..3db65b0bd5f95 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.ts @@ -1,4 +1,5 @@ import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -349,12 +350,15 @@ export const make = Effect.gen(function* () { }; // Git misreads @ in a pathless URL's query as userinfo and echoes its suffix as the host. const normalizedCloneUrl = cloneUrl.replace(/^([a-z][a-z0-9+.-]*:\/\/[^/?#]+)\?/i, "$1/?"); - yield* git + const provider = input.provider ?? SourceControlProviderKind.make("unknown"); + const timeoutMs = options?.timeoutMs === undefined ? CLONE_TIMEOUT_MS : options.timeoutMs; + const clone = git .execute({ operation: "SourceControlRepositoryService.cloneRepository", cwd: path.dirname(destinationPath), args: ["clone", "--progress", "--", normalizedCloneUrl, path.basename(destinationPath)], - timeoutMs: options?.timeoutMs === undefined ? CLONE_TIMEOUT_MS : options.timeoutMs, + // The deadline is applied below so a timeout is reported as one, not as git's last line. + timeoutMs: null, // Buffered output is unused and progress can be huge; cap it while retaining line callbacks. maxOutputBytes: 256 * 1024, appendTruncationMarker: true, @@ -367,7 +371,7 @@ export const make = Effect.gen(function* () { (cause) => new SourceControlRepositoryError({ operation: "cloneRepository", - provider: input.provider ?? SourceControlProviderKind.make("unknown"), + provider, detail: stderrTail.length > 0 ? stderrTail.join(" ") @@ -376,6 +380,21 @@ export const make = Effect.gen(function* () { }), ), ); + if (timeoutMs === null) return yield* clone; + // Interrupting the clone closes the scope that owns git, which kills it. + return yield* clone.pipe( + Effect.timeoutOrElse({ + duration: timeoutMs, + orElse: () => + Effect.fail( + new SourceControlRepositoryError({ + operation: "cloneRepository", + provider, + detail: `The clone timed out after ${Duration.format(Duration.millis(timeoutMs))}.`, + }), + ), + }), + ); }); const cloneRepository = Effect.fn("SourceControlRepositoryService.cloneRepository")(function* ( From 1a8ecc64b7126d312bb0873f1500e525be12fa2f Mon Sep 17 00:00:00 2001 From: maria Date: Wed, 11 Nov 2026 20:08:27 +0000 Subject: [PATCH 0106/1165] feat(web): a quote in a sent message shows as an inset card (#34181) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- apps/web/src/components/ChatMarkdown.tsx | 23 ++- .../components/ComposerPromptEditorTiptap.tsx | 1 - .../AssistantCitationChip.browser.test.tsx | 1 - .../components/chat/AssistantCitationChip.tsx | 153 +++++++++--------- .../chat/MessagesTimeline.logic.test.ts | 13 +- .../components/chat/MessagesTimeline.logic.ts | 9 +- packages/shared/src/threadFindText.test.ts | 13 +- packages/shared/src/threadFindText.ts | 6 +- 8 files changed, 122 insertions(+), 97 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index f76253ee00fc8..50a0556d7ec40 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -96,7 +96,7 @@ import { defaultUrlTransform } from "react-markdown"; import { parseAssistantCitationHref } from "@t3tools/shared/assistantCitations"; import { parseComposerContextHref } from "@t3tools/shared/composerContextReferences"; import { parseThreadLinkHref } from "@t3tools/shared/threadLinks"; -import { AssistantCitationChip } from "./chat/AssistantCitationChip"; +import { AssistantCitationEmbed } from "./chat/AssistantCitationChip"; import { MarkdownThreadLink } from "./chat/MarkdownThreadLink"; import { mathKind } from "@t3tools/shared/markdownMath"; import remarkMath from "remark-math"; @@ -964,6 +964,15 @@ const ChatMarkdownRendererContext = React.createContext< // Isolate streamed source text from the renderer context; only offset-based renderers need it. const ChatMarkdownTextContext = React.createContext(""); +// A quote card is a block. These say nothing else shares its paragraph or list item on that side. +const LIST_MARKER = String.raw`(?:[-*+]|\d+[.)])[ \t]+`; +const CITATION_LEADS_BLOCK = new RegExp( + String.raw`(?:(?:^|\n[ \t]*\n)[ \t>]*(?:${LIST_MARKER})?|\n[ \t>]*${LIST_MARKER})$`, +); +const CITATION_ENDS_BLOCK = new RegExp( + String.raw`^[ \t]*(?:$|\n[ \t]*(?:\n|$)|\n[ \t>]*${LIST_MARKER})`, +); + function markdownHeadingRenderer(level: 1 | 2 | 3 | 4 | 5 | 6) { const Tag = `h${level}` as const; return function MarkdownHeading({ @@ -1108,7 +1117,17 @@ const CHAT_MARKDOWN_COMPONENTS = { } = use(ChatMarkdownRendererContext); const text = use(ChatMarkdownTextContext); const citation = href ? parseAssistantCitationHref(href) : null; - if (citation) return ; + if (citation) { + const before = node?.position?.start.offset; + const after = node?.position?.end.offset; + return ( + + ); + } const threadLink = href ? parseThreadLinkHref(href) : null; if (threadLink) { const written = hastPlainTextDeep(node); diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 1986cc888efba..40accf71b219d 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -523,7 +523,6 @@ function ComposerCitationNodeView({ node, editor, getPos }: NodeViewProps) { > ); diff --git a/apps/web/src/components/chat/AssistantCitationChip.tsx b/apps/web/src/components/chat/AssistantCitationChip.tsx index 35935171f46ab..803779b995d52 100644 --- a/apps/web/src/components/chat/AssistantCitationChip.tsx +++ b/apps/web/src/components/chat/AssistantCitationChip.tsx @@ -5,7 +5,7 @@ import { serializeAssistantCitation, } from "@t3tools/shared/assistantCitations"; import { Link, useNavigate } from "@tanstack/react-router"; -import { ArrowUpRightIcon, PencilIcon, QuoteIcon } from "lucide-react"; +import { PencilIcon, QuoteIcon } from "lucide-react"; import { useEffect, useEffectEvent, @@ -24,24 +24,93 @@ import { import { cn } from "~/lib/utils"; import { useQuietThreadShell } from "~/state/entities"; import { ContextChip, ContextChipAction, ContextChipLabel } from "../ContextChip"; -import { ContextChipPopover } from "../contextChipParts"; -import { Button } from "../ui/button"; -import { Popover, PopoverClose, PopoverPopup, PopoverTrigger } from "../ui/popover"; -import { getVirtualizedScrollFadeClassName } from "../ui/scroll-area"; +import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { AssistantCitationCommentEditor } from "./AssistantCitationCommentEditor"; import { resolveAssistantCitationCommentDismissal } from "./assistantCitationCommentDismissal"; import { observeAssistantCitationCommentSource } from "./AssistantCitationSource"; import { composerFloatingLayerProps } from "./composerEventScope"; -import { observeResize } from "~/lib/observeResize"; import { textDirection } from "~/textDirection"; +function useCitationSourceLinkProps(citation: AssistantCitation) { + const navigate = useNavigate(); + const sourceThread = useQuietThreadShell( + scopeThreadRef(citation.environmentId, citation.threadId), + ); + return { + ...assistantCitationRoute(citation, sourceThread), + "data-markdown-copy": serializeAssistantCitation(citation), + onClick: (event: ReactMouseEvent) => { + if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) { + return; + } + event.preventDefault(); + void navigate(assistantCitationNavigation(citation, sourceThread)); + }, + }; +} + +/** + * A quoted response inside a message. Clicking it goes to the source; its comment follows as + * text. `leading` and `trailing` say it starts or ends its block, which already has spacing. + */ +export function AssistantCitationEmbed({ + citation, + leading, + trailing, +}: { + citation: AssistantCitation; + leading: boolean; + trailing: boolean; +}) { + const wholeResponse = citation.prefix === "" && citation.suffix === ""; + const flat = (text: string) => text.replace(/\s+/g, " "); + const { comment, ...quoted } = citation; + const linkProps = useCitationSourceLinkProps(citation); + return ( + // One copy boundary for the card and its comment, so a copied quote carries the comment once. + + + + {wholeResponse ? ( + flat(citation.text) + ) : ( + <> + {citation.prefix ? `…${flat(citation.prefix)}` : null} + + {flat(citation.text)} + + {citation.suffix ? `${flat(citation.suffix)}…` : null} + + )} + + + {comment ? ( + + {comment} + + ) : null} + + ); +} + +/** The citation chip in the composer, with its comment editor. */ export function AssistantCitationChip({ citation, - composer = false, commentEditor, }: { citation: AssistantCitation; - composer?: boolean; commentEditor?: { open: boolean; sourceAnchor?: AssistantCitationSourceAnchor | undefined; @@ -53,10 +122,7 @@ export function AssistantCitationChip({ onRestoreFocus?: () => void; }; }) { - const navigate = useNavigate(); - const sourceThread = useQuietThreadShell( - scopeThreadRef(citation.environmentId, citation.threadId), - ); + const sourceLinkProps = useCitationSourceLinkProps(citation); const commentInputRef = useRef(null); const commentPopupRef = useRef(null); const draftCommentRef = useRef(null); @@ -116,17 +182,6 @@ export function AssistantCitationChip({ } : undefined; const label = assistantCitationLabel(citation); - const sourceLinkProps = { - ...assistantCitationRoute(citation, sourceThread), - "data-markdown-copy": serializeAssistantCitation(citation), - onClick: (event: ReactMouseEvent) => { - if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) { - return; - } - event.preventDefault(); - void navigate(assistantCitationNavigation(citation, sourceThread)); - }, - }; const composerSourceLink = ( ); - if (!composer) { - return ( - } - label={label} - accessibleLabel={`Quoted assistant text: ${label}`} - copyMarkdown={serializeAssistantCitation(citation)} - > -
- - } />} - > - -
-
- ); - } return ( ); } - -function AssistantCitationQuote({ citation }: { citation: AssistantCitation }) { - const [fade, setFade] = useState({ top: false, bottom: false }); - const updateFade = (element: HTMLElement) => { - const top = element.scrollTop > 1; - const bottom = element.scrollHeight - element.clientHeight - element.scrollTop > 1; - setFade((current) => - current.top === top && current.bottom === bottom ? current : { top, bottom }, - ); - }; - const observeFade = (element: HTMLDivElement | null) => - element ? observeResize(element, () => updateFade(element)) : undefined; - return ( -
updateFade(event.currentTarget)} - className={cn( - "max-h-64 min-h-0 space-y-3 self-stretch overflow-y-auto whitespace-pre-wrap wrap-break-word", - getVirtualizedScrollFadeClassName(fade), - )} - > -
- {citation.text} -
- {citation.comment ?

{citation.comment}

: null} -
- ); -} diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 32d4f9b70f7c8..966b8126a45bd 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -4762,22 +4762,25 @@ describe("subagent continued by a later turn", () => { }); describe("shouldCollapseUserMessage", () => { - it("measures a quote chip by its label, not its encoded link", () => { + it("measures a quote by its card, not its encoded link", () => { const quote = "A long assistant paragraph that the user quoted. ".repeat(40); - const citation = serializeAssistantCitation({ - version: 1, + const source = { + version: 1 as const, environmentId: EnvironmentId.make("environment"), threadId: ThreadId.make("thread"), messageId: MessageId.make("source"), text: quote, - comment: "Why does this matter?", start: 0, end: quote.length, prefix: "", suffix: "", - }); + }; + const citation = serializeAssistantCitation({ ...source, comment: "Why does this matter?" }); + const bare = serializeAssistantCitation(source); expect(shouldCollapseUserMessage(`${citation} Can you expand on this?`)).toBe(false); expect(shouldCollapseUserMessage(`${citation} ${"More text. ".repeat(60)}`)).toBe(true); + expect(shouldCollapseUserMessage(bare.repeat(2))).toBe(false); + expect(shouldCollapseUserMessage(bare.repeat(10))).toBe(true); }); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index ebc24cd7de5a9..d9e4980d78ca1 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -3,10 +3,7 @@ import { resolveUserMessagePresentation } from "@t3tools/client-runtime/user-mes import { isLiveSubagentTurnItem } from "@t3tools/client-runtime/state/subagentRuntime"; import * as DateTime from "effect/DateTime"; import * as Equal from "effect/Equal"; -import { - assistantCitationLabel, - collectAssistantCitations, -} from "@t3tools/shared/assistantCitations"; +import { collectAssistantCitations } from "@t3tools/shared/assistantCitations"; import { shallow } from "zustand/vanilla/shallow"; import { claudeResumeCompactionAge } from "@t3tools/shared/claudeCompaction"; import { @@ -2701,7 +2698,9 @@ function visibleUserMessageText(text: string): string { let visible = ""; let cursor = 0; for (const match of collectAssistantCitations(text)) { - visible += linkLabels(text.slice(cursor, match.start)) + assistantCitationLabel(match.citation); + // A quote shows as a two-line card, counted here as a mark, with its comment under it. + visible += linkLabels(text.slice(cursor, match.start)); + visible += "\n\u275D\n" + (match.citation.comment ?? "") + "\n"; cursor = match.end; } return visible + linkLabels(text.slice(cursor)); diff --git a/packages/shared/src/threadFindText.test.ts b/packages/shared/src/threadFindText.test.ts index 0023946b5b9fa..d1929afd1ea1d 100644 --- a/packages/shared/src/threadFindText.test.ts +++ b/packages/shared/src/threadFindText.test.ts @@ -96,7 +96,7 @@ it("indexes nested disclosure summaries and bodies in rendered order", () => { ).toEqual(["Outer", "first", "Inner", "second"]); }); -it("indexes the citation chip label instead of its link text", () => { +it("indexes a citation by its comment, not its quote or link text", () => { const citation = { version: 1 as const, environmentId: EnvironmentId.make("environment"), @@ -110,12 +110,13 @@ it("indexes the citation chip label instead of its link text", () => { }; const text = `Please fix ${serializeAssistantCitation(citation)} thanks`; expect(searchableMessageSegments({ role: "user", text, streaming: false })).toEqual([ - "Please fix cited needle thanks", - ]); - const commented = serializeAssistantCitation({ ...citation, comment: "my note" }); - expect(searchableMessageSegments({ role: "user", text: commented, streaming: false })).toEqual([ - "my note", + "Please fix ", + " thanks", ]); + const commented = serializeAssistantCitation({ ...citation, comment: "my\nnote" }); + expect( + searchableMessageSegments({ role: "user", text: `fix ${commented} thanks`, streaming: false }), + ).toEqual(["fix ", "my note thanks"]); }); it("indexes insight bodies without hidden fences and keeps user and plan text literal", () => { diff --git a/packages/shared/src/threadFindText.ts b/packages/shared/src/threadFindText.ts index b120a0f0a4d49..871f14562f9e5 100644 --- a/packages/shared/src/threadFindText.ts +++ b/packages/shared/src/threadFindText.ts @@ -25,7 +25,7 @@ import { isAttachmentOnlyBootstrapPrompt, parseComposerContextHref, } from "./composerContextReferences.ts"; -import { assistantCitationLabel, parseAssistantCitationHref } from "./assistantCitations.ts"; +import { parseAssistantCitationHref } from "./assistantCitations.ts"; import type { OrchestrationV2ConversationMessage } from "@t3tools/contracts"; import { proposedPlanTitle, stripDisplayedPlanMarkdown } from "./proposedPlanText.ts"; import { unified } from "unified"; @@ -150,7 +150,9 @@ function markdownThreadFindText( const citation = node.tagName === "a" && typeof href === "string" ? parseAssistantCitationHref(href) : null; if (citation) { - text += assistantCitationLabel(citation); + // The quote card is not searched, and it splits the text before it from its comment. + flush(); + text += (citation.comment ?? "").replace(/\r?\n/g, " "); return; } { From b015ffd7c2d360aed7debb52a3b64483ab4466ab Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 11 Nov 2026 12:12:28 -0800 Subject: [PATCH 0107/1165] perf(server): very long threads open about 10x faster (#34490) Co-authored-by: Claude Fable 5.2 --- .../orchestration-v2/ProjectionStore.test.ts | 152 ++++++++++++++- .../src/orchestration-v2/ProjectionStore.ts | 174 ++++++++++++++---- apps/server/src/persistence/Migrations.ts | 2 + .../082_TurnItemConversationIndex.ts | 16 ++ .../reconcileV2PreviewMigration.test.ts | 2 + 5 files changed, 306 insertions(+), 40 deletions(-) create mode 100644 apps/server/src/persistence/Migrations/082_TurnItemConversationIndex.ts diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index ae1640309c133..2ba22350330b7 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -1562,7 +1562,9 @@ const commandReadsSkipSettledLeafNodes = Effect.gen(function* () { ["settled-reasoning", settledRun, "reasoning", "cancelled"], ["settled-subagent", settledRun, "subagent", "completed"], ["settled-run-live-tool", settledRun, "tool_call", "running"], + ["settled-run-todo", settledRun, "todo_list", "running"], ["active-run-done-tool", activeRun, "tool_call", "completed"], + ["active-run-todo", activeRun, "todo_list", "running"], ] as const; for (const [name, run, kind, status] of nodes) { const id = NodeId.make(`node:settled-leaf-nodes:${name}`); @@ -1592,7 +1594,49 @@ const commandReadsSkipSettledLeafNodes = Effect.gen(function* () { completedAt: status === "running" ? null : now, }, }); + if (kind === "todo_list") { + yield* store.apply({ + id: EventId.make(`event:settled-leaf-nodes:${name}:plan`), + type: "plan.updated", + threadId, + runId: run.id, + nodeId: id, + driver, + occurredAt: now, + payload: { + id: PlanId.make(`plan:settled-leaf-nodes:${name}`), + threadId, + runId: run.id, + nodeId: id, + status: "active", + kind: "todo_list", + steps: [], + }, + }); + } } + // No row or node of this run is in the window. + const settledCheckpointId = CheckpointId.make("checkpoint:settled-leaf-nodes:earlier-run"); + yield* store.apply({ + id: EventId.make("event:settled-leaf-nodes:earlier-run-checkpoint"), + type: "checkpoint.captured", + threadId, + occurredAt: now, + payload: { + id: settledCheckpointId, + threadId, + scopeId: CheckpointScopeId.make("scope:settled-leaf-nodes:root"), + runId: RunId.make("run:settled-leaf-nodes:earlier"), + nodeId: NodeId.make("node:settled-leaf-nodes:earlier-root"), + parentCheckpointId: null, + ordinalWithinScope: 1, + appRunOrdinal: 1, + ref: CheckpointRef.make("refs/t3/settled-leaf-nodes/1"), + status: "ready", + files: [], + capturedAt: now, + }, + }); const nodeNames = (records: { readonly nodes: ReadonlyArray<{ readonly id: string }> }) => records.nodes .map((node) => node.id.replace("node:settled-leaf-nodes:", "")) @@ -1601,12 +1645,32 @@ const commandReadsSkipSettledLeafNodes = Effect.gen(function* () { assert.deepEqual( nodeNames(yield* store.getThreadRecords(threadId, ["nodes"], { omitSettledLeafNodes: true })), - ["active-run-done-tool", "settled-root", "settled-run-live-tool", "settled-subagent"], + [ + "active-run-done-tool", + "active-run-todo", + "settled-root", + "settled-run-live-tool", + "settled-run-todo", + "settled-subagent", + ], ); assert.deepEqual( nodeNames(yield* store.getThreadRecords(threadId, ["nodes"])), nodes.map(([name]) => name).toSorted(), ); + // A todo list stays running after its run ends. Counted as live work, every old run of a + // long thread would ride along with each window, with its nodes and subagents. + const liveWindow = (yield* store.getThreadSnapshotWindow(threadId, { rowLimit: 1 })).projection; + assert.deepEqual(nodeNames(liveWindow), ["active-run-todo", "settled-run-live-tool"]); + assert.deepEqual( + liveWindow.plans.map((plan) => plan.id), + [PlanId.make("plan:settled-leaf-nodes:active-run-todo")], + ); + // Clients show the diff of the last finished turn from this record. + assert.deepEqual( + liveWindow.checkpoints.map((checkpoint) => checkpoint.id), + [settledCheckpointId], + ); const subagents = [ ["delivered", settledRun, "completed", "delivered"], @@ -3663,6 +3727,92 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { SET type = 'user_message', payload_json = json_set(payload_json, '$.type', 'user_message') WHERE thread_id = ${threadId} AND ordinal = 1 `; + // A running run folds nothing, so its newest rows are the window. Reading them must not + // walk the rest of the turn: 999 commands here, tens of thousands after a day of work. + const setSharedRunStatus = (status: "running" | "completed") => sql` + UPDATE orchestration_v2_projection_runs + SET status = ${status}, payload_json = json_set(payload_json, '$.status', ${status}) + WHERE run_id = ${sharedRunId}`; + yield* setSharedRunStatus("running"); + // Another thread's rows that carry this run's id must not count toward the tail. + const foreignThreadId = "thread:bounded-sql-history:foreign"; + yield* sql` + INSERT INTO orchestration_v2_projection_turn_items ( + turn_item_id, thread_id, run_id, node_id, provider_thread_id, provider_turn_id, + parent_item_id, ordinal, type, status, updated_at, payload_json + ) + SELECT turn_item_id || ':foreign', ${foreignThreadId}, run_id, NULL, NULL, NULL, + NULL, ordinal + 5000, type, status, updated_at, payload_json + FROM orchestration_v2_projection_turn_items + WHERE thread_id = ${threadId} AND ordinal > 400`; + const liveStatements: Array]> = []; + const liveWindow = yield* projectionStore + .getThreadSnapshotWindow(threadId, { + rowLimit: sqlPageLimit, + userTurnLimit: 10, + deferWork: true, + }) + .pipe( + Effect.provideService(Statement.CurrentTransformer, (statement) => { + liveStatements.push(statement.compile()); + return Effect.succeed(statement); + }), + ); + assert.deepEqual( + liveWindow.projection.turnItems.map((item) => item.ordinal), + Array.from({ length: sqlPageLimit }, (_, index) => 1001 - sqlPageLimit + index), + ); + { + const livePath = "file:/thread-window-live-steps?vfs=memdb"; + using copy = new NodeSqlite.DatabaseSync(livePath); + yield* sql`VACUUM INTO ${livePath}`; + const liveSteps = (statements = liveStatements) => + statements.reduce((steps, [query, params]) => { + const statement = copy.prepare(query); + statement.all(...(params as ReadonlyArray)); + return steps + statement.stat("vmStep"); + }, 0); + const deleteOrdinals = (from: number, to: number) => + copy.exec( + `DELETE FROM orchestration_v2_projection_turn_items WHERE ordinal BETWEEN ${from} AND ${to}`, + ); + // Nothing below the newest 502 rows is read to find the tail. + const fullSteps = liveSteps(); + assert.isAbove(fullSteps, 0); + deleteOrdinals(2, 498); + assert.isAtMost(fullSteps, liveSteps() * 1.1); + // The rows themselves are read only from the newest page, not from all 502. + const rowStatements = liveStatements.filter(([query]) => query.includes("work_segments")); + assert.lengthOf(rowStatements, 1); + const pageSteps = liveSteps(rowStatements); + deleteOrdinals(499, 900); + assert.isAtMost(pageSteps, liveSteps(rowStatements) * 1.1); + // The tail search reads conversation rows from their own index, not past the work rows. + const tailSearch = liveStatements.find(([query]) => query.includes("visible_tail")); + assert.isDefined(tailSearch); + const plan = copy + .prepare(`EXPLAIN QUERY PLAN ${tailSearch![0]}`) + .all(...(tailSearch![1] as ReadonlyArray)) + .map((row) => String(row.detail)); + assert.isTrue( + plan.some((detail) => detail.includes("turn_items_conversation_idx")), + plan.join("\n"), + ); + } + const olderLivePage = yield* projectionStore.getThreadSnapshotWindow(threadId, { + rowLimit: sqlPageLimit, + userTurnLimit: 10, + deferWork: true, + anchorItemId: TurnItemId.make("turn-item:bounded-sql-history:600"), + }); + // Rows above the anchor must not set the tail, or the page would start above its end. + assert.deepEqual( + olderLivePage.projection.turnItems.map((item) => item.ordinal), + Array.from({ length: sqlPageLimit }, (_, index) => 601 - sqlPageLimit + index), + ); + yield* sql`DELETE FROM orchestration_v2_projection_turn_items + WHERE thread_id = ${foreignThreadId}`; + yield* setSharedRunStatus("completed"); yield* sql` UPDATE orchestration_v2_projection_turn_items SET run_id = 'run:bounded-sql-history:' || ordinal, diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index a566fd044282c..9e27210350642 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -2023,6 +2023,20 @@ export const layer: Layer.Layer = OR item.payload_json LIKE '%"viewedImagePath"%' OR item.payload_json LIKE '%"toolName":"%delegate_task"%')`; + // SQLite uses the partial conversation index of migration 082 only for this exact predicate. + const CONVERSATION_INDEX_EXCLUDED_TYPES = sql.literal( + `(${DEFERRED_WORK_ITEM_TYPES.map((type) => `'${type}'`).join(", ")})`, + ); + + // A todo list is never closed when its run ends, so a long thread holds an active one per + // old run. Counting those as live work drags each run's nodes and subagents into every window. + const todoListOfSettledRun = (row: Statement.Fragment) => sql`( + ${row}.kind = 'todo_list' AND ${row}.run_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM orchestration_v2_projection_runs AS todo_run + WHERE todo_run.run_id = ${row}.run_id + AND todo_run.status IN ('queued','preparing','starting','running','waiting') + ))`; + // Start from the few owned provider threads; a thread can have a large settled node history. const nodesOwningProviderThreads = (threadId: ThreadId) => sql` SELECT owner.node_id @@ -3075,55 +3089,123 @@ export const layer: Layer.Layer = `; const boundary = anchors[0]!; // Folding needs whole segments, so the window would walk every item since the boundary. - // Non-deferrable types always keep their own row, so the window stays above the newest - // `tailRows` of them. Start at the latest segment start of each run that reaches that - // point, clamped to it so runless rows below stay in the scan. + // It stays above the newest `tailRows` rows that are sure to keep their own row. Start at + // the latest segment start of each run that reaches that point, clamped to it so runless + // rows below stay in the scan. const tailRows = Math.max(window.rowLimit, THREAD_HISTORY_TURN_SNAPSHOT_ROW_LIMIT); - const tailWorkStart = sql<{ readonly ordinal: number | null }>` - ${eligible}, visible_tail AS ( - SELECT ordinal FROM eligible - WHERE ordinal >= ${boundary.ordinal} - AND type NOT IN ${sql.in(DEFERRED_WORK_ITEM_TYPES)} + // The newest rows sure to keep their own row: conversation rows, and every row of the + // running run, which folds nothing. More of them than a whole turn may hold means the + // window pages from inside the newest turn with the row limit, so the tail shrinks to + // that limit. A turn of thousands of rows is then not walked to return the newest few. + const tailWorkStart = sql<{ + readonly ordinal: number | null; + readonly long_turn: number; + }>` + ${eligible}, live_run AS ( + -- The oldest live run is the one at work; queued runs follow it. + SELECT live.run_id FROM orchestration_v2_projection_runs AS live + WHERE live.thread_id = ${threadId} + AND live.status IN ('queued','preparing','starting','running','waiting') + AND ${window.requiredRunId ?? null} IS NULL + AND NOT EXISTS ( + SELECT 1 FROM orchestration_v2_projection_run_attempts + WHERE run_id = live.run_id AND status = 'superseded' + ) + ORDER BY +live.ordinal ASC LIMIT 1 + ), visible_tail AS ( + SELECT ordinal FROM ( + SELECT ordinal FROM eligible + WHERE ordinal >= ${boundary.ordinal} + AND type NOT IN ${CONVERSATION_INDEX_EXCLUDED_TYPES} + ORDER BY ordinal DESC + LIMIT ${tailRows} + ) + UNION + SELECT ordinal FROM ( + SELECT live_item.ordinal + FROM orchestration_v2_projection_turn_items AS live_item + -- The unary plus keeps this on the run index, whose ordinal order ends + -- the walk at the limit. + WHERE live_item.run_id = (SELECT run_id FROM live_run) + AND +live_item.thread_id = ${threadId} + AND live_item.ordinal >= ${boundary.ordinal} + AND live_item.ordinal <= COALESCE( + ( + SELECT ordinal FROM orchestration_v2_projection_turn_items + WHERE thread_id = ${threadId} + AND turn_item_id = ${window.anchorItemId ?? null} + LIMIT 1 + ), + 9223372036854775807 + ) + ORDER BY live_item.ordinal DESC + LIMIT ${tailRows} + ) ORDER BY ordinal DESC LIMIT ${tailRows} + ), turn AS ( + SELECT ( + SELECT COUNT(*) FROM visible_tail WHERE ordinal >= ${boundary.latest} + ) > ${THREAD_HISTORY_PAGE_POLICY.maxTurnItems} AS long_turn ), tail AS ( - SELECT MIN(ordinal) AS ordinal FROM visible_tail HAVING COUNT(*) >= ${tailRows} + SELECT visible_tail.ordinal FROM visible_tail + ORDER BY visible_tail.ordinal DESC + LIMIT 1 OFFSET ( + SELECT MAX(CASE WHEN long_turn THEN ${window.rowLimit} ELSE ${tailRows} END, 1) - 1 + FROM turn + ) ), tail_runs AS ( SELECT DISTINCT item.run_id FROM tail CROSS JOIN eligible AS item WHERE item.ordinal >= tail.ordinal AND item.run_id IS NOT NULL ) - SELECT MIN(MIN(COALESCE( - ( - SELECT start.ordinal FROM eligible AS start - WHERE start.run_id = tail_runs.run_id AND start.type = 'user_message' - AND start.ordinal <= tail.ordinal - ORDER BY start.ordinal DESC LIMIT 1 - ), - ( - SELECT MIN(first.ordinal) FROM orchestration_v2_projection_turn_items AS first - WHERE first.thread_id = ${threadId} AND first.run_id = tail_runs.run_id - ), - tail.ordinal - ), tail.ordinal)) AS ordinal + SELECT MIN(MIN(CASE + -- Only a completed run or a superseded attempt folds work, and folding is + -- what needs the whole segment. Any other run keeps every row. + WHEN ( + SELECT status FROM orchestration_v2_projection_runs + WHERE run_id = tail_runs.run_id + ) IS NOT 'completed' AND NOT EXISTS ( + SELECT 1 FROM orchestration_v2_projection_run_attempts + WHERE run_id = tail_runs.run_id AND status = 'superseded' + ) THEN tail.ordinal + ELSE COALESCE( + ( + SELECT start.ordinal FROM eligible AS start + WHERE start.run_id = tail_runs.run_id AND start.type = 'user_message' + AND start.ordinal <= tail.ordinal + ORDER BY start.ordinal DESC LIMIT 1 + ), + ( + SELECT MIN(first.ordinal) FROM orchestration_v2_projection_turn_items AS first + WHERE first.thread_id = ${threadId} AND first.run_id = tail_runs.run_id + ), + tail.ordinal + ) + END, tail.ordinal)) AS ordinal, (SELECT long_turn FROM turn) AS long_turn FROM tail LEFT JOIN tail_runs `; // A negative or fractional limit makes SQLite's LIMIT disagree with `tailRows`; keep the old start. - const workStart = + const tail = window.deferWork === true && boundary.latest !== null && Number.isSafeInteger(window.rowLimit) && window.rowLimit >= 0 - ? Math.max(boundary.ordinal, (yield* tailWorkStart)[0]?.ordinal ?? 0) - : boundary.ordinal; + ? (yield* tailWorkStart)[0] + : undefined; + const longTurn = tail?.long_turn === 1; + const workStart = Math.max(boundary.ordinal, tail?.ordinal ?? 0); return sql` ${eligible}, blocked_runs AS MATERIALIZED ( SELECT DISTINCT run_id FROM orchestration_v2_projection_turn_items WHERE thread_id = ${threadId} AND run_id IS NOT NULL AND ${window.deferWork ? 1 : 0} = 1 + -- Only a run that can fold asks whether it is blocked. A live run cannot, + -- and this check would read every row it ever wrote. AND run_id IN ( SELECT run_id FROM eligible WHERE ordinal >= ${workStart} AND run_id IS NOT NULL + AND (run_status = 'completed' OR attempt_status = 'superseded') ) AND (type IN ('run_interrupt_request', 'run_interrupt_result') OR (type = 'error' AND status = 'failed' AND parent_item_id IS NULL)) @@ -3177,6 +3259,7 @@ export const layer: Layer.Layer = -- Every LIMIT here and in latest_user_turn must stay <= tailRows, or rows below workStart go missing. LIMIT CASE WHEN ${window.rowLimit} = 0 THEN 0 + WHEN ${longTurn ? 1 : 0} = 1 THEN ${window.rowLimit} WHEN (SELECT row_count FROM latest_user_turn) BETWEEN 1 AND ${THREAD_HISTORY_PAGE_POLICY.maxTurnItems} THEN ${THREAD_HISTORY_TURN_SNAPSHOT_ROW_LIMIT} @@ -3326,8 +3409,9 @@ export const layer: Layer.Layer = WITH RECURSIVE retained(node_id) AS ( SELECT value FROM json_each(${cohortJson("nodeId")}) UNION - SELECT node_id FROM orchestration_v2_projection_nodes + SELECT node_id FROM orchestration_v2_projection_nodes AS live WHERE thread_id = ${threadId} AND status IN ('pending','starting','running','waiting') + AND NOT ${todoListOfSettledRun(sql`live`)} UNION SELECT root_node_id FROM orchestration_v2_projection_run_attempts WHERE thread_id = ${threadId} AND ( @@ -3697,18 +3781,23 @@ export const layer: Layer.Layer = ORDER BY created_at ASC, message_id ASC ` : sql` - SELECT payload_json FROM orchestration_v2_projection_messages AS message - WHERE message.thread_id = ${threadId} - AND ( - message.message_id IN ( - SELECT value FROM json_each(${cohortMessageIds}) - ) - OR message.run_id IN ( - SELECT run_id FROM orchestration_v2_projection_runs - WHERE thread_id = ${threadId} - AND status IN ('queued', 'preparing', 'starting', 'running', 'waiting') - ) - ) + -- Look the window's messages up by key and the live runs' by run. One filter + -- over the thread reads every message the thread ever had. + SELECT payload_json FROM ( + SELECT message.payload_json, message.created_at, message.message_id + FROM json_each(${cohortMessageIds}) AS wanted + CROSS JOIN orchestration_v2_projection_messages AS message + ON message.message_id = wanted.value + WHERE message.thread_id = ${threadId} + UNION + SELECT message.payload_json, message.created_at, message.message_id + FROM orchestration_v2_projection_runs AS run + CROSS JOIN orchestration_v2_projection_messages AS message + ON message.run_id = run.run_id + WHERE run.thread_id = ${threadId} + AND run.status IN ('queued', 'preparing', 'starting', 'running', 'waiting') + AND message.thread_id = ${threadId} + ) ORDER BY created_at ASC, message_id ASC `, fields !== undefined && !fields.includes("plans") @@ -3722,7 +3811,7 @@ export const layer: Layer.Layer = SELECT payload_json FROM orchestration_v2_projection_plans AS plan WHERE plan.thread_id = ${threadId} AND ( - plan.status = 'active' + (plan.status = 'active' AND NOT ${todoListOfSettledRun(sql`plan`)}) OR plan.plan_id = ${latestTodoList?.plan_id ?? null} OR plan.plan_id IN ( SELECT value FROM json_each(${cohortPlanIds}) @@ -3763,6 +3852,13 @@ export const layer: Layer.Layer = WHERE thread_id = ${threadId} AND (status IN ('pending','capturing') OR (run_id IS NULL AND ordinal_within_scope = 0 AND status = 'ready') + -- The diff of the last finished turn stays in reach while a long run is at work. + OR checkpoint_id = ( + SELECT checkpoint_id FROM orchestration_v2_projection_checkpoints + WHERE thread_id = ${threadId} AND status = 'ready' + AND app_run_ordinal IS NOT NULL + ORDER BY app_run_ordinal DESC LIMIT 1 + ) OR checkpoint_id IN (SELECT value FROM json_each(${cohortCheckpointIds})) OR run_id IN (SELECT value FROM json_each(${cohortRunIds})) OR node_id IN (SELECT value FROM json_each(${cohortNodeIds}))) diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index c3a9efe150e44..e5aa6c68dc755 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -84,6 +84,7 @@ import Migration0078 from "./Migrations/078_DropOrchestrationEventCorrelationInd import Migration0079 from "./Migrations/079_DelegatedRecoveryIndexes.ts"; import Migration0080 from "./Migrations/080_ScheduledTaskThreadMode.ts"; import Migration0081 from "./Migrations/081_ProjectionMessageSearchText.ts"; +import Migration0082 from "./Migrations/082_TurnItemConversationIndex.ts"; const migrationEntries = [ [1, "OrchestrationEvents", Migration0001], @@ -168,6 +169,7 @@ const migrationEntries = [ [79, "DelegatedRecoveryIndexes", Migration0079], [80, "ScheduledTaskThreadMode", Migration0080], [81, "ProjectionMessageSearchText", Migration0081], + [82, "TurnItemConversationIndex", Migration0082], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/082_TurnItemConversationIndex.ts b/apps/server/src/persistence/Migrations/082_TurnItemConversationIndex.ts new file mode 100644 index 0000000000000..fb1caac44f3ec --- /dev/null +++ b/apps/server/src/persistence/Migrations/082_TurnItemConversationIndex.ts @@ -0,0 +1,16 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + // The thread window finds its newest conversation rows here instead of walking the tool calls + // between them. The read in ProjectionStore must spell this type list the same way. + yield* sql` + CREATE INDEX IF NOT EXISTS orchestration_v2_projection_turn_items_conversation_idx + ON orchestration_v2_projection_turn_items(thread_id, ordinal, type, run_id) + WHERE type NOT IN ( + 'command_execution', 'reasoning', 'file_change', 'file_search', 'web_search', 'dynamic_tool' + ) + `; +}); diff --git a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts index 8acfd0cb6b9c4..88348b742b470 100644 --- a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts +++ b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts @@ -61,6 +61,7 @@ describe("V2 preview upgrade", () => { [79, "DelegatedRecoveryIndexes"], [80, "ScheduledTaskThreadMode"], [81, "ProjectionMessageSearchText"], + [82, "TurnItemConversationIndex"], ]); assert.deepStrictEqual(yield* runMigrations(), []); assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_legacy_imports`, imports); @@ -165,6 +166,7 @@ describe("V2 preview upgrade", () => { [79, "DelegatedRecoveryIndexes"], [80, "ScheduledTaskThreadMode"], [81, "ProjectionMessageSearchText"], + [82, "TurnItemConversationIndex"], ]); }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), ); From c8e8b58ee6f570a1a3f538e615aa800c406d73f4 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Wed, 11 Nov 2026 14:18:17 -0600 Subject: [PATCH 0108/1165] fix(web): improve Excel spreadsheet previews (#34429) --- apps/web/package.json | 2 +- .../AttachmentFilePreview.browser.test.tsx | 216 ++++++++++++------ .../components/files/OpenXmlOfficeViewer.tsx | 14 +- .../components/files/office/Spreadsheet.tsx | 8 +- .../files/office/fonts.browser.test.ts | 13 +- apps/web/src/components/files/office/fonts.ts | 10 + .../components/files/office/sheet-fonts.ts | 58 +++++ .../components/files/office/sheet-frame.ts | 77 ++++++- .../components/files/office/sheet-protocol.ts | 3 +- .../files/office/spreadsheet.worker.ts | 31 ++- .../web/src/components/files/officeArchive.ts | 193 ++++++++++++++-- .../components/files/officePreviewProtocol.ts | 10 + pnpm-lock.yaml | 10 +- third-party-licenses.config.json | 2 +- 14 files changed, 534 insertions(+), 113 deletions(-) create mode 100644 apps/web/src/components/files/office/sheet-fonts.ts diff --git a/apps/web/package.json b/apps/web/package.json index fc268cc492d28..b92240a8833ad 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -17,7 +17,7 @@ "@betteroffice/fonts": "0.4.0", "@betteroffice/fonts-cjk": "0.4.0", "@betteroffice/pptx": "0.2.0", - "@betteroffice/xlsx": "0.3.0", + "@betteroffice/xlsx": "0.5.1", "@clerk/clerk-js": "catalog:", "@clerk/electron": "catalog:", "@clerk/react": "catalog:", diff --git a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx index 7eccb21c77814..4ecc965f6fe32 100644 --- a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx +++ b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx @@ -1039,71 +1039,90 @@ it.each(["utf-16le", "utf-16be", "windows-1252"])( ); it.each([ - ["", "worksheets/./sheet1.xml", false], - ["x:", "../xl/worksheets/sheet1.xml", false], - ["", "/xl/worksheets/sheet1.xml", true], -])("preserves %sworksheet metadata with relationship target %s", async (prefix, target, empty) => { - const { default: JSZip } = await import("jszip"); - const archive = new JSZip(); - const ns = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"; - archive.file( - "xl/workbook.xml", - '', - ); - archive.file( - "xl/_rels/workbook.xml.rels", - ``, - ); - archive.file( - "xl/worksheets/sheet1.xml", - `<${prefix}worksheet xmlns${prefix ? ":x" : ""}="${ns}"><${prefix}dimension ref="XFD1048576"/><${prefix}cols><${prefix}col min="${OFFICE_PREVIEW_MAX_COLUMNS - 1}" max="${OFFICE_PREVIEW_MAX_COLUMNS + 10}" width="20"/><${prefix}col min="${OFFICE_PREVIEW_MAX_COLUMNS + 1}" max="${OFFICE_PREVIEW_MAX_COLUMNS + 30}" hidden="1"/><${prefix}sheetData><${prefix}row ht="42" customHeight="1" hidden="1"><${prefix}c r="A50" t="inlineStr"><${prefix}is><${prefix}t>${"value".repeat(1000)}<${prefix}mergeCells><${prefix}mergeCell ref="XFD1048576"/>` - .replace(/<((?:x:)?(?:col|dimension|mergeCell))\b([^>]*)\/>/g, prefix ? "<$1$2>" : "$&") - .replace(/[\s\S]*?<\/sheetData>/, (data) => (empty ? "" : data)) - .replace(/="([^"]*)"/g, (attribute, value: string) => - prefix || empty - ? ` = '${value.replace(/\d/g, (digit) => `&#${digit.charCodeAt(0)};`)}'` - : attribute, - ), - ); - const bytes = await archive.generateAsync({ type: "arraybuffer" }); - const worker = new Worker(new URL("./officePreview.worker.ts", import.meta.url), { - type: "module", - }); - try { - const result = await new Promise((resolve) => { - worker.addEventListener( - "message", - (event: MessageEvent) => resolve(event.data), - { once: true }, - ); - worker.postMessage({ type: "open", kind: "xlsx", bytes }, [bytes]); - }); - expect(result.type).toBe("xlsx"); - if (result.type !== "xlsx") throw new Error("Expected bounded workbook"); - const bounded = await JSZip.loadAsync(result.bytes); - const sheet = new DOMParser().parseFromString( - await bounded.file("xl/worksheets/sheet1.xml")!.async("string"), - "application/xml", + ["", "worksheets/./sheet1.xml", false, false], + ["x:", "../xl/worksheets/sheet1.xml", false, false], + ["", "/xl/worksheets/sheet1.xml", true, false], + ["", "worksheets/sheet1.xml", false, true], + ["", "worksheets/sheet1.xml", true, true], +])( + "preserves %sworksheet metadata with relationship target %s", + async (prefix, target, empty, localPrefix) => { + const { default: JSZip } = await import("jszip"); + const archive = new JSZip(); + const ns = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"; + archive.file( + "xl/workbook.xml", + '', ); - expect(sheet.querySelector("parsererror")).toBeNull(); - const row = sheet.getElementsByTagNameNS(ns, "row")[0]; - if (empty) expect(row).toBeUndefined(); - else { - expect(row!.getAttribute("ht")).toBe("42"); - expect(row!.getAttribute("hidden")).toBe("1"); - expect(row!.getElementsByTagNameNS(ns, "c")[0]!.getAttribute("r")).toBe("A50"); - expect(row!.getElementsByTagNameNS(ns, "t")[0]!.textContent).toHaveLength(4096); + archive.file( + "xl/_rels/workbook.xml.rels", + ``, + ); + archive.file( + "xl/worksheets/sheet1.xml", + `<${prefix}worksheet xmlns${prefix ? ":x" : ""}="${ns}"><${prefix}dimension ref="XFD1048576"/><${prefix}sheetViews><${prefix}sheetView showGridLines="0"/><${prefix}sheetFormatPr defaultColWidth="30"/><${prefix}cols><${prefix}col min="${OFFICE_PREVIEW_MAX_COLUMNS - 1}" max="${OFFICE_PREVIEW_MAX_COLUMNS + 10}" width="20"/><${prefix}col min="${OFFICE_PREVIEW_MAX_COLUMNS + 1}" max="${OFFICE_PREVIEW_MAX_COLUMNS + 30}" hidden="1"/><${prefix}sheetData><${prefix}row ht="42" customHeight="1" hidden="1"><${prefix}c r="A50" t="inlineStr"><${prefix}is><${prefix}t>${"value".repeat(1000)}<${prefix}mergeCells><${prefix}mergeCell ref="XFD1048576"/>` + .replace( + /<((?:x:)?(?:col|dimension|mergeCell))\b([^>]*)\/>/g, + prefix ? "<$1$2>" : "$&", + ) + .replace(/[\s\S]*?<\/sheetData>/, (data) => (empty ? "" : data)) + .replace(/<(\/?)sheetData\b/g, (tag, closing: string) => + localPrefix ? `<${closing}x:sheetData${closing ? "" : ` xmlns:x="${ns}"`}` : tag, + ) + .replace(/="([^"]*)"/g, (attribute, value: string) => + prefix || empty + ? ` = '${value.replace(/\d/g, (digit) => `&#${digit.charCodeAt(0)};`)}'` + : attribute, + ), + ); + const bytes = await archive.generateAsync({ type: "arraybuffer" }); + const worker = new Worker(new URL("./officePreview.worker.ts", import.meta.url), { + type: "module", + }); + try { + const result = await new Promise((resolve) => { + worker.addEventListener( + "message", + (event: MessageEvent) => resolve(event.data), + { once: true }, + ); + worker.postMessage({ type: "open", kind: "xlsx", bytes }, [bytes]); + }); + expect(result.type).toBe("xlsx"); + if (result.type !== "xlsx") throw new Error("Expected bounded workbook"); + // The engine counts the chart sheet before it. + expect(result.view?.hiddenGridlines).toEqual([1]); + const bounded = await JSZip.loadAsync(result.bytes); + const sheet = new DOMParser().parseFromString( + await bounded.file("xl/worksheets/sheet1.xml")!.async("string"), + "application/xml", + ); + expect(sheet.querySelector("parsererror")).toBeNull(); + const row = sheet.getElementsByTagNameNS(ns, "row")[0]; + if (empty) expect(row).toBeUndefined(); + else { + expect(row!.getAttribute("ht")).toBe("42"); + expect(row!.getAttribute("hidden")).toBe("1"); + expect(row!.getElementsByTagNameNS(ns, "c")[0]!.getAttribute("r")).toBe("A50"); + expect(row!.getElementsByTagNameNS(ns, "t")[0]!.textContent).toHaveLength(4096); + } + expect(sheet.getElementsByTagNameNS(ns, "dimension")).toHaveLength(0); + expect(sheet.getElementsByTagNameNS(ns, "mergeCell")).toHaveLength(0); + const columns = [...sheet.getElementsByTagNameNS(ns, "col")].map((column) => + ["min", "max", "width"].map((name) => column.getAttribute(name)), + ); + // The engine ignores `defaultColWidth`, so the columns it sizes are spelled out. + expect(columns).toEqual([ + [String(OFFICE_PREVIEW_MAX_COLUMNS - 1), String(OFFICE_PREVIEW_MAX_COLUMNS), "20"], + // Hidden columns keep their span past the limit, so they stay hidden. + [String(OFFICE_PREVIEW_MAX_COLUMNS + 1), String(OFFICE_PREVIEW_MAX_COLUMNS + 30), null], + ["1", String(OFFICE_PREVIEW_MAX_COLUMNS - 2), "30"], + ]); + } finally { + worker.terminate(); } - expect(sheet.getElementsByTagNameNS(ns, "dimension")).toHaveLength(0); - expect(sheet.getElementsByTagNameNS(ns, "mergeCell")).toHaveLength(0); - const columns = sheet.getElementsByTagNameNS(ns, "col"); - expect(columns).toHaveLength(1); - expect(columns[0]!.getAttribute("min")).toBe(String(OFFICE_PREVIEW_MAX_COLUMNS - 1)); - expect(columns[0]!.getAttribute("max")).toBe(String(OFFICE_PREVIEW_MAX_COLUMNS)); - } finally { - worker.terminate(); - } -}); + }, +); it("reports the final slide at maximum scroll after page visibility changes", async () => { const { OfficePages } = await import("./office/OfficePages"); @@ -1717,7 +1736,7 @@ it.each(["repeated binary", "XML relationship alias", "duplicate relationship ID }, ); -it.each(["XFD200", "XFD1048576", "AX500", "AZ9999999"])( +it.each(["XFD200", "XFD300", "XFD1048576", "AX500", "AZ9999999"])( "bounds spreadsheet cells at %s before padding", async (ref) => { const { default: JSZip } = await import("jszip"); @@ -1733,7 +1752,7 @@ it.each(["XFD200", "XFD1048576", "AX500", "AZ9999999"])( ); archive.file( "xl/custom.xml", - `79`, + `7${ref === "XFD300" ? `` : `9`}`, ); const bytes = await archive.generateAsync({ type: "arraybuffer" }); const worker = new Worker(new URL("./officePreview.worker.ts", import.meta.url), { @@ -1763,7 +1782,7 @@ it.each(["XFD200", "XFD1048576", "AX500", "AZ9999999"])( ); expect(sheet.querySelector('c[r="A1"] v')?.textContent).toBe("7"); expect(sheet.querySelectorAll("row").length).toBeLessThanOrEqual(500); - expect(result.truncated).toBe(ref !== "AX500"); + expect(result.truncated).toBe(ref === "XFD200" || ref === "XFD300"); if (ref === "AX500") expect(sheet.querySelector('c[r="AX500"] v')?.textContent).toBe("9"); else expect(sheet.querySelector('c[r="' + ref + '"]')).toBeNull(); } @@ -1773,6 +1792,62 @@ it.each(["XFD200", "XFD1048576", "AX500", "AZ9999999"])( }, ); +it.each(['', ''])( + "shows workbook formats and underline %s", + async (underline) => { + const { default: JSZip } = await import("jszip"); + const archive = new JSZip(); + archive.file( + "xl/workbook.xml", + '', + ); + archive.file( + "xl/_rels/workbook.xml.rels", + '', + ); + archive.file( + "xl/styles.xml", + `${underline}`, + ); + archive.file( + "xl/worksheets/sheet1.xml", + '10044986Total', + ); + const { prepareOffice } = await import("./officeArchive"); + const prepared = await prepareOffice( + await archive.generateAsync({ type: "arraybuffer" }), + "xlsx", + ); + const styles = await ( + await JSZip.loadAsync(prepared.bytes) + ) + .file("xl/styles.xml")! + .async("string"); + expect(styles).toContain(""); + const worker = new Worker(new URL("./office/spreadsheet.worker.ts", import.meta.url), { + type: "module", + }); + try { + const { createWorkerClient } = await import("./office/worker-rpc"); + const client = createWorkerClient(worker); + const bytes = prepared.bytes; + await client.call("open", { bytes, view: prepared.view! }, { transfer: [bytes] }); + const cells = await client.call("cells", { + sheet: 0, + range: { top: 0, bottom: 0, left: 0, right: 1 }, + }); + // Ids under 164 that a workbook defines keep its formats, not Excel's built-in ones. + expect(cells[0]).toEqual(["¥100.00", "01-Mar-2023"]); + } finally { + worker.terminate(); + } + archive.file("xl/styles.xml", ""); + await expect( + prepareOffice(await archive.generateAsync({ type: "arraybuffer" }), "xlsx"), + ).rejects.toThrow("duplicate stylesheet blocks"); + }, +); + it("keeps spreadsheet dates on their calendar day west of UTC", async () => { const { default: JSZip } = await import("jszip"); const archive = new JSZip(); @@ -1831,7 +1906,14 @@ it("keeps spreadsheet dates on their calendar day west of UTC", async () => { }); const { createWorkerClient } = await import("./office/worker-rpc"); const client = createWorkerClient(worker); - await client.call("open", { bytes }, { transfer: [bytes] }); + await client.call( + "open", + { + bytes, + view: { hiddenGridlines: [], themeFonts: { major: "Calibri Light", minor: "Calibri" } }, + }, + { transfer: [bytes] }, + ); const cells = await client.call("cells", { sheet: 0, range: { top: 0, bottom: 0, left: 0, right: 0 }, diff --git a/apps/web/src/components/files/OpenXmlOfficeViewer.tsx b/apps/web/src/components/files/OpenXmlOfficeViewer.tsx index 8802e9fd2913e..7e7268cdf03c2 100644 --- a/apps/web/src/components/files/OpenXmlOfficeViewer.tsx +++ b/apps/web/src/components/files/OpenXmlOfficeViewer.tsx @@ -104,8 +104,7 @@ export default function OfficeViewer(props: { ); if (!prepared) return ; - const Viewer = - props.kind === "docx" ? WordDocument : props.kind === "xlsx" ? Spreadsheet : Presentation; + const Viewer = props.kind === "docx" ? WordDocument : Presentation; return (
{details ? ( @@ -122,7 +121,16 @@ export default function OfficeViewer(props: { ) : null} }> - + {prepared.view ? ( + + ) : ( + + )}
); diff --git a/apps/web/src/components/files/office/Spreadsheet.tsx b/apps/web/src/components/files/office/Spreadsheet.tsx index ed2274025fbf2..88e7d085b6d4c 100644 --- a/apps/web/src/components/files/office/Spreadsheet.tsx +++ b/apps/web/src/components/files/office/Spreadsheet.tsx @@ -14,6 +14,7 @@ import { type CellAddr, } from "@betteroffice/xlsx/headless"; import { FileSurfaceLoading, FileSurfaceNotice } from "../fileSurfaceChrome"; +import type { WorkbookView } from "../officePreviewProtocol"; import { createWorkerClient, SupersededError } from "./worker-rpc"; import type { SheetLayout, SheetMethods, SheetFrameRequest, SheetTab } from "./sheet-protocol"; import { @@ -45,10 +46,11 @@ type Opened = { }; export default function Spreadsheet(props: { bytes: ArrayBuffer; + view: WorkbookView; onDetails: (text: string) => void; onError: (cause: unknown) => void; }) { - const { onError, bytes: source, onDetails } = props; + const { onError, bytes: source, onDetails, view } = props; const [opened, setOpened] = useState(); const [error, setError] = useState(""); const picking = useRef(null); @@ -66,7 +68,7 @@ export default function Spreadsheet(props: { worker.addEventListener("messageerror", fail); const bytes = source.slice(0); void engine - .call("open", { bytes }, { transfer: [bytes], signal: controller.signal }) + .call("open", { bytes, view }, { transfer: [bytes], signal: controller.signal }) .then((value) => { if (!value) throw new Error("This workbook has no visible sheets."); setOpened({ ...value, engine }); @@ -78,7 +80,7 @@ export default function Spreadsheet(props: { picking.current?.abort(); engine.close(); }; - }, [source, onDetails, onError]); + }, [source, view, onDetails, onError]); const show = (sheet: number, target?: CellAddr) => { if (!opened) return; picking.current?.abort(); diff --git a/apps/web/src/components/files/office/fonts.browser.test.ts b/apps/web/src/components/files/office/fonts.browser.test.ts index b9f67db4d7b6f..65a56a86fd18a 100644 --- a/apps/web/src/components/files/office/fonts.browser.test.ts +++ b/apps/web/src/components/files/office/fonts.browser.test.ts @@ -1,6 +1,6 @@ import { BUNDLED_FONTS } from "@betteroffice/fonts"; import { describe, expect, it } from "vite-plus/test"; -import { wordFonts } from "./fonts"; +import { sheetFace, wordFonts } from "./fonts"; const notoSansJp = BUNDLED_FONTS.find((face) => face.file === "NotoSansJP-Regular.otf")!; const fonts = wordFonts(); @@ -16,3 +16,14 @@ describe("wordFonts", () => { expect(bytes?.byteLength).toBe(notoSansJp.byteLength); }); }); + +it.each([false, true])( + "uses the available Comic Relief face for italic spreadsheet text (bold: %s)", + (bold) => { + expect(sheetFace("Comic Relief", bold, true)).toMatchObject({ + family: "Comic Relief", + weight: bold ? 700 : 400, + style: "normal", + }); + }, +); diff --git a/apps/web/src/components/files/office/fonts.ts b/apps/web/src/components/files/office/fonts.ts index 410b725103cf1..34742174f475c 100644 --- a/apps/web/src/components/files/office/fonts.ts +++ b/apps/web/src/components/files/office/fonts.ts @@ -35,6 +35,16 @@ export function presentationFace(family: string, bold: boolean, italic: boolean) ); } +/** The face Excel's cells draw a family with when it is missing: its metric-compatible or bundled face, if any. */ +export function sheetFace(family: string, bold: boolean, italic: boolean) { + return ( + resolveMetricCompatFace(family, bold, italic) ?? + resolveBundledFamilyFace(family, bold, italic) ?? + resolveBundledFamilyFace(family, bold, false) ?? + resolveBundledFamilyFace(family, false, false) + ); +} + /** * A bundled face's bytes. The font package caches them per worker, so a face loads once per open file; the browser's * caches, which every worker shares, serve the files to the next. diff --git a/apps/web/src/components/files/office/sheet-fonts.ts b/apps/web/src/components/files/office/sheet-fonts.ts new file mode 100644 index 0000000000000..f4b4f67f1f4be --- /dev/null +++ b/apps/web/src/components/files/office/sheet-fonts.ts @@ -0,0 +1,58 @@ +import type { DisplayList } from "@betteroffice/xlsx"; +import { loadFace, sheetFace } from "./fonts"; + +/** + * The metric-compatible faces a workbook's cells draw with, such as Carlito for Calibri, added to the worker's font set + * under the family name the painter asks for, so text widths, wrapping and overflow match Excel on machines without + * Office fonts. A family the app ships no face for stays with the browser's own fallback. + */ +export function sheetFonts() { + // SAFETY: a worker's global scope is a FontFaceSource, whose `fonts` is a FontFaceSet (CSS Font Loading, exposed to + // workers). The window's types declare it on documents only. Without one, cells keep the browser's fallback. + const fonts = "fonts" in self ? (self.fonts as FontFaceSet) : undefined; + const loads = new Map>(); + + const load = (family: string, bold: boolean, italic: boolean) => { + const key = `${family}\u0000${bold}\u0000${italic}`; + const existing = loads.get(key); + + if (existing) return existing; + + const face = sheetFace(family, bold, italic); + const loading = + face && fonts + ? loadFace(face) + // A family without the requested style gets the face it has, which the browser then slants or emboldens. + .then((bytes) => + new FontFace(family, bytes, { + weight: face.weight === 700 ? "700" : "400", + style: face.style === "italic" ? "italic" : "normal", + }).load(), + ) + .then((loaded) => void fonts.add(loaded)) + .catch(() => undefined) + : Promise.resolve(); + + loads.set(key, loading); + + return loading; + }; + + /** Loads the faces a frame's cell text uses before it paints. */ + return (lists: readonly DisplayList[]) => + Promise.all( + lists.flatMap((list) => + list.commands.flatMap((command) => { + // Chart text arrives as a CSS family list whose first family is the one to load. + const family = + command.op === "text" + ? (/^"([^"]+)"/.exec(command.fontFamily ?? "")?.[1] ?? command.fontFamily) + : undefined; + + return command.op === "text" && family + ? [load(family, !!command.bold, !!command.italic)] + : []; + }), + ), + ); +} diff --git a/apps/web/src/components/files/office/sheet-frame.ts b/apps/web/src/components/files/office/sheet-frame.ts index 67789b6b79fde..3b7868bf2d3fe 100644 --- a/apps/web/src/components/files/office/sheet-frame.ts +++ b/apps/web/src/components/files/office/sheet-frame.ts @@ -50,7 +50,7 @@ export function drawFrame( frozen: { readonly rows: number; readonly cols: number }, wider?: SheetViewport, ): Frame | undefined { - const around = wider && attempt(() => workbook.displayList(wider)); + const around = wider && untruncated(workbook, wider); if (wider && around) return { @@ -59,15 +59,15 @@ export function drawFrame( viewport: wider, }; - const whole = attempt(() => workbook.displayList(viewport)); + const whole = untruncated(workbook, viewport); if (whole) return { list: whole, tiles: [{ list: whole, x: 0, y: 0, clipX: 0, clipY: 0 }], viewport }; // The engine refuses a viewport with too many cells, as a wide pane of narrow columns has. One row and one column of // the frame give its tracks, and so where its frozen panes end. - const columns = attempt(() => workbook.displayList({ ...viewport, height: 1 }).grid); - const rows = attempt(() => workbook.displayList({ ...viewport, width: 1 }).grid); + const columns = untruncated(workbook, { ...viewport, height: 1 })?.grid; + const rows = untruncated(workbook, { ...viewport, width: 1 })?.grid; if (!columns || !rows) return undefined; @@ -83,6 +83,54 @@ export function drawFrame( return tiled(workbook, viewport, frozenSize(grid, frozen), grid, 2); } +/** The engine's screen gridlines: one-pixel solid lines in its gridline grey, unlike any border it draws. */ +const gridline = (command: DisplayList["commands"][number]) => + command.op === "line" && command.color === "#d4d4d4" && command.width === 1 && !command.style; + +/** A frame without the gridlines the engine draws on every sheet, for a sheet whose view hides them. */ +export function withoutGridlines(frame: Frame): Frame { + return { + ...frame, + tiles: frame.tiles.map((tile) => ({ + ...tile, + list: { ...tile.list, commands: tile.list.commands.filter((command) => !gridline(command)) }, + })), + }; +} + +/** + * A frame whose chart text names real fonts. The engine leaves the theme's `+mj-lt` and `+mn-lt` in chart text and the + * painter sets chart families as CSS as they are, so an unresolved or missing family would keep the previous font. + */ +export function chartFonts( + frame: Frame, + theme: { readonly major: string; readonly minor: string }, +): Frame { + const family = (name: string) => { + const resolved = name.startsWith("+mj-") + ? theme.major + : name.startsWith("+mn-") + ? theme.minor + : name; + return `"${resolved.replaceAll('"', "")}", sans-serif`; + }; + + return { + ...frame, + tiles: frame.tiles.map((tile) => ({ + ...tile, + list: { + ...tile.list, + commands: tile.list.commands.map((command) => + command.op === "text" && command.chart + ? { ...command, fontFamily: family(command.fontFamily ?? theme.minor) } + : command, + ), + }, + })), + }; +} + /** * Draws a frame's tiles with the cells' top-left corner at `origin`, in device pixels, each tile from its seam on, so a * tile covers the panes it repeats. @@ -175,14 +223,12 @@ function tiled( const right = column.end === viewport.width ? column.end : column.end + seamOverlap; const bottom = row.end === viewport.height ? row.end : row.end + seamOverlap; - const list = attempt(() => - workbook.displayList({ - x: viewport.x + x, - y: viewport.y + y, - width: right - x, - height: bottom - y, - }), - ); + const list = untruncated(workbook, { + x: viewport.x + x, + y: viewport.y + y, + width: right - x, + height: bottom - y, + }); if (list) tiles.push({ list, x, y, clipX: column.start, clipY: row.start }); @@ -205,6 +251,13 @@ function tiled( return tiled(workbook, viewport, panes, grid, split + 1); } +/** A viewport's display list, or undefined where the engine refuses it or cuts it short at its cell limit. */ +function untruncated(workbook: WorkbookHandle, viewport: SheetViewport) { + const list = attempt(() => workbook.displayList(viewport)); + + return list?.truncated ? undefined : list; +} + /** Even spans of a side, the first holding the frozen panes whole; a seam inside them is dropped. */ function segments(size: number, panes: number, split: number) { const starts = Array.from({ length: split }, (_, part) => diff --git a/apps/web/src/components/files/office/sheet-protocol.ts b/apps/web/src/components/files/office/sheet-protocol.ts index 6f41fc8c3cfe0..fd66ef9f74050 100644 --- a/apps/web/src/components/files/office/sheet-protocol.ts +++ b/apps/web/src/components/files/office/sheet-protocol.ts @@ -1,6 +1,7 @@ // Adapted from opencode (MIT). Copyright (c) 2025 opencode. See LICENSE.opencode. import type { SheetInfo } from "@betteroffice/xlsx"; import type { CellAddr, CellRange, GridMeta, HyperlinkRegion } from "@betteroffice/xlsx/headless"; +import type { WorkbookView } from "../officePreviewProtocol"; import type { Span } from "./sheet-cells"; import type { Method } from "./worker-rpc"; @@ -70,7 +71,7 @@ export type SheetMethods = { * saved on a hidden sheet. Undefined when no sheet is visible. */ readonly open: Method< - { readonly bytes: ArrayBuffer }, + { readonly bytes: ArrayBuffer; readonly view: WorkbookView }, { readonly tabs: readonly SheetTab[]; readonly layout: SheetLayout } | undefined >; /** Makes a sheet the active one; rejects, leaving the sheet before it active, when it cannot open. */ diff --git a/apps/web/src/components/files/office/spreadsheet.worker.ts b/apps/web/src/components/files/office/spreadsheet.worker.ts index eeb6b41478fdf..466e29b29bff2 100644 --- a/apps/web/src/components/files/office/spreadsheet.worker.ts +++ b/apps/web/src/components/files/office/spreadsheet.worker.ts @@ -10,7 +10,9 @@ import { } from "@betteroffice/xlsx"; import type { CellRange } from "@betteroffice/xlsx/headless"; import { anchorRange, attempt, lastCell, parseRange, parseSpans } from "./sheet-cells"; -import { drawFrame, paintFrame } from "./sheet-frame"; +import type { WorkbookView } from "../officePreviewProtocol"; +import { chartFonts, drawFrame, paintFrame, withoutGridlines } from "./sheet-frame"; +import { sheetFonts } from "./sheet-fonts"; import { frozenSize } from "./sheet-paint"; import type { SheetLayout, SheetMethods, SheetTab } from "./sheet-protocol"; import { serveWorker, Transfer } from "./worker-rpc"; @@ -26,6 +28,9 @@ type Opened = { readonly ends: Map; /** The one canvas every frame paints on and hands over as a bitmap. */ readonly surface: OffscreenCanvas; + /** The sheets whose view hides gridlines, which the engine draws on every sheet. */ + readonly hiddenGridlines: ReadonlySet; + readonly themeFonts: WorkbookView["themeFonts"]; }; /** The workbook this worker opened, once it has. */ @@ -33,6 +38,8 @@ type State = { opened: Opened | undefined }; const state: State = { opened: undefined }; +const loadFonts = sheetFonts(); + serveWorker({ open: async (input) => { if (state.opened) throw new Error("The worker already holds a workbook"); @@ -42,7 +49,14 @@ serveWorker({ const workbook = openWorkbook(new Uint8Array(input.bytes)); const info = workbook.sheetInfo(); - state.opened = { workbook, info, ends: new Map(), surface: new OffscreenCanvas(1, 1) }; + state.opened = { + workbook, + info, + ends: new Map(), + surface: new OffscreenCanvas(1, 1), + hiddenGridlines: new Set(input.view.hiddenGridlines), + themeFonts: input.view.themeFonts, + }; const tabs = visibleTabs(workbook, info); // A workbook saved on a hidden sheet opens on the first visible one, as Excel does. @@ -64,13 +78,20 @@ serveWorker({ throw new Error(`Sheet ${input.sheet} could not open`); }, - frame: (input) => { + frame: async (input) => { const opened = active(input.sheet); const frozen = { rows: opened.info.frozenRows, cols: opened.info.frozenCols }; const viewport = { x: input.x, y: input.y, width: input.width, height: input.height }; - const drawn = drawFrame(opened.workbook, viewport, frozen, input.wider); + const frame = drawFrame(opened.workbook, viewport, frozen, input.wider); + + if (!frame) return undefined; + + const drawn = chartFonts( + opened.hiddenGridlines.has(input.sheet) ? withoutGridlines(frame) : frame, + opened.themeFonts, + ); - if (!drawn) return undefined; + await loadFonts(drawn.tiles.map((tile) => tile.list)); const surface = opened.surface; const width = Math.ceil(input.offset.x + drawn.viewport.width * input.ratio); diff --git a/apps/web/src/components/files/officeArchive.ts b/apps/web/src/components/files/officeArchive.ts index a75a215b526b8..dcdda5cf3e952 100644 --- a/apps/web/src/components/files/officeArchive.ts +++ b/apps/web/src/components/files/officeArchive.ts @@ -253,10 +253,21 @@ function boundedWorksheet( const rowTags = new Map(); const metadataAttributes = new Map< string, - { ref: string | undefined; min: string | undefined; max: string | undefined } + { + ref: string | undefined; + min: string | undefined; + max: string | undefined; + hidden: string | undefined; + } >(); let dataStart = 0; + let dataTag = 0; + let dataPrefix = ""; + let dataNamespace = ""; let dataEnd = 0; + let defaultWidth: number | undefined; + let gridlines: boolean | undefined; + const sizedColumns: { min: number; max: number }[] = []; let emptyData = false; let depth = 0; let row = 0; @@ -305,17 +316,42 @@ function boundedWorksheet( ref: attribute("ref"), min: attribute("min"), max: attribute("max"), + hidden: attribute("hidden"), }, ); + // The engine draws gridlines on every sheet, so the first view's choice travels beside the bytes. + if (depth === 3 && tag.local === "sheetView" && gridlines === undefined) { + const shown = attribute("showGridLines"); + gridlines = shown !== "0" && shown !== "false"; + } + if (depth === 2 && tag.local === "sheetFormatPr") { + const width = Number(attribute("defaultColWidth") ?? Number.NaN); + if (width >= 0 && width <= 255) defaultWidth = width; + } + if (depth === 3 && tag.local === "col") { + const hidden = attribute("hidden"); + const min = Number(attribute("min") ?? 1); + const max = Number(attribute("max") ?? min); + if ( + (attribute("width") !== undefined || hidden === "1" || hidden === "true") && + Number.isInteger(min) && + Number.isInteger(max) + ) + sizedColumns.push({ min, max }); + } if (depth === 2 && tag.local === "sheetData") { dataStart = parser.position; + dataTag = xml.lastIndexOf("<", parser.position - 1); + dataPrefix = tag.prefix ? `${tag.prefix}:` : ""; + dataNamespace = tag.uri + .replaceAll("&", "&") + .replaceAll('"', """) + .replaceAll("<", "<"); emptyData = tag.isSelfClosing; } + // A dimension past the limits loses nothing by itself; the cells past them say whether content is lost. if (depth === 2 && tag.local === "dimension") { - for (const ref of (attribute("ref") ?? "").split(":")) { - const { col, line } = coordinate(ref); - if (col > OFFICE_PREVIEW_MAX_COLUMNS || line > OFFICE_PREVIEW_MAX_ROWS) truncated = true; - } + for (const ref of (attribute("ref") ?? "").split(":")) coordinate(ref); } else if (depth === 3 && tag.local === "row") { explicitRow = attribute("r") !== undefined; row = attribute("r") === undefined ? row + 1 : Number(attribute("r")); @@ -441,7 +477,12 @@ function boundedWorksheet( } if (/<(?:\w+:)?col\b/.test(opening)) { const min = Math.max(1, Number(attributes.min ?? 1)); - const max = Math.min(OFFICE_PREVIEW_MAX_COLUMNS, Number(attributes.max ?? 16384)); + // Hidden columns keep their full span, or the columns past the limit would show again. + const hidden = attributes.hidden === "1" || attributes.hidden === "true"; + const max = Math.min( + hidden ? 16384 : OFFICE_PREVIEW_MAX_COLUMNS, + Number(attributes.max ?? 16384), + ); if (!Number.isInteger(min) || !Number.isInteger(max) || min > max) return ""; replace("min", String(min)); replace("max", String(max)); @@ -449,10 +490,28 @@ function boundedWorksheet( return tag; }, ); + // The engine sizes columns without a width at Excel's 8.43 characters and ignores `defaultColWidth`, so the + // columns it covers get that width spelled out. A second `` is valid: the schema allows any number. + let defaultColumns = ""; + if (defaultWidth !== undefined) { + let next = 1; + for (const span of sizedColumns.toSorted((a, b) => a.min - b.min)) { + if (span.min > next) + defaultColumns += `<${dataPrefix}col min="${next}" max="${Math.min(span.min - 1, OFFICE_PREVIEW_MAX_COLUMNS)}" width="${defaultWidth}"/>`; + next = Math.max(next, span.max + 1); + if (next > OFFICE_PREVIEW_MAX_COLUMNS) break; + } + if (next <= OFFICE_PREVIEW_MAX_COLUMNS) + defaultColumns += `<${dataPrefix}col min="${next}" max="${OFFICE_PREVIEW_MAX_COLUMNS}" width="${defaultWidth}"/>`; + if (defaultColumns) + defaultColumns = `<${dataPrefix}cols xmlns${dataPrefix ? `:${dataPrefix.slice(0, -1)}` : ""}="${dataNamespace}">${defaultColumns}`; + } + const head = (part: string) => + metadata(part.slice(0, dataTag) + defaultColumns + part.slice(dataTag)); return { xml: emptyData - ? metadata(xml) - : `${metadata(xml.slice(0, dataStart))}${[...rows] + ? head(xml) + : `${head(xml.slice(0, dataStart))}${[...rows] .sort(([a], [b]) => a - b) .map( ([line, values]) => @@ -463,12 +522,84 @@ function boundedWorksheet( ) .join("")}${metadata(xml.slice(dataEnd))}`, truncated, + gridlines: gridlines ?? true, }; } +/** + * A stylesheet as Excel reads it, in the terms the engine understands. The engine takes a number format id under 164 as + * Excel's built-in one, though workbooks define their own there (Excel writes its locale formats 5 to 8 that way), so + * those move to free ids. It also reads `` as a boolean, which loses every named underline but the bare one. + */ +function engineStyles(xml: string) { + const name = (local: string) => `<((?:[\\w.-]+:)?)${local}\\b`; + const blocks = new Map(); + const parser = xmlParser(); + let depth = 0; + parser.on("opentag", (tag) => { + if (++depth > 64) throw new Error("This workbook contains excessive XML nesting."); + if (depth !== 2 || !["numFmts", "cellStyleXfs", "cellXfs"].includes(tag.local)) return; + if (blocks.has(tag.local)) + throw new Error("This workbook contains duplicate stylesheet blocks."); + blocks.set(tag.local, { + start: xml.lastIndexOf("<", parser.position - 1), + end: parser.position, + }); + }); + parser.on("closetag", (tag) => { + if (depth === 2) { + const block = blocks.get(tag.local); + if (block) block.end = parser.position; + } + depth--; + }); + parser.write(xml).close(); + const id = /(numFmtId\s*=\s*)(["'])(\d+)\2/g; + const moved = new Map(); + const formatBlock = blocks.get("numFmts"); + const formats = formatBlock ? xml.slice(formatBlock.start, formatBlock.end) : ""; + const used = new Set([...formats.matchAll(id)].map((match) => Number(match[3]))); + let next = 164; + const renumber = (part: string, assign: boolean) => + part.replace(id, (attribute: string, key: string, quote: string, value: string) => { + const number = Number(value); + if (assign && number < 164 && !moved.has(number)) { + while (used.has(next)) next++; + if (next > 65535) throw new Error("This workbook has too many custom number formats."); + moved.set(number, next++); + } + const to = moved.get(number); + return to === undefined ? attribute : `${key}${quote}${to}${quote}`; + }); + const numberedFormats = renumber(formats, true); + const parts: string[] = []; + let offset = 0; + for (const [local, block] of blocks) { + parts.push( + xml.slice(offset, block.start), + local === "numFmts" ? numberedFormats : renumber(xml.slice(block.start, block.end), false), + ); + offset = block.end; + } + parts.push(xml.slice(offset)); + return parts + .join("") + .replace( + new RegExp( + `${name("u")}\\s+val\\s*=\\s*(["'])(?:single|double|singleAccounting|doubleAccounting)\\2\\s*(?:/>|>\\s*)`, + "g", + ), + "<$1u/>", + ); +} + async function boundWorkbook(archive: JSZip) { const paths = new Map(); + // Chart sheets and other non-grid sheets hold no cells to bound. + const otherSheets = new Set(); let sharedPath = "xl/sharedStrings.xml"; + let stylesPath = "xl/styles.xml"; + let themePath = "xl/theme/theme1.xml"; const relationships = xmlParser(); relationships.on("opentag", (tag) => { if (tag.local !== "Relationship") return; @@ -478,22 +609,44 @@ async function boundWorkbook(archive: JSZip) { "https://docx.invalid/xl/workbook.xml", ).pathname.slice(1); if (type?.endsWith("/worksheet")) paths.set(tag.attributes.Id?.value ?? "", path); + else if (/\/(?:chartsheet|dialogsheet|xlMacrosheet|xlIntlMacrosheet)$/.test(type ?? "")) + otherSheets.add(tag.attributes.Id?.value ?? ""); if (type?.endsWith("/sharedStrings")) sharedPath = path; + if (type?.endsWith("/styles")) stylesPath = path; + if (type?.endsWith("/theme")) themePath = path; }); relationships .write((await archive.file("xl/_rels/workbook.xml.rels")?.async("string")) ?? "") .close(); const sheetPaths: string[] = []; + // Every sheet in workbook order, as the engine indexes them; chart sheets have no worksheet path. + const sheetOrder: (string | undefined)[] = []; const workbook = xmlParser(); workbook.on("opentag", (tag) => { if (tag.local !== "sheet") return; const id = Object.values(tag.attributes).find((attribute) => attribute.local === "id")?.value; + if (otherSheets.has(id ?? "")) { + sheetOrder.push(undefined); + return; + } const path = paths.get(id ?? ""); if (!path || sheetPaths.length === 100) throw new Error("This workbook has invalid or too many sheets."); sheetPaths.push(path); + sheetOrder.push(path); }); workbook.write((await archive.file("xl/workbook.xml")?.async("string")) ?? "").close(); + const theme = (await archive.file(themePath)?.async("string")) ?? ""; + const typeface = (font: string, fallback: string) => + new RegExp( + `<(?:[\\w.-]+:)?${font}\\b[^>]*>\\s*<(?:[\\w.-]+:)?latin\\b[^>]*\\btypeface\\s*=\\s*["']([^"']+)["']`, + ).exec(theme)?.[1] ?? fallback; + const themeFonts = { + major: typeface("majorFont", "Calibri Light"), + minor: typeface("minorFont", "Calibri"), + }; + const styles = await archive.file(stylesPath)?.async("string"); + if (styles) archive.file(stylesPath, engineStyles(styles)); const strings: string[] = []; const shared = await archive.file(sharedPath)?.async("string"); if (shared) { @@ -516,15 +669,20 @@ async function boundWorkbook(archive: JSZip) { parser.write(shared).close(); } const budget = { remaining: 1024 * 1024, cells: 0 }; - const limited = new Map(); + const bounded = new Map(); for (const path of new Set(sheetPaths)) { const file = archive.file(path); if (!file) throw new Error("This workbook has a missing worksheet."); const sheet = boundedWorksheet(await file.async("string"), strings, budget); archive.file(path, sheet.xml); - limited.set(path, sheet.truncated); + bounded.set(path, sheet); } - return sheetPaths.map((path) => limited.get(path) ?? false); + return { + sheets: sheetOrder.map( + (path) => (path && bounded.get(path)) || { truncated: false, gridlines: true }, + ), + themeFonts, + }; } const WORD_RELATIONSHIP = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/"; @@ -1158,8 +1316,8 @@ export async function boundedArchive(input: Extract sheet.truncated) || false, + view: result.workbook && { + hiddenGridlines: result.workbook.sheets.flatMap((sheet, index) => + sheet.gridlines ? [] : [index], + ), + themeFonts: result.workbook.themeFonts, + }, }; } diff --git a/apps/web/src/components/files/officePreviewProtocol.ts b/apps/web/src/components/files/officePreviewProtocol.ts index 51f3b8f4cd194..bd35181dd2f2e 100644 --- a/apps/web/src/components/files/officePreviewProtocol.ts +++ b/apps/web/src/components/files/officePreviewProtocol.ts @@ -4,6 +4,14 @@ export const OFFICE_PREVIEW_MAX_BYTES = 50 * 1024 * 1024; export const OFFICE_PREVIEW_MAX_ROWS = 250_000; export const OFFICE_PREVIEW_MAX_COLUMNS = 1024; +/** What a workbook's views ask of the preview that its engine does not read. */ +export type WorkbookView = { + /** The sheets, by index in workbook order, whose view hides gridlines. */ + readonly hiddenGridlines: readonly number[]; + /** The theme's heading and body fonts, which chart text names as `+mj-lt` and `+mn-lt`. */ + readonly themeFonts: { readonly major: string; readonly minor: string }; +}; + export type OfficeWorkerRequest = | { readonly type: "open"; readonly kind: OfficePreviewKind; readonly bytes: ArrayBuffer } | { readonly type: "sheet-page"; readonly sheet: number; readonly index: number }; @@ -26,6 +34,8 @@ export type OfficeWorkerResult = readonly type: "docx" | "xlsx" | "pptx"; readonly bytes: ArrayBuffer; readonly truncated: boolean; + /** For a workbook; undefined for other files. */ + readonly view: WorkbookView | undefined; } | { readonly type: "ods"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4be1be5eec243..7c3bed5898fc1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1029,8 +1029,8 @@ importers: specifier: 0.2.0 version: 0.2.0 '@betteroffice/xlsx': - specifier: 0.3.0 - version: 0.3.0 + specifier: 0.5.1 + version: 0.5.1 '@clerk/clerk-js': specifier: 6.42.3 version: 6.42.3(react-dom@19.3.0(react@19.3.0))(react@19.3.0) @@ -3274,8 +3274,8 @@ packages: '@betteroffice/pptx@0.2.0': resolution: {integrity: sha512-pNPQ58tlA+pVibERbLdQNskvP7a0GIMf4jhkCU/gPp8043Ichf6gB3PSb4ra9sfqoIaWqOaZKCWyptovA2+Q0A==} - '@betteroffice/xlsx@0.3.0': - resolution: {integrity: sha512-K2bHtdk/epAxsmvxJlIWBRndjP3ltQU0Oh3p4gcZd2n5/YgXglo3SmMYX4nQe8WEm/yvz91p+aIasKyXXQP9Wg==} + '@betteroffice/xlsx@0.5.1': + resolution: {integrity: sha512-UHOnzcMML6Ud9D1M4d8FZKmw3AAg2/DttE/sVxM858SgiIpOtzGOqlh62RM9/8y/JW8bR8t+wN69EX8UJvR8EQ==} '@blazediff/core@1.10.0': resolution: {integrity: sha512-AOQff0zgR7cGsZL+4E7hVkmujoPUpm0J9xzWGWZj5wCjd3gmxESXAPfKyuzs93VdpQNFhHlBhfOjrcZ+XTERtQ==} @@ -13479,7 +13479,7 @@ snapshots: '@betteroffice/pptx@0.2.0': {} - '@betteroffice/xlsx@0.3.0': {} + '@betteroffice/xlsx@0.5.1': {} '@blazediff/core@1.10.0': {} diff --git a/third-party-licenses.config.json b/third-party-licenses.config.json index c11d58d8ae4fd..8bb69ec6204c4 100644 --- a/third-party-licenses.config.json +++ b/third-party-licenses.config.json @@ -261,7 +261,7 @@ }, { "name": "@betteroffice/xlsx", - "version": "0.3.0", + "version": "0.5.1", "license": "Apache-2.0", "sourceUrl": "https://github.com/openooxml/betteroffice", "generatedNotice": { From f31ce3eeb5f4adbfe7f961403822cf90db05808f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 12:22:41 -0800 Subject: [PATCH 0109/1165] fix(server): pass the process runner to the systemd unit directory read in bootServiceUnitPath (#34518) --- apps/server/src/cloud/bootService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index dade2d8da0356..dd5a1f060bae6 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -411,7 +411,7 @@ export const bootServiceUnitPath = Effect.fn("cloud.boot_service.unit_path")(fun if (platform === "darwin") return launchdPlistPath(path, homeDir); if (platform !== "linux") return undefined; const unitDir = - (yield* readSystemdUnitDirectory()) ?? + (yield* readSystemdUnitDirectory(yield* ProcessRunner.ProcessRunner, path)) ?? defaultSystemdUnitDir(path, homeDir, (yield* HostProcess.Environment).XDG_CONFIG_HOME ?? ""); return path.join(unitDir, BOOT_SERVICE_UNIT_FILE); }); From 9ef259e5173646c30f4f862c6fccce7bcb9217c2 Mon Sep 17 00:00:00 2001 From: maria Date: Wed, 11 Nov 2026 20:36:39 +0000 Subject: [PATCH 0110/1165] fix(mobile): sidebar empty states and repeated usage notices (#33188) --- apps/mobile/src/features/home/HomeScreen.tsx | 8 ++- .../threads/ThreadNavigationSidebar.tsx | 56 ++++++++++++------- packages/shared/src/usageLimits.test.ts | 17 ++++++ packages/shared/src/usageLimits.ts | 10 ++-- 4 files changed, 66 insertions(+), 25 deletions(-) diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 4ed447eb69f23..0de32d202009d 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -748,7 +748,13 @@ export function HomeScreen(props: HomeScreenProps) { ); const v2ListEmpty = - hasSearchQuery && threadSearch.isPending ? undefined : hasSearchQuery ? ( + hasSearchQuery && threadSearch.isPending ? ( + + ) : hasSearchQuery ? ( ); + const showAddEnvironment = + !catalogState.isLoadingConnections && + !catalogState.hasReadyEnvironment && + !catalogState.hasConnectingEnvironment; const listEmpty = ( - - {catalogState.isLoadingConnections - ? "Loading threads…" - : Platform.OS === "android" && !catalogState.hasConnections - ? "No environments connected" - : props.searchQuery.trim().length > 0 - ? threadSearch.isPending - ? "Searching thread messages…" - : "No matching threads" - : selectedProjectScope !== null - ? `No threads in ${selectedProjectScope.title}` - : "No threads yet"} - + + + {catalogState.isLoadingConnections + ? "Loading threads…" + : showAddEnvironment + ? "No environments connected" + : props.searchQuery.trim().length > 0 + ? threadSearch.isPending + ? "Searching thread messages…" + : "No matching threads" + : selectedProjectScope !== null + ? `No threads in ${selectedProjectScope.title}` + : "No threads yet"} + + {showAddEnvironment ? ( + + + + + ), +})); +vi.mock(import("../DiffWorkerPoolProvider"), () => ({ + DiffWorkerPoolProvider: ({ children }: { children?: ReactNode }) => <>{children}, +})); vi.mock(import("./projectFilesQueryState"), async (original) => ({ ...(await original()), useProjectFileQuery: () => ({ @@ -82,6 +107,7 @@ function setup() { const previous = keys.map((key) => localStorage.getItem(key)); localStorage.setItem(keys[0]!, "false"); localStorage.setItem(keys[1]!, "true"); + state.file = null; state.optimistic = null; state.writeFile.mockReset().mockResolvedValue(AsyncResult.success(undefined)); return { @@ -102,12 +128,16 @@ function markdownPreview(truncated: boolean) { byteLength: truncated ? 1_200_000 : 32, truncated, }; + return filePreview("tasks.md"); +} + +function filePreview(relativePath: string) { return ( { + it("hands panel focus to the file's editor, which owns mod+f", async () => { + using _settings = setup(); + const style = document.createElement("style"); + style.textContent = appCss; + document.head.append(style); + using _style = { [Symbol.dispose]: () => style.remove() }; + await preloadHighlighter({ themes: Object.values(DEFAULT_THEMES), langs: ["typescript"] }); + const panel = () => ( +
+
{filePreview("server.ts")}
+
+ ); + const editorHasFocus = () => expect(deepActiveElement()?.matches("[data-content]")).toBe(true); + + await using screen = await render(panel()); + const focus = createWorkspaceFocusController(document); + using _focus = { [Symbol.dispose]: focus.dispose }; + expect(focus.focus("panel")).toBe(true); + state.file = { + relativePath: "server.ts", + contents: "const server = 1;\n", + byteLength: 18, + truncated: false, + }; + await screen.rerender(panel()); + await vi.waitFor(editorHasFocus); + await userEvent.keyboard("x"); + await vi.waitFor(() => expect(deepActiveElement()?.textContent).toContain("xconst server")); + + (deepActiveElement() as HTMLElement).blur(); + expect(focus.focus("panel", false)).toBe(true); + await vi.waitFor(editorHasFocus); + await userEvent.keyboard( + isMacPlatform(navigator.platform) ? "{Meta>}f{/Meta}" : "{Control>}f{/Control}", + ); + await vi.waitFor(() => expect(deepActiveElement()).toBeInstanceOf(HTMLInputElement)); + }); + + it("focuses the editor when a file row is clicked or activated with Enter", async () => { + using _settings = setup(); + localStorage.setItem("t3code.fileExplorerOpen", "true"); + const style = document.createElement("style"); + style.textContent = appCss; + document.head.append(style); + using _style = { [Symbol.dispose]: () => style.remove() }; + await preloadHighlighter({ themes: Object.values(DEFAULT_THEMES), langs: ["typescript"] }); + state.file = { + relativePath: "server.ts", + contents: "const server = 1;\n", + byteLength: 18, + truncated: false, + }; + await using screen = await render( +
+
{filePreview("server.ts")}
+
, + ); + using _focus = { + [Symbol.dispose]: startWorkspaceFocus({ subscribe: () => () => {} } as never), + }; + const editorHasFocus = () => expect(deepActiveElement()?.matches("[data-content]")).toBe(true); + // The explorer measures its parent row; a wrapper without a box would cap it at the minimum. + await expect + .element(screen.getByRole("separator", { name: "Resize file explorer" })) + .toHaveAttribute("aria-valuemax", "560"); + const folderRow = screen.getByRole("treeitem", { name: "src" }); + const fileRow = screen.getByRole("treeitem", { name: "server.ts" }); + + (folderRow.element() as HTMLElement).focus(); + await userEvent.keyboard("{Enter}"); + expect(document.activeElement).toBe(folderRow.element()); + + await userEvent.click(fileRow); + await vi.waitFor(editorHasFocus); + + (fileRow.element() as HTMLElement).focus(); + await userEvent.keyboard("{Enter}"); + await vi.waitFor(editorHasFocus); + }); +}); + describe("rendered Markdown task saves", () => { it("keeps a truncated read read-only without queuing a replacement", async () => { using _settings = setup(); diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index 0311ce254ff4f..4904211de72df 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -57,10 +57,12 @@ import { MediaActions, type MediaActionSource } from "~/components/media/MediaAc import { useRemoteOpenState } from "~/remoteOpen"; import { useClientSettings } from "~/hooks/useSettings"; import { useTheme } from "~/hooks/useTheme"; +import { useStableCallback } from "~/hooks/useStableCallback"; import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "~/hooks/useLocalStorage"; import { resolveDiffThemeName } from "~/lib/diffRendering"; import { PREFERRED_HIGHLIGHTER } from "~/lib/syntaxHighlighting"; import { cn } from "~/lib/utils"; +import { requestWorkspaceFocus } from "~/lib/workspaceFocus"; import type { ChatFileAttachment } from "~/types"; import { isHostPath } from "@t3tools/shared/path"; import { resolvePathLinkTarget } from "@t3tools/shared/fileLinks"; @@ -598,6 +600,7 @@ const createFileEditor: EditorFactory = ( options, editStateKey, ) => new Editor(editorType, options, editStateKey); +type FileEditor = Editor<"file", FileCommentAnnotationGroup, undefined>; function editableFileContents( environmentId: EnvironmentId, @@ -661,6 +664,7 @@ interface EditableFileSurfaceProps { wordWrap: boolean; onPostRender: FilePostRender; onPendingChange: (relativePath: string, pending: boolean) => void; + onEditorChange: (editor: FileEditor | null) => void; } interface FileSelectionOverride { @@ -680,6 +684,7 @@ function EditableFileSurface({ wordWrap, onPostRender, onPendingChange, + onEditorChange, }: EditableFileSurfaceProps) { const addReviewComment = useComposerDraftStore((store) => store.addReviewComment); const removeReviewComment = useComposerDraftStore((store) => store.removeReviewComment); @@ -705,13 +710,15 @@ function EditableFileSurface({ } const { ready: editable, onPostRender: onEditablePostRender } = useEditableAfterHighlight(externalFile); - const editorRef = useRef | null>(null); + const editorRef = useRef(null); const editorOptions: EditorOptions<"file", FileCommentAnnotationGroup, undefined> = { onAttach: (editor) => { editorRef.current = editor; + onEditorChange(editor); }, onComplete: () => { editorRef.current = null; + onEditorChange(null); }, }; @@ -943,6 +950,7 @@ function RenderedMarkdownSurface({ | "revealRequestId" | "wordWrap" | "onPostRender" + | "onEditorChange" > & { threadRef: ScopedThreadRef; readOnly: boolean; @@ -1142,6 +1150,55 @@ export default function FilePreviewPanel({ !(tableDelimiter && renderTable) && !renderBrowserFile && !isDocument; + const mayOpenEditor = + previewPath !== null && + !isHostFile && + canWriteFiles && + file.error === null && + !file.data?.truncated && + !isMedia && + !isDocument && + !renderBrowserFile && + !renderMermaid && + !(isMarkdown && renderMarkdown) && + !(tableDelimiter && renderTable); + const contentFocusRef = useRef(null); + const fileEditorRef = useRef(null); + const focusEditorFromContent = () => { + const editor = fileEditorRef.current; + if (!editor || document.activeElement !== contentFocusRef.current) return; + // Without a caret the editor focuses with no selection, so no caret shows and typing + // lands on whatever the browser selection held. + const hasCaret = Boolean(editor.getViewState().selections?.length); + editor.focus( + hasCaret + ? { preventScroll: true } + : { preventScroll: true, lineNumber: revealLine ?? "first-visible" }, + ); + }; + const handleEditorChange = (editor: FileEditor | null) => { + fileEditorRef.current = editor; + focusEditorFromContent(); + }; + const handleEditorChangeStable = useStableCallback(handleEditorChange); + const focusEditorOnTreeRowClickOrEnter = (event: { + nativeEvent: Event; + altKey: boolean; + ctrlKey: boolean; + metaKey: boolean; + shiftKey: boolean; + }) => { + if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return; + const row = event.nativeEvent + .composedPath() + .find( + (node): node is Element => + node instanceof Element && node.matches('[role="treeitem"][data-item-path]'), + ); + const path = row?.getAttribute("data-item-path"); + if (!path || path.endsWith("/")) return; + requestWorkspaceFocus({ area: "panel", selector: "[data-file-content-focus]" }); + }; const rendered = isMermaid ? renderMermaid : isMarkdown @@ -1364,6 +1421,20 @@ export default function FilePreviewPanel({
+ {mayOpenEditor ? ( + // Panel focus lands here and passes to the editor once it attaches. sr-only keeps the + // 1px box workspace focus needs, and clips it so clicks in the file never reach it. + // The hand-off waits a microtask: workspace focus checks that focus stayed here. +
queueMicrotask(focusEditorFromContent)} + /> + ) : null} {isFolder ? null : relativePath && attachment ? ( ) @@ -1546,29 +1618,37 @@ export default function FilePreviewPanel({
{showExplorer ? ( - { - if (!isDocument) file.refresh(); - setFileRefreshRevision((value) => value + 1); - }, - } - : {})} - /> +
{ + if (event.key === "Enter") focusEditorOnTreeRowClickOrEnter(event); + }} + > + { + if (!isDocument) file.refresh(); + setFileRefreshRevision((value) => value + 1); + }, + } + : {})} + /> +
) : null}
From 462eeac63bb3573d9efca2311d8b9d9b740c2fd3 Mon Sep 17 00:00:00 2001 From: Praffi Ramadhani <167605652+praffiii@users.noreply.github.com> Date: Thu, 12 Nov 2026 04:23:56 +0700 Subject: [PATCH 0122/1165] fix(mobile): stale Claude threads show a compact-before-send notice (#34263) Co-authored-by: Julius Marminge --- .../features/threads/ThreadDetailScreen.tsx | 18 +++++ .../features/threads/ThreadRouteScreen.tsx | 1 + .../src/state/thread-task-permissions.test.ts | 45 +++++++++-- .../src/state/use-thread-composer-state.ts | 77 ++++++++++++++----- .../src/state/contextWindow.test.ts | 8 +- .../client-runtime/src/state/contextWindow.ts | 19 ++--- 6 files changed, 130 insertions(+), 38 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 72361dd242047..45d2aad72e428 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -176,6 +176,8 @@ import { PendingApprovalCard } from "./PendingApprovalCard"; import { ComposerErrorNotice } from "./ComposerErrorNotice"; import { ManagedRuntimeModeNotice } from "./ManagedRuntimeModeNotice"; import { FreshSessionNotice } from "./FreshSessionNotice"; +import { Notice } from "../../components/ui/Notice"; +import { formatTokens } from "@t3tools/shared/usageFormat"; import { ComposerFeedback } from "./ComposerFeedback"; import { ComposerUsageLimits } from "./ComposerUsageLimits"; import { PendingUserInputCard } from "./PendingUserInputCard"; @@ -244,6 +246,11 @@ export interface ThreadDetailScreenProps { readonly providerSubagentStatus?: ProviderSubagentStatus | null; readonly extensionStatuses?: NonNullable; readonly isCompacting: boolean; + /** Set while the next send compacts the thread first. */ + readonly compactBeforeSend?: { + readonly tokens: number; + readonly keepFullHistory: () => void; + } | null; readonly creationState: | { readonly kind: "preparing"; @@ -1681,6 +1688,17 @@ function ThreadDetailBody( {freshSessionAvailable ? ( ) : null} + {props.compactBeforeSend ? ( + + + + ) : null} {props.canOperateThread ? ( ({ pendingRequests: { approvals: [], userInputs: [] } as PendingThreadRequests, enqueue: vi.fn(async () => undefined), freshPending: false, - compactDue: false, + compactModel: null as string | null, + compactDueAt: "", setFreshPending: vi.fn(), clearDraft: vi.fn(), approve: vi.fn(), @@ -79,8 +80,12 @@ vi.mock("react", () => ({ vi.mock("expo-crypto", () => ({ randomUUID: () => "uuid" })); vi.mock("@t3tools/client-runtime/state/context-window", async (importOriginal) => ({ ...(await importOriginal()), - shouldCompactBeforeSend: () => state.compactDue, + compactBeforeSendTokens: (input: { readonly selection: ModelSelection; readonly now: string }) => + input.selection.model === state.compactModel && input.now >= state.compactDueAt + ? 248_000 + : null, })); +vi.mock("../lib/clock", () => ({ useNowMinuteMs: () => 0 })); vi.mock("../lib/attachmentUpload", () => ({ prepareTurnAttachments: vi.fn() })); vi.mock("../lib/uuid", () => ({ uuidv4: () => "uuid" })); vi.mock("./queued-run-edit", () => ({ @@ -203,7 +208,8 @@ beforeEach(() => { runtimeMode: undefined, }; state.freshPending = false; - state.compactDue = false; + state.compactModel = null; + state.compactDueAt = ""; state.thread.latestRun = null; state.projection = null; state.serverConfig = { @@ -288,7 +294,7 @@ describe("mobile task permissions", () => { expect(state.setFreshPending).toHaveBeenCalledWith("secondary:thread", false); }); - it("compacts a stale thread before sending unless a fresh session is armed", async () => { + it("compacts a stale thread before sending unless kept or a fresh session is armed", async () => { state.grantedEnvironments.add("secondary"); state.projection = { thread: { @@ -314,12 +320,41 @@ describe("mobile task permissions", () => { visibleTurnItems: [], updatedAt: DateTime.makeUnsafe(0), } as unknown as OrchestrationV2ThreadProjection; - state.compactDue = true; + state.compactModel = "gpt-5.4"; + // The notice's minute clock (the epoch here) trails a deadline the send's exact time has passed. + state.compactDueAt = "2026-01-01T00:00:00.000Z"; + const unannounced = useThreadComposerState(NO_SIDE_CHATS); + expect(unannounced.compactBeforeSend).toBeNull(); + await unannounced.onSendMessage(); + expect(state.enqueue).toHaveBeenLastCalledWith( + expect.not.objectContaining({ compactBeforeSend: true }), + ); + state.compactDueAt = ""; const composer = useThreadComposerState(NO_SIDE_CHATS); + expect(composer.compactBeforeSend?.tokens).toBe(248_000); await composer.onSendMessage(); expect(state.enqueue).toHaveBeenLastCalledWith( expect.objectContaining({ compactBeforeSend: true }), ); + // A model picked after the last render still decides the send. + state.draft = { + ...state.draft, + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.5" }, + }; + await composer.onSendMessage(); + expect(state.enqueue).toHaveBeenLastCalledWith( + expect.not.objectContaining({ compactBeforeSend: true }), + ); + state.draft = { ...state.draft, modelSelection: undefined }; + composer.compactBeforeSend?.keepFullHistory(); + const kept = useThreadComposerState(NO_SIDE_CHATS); + expect(kept.compactBeforeSend).toBeNull(); + await kept.onSendMessage(); + expect(state.enqueue).toHaveBeenLastCalledWith( + expect.not.objectContaining({ compactBeforeSend: true }), + ); + // Keeping covers one send, as on web. + expect(useThreadComposerState(NO_SIDE_CHATS).compactBeforeSend?.tokens).toBe(248_000); state.freshPending = true; await composer.onSendMessage(); expect(state.enqueue).toHaveBeenLastCalledWith( diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 702aa53f90f0f..896adc53f5203 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -3,7 +3,7 @@ import * as Dialog from "../components/ui/Dialog"; import { AuthOrchestrationOperateScope } from "@t3tools/contracts"; import { readEnvironmentScope } from "./session"; import { useAtomValue } from "@effect/atom-react"; -import { shouldCompactBeforeSend } from "@t3tools/client-runtime/state/context-window"; +import { compactBeforeSendTokens } from "@t3tools/client-runtime/state/context-window"; import { threadRuntimeIsActive } from "@t3tools/client-runtime/state/shell"; import type { ThreadCheckpointSummary } from "@t3tools/client-runtime/state/thread-checkpoints"; import { threadSteersDuringCompaction } from "@t3tools/client-runtime/state/thread-workflows"; @@ -42,6 +42,7 @@ import { upgradeLegacyContextMessage } from "@t3tools/shared/composerContextLega import { restoreAttachmentOnlyBootstrapPrompt } from "@t3tools/shared/composerContextReferences"; import { composerContextSendBlockReason, reidentifyComposerContext } from "../lib/composerContext"; import { uuidv4 } from "../lib/uuid"; +import { useNowMinuteMs } from "../lib/clock"; import { makeQueuedMessageMetadata } from "../lib/commandMetadata"; import { @@ -120,6 +121,17 @@ import { useAtomCommand } from "./use-atom-command"; const EMPTY_QUEUE_WORKFLOW_ATOM = Atom.make(null).pipe( Atom.withLabel("mobile-thread-queue-workflow:empty"), ); +// Threads whose next send keeps the full history instead of compacting first. +const fullHistoryThreadKeysAtom = Atom.make>>({}).pipe( + Atom.keepAlive, +); + +function setKeepFullHistory(threadKey: string, keep: boolean) { + const { [threadKey]: kept, ...rest } = appAtomRegistry.get(fullHistoryThreadKeysAtom); + if ((kept === true) === keep) return; + appAtomRegistry.set(fullHistoryThreadKeysAtom, keep ? { ...rest, [threadKey]: true } : rest); +} + const EMPTY_CHECKPOINT_SUMMARIES_ATOM = Atom.make>([]); const EMPTY_THREAD_SEQUENCE_ATOM = Atom.make(0); @@ -398,6 +410,34 @@ export function useThreadComposerState(input: { : null; const activeThreadBusy = threadRuntimeIsActive(selectedThreadRuntime); + const resumeCompactionTokens = (selection: ModelSelection | null, text: string, now: string) => + selectedEnvironmentRuntime?.connectionState === "connected" && + selectedThreadProjection !== null && + serverConfig != null && + selection !== null + ? compactBeforeSendTokens({ + projection: selectedThreadProjection.projection, + visibleTurnItems: selectedThreadVisibleTurnItems, + selection, + providers: serverConfig.providers, + settings: serverConfig.settings, + text, + threadIdle: + !activeThreadBusy && + !runlessWorkActive && + !isCompacting && + selectedThreadQueuedMessages.length === 0, + now, + }) + : null; + const nowMinuteMs = useNowMinuteMs(); + const compactTokens = + selectedDraft.freshContext === true + ? null + : resumeCompactionTokens(modelSelection, "", new Date(nowMinuteMs).toISOString()); + const fullHistoryThreadKeys = useAtomValue(fullHistoryThreadKeysAtom); + const keepFullHistory = + selectedThreadKey !== null && fullHistoryThreadKeys[selectedThreadKey] === true; const interruptibleRunId = threadRuntimeHasInterruptibleRun(selectedThreadRuntime) ? (selectedThreadRuntime?.activeRunId ?? null) : null; @@ -731,27 +771,14 @@ export function useThreadComposerState(input: { // A redirect belongs to the approval it answers; the armed session waits for the next message. const freshContext = beforeDispatch === undefined && isFreshSessionPending(threadKey); const metadata = makeQueuedMessageMetadata(); - // A fresh session drops the context a compaction would summarize. + // A fresh session drops the context a compaction would summarize. Only a send the notice + // announced compacts, since the notice's minute clock can trail the exact deadline. const compactBeforeSend = + compactTokens !== null && !freshContext && followUpDispatchMode === null && - selectedEnvironmentRuntime?.connectionState === "connected" && - selectedThreadProjection !== null && - serverConfig != null && - shouldCompactBeforeSend({ - projection: selectedThreadProjection.projection, - visibleTurnItems: selectedThreadVisibleTurnItems, - selection: modelSelection, - providers: serverConfig.providers, - settings: serverConfig.settings, - text, - threadIdle: - !activeThreadBusy && - !runlessWorkActive && - !isCompacting && - selectedThreadQueuedMessages.length === 0, - now: metadata.createdAt, - }); + appAtomRegistry.get(fullHistoryThreadKeysAtom)[threadKey] !== true && + resumeCompactionTokens(modelSelection, text, metadata.createdAt) !== null; const messageId = MessageId.make(metadata.messageId); onWillEnqueue?.(); clearThreadComposerError(threadKey); @@ -777,7 +804,10 @@ export function useThreadComposerState(input: { clearComposerDraftContent(threadKey, { deferAttachmentCleanup: true }); if (freshContext) setFreshSessionPending(threadKey, false); enqueuePromise.then( - () => scheduleUnusedComposerAttachmentCleanup(attachments), + () => { + setKeepFullHistory(threadKey, false); + scheduleUnusedComposerAttachmentCleanup(attachments); + }, (error: unknown) => { // Restore with uncapped append; capped merge could drop attachments added during the failed write. void mergeComposerDraftContent(threadKey, { @@ -861,6 +891,13 @@ export function useThreadComposerState(input: { ), providerSubagentStatus, isCompacting, + compactBeforeSend: + compactTokens === null || selectedThreadKey === null || keepFullHistory + ? null + : { + tokens: compactTokens, + keepFullHistory: () => setKeepFullHistory(selectedThreadKey, true), + }, composerDraftKey, followUpBehavior, canSteerActiveTurn, diff --git a/packages/client-runtime/src/state/contextWindow.test.ts b/packages/client-runtime/src/state/contextWindow.test.ts index 3f1a4d3d41994..994d3d62a2065 100644 --- a/packages/client-runtime/src/state/contextWindow.test.ts +++ b/packages/client-runtime/src/state/contextWindow.test.ts @@ -14,7 +14,7 @@ import { import * as DateTime from "effect/DateTime"; import { describe, expect, it } from "vite-plus/test"; -import { shouldCompactBeforeSend } from "./contextWindow.ts"; +import { compactBeforeSendTokens } from "./contextWindow.ts"; import type { ThreadStructure } from "./models.ts"; import { v2Projection } from "./orchestrationV2TestFixtures.ts"; @@ -181,7 +181,7 @@ function sendInput( }; } -describe("shouldCompactBeforeSend", () => { +describe("compactBeforeSendTokens", () => { it.each([ { name: "a stale Claude thread over 200K", expected: true }, { @@ -257,8 +257,8 @@ describe("shouldCompactBeforeSend", () => { { name: "with no conversation but /compact", onlyCompactMessages: true, expected: false }, { name: "for a typed /compact", text: "/compact keep the plan", expected: false }, ] as const)("$name", ({ name: _name, expected, ...overrides }) => { - expect(shouldCompactBeforeSend(sendInput(overrides as Parameters[0]))).toBe( - expected, + expect(compactBeforeSendTokens(sendInput(overrides as Parameters[0]))).toBe( + expected ? 293_611 : null, ); }); }); diff --git a/packages/client-runtime/src/state/contextWindow.ts b/packages/client-runtime/src/state/contextWindow.ts index 66c48807465af..94be1e481ad42 100644 --- a/packages/client-runtime/src/state/contextWindow.ts +++ b/packages/client-runtime/src/state/contextWindow.ts @@ -234,11 +234,12 @@ export function shouldOfferResumeCompaction(input: { } /** - * Whether a plain send into an idle thread compacts it first, as web's composer does under - * "When you return". Web reads the same state for its "Compact and send" button. A send that - * hands off to another conversation starts that one fresh, so only the resumed one counts. + * The tokens a plain send into an idle thread compacts first, as web's composer does under + * "When you return", or null when it sends as is. Web reads the same state for its "Compact and + * send" button. A send that hands off to another conversation starts that one fresh, so only the + * resumed one counts. */ -export function shouldCompactBeforeSend(input: { +export function compactBeforeSendTokens(input: { readonly projection: Pick< ThreadStructure, | "thread" @@ -259,7 +260,7 @@ export function shouldCompactBeforeSend(input: { readonly text: string; readonly threadIdle: boolean; readonly now: string; -}): boolean { +}): number | null { const { projection } = input; const provider = input.providers.find((entry) => entry.instanceId === input.selection.instanceId); if ( @@ -290,7 +291,7 @@ export function shouldCompactBeforeSend(input: { .map((request) => ({ kind: "user-input.resolved", payload: { answers: request.answers } })), ) ) { - return false; + return null; } const activeProviderThreadId = projection.thread.activeProviderThreadId; const providerThread = projection.providerThreads.find( @@ -306,8 +307,7 @@ export function shouldCompactBeforeSend(input: { liveUsage, providerThread, ); - return ( - contextWindow !== null && + return contextWindow !== null && shouldOfferResumeCompaction({ provider: provider.driver, usedTokens: contextWindow.usedTokens, @@ -315,5 +315,6 @@ export function shouldCompactBeforeSend(input: { promptCacheTtlMs: liveUsage?.promptCacheTtlMs, now: input.now, }) - ); + ? contextWindow.usedTokens + : null; } From 80d8623fc3435e3ec5f62d5f40d8aed8ca00b7c9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 13:24:20 -0800 Subject: [PATCH 0123/1165] fix(cursor): retry a timed-out catalog read before reporting an error (#34553) --- .../provider-cursor/src/server/status.test.ts | 32 +++++++++++++++++++ packages/provider-cursor/src/server/status.ts | 11 +++++-- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/packages/provider-cursor/src/server/status.test.ts b/packages/provider-cursor/src/server/status.test.ts index c3dc7deb120ff..ddbddcac1f1eb 100644 --- a/packages/provider-cursor/src/server/status.test.ts +++ b/packages/provider-cursor/src/server/status.test.ts @@ -1,7 +1,9 @@ import type { SDKModel } from "@cursor/sdk"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Schema from "effect/Schema"; +import * as TestClock from "effect/testing/TestClock"; import type { CursorSettings } from "../settings.ts"; import { CursorSettings as CursorSettingsSchema } from "../settings.ts"; import { createModelCapabilities } from "@t3tools/shared/model"; @@ -354,6 +356,36 @@ describe("checkCursorProviderStatus", () => { }), ); + it.effect("does not report a single slow catalog read as a provider error", () => + Effect.gen(function* () { + let reads = 0; + const fiber = yield* checkCursorProviderStatus(baseCursorSettings, { + CURSOR_API_KEY: "test-cursor-key", + }).pipe( + Effect.provide( + CursorSdkCatalog.layerTest(() => { + reads += 1; + return reads === 1 + ? Effect.never + : Effect.succeed({ + user: { + apiKeyName: "test-key", + userEmail: "cursor@example.com", + createdAt: "2026-01-01T00:00:00.000Z", + }, + models: [sdkParameterizedModel], + }); + }), + ), + Effect.forkChild({ startImmediately: true }), + ); + yield* TestClock.adjust("1 minute"); + const provider = yield* Fiber.join(fiber); + + expect(provider.status).not.toBe("error"); + }), + ); + it.effect("requires a Cursor API key without probing any external Cursor binary", () => Effect.gen(function* () { const provider = yield* checkCursorProviderStatus(baseCursorSettings).pipe( diff --git a/packages/provider-cursor/src/server/status.ts b/packages/provider-cursor/src/server/status.ts index ffeca7a1da0b7..0330fcaca0bc1 100644 --- a/packages/provider-cursor/src/server/status.ts +++ b/packages/provider-cursor/src/server/status.ts @@ -286,9 +286,14 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( } const sdkCatalog = yield* CursorSdkCatalog.CursorSdkCatalog; - const catalogResult = yield* sdkCatalog - .read(sdkApiKey) - .pipe(Effect.timeoutOption(CURSOR_SDK_CATALOG_TIMEOUT_MS), Effect.result); + const readCatalog = Effect.suspend(() => sdkCatalog.read(sdkApiKey)).pipe( + Effect.timeoutOption(CURSOR_SDK_CATALOG_TIMEOUT_MS), + ); + // api.cursor.com latency is intermittent; one slow read should not flip the provider to error. + const catalogResult = yield* readCatalog.pipe( + Effect.filterOrElse(Option.isSome, () => readCatalog), + Effect.result, + ); if (Result.isFailure(catalogResult)) { yield* Effect.logWarning("Cursor SDK catalog probe failed", { From 21a9aa1d79684b22adb3cd9331deacd547762eff Mon Sep 17 00:00:00 2001 From: Sepehr Rajabi <50242165+SepehrRajabi@users.noreply.github.com> Date: Wed, 11 Nov 2026 22:24:36 +0100 Subject: [PATCH 0124/1165] fix(web): wrapping a selection in backticks formats it as code (#14870) Co-authored-by: Julius Marminge Co-authored-by: maria-rcks --- .../components/ComposerPromptEditorTiptap.tsx | 6 ++ apps/web/src/composer-rich-text-doc.test.ts | 81 +++++++++++++++++++ apps/web/src/composer-rich-text-doc.ts | 39 +++++++++ 3 files changed, 126 insertions(+) diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 40accf71b219d..c443825dffd5c 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -92,6 +92,7 @@ import { serializeSelection, skillChipReplacements, splitOrLiftListItem, + surroundSelectionWithMark, stepCaretAcrossStyledEdge, type IsKnownModel, type SkillMeta, @@ -1355,6 +1356,11 @@ function ComposerPromptEditorTiptapInner( const startMd = flatToMarkdown(map, pmToFlat(map, from)); const endMd = flatToMarkdown(map, pmToFlat(map, to)); if (selectionTouchesMentionBoundary(map.value, startMd, endMd)) return false; + const markTr = surroundSelectionWithMark(view.state, from, to, text); + if (markTr) { + view.dispatch(markTr); + return true; + } const tr = view.state.tr.insertText(closer, to).insertText(text, from); tr.setSelection(TextSelection.create(tr.doc, from + text.length, to + text.length)); view.dispatch(tr); diff --git a/apps/web/src/composer-rich-text-doc.test.ts b/apps/web/src/composer-rich-text-doc.test.ts index fcdb8165662b5..e8a4bdccfcc48 100644 --- a/apps/web/src/composer-rich-text-doc.test.ts +++ b/apps/web/src/composer-rich-text-doc.test.ts @@ -33,6 +33,7 @@ import { serializeSelection, skillChipReplacements, stepCaretAcrossStyledEdge, + surroundSelectionWithMark, } from "./composer-rich-text-doc"; function stubAtom(name: string, attrs: Record) { @@ -1359,3 +1360,83 @@ describe("inline code entry", () => { expect(marked(editor, mark)).toEqual(["foo"]); }); }); + +describe("surroundSelectionWithMark", () => { + it("keeps markers inside math literal across draft restoration", () => { + const doc = schema.node("doc", null, [schema.node("paragraph", null, schema.text("$x$"))]); + const state = EditorState.create({ doc, selection: TextSelection.create(doc, 2, 3) }); + expect(surroundSelectionWithMark(state, 2, 3, "*")).toBeNull(); + }); + + it.each([ + ["`", "hello", "say `hello` now"], + ["*", "hello", "say *hello* now"], + ["_", "hello", "say *hello* now"], + ["`", " ", "say ` ` now"], + ])("formats a selection with %s like typed markers (%j)", (marker, selected, expected) => { + const doc = schema.node("doc", null, [ + schema.node("paragraph", null, schema.text(`say ${selected} now`)), + ]); + const to = 5 + selected.length; + const state = EditorState.create({ doc, selection: TextSelection.create(doc, 5, to) }); + const tr = surroundSelectionWithMark(state, 5, to, marker); + expect(tr).not.toBeNull(); + const formatted = state.apply(tr!); + const typed = ProseMirrorNode.fromJSON( + schema, + buildDocJson(`say ${marker}${selected}${marker} now`, (name) => ({ + label: name, + description: null, + })), + ); + expect(formatted.doc.eq(typed)).toBe(true); + expect(serializeEditorDoc(formatted.doc).value).toBe(expected); + expect(formatted.doc.textBetween(formatted.selection.from, formatted.selection.to)).toBe( + selected, + ); + }); + + it.each([ + ["*", " "], + ["_", " "], + ["*", "a*b"], + ["`", "foo`bar"], + ])("leaves %s around %j to literal wrapping when the mark cannot round-trip", (marker, text) => { + const doc = schema.node("doc", null, [schema.node("paragraph", null, schema.text(text))]); + const to = 1 + text.length; + const state = EditorState.create({ doc, selection: TextSelection.create(doc, 1, to) }); + expect(surroundSelectionWithMark(state, 1, to, marker)).toBeNull(); + }); + + it.each([ + [ + "plain mode", + plainSchema.node("doc", null, [plainSchema.node("paragraph", null, plainSchema.text("one"))]), + ], + [ + "paragraphs", + schema.node("doc", null, [ + schema.node("paragraph", null, schema.text("one")), + schema.node("paragraph", null, schema.text("two")), + ]), + ], + [ + "a hard break", + schema.node("doc", null, [ + schema.node("paragraph", null, [ + schema.text("one"), + schema.node("hardBreak"), + schema.text("two"), + ]), + ]), + ], + [ + "a code block", + schema.node("doc", null, [schema.node("codeBlock", null, schema.text("one"))]), + ], + ])("leaves selections in %s to literal wrapping", (_, doc) => { + doc.check(); + const state = EditorState.create({ doc }); + expect(surroundSelectionWithMark(state, 1, doc.content.size - 1, "`")).toBeNull(); + }); +}); diff --git a/apps/web/src/composer-rich-text-doc.ts b/apps/web/src/composer-rich-text-doc.ts index 6d308cd9226e1..1712fc0f5c98b 100644 --- a/apps/web/src/composer-rich-text-doc.ts +++ b/apps/web/src/composer-rich-text-doc.ts @@ -304,6 +304,45 @@ function markInputRuleOutsideCode(find: RegExp, type: MarkType) { }); } +const SURROUND_MARKS: Record = { "`": "code", "*": "italic", _: "italic" }; + +export function surroundSelectionWithMark( + state: EditorState, + from: number, + to: number, + marker: string, +) { + const markType = state.schema.marks[SURROUND_MARKS[marker] ?? ""]; + if (!markType || touchesMarkdownSource(state, from, to)) return null; + const $from = state.doc.resolve(from); + if (!$from.sameParent(state.doc.resolve(to)) || !$from.parent.type.allowsMarkType(markType)) { + return null; + } + // Inline markdown cannot span lines, so keep literal wrapping across hard breaks. + let crossesLine = false; + state.doc.nodesBetween(from, to, (node) => { + if (node.type.name === "hardBreak") crossesLine = true; + return !crossesLine; + }); + if (crossesLine) return null; + const tr = state.tr.addMark(from, to, markType.create()); + // Nearby delimiters can change how the saved span parses. Atoms are opaque + // to inline markdown, so use a placeholder instead of parsing their sources. + const children: ProseMirrorNode[] = []; + tr.doc.resolve(from).parent.forEach((node) => { + children.push( + node.isAtom && !node.isText && node.type.name !== "hardBreak" + ? state.schema.text("\uFFFC", node.marks) + : node, + ); + }); + const paragraph = state.schema.nodes.paragraph!.create(null, children); + const markdown = serializeEditorDoc(state.doc.type.create(null, paragraph)).value; + const rebuilt = buildTiptapContent(markdown, () => null, { blocks: false }); + const restored = state.schema.nodeFromJSON({ type: "doc", content: rebuilt }); + return paragraph.content.eq(restored.firstChild!.content) ? tr : null; +} + /** * The composer writes italic as `*text*`, so it keeps only the `*` shortcuts. * Tiptap's `_text_` rules fire on the `_` in names like `node_modules` and From 330b9f484032e7a7f9df01c5d73accd97ec356b8 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 11 Nov 2026 13:34:32 -0800 Subject: [PATCH 0125/1165] fix(web): arrow keys and buttons step through the images an agent viewed (#34333) Co-authored-by: Claude Fable 5.2 --- .../chat/MessagesTimeline.browser.test.tsx | 50 +++++++++++++++++-- .../components/chat/TimelineWorkEntryRow.tsx | 2 +- 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx index 3bd6d9c88fb8d..20fdcd70ac2bb 100644 --- a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx @@ -44,7 +44,9 @@ import { deriveTimelineEntriesFromVisibleTurnItems, } from "../../session-logic"; import type { Root } from "mdast"; +import { ViewedImagesTimelineRow } from "./TimelineWorkEntryRow"; import { remarkThoughtPreview, THOUGHT_PREVIEW_MAX_LENGTH } from "./TimelineWorkEntryRow.logic"; +import { TimelineRowCtx } from "./timelineRowContext"; import { parseMarkdownWithMath } from "@t3tools/shared/markdownMath"; import { collectThreadFindRanges } from "./threadFindHighlights"; import { toastManager } from "../ui/toast"; @@ -74,14 +76,14 @@ vi.mock(import("~/state/query"), async (original) => ({ })) as never, })); -const audioAsset = vi.hoisted(() => ({ url: "" })); +const stubbedAsset = vi.hoisted(() => ({ url: "" })); vi.mock(import("~/assets/assetUrls"), async (original) => { const actual = await original(); return { ...actual, useAssetUrlState: ((...args: Parameters) => - audioAsset.url - ? { _tag: "Success", url: audioAsset.url } + stubbedAsset.url + ? { _tag: "Success", url: stubbedAsset.url } : actual.useAssetUrlState(...args)) as typeof actual.useAssetUrlState, }; }); @@ -1457,9 +1459,9 @@ describe("MessagesTimeline attachments", () => { ); const src = "data:audio/wav;base64,UklGRiUAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQEAAACA"; - audioAsset.url = src; + stubbedAsset.url = src; onTestFinished(() => { - audioAsset.url = ""; + stubbedAsset.url = ""; }); const props = baseProps(); await using screen = await renderTimeline( @@ -1527,6 +1529,44 @@ describe("MessagesTimeline attachments", () => { }); await vi.waitFor(() => expect(one!.isConnected).toBe(false)); }); + + it("opens a viewed image as a gallery of the images beside it", async () => { + stubbedAsset.url = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="; + onTestFinished(() => { + stubbedAsset.url = ""; + }); + const onImageExpand = vi.fn["onImageExpand"]>(); + await using screen = await renderElement( + + + , + ); + await screen.getByRole("button", { name: "Preview second.png" }).click(); + expect(onImageExpand).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + index: 1, + images: ["first.png", "second.png", "third.png"].map((name) => + expect.objectContaining({ name }), + ), + }), + ); + }); }); describe("MessagesTimeline thread find", () => { diff --git a/apps/web/src/components/chat/TimelineWorkEntryRow.tsx b/apps/web/src/components/chat/TimelineWorkEntryRow.tsx index 2836dde0c2456..253c83de3f4db 100644 --- a/apps/web/src/components/chat/TimelineWorkEntryRow.tsx +++ b/apps/web/src/components/chat/TimelineWorkEntryRow.tsx @@ -182,7 +182,7 @@ export function ViewedImagesTimelineRow({ const { threadRef, workspaceRoot, onImageExpand } = use(TimelineRowCtx); if (!threadRef) return null; return ( -
+
{row.paths.map((path) => { const image = resolveViewedImageAsset(path, { threadId: threadRef.threadId, From 01b815ffc29b634a7ede198e67c60460319fc1cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Ferro?= Date: Wed, 11 Nov 2026 21:38:17 +0000 Subject: [PATCH 0126/1165] fix(web): draw rounded powerline caps to fill the terminal cell (#34552) Co-authored-by: Julius Marminge --- .../terminal/ghostty/renderer.browser.test.ts | 54 +++++++++++++++++++ apps/web/src/terminal/ghostty/renderer.ts | 32 +++++++++-- 2 files changed, 81 insertions(+), 5 deletions(-) diff --git a/apps/web/src/terminal/ghostty/renderer.browser.test.ts b/apps/web/src/terminal/ghostty/renderer.browser.test.ts index eb48b04167748..3d3aec210d30e 100644 --- a/apps/web/src/terminal/ghostty/renderer.browser.test.ts +++ b/apps/web/src/terminal/ghostty/renderer.browser.test.ts @@ -29,6 +29,13 @@ function pixelsOffBackground(context: CanvasRenderingContext2D): number { return count; } +/** Whether the device pixel under a CSS pixel position is closer to black than white. */ +function isDarkAt(context: CanvasRenderingContext2D, x: number, y: number): boolean { + const { a, d } = context.getTransform(); + const { data } = context.getImageData(Math.floor(x * a), Math.floor(y * d), 1, 1); + return data[0]! < 128 && data[1]! < 128 && data[2]! < 128; +} + /** A 40 by 60 CSS pixel canvas drawn at the given device pixel ratio. */ function scaledContext(scale: number): CanvasRenderingContext2D { const canvas = document.createElement("canvas"); @@ -114,4 +121,51 @@ describe.each([1.25, 2.2])("renderGhosttySnapshot at a %sx device pixel ratio", renderGhosttySnapshot({ ...options, snapshot: firstRow(blank) }); expect(pixelsOffBackground(context)).toBe(0); }); + + // Cell 1 spans x 11.8 to 19.6 and row 0 spans y 4 to 21 in CSS pixels. + it.each([ + ["right", "\ue0b4", { flat: 12.3, round: 19.1, neighbour: 0, outside: 11.3 }], + ["left", "\ue0b6", { flat: 19.1, round: 12.3, neighbour: 2, outside: 20.1 }], + ])("fills the rounded %s cap to the cell edges, under a block cursor too", (_side, text, x) => { + const context = scaledContext(scale); + const segment = { ...blank, background: { r: 0, g: 0, b: 0 } }; + const cells = [blank, { ...blank, text }, blank]; + cells[x.neighbour] = segment; + const snapshot = (cursorVisible: boolean): GhosttySnapshot => ({ + cols: 3, + rows: 1, + foreground: { r: 0, g: 0, b: 0 }, + background, + cursor: { r: 0, g: 0, b: 0 }, + cursorX: 1, + cursorY: 0, + cursorVisible, + cursorBlinking: false, + cursorStyle: 1, + dirtyRows: new Set([0]), + rowData: [{ cells, text: "", isWrapContinuation: false, wrapsToNext: false }], + }); + + renderGhosttySnapshot({ + ...layout, + context, + snapshot: snapshot(false), + forceFull: true, + cursorOn: false, + }); + expect(isDarkAt(context, x.flat, 4.5)).toBe(true); + expect(isDarkAt(context, x.flat, 20.5)).toBe(true); + expect(isDarkAt(context, x.round, 12.5)).toBe(true); + expect(isDarkAt(context, x.round, 4.5)).toBe(false); + + renderGhosttySnapshot({ + ...layout, + context, + snapshot: snapshot(true), + forceFull: true, + cursorOn: true, + }); + expect(isDarkAt(context, x.outside, 12.5)).toBe(true); + expect(isDarkAt(context, x.flat, 12.5)).toBe(false); + }); }); diff --git a/apps/web/src/terminal/ghostty/renderer.ts b/apps/web/src/terminal/ghostty/renderer.ts index 98e3699ab314b..630745c4faf3e 100644 --- a/apps/web/src/terminal/ghostty/renderer.ts +++ b/apps/web/src/terminal/ghostty/renderer.ts @@ -20,6 +20,8 @@ export interface GhosttyCellRange { const DEFAULT_SELECTION_BACKGROUND = "rgba(72, 122, 191, 0.35)"; const POWERLINE_SOLID_RIGHT = "\ue0b0"; const POWERLINE_SOLID_LEFT = "\ue0b2"; +const POWERLINE_ROUND_RIGHT = "\ue0b4"; +const POWERLINE_ROUND_LEFT = "\ue0b6"; function cssColor(color: GhosttyColor): string { return `rgb(${color.r}, ${color.g}, ${color.b})`; @@ -36,12 +38,17 @@ function sameTextStyle(left: GhosttyCell, right: GhosttyCell): boolean { } function isSolidPowerlineSeparator(text: string): boolean { - return text === POWERLINE_SOLID_RIGHT || text === POWERLINE_SOLID_LEFT; + return ( + text === POWERLINE_SOLID_RIGHT || + text === POWERLINE_SOLID_LEFT || + text === POWERLINE_ROUND_RIGHT || + text === POWERLINE_ROUND_LEFT + ); } // Fallback fonts inset or scale these private-use glyphs, leaving a gap where -// the separator should meet the next cell. Fill the triangle from the cell's -// own edges instead, as native terminals do. +// the separator should meet the next cell. Fill the triangle or half-ellipse +// from the cell's own edges instead, as native terminals do. function fillSolidPowerlineSeparator( context: CanvasRenderingContext2D, text: string, @@ -49,12 +56,26 @@ function fillSolidPowerlineSeparator( top: number, width: number, height: number, + seamOverlap: number, ): void { const right = left + width; + const middle = top + height / 2; + if (text === POWERLINE_ROUND_RIGHT || text === POWERLINE_ROUND_LEFT) { + const [base, start, inward] = + text === POWERLINE_ROUND_RIGHT ? [left, -Math.PI / 2, -1] : [right, Math.PI / 2, 1]; + context.beginPath(); + context.ellipse(base, middle, width, height / 2, 0, start, start + Math.PI); + context.closePath(); + // Overlap the segment this cap closes so anti-aliasing at a fractional + // cell edge does not leave a hairline seam. + context.rect(Math.min(base, base + inward * seamOverlap), top, seamOverlap, height); + context.fill(); + return; + } const [base, point] = text === POWERLINE_SOLID_RIGHT ? [left, right] : [right, left]; context.beginPath(); context.moveTo(base, top); - context.lineTo(point, top + height / 2); + context.lineTo(point, middle); context.lineTo(base, top + height); context.closePath(); context.fill(); @@ -234,6 +255,7 @@ export function renderGhosttySnapshot(options: { top, metrics.width, height, + 1, ); } runStart += 1; @@ -311,7 +333,7 @@ export function renderGhosttySnapshot(options: { if (cell?.text) { context.fillStyle = cssColor(snapshot.background); if (isSolidPowerlineSeparator(cell.text)) { - fillSolidPowerlineSeparator(context, cell.text, left, top, metrics.width, height); + fillSolidPowerlineSeparator(context, cell.text, left, top, metrics.width, height, 0); } else { context.font = fontForCell(cell, fontSize, fontFamily); context.fillText(cell.text, left, top + metrics.baseline, metrics.width); From a9f2d7d5f4bd9191daa114b0a55d7b6bdda3484a Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 11 Nov 2026 13:42:42 -0800 Subject: [PATCH 0127/1165] fix(web): "Show full message" only appears when it at least doubles the message (#34306) Co-authored-by: Claude Fable 5.2 --- .../chat/MessagesTimeline.browser.test.tsx | 19 ++++++++++++ .../chat/MessagesTimeline.logic.test.ts | 28 +---------------- .../components/chat/MessagesTimeline.logic.ts | 30 ------------------- .../chat/TimelineUserMessageRow.tsx | 21 +++++++------ .../components/chat/WorkflowOpeningBlock.tsx | 8 +++-- .../components/chat/useTallEnoughToFold.ts | 28 +++++++++++++++++ 6 files changed, 65 insertions(+), 69 deletions(-) create mode 100644 apps/web/src/components/chat/useTallEnoughToFold.ts diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx index 20fdcd70ac2bb..d970ec8338f99 100644 --- a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx @@ -487,6 +487,25 @@ describe("MessagesTimeline messages", () => { expect(maxNestedCommits).toBeLessThan(20); }); + it("folds a user message only when the fold hides at least as much as it shows", async () => { + setup(); + const lines = (count: number) => + Array.from({ length: count }, (_, index) => `Line ${index}`).join("\n"); + await using screen = await renderTimeline([ + message(lines(12), {}, "medium"), + message(lines(60), {}, "long"), + ]); + await vi.waitFor(() => expect(screen.buttons("Show full message")).toHaveLength(1)); + const [medium, long] = [ + ...screen.container.querySelectorAll("[data-user-message-body]"), + ]; + expect(medium!.dataset.userMessageCollapsible).toBe("false"); + expect(medium!.textContent).toContain("Line 11"); + const foldedHeight = long!.offsetHeight; + await click(screen.button("Show full message")); + expect(long!.offsetHeight).toBeGreaterThanOrEqual(foldedHeight * 2); + }); + it("keeps the first and last message headings reachable from either end", async () => { setup(); const props = baseProps(); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index a37a8c703bd0a..09cbfe4e7775a 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -21,15 +21,13 @@ import { import { makeStreamingTimelineFixture } from "../../test-fixtures"; import type { TurnDiffSummary } from "../../types"; import { describe, expect, it } from "vite-plus/test"; -import { EnvironmentId, MessageId, RunId } from "@t3tools/contracts"; -import { serializeAssistantCitation } from "@t3tools/shared/assistantCitations"; +import { MessageId, RunId } from "@t3tools/contracts"; import { computeStableMessagesTimelineRows, deriveMessagesTimelineRows, timelineTurnFoldRunIdsByEntryId, liveAnswerPreview, deriveMessagesTimelineRowsWithState, - shouldCollapseUserMessage, resolveAssistantMessageCopyState, type MessagesTimelineRow, workEntryDisplayLabel, @@ -4779,27 +4777,3 @@ describe("subagent continued by a later turn", () => { ]); }); }); - -describe("shouldCollapseUserMessage", () => { - it("measures a quote by its card, not its encoded link", () => { - const quote = "A long assistant paragraph that the user quoted. ".repeat(40); - const source = { - version: 1 as const, - environmentId: EnvironmentId.make("environment"), - threadId: ThreadId.make("thread"), - messageId: MessageId.make("source"), - text: quote, - start: 0, - end: quote.length, - prefix: "", - suffix: "", - }; - const citation = serializeAssistantCitation({ ...source, comment: "Why does this matter?" }); - const bare = serializeAssistantCitation(source); - - expect(shouldCollapseUserMessage(`${citation} Can you expand on this?`)).toBe(false); - expect(shouldCollapseUserMessage(`${citation} ${"More text. ".repeat(60)}`)).toBe(true); - expect(shouldCollapseUserMessage(bare.repeat(2))).toBe(false); - expect(shouldCollapseUserMessage(bare.repeat(10))).toBe(true); - }); -}); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 419eb9eaf41fb..c422656647863 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -3,7 +3,6 @@ import { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setu import { resolveUserMessagePresentation } from "@t3tools/client-runtime/user-message"; import { isLiveSubagentTurnItem } from "@t3tools/client-runtime/state/subagentRuntime"; import * as Equal from "effect/Equal"; -import { collectAssistantCitations } from "@t3tools/shared/assistantCitations"; import { shallow } from "zustand/vanilla/shallow"; import { claudeResumeCompactionAge } from "@t3tools/shared/claudeCompaction"; import { @@ -2699,35 +2698,6 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean } } -const MAX_COLLAPSED_USER_MESSAGE_LINES = 8; -const MAX_COLLAPSED_USER_MESSAGE_LENGTH = 600; -const MARKDOWN_LINK = /!?\[([^\]\n]*)\]\((?:<[^>\n]*>|[^\s)]*)\)/g; - -function visibleUserMessageText(text: string): string { - const linkLabels = (segment: string) => segment.replace(MARKDOWN_LINK, "$1"); - let visible = ""; - let cursor = 0; - for (const match of collectAssistantCitations(text)) { - // A quote shows as a two-line card, counted here as a mark, with its comment under it. - visible += linkLabels(text.slice(cursor, match.start)); - visible += "\n\u275D\n" + (match.citation.comment ?? "") + "\n"; - cursor = match.end; - } - return visible + linkLabels(text.slice(cursor)); -} - -export function shouldCollapseUserMessage(text: string): boolean { - const visible = visibleUserMessageText(text); - if (visible.trim().length === 0) { - return false; - } - - return ( - visible.length > MAX_COLLAPSED_USER_MESSAGE_LENGTH || - visible.split("\n").length > MAX_COLLAPSED_USER_MESSAGE_LINES - ); -} - export function handoffRunsSignature(runs: ReadonlyArray): string { return runs .map( diff --git a/apps/web/src/components/chat/TimelineUserMessageRow.tsx b/apps/web/src/components/chat/TimelineUserMessageRow.tsx index 321b77e4e78bd..21b27f7c64724 100644 --- a/apps/web/src/components/chat/TimelineUserMessageRow.tsx +++ b/apps/web/src/components/chat/TimelineUserMessageRow.tsx @@ -57,7 +57,6 @@ import { MessageCopyButton } from "./MessageCopyButton"; import { promptLinkFor } from "../promptLinks/copyPromptLink"; import { PierreEntryIcon } from "./PierreEntryIcon"; import { formatBytes } from "../../lib/formatBytes"; -import { shouldCollapseUserMessage } from "./MessagesTimeline.logic"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { asKnownContextRecord, @@ -80,6 +79,7 @@ import { markdownProseDirection } from "~/textDirection"; import { useMediaQuery } from "~/hooks/useMediaQuery"; import { formatChatTimestampTooltip, formatDayAwareTimestamp } from "../../timestampFormat"; import { WorkflowOpeningBlock } from "./WorkflowOpeningBlock"; +import { useTallEnoughToFold } from "./useTallEnoughToFold"; import { TimelineRowCtx, TimelineRowActivityCtx, type TimelineRow } from "./timelineRowContext"; import { MESSAGE_HEADING_LEVEL, @@ -1130,8 +1130,9 @@ const CollapsibleUserMessageBody = memo(function CollapsibleUserMessageBody(prop const revealForFind = () => setExpanded(true); const revealRef = findRevealRef(revealForFind); const hasVisibleBody = props.text.trim().length > 0; - const canCollapse = - props.collapsible !== false && hasVisibleBody && shouldCollapseUserMessage(props.text); + const { ref: contentRef, tallEnough: canCollapse } = useTallEnoughToFold( + props.collapsible !== false && hasVisibleBody, + ); const isCollapsed = canCollapse && !expanded; const showCollapseControl = canCollapse; @@ -1156,12 +1157,14 @@ const CollapsibleUserMessageBody = memo(function CollapsibleUserMessageBody(prop : undefined } > - +
+ +
) : null} {showCollapseControl ? ( diff --git a/apps/web/src/components/chat/WorkflowOpeningBlock.tsx b/apps/web/src/components/chat/WorkflowOpeningBlock.tsx index c6be9690e9940..3526bbf931a3a 100644 --- a/apps/web/src/components/chat/WorkflowOpeningBlock.tsx +++ b/apps/web/src/components/chat/WorkflowOpeningBlock.tsx @@ -23,7 +23,7 @@ import { Button } from "../ui/button"; import { Tooltip, TooltipTrigger } from "../ui/tooltip"; import { AgentElapsed } from "./AgentElapsed"; import { findRevealRef } from "./markdownFindContext"; -import { shouldCollapseUserMessage } from "./MessagesTimeline.logic"; +import { useTallEnoughToFold } from "./useTallEnoughToFold"; import { SubagentTooltipContent } from "./SubagentTooltipContent"; import { EnvironmentSubagentAvatar } from "./V2LifecycleRow"; @@ -204,7 +204,7 @@ function WorkflowMemberRow(props: { function WorkflowScript(props: { readonly script: string }) { const [expanded, setExpanded] = useState(false); const revealRef = findRevealRef(() => setExpanded(true)); - const canFold = shouldCollapseUserMessage(props.script); + const { ref: contentRef, tallEnough: canFold } = useTallEnoughToFold(); const folded = canFold && !expanded; return (
@@ -219,7 +219,9 @@ function WorkflowScript(props: { readonly script: string }) { : undefined } > - +
+ +
{canFold ? (
diff --git a/apps/web/src/components/chat/useTallEnoughToFold.ts b/apps/web/src/components/chat/useTallEnoughToFold.ts new file mode 100644 index 0000000000000..1e6c665024b2c --- /dev/null +++ b/apps/web/src/components/chat/useTallEnoughToFold.ts @@ -0,0 +1,28 @@ +import { useLayoutEffect, useRef, useState } from "react"; + +import { observeResize } from "~/lib/observeResize"; + +// The folds clip with `max-h-44`, which is 11rem. +const FOLDED_HEIGHT_REM = 11; + +/** + * Put `ref` on the unclipped content inside a fold. `tallEnough` is true when that content is at + * least twice the folded height, so opening a fold always at least doubles what was on screen. + */ +export function useTallEnoughToFold(enabled = true) { + const ref = useRef(null); + const [tallEnough, setTallEnough] = useState(false); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- the fold depends on the rendered height, which only the DOM knows + useLayoutEffect(() => { + const content = ref.current; + if (!enabled || !content) return; + const measure = () => { + const remPx = Number.parseFloat(getComputedStyle(document.documentElement).fontSize) || 16; + setTallEnough(content.offsetHeight >= FOLDED_HEIGHT_REM * 2 * remPx); + }; + // Measured here too: waiting for the observer would paint one unfolded frame. + measure(); + return observeResize(content, measure); + }, [enabled]); + return { ref, tallEnough: enabled && tallEnough }; +} From 694076dcf34aac70f5d55feb6c15e7b895a5424a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 13:46:08 -0800 Subject: [PATCH 0128/1165] fix(server): show when Stop leaves a Claude steer unconfirmed (#23807) --- .../Adapters/ClaudeAdapterV2.test.ts | 148 ++++++++++++++++++ .../Adapters/ClaudeAdapterV2.ts | 87 ++++++++-- 2 files changed, 223 insertions(+), 12 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index a7655e36a9159..95df58c2bff32 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -4958,6 +4958,154 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + // regression: #15708 Stop must not silently drop a steer Claude queued behind a foreground task + it.effect.each([ + { + readBy: "none", + echoes: true, + title: "warns that a steer Claude never read before Stop was not delivered", + }, + { + readBy: "none", + echoes: false, + title: "does not warn about a steer on a CLI that never echoes prompt uuids", + }, + { + readBy: "assistant", + echoes: true, + title: "does not warn about a steer Claude read before Stop", + }, + ] as const)("$title", ({ readBy, echoes }) => + Effect.gen(function* () { + const harness = yield* makeWakeHarnessWithOptions({ + close: (sdkMessages) => Queue.shutdown(sdkMessages), + freshQueueOnReopen: true, + }); + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const firstInput = makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-claude-unread-steer-1"), + text: "Run the slow curl in the foreground.", + attachments: [], + }); + yield* harness.runtime.startTurn(firstInput); + const providerTurnId = idAllocator.derive.providerTurn({ + driver: ClaudeAdapterV2.CLAUDE_PROVIDER, + nativeTurnId: `turn:${firstInput.attemptId}`, + }); + yield* harness.offerAndWait( + claudeSdkFrame({ + type: "assistant", + uuid: "00000000-0000-4000-8000-000000015701", + session_id: WAKE_NATIVE_SESSION, + parent_tool_use_id: null, + ...(echoes ? { user_message_uuids: [harness.promptUuid(0)] } : {}), + message: { + id: "msg_unread_steer_bash", + type: "message", + role: "assistant", + model: "claude-sonnet-4-6", + content: [ + { + type: "tool_use", + id: "toolu-unread-steer-bash", + name: "Bash", + input: { command: "curl http://127.0.0.1:9/hang" }, + }, + ], + }, + }), + ); + // Past task_started the CLI queues a "now" steer until the call returns. + yield* harness.offerAndWait( + claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: "task-unread-steer-bash", + tool_use_id: "toolu-unread-steer-bash", + description: "Slow curl", + is_backgrounded: false, + task_type: "local_bash", + uuid: "00000000-0000-4000-8000-000000015702", + session_id: WAKE_NATIVE_SESSION, + }), + ); + const steerText = "Codeword: PELICAN. Remember it, I will ask for it later."; + yield* harness.runtime.steerTurn({ + threadId: harness.threadId, + runId: firstInput.runId, + providerThread: harness.providerThread, + providerTurnId, + message: { + createdBy: "user", + creationSource: "web", + messageId: MessageId.make("unread-steer"), + text: steerText, + attachments: [], + }, + }); + const steerUuid = harness.offeredMessages.at(-1)?.uuid; + assert.isDefined(steerUuid); + if (readBy === "assistant") { + yield* harness.offerAndWait( + claudeSdkFrame({ + type: "assistant", + uuid: "00000000-0000-4000-8000-000000015703", + session_id: WAKE_NATIVE_SESSION, + parent_tool_use_id: null, + user_message_uuids: [steerUuid], + message: { + id: "msg_unread_steer_reply", + type: "message", + role: "assistant", + model: "claude-sonnet-4-6", + content: [{ type: "text", text: "Noted." }], + }, + }), + ); + } + const noticesBefore = harness.events.length; + yield* harness.runtime.interruptTurn({ + providerThread: harness.providerThread, + providerTurnId, + }); + assert.equal((yield* Queue.take(harness.terminalReceipts)).status, "interrupted"); + + const notices = harness.events + .slice(noticesBefore) + .flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.type === "system_notice" + ? [event.turnItem.message] + : [], + ); + const undelivered = notices.find((message) => + /not (have )?(been )?delivered|never (reached|delivered)/i.test(message ?? ""), + ); + if (readBy !== "none" || !echoes) { + assert.isUndefined(undelivered); + return; + } + assert.isDefined( + undelivered, + `expected an undelivered-steer notice, got ${JSON.stringify(notices)}`, + ); + assert.match(undelivered ?? "", /resend|send it again|re-send/i); + assert.include(undelivered, "PELICAN"); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + // regression: #23948 Claude holds a steer behind a running foreground tool // call and goes silent, so each steer stays pending until Claude starts it. it.effect("keeps a steer pending until Claude starts it", () => diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index f29ed3cb65e51..378e399e2532b 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -4453,6 +4453,9 @@ interface ActiveClaudeTurnContext { compactionFailure: { readonly message: string | undefined } | null; // Steers end segments whose usage is excluded from the final result; bill them too, oldest first. readonly steeredSegmentUsage: Array; + // Set as finalization starts: a steer admitted later would stay pending + // forever, so it fails and the orchestrator follows up instead. + steersClosed: boolean; // Ledger growth of the steered segments; undefined once any segment's growth is unknown. steeredLedgerUsage: ClaudeLedgerUsage | undefined; readonly rootModels: Set; @@ -4465,8 +4468,16 @@ interface ActiveClaudeTurnContext { gatedFramesBeforeEcho: number; readonly heldRootFrames: Array; // Steers Claude has not started or echoed yet, by prompt uuid. Claude holds - // a steer behind a running foreground tool call without saying so. - readonly pendingSteers: Map; + // a steer behind a running foreground tool call without saying so, and Stop + // drops it unread. + readonly pendingSteers: Map< + string, + { + readonly messageId: MessageId; + readonly text: string; + readonly liveQuery: ClaudeLiveQueryContext; + } + >; // Claude may settle a turn with a result that echoes only a steer's uuid. readonly steerUuids: Set; // Claude can run another model than requested, such as when an organization disallows it. @@ -8124,10 +8135,10 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ? [lifecycle.uuid] : []; for (const uuid of accepted) { - const messageId = context.pendingSteers.get(uuid); - if (messageId === undefined) continue; + const steer = context.pendingSteers.get(uuid); + if (steer === undefined) continue; context.pendingSteers.delete(uuid); - yield* emitSteerStatus(context, messageId, "completed"); + yield* emitSteerStatus(context, steer.messageId, "completed"); } }); @@ -8142,16 +8153,18 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( readonly threadDisposition?: "reusable" | "broken"; readonly result?: SDKResultMessage; }) { + input.context.steersClosed = true; yield* reasoningDeltas.flushTurn(input.context.nativeTurnId); // A turn that completes has taken in every steer; one that stops drops them unread. - for (const messageId of input.context.pendingSteers.values()) { + const droppedSteers = [...input.context.pendingSteers]; + input.context.pendingSteers.clear(); + for (const [, { messageId }] of droppedSteers) { yield* emitSteerStatus( input.context, messageId, input.status === "completed" ? "completed" : "cancelled", ); } - input.context.pendingSteers.clear(); if (input.context.compaction?.status === "running") { const reason = input.result?.subtype === "success" ? input.result.result.trim() : ""; yield* emitProviderEvent({ @@ -8289,6 +8302,49 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }); } + if (input.status === "interrupted") { + for (const [uuid, { text, liveQuery }] of droppedSteers) { + // Only a CLI proven to echo prompt uuids can leave a steer unconfirmed. + if ( + liveQuery.promptEchoMode !== "early" && + liveQuery.promptEchoMode !== "result_only" + ) { + continue; + } + const nativeItemId = `unread-steer:${uuid}`; + const notice = `Claude did not confirm reading your steer before Stop. It may not have been delivered. Resend it if needed: "${text}"`; + yield* emitProviderEvent({ + type: "turn_item.updated", + driver: CLAUDE_PROVIDER, + turnItem: { + id: idAllocator.derive.turnItemFromProviderItem({ + driver: CLAUDE_PROVIDER, + nativeItemId, + }), + threadId: input.context.input.threadId, + runId: input.context.input.runId, + nodeId: input.context.input.rootNodeId, + providerThreadId: input.context.input.providerThread.id, + providerTurnId: input.context.providerTurnId, + nativeItemRef: { + driver: CLAUDE_PROVIDER, + nativeId: nativeItemId, + strength: "weak", + }, + parentItemId: null, + ordinal: yield* resolveItemOrdinal(input.context, nativeItemId), + type: "system_notice", + status: "completed", + title: "Steer delivery not confirmed", + message: notice, + startedAt: input.completedAt, + completedAt: input.completedAt, + updatedAt: input.completedAt, + }, + }); + } + } + const threadDisposition = input.threadDisposition ?? "reusable"; const terminalEvent: ProviderAdapter.ProviderAdapterV2Event = input.status === "failed" @@ -11538,6 +11594,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( latestAssistantRateLimit: null, compactionFailure: null, steeredSegmentUsage: [], + steersClosed: false, steeredLedgerUsage: { inputTokens: 0, outputTokens: 0 }, rootModels: new Set(), subagentsByTaskId: new Map(), @@ -11978,22 +12035,28 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( // the steer fails and the orchestrator starts the follow-up turn. if ( (yield* Ref.get(activeTurn))?.providerTurnId !== turnInput.providerTurnId || - (yield* Ref.get(queryContext)) !== existing + (yield* Ref.get(queryContext)) !== existing || + // No yield between this check and the set below. + currentTurn.steersClosed ) { return yield* new ProviderAdapter.ProviderAdapterProtocolError({ driver: CLAUDE_PROVIDER, detail: `Claude provider turn ${turnInput.providerTurnId} ended before the steer was sent.`, }); } + // Register before offering: Claude can start the steer and end the turn before offer returns. + const messageId = turnInput.message.messageId; + currentTurn.steerUuids.add(steerUuid); + currentTurn.pendingSteers.set(steerUuid, { + messageId, + text: turnInput.message.text, + liveQuery: existing, + }); yield* Ref.update(steeredTurns, (current) => { const next = new Set(current); next.add(turnInput.providerTurnId); return next; }); - // Register before offering: Claude can start the steer and end the turn before offer returns. - const messageId = turnInput.message.messageId; - currentTurn.steerUuids.add(steerUuid); - currentTurn.pendingSteers.set(steerUuid, messageId); yield* emitSteerStatus(currentTurn, messageId, "pending"); yield* Effect.uninterruptibleMask((restore) => restore( From fcc25c8bb283ae89a2c852b6760fce15d78b1dac Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 13:54:53 -0800 Subject: [PATCH 0129/1165] ci: split web tests over two runners so every run finishes sooner (#34554) Co-authored-by: Claude Opus 5.5 --- .github/workflows/ci.yml | 30 +- .github/workflows/release.yml | 9 +- .../AttachmentFilePreview.browser.test.tsx | 1043 +--------------- ...achmentFilePreview.office.browser.test.tsx | 1086 +++++++++++++++++ 4 files changed, 1121 insertions(+), 1047 deletions(-) create mode 100644 apps/web/src/components/files/AttachmentFilePreview.office.browser.test.tsx diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f5770c1f4516b..ba1e1e612e6d3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -355,16 +355,22 @@ jobs: # The web suite is the largest outside the server. In the shared Test job it # started last and ran alone for about 40s after everything else finished. - # release.yml mirrors this test, test_web, and test_server split. + # Browser tests are bound by the Vite server transforming modules for each + # file, not by Chromium, so a second runner (and Vite server) halves the job + # that gated every run. release.yml mirrors this test, test_web, and test_server split. test_web: permissions: contents: read id-token: write - name: Test Web + name: Test Web ${{ matrix.shard }} needs: changes if: ${{ !cancelled() && needs.changes.outputs.test_web != 'false' }} runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + shard: [1, 2] steps: - name: Checkout uses: actions/checkout@v6 @@ -406,7 +412,7 @@ jobs: fi - name: Test - run: vp run --filter @t3tools/web test + run: vp run --filter @t3tools/web test --shard ${{ matrix.shard }}/${{ strategy.job-total }} # apps/server sets `fileParallelism: false`, so its test files run strictly # one at a time. Sharding spreads them over separate runners instead of @@ -481,6 +487,17 @@ jobs: node apps/server/node_modules/playwright-core/cli.js install-deps chromium fi + # Each server test file runs in its own Vitest process, and a fresh install starts + # Vitest's content-keyed module cache empty, so every file re-transformed its imports. + # Only pushes to main save it, so pull requests never evict each other's entries. + - name: Restore Vitest module cache + if: steps.changes.outputs.test_server != 'false' + uses: actions/cache/restore@v6 + with: + path: node_modules/.vitest-cache + key: vitest-modules-server-${{ matrix.shard }}-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ github.sha }} + restore-keys: vitest-modules-server-${{ matrix.shard }}-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}- + - name: Test if: steps.changes.outputs.test_server != 'false' env: @@ -491,6 +508,13 @@ jobs: --uncached apps/server/integration/transferBudgetV2.integration.test.ts --verify-hits 1 apps/server + - name: Save Vitest module cache + if: github.event_name == 'push' && steps.changes.outputs.test_server != 'false' + uses: actions/cache/save@v6 + with: + path: node_modules/.vitest-cache + key: vitest-modules-server-${{ matrix.shard }}-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ github.sha }} + # integration/transferBudgetV2.integration.test.ts writes the report; exactly one shard # produces these files. Gating the upload on their presence keeps a # single `thread-transfer-results` artifact per run, which is the name diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 84acc7c74f865..8827876b3829f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -300,11 +300,15 @@ jobs: # Web browser tests time out when they share a runner with the other package # suites: Chromium gets too little CPU and diff workers miss their 10s start budget. test_web: - name: Release web tests + name: Release web tests ${{ matrix.shard }} needs: [preflight] if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' }} runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + shard: [1, 2] steps: - name: Checkout uses: actions/checkout@v6 @@ -344,10 +348,11 @@ jobs: fi - name: Test - run: vp run --filter @t3tools/web test + run: vp run --filter @t3tools/web test --shard ${{ matrix.shard }}/${{ strategy.job-total }} # Browser tests run source modules; this boots the production chunk graph. - name: Boot the built web client + if: matrix.shard == 1 run: | vp run --filter @t3tools/web build node apps/web/scripts/verify-built-boot.ts diff --git a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx index 4ecc965f6fe32..285032b5b6a0a 100644 --- a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx +++ b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx @@ -78,6 +78,7 @@ import { decodeImagePreview } from "./decodeImagePreview"; import type { BinaryWorkerResult } from "./binaryPreviewProtocol"; import type { TextPreviewResult } from "./largeTextProtocol"; import type { ReactElement } from "react"; +import type { OfficeWorkerResult } from "./officePreviewProtocol"; // Real worker imports and WASM initialization can exceed waitFor's default second // when the full browser project runs concurrently on CI. @@ -481,8 +482,6 @@ describe("bounded binary previews", () => { } }); }); -import { OFFICE_PREVIEW_MAX_COLUMNS, type OfficeWorkerResult } from "./officePreviewProtocol"; -import { WORD_LIMITS } from "./officeArchive"; describe("large text preview recovery", () => { it("retains the workspace viewer across URL renewals and reloads revisions and manual refreshes", async () => { @@ -921,1046 +920,6 @@ describe("large text preview recovery", () => { await vi.waitFor(() => expect(load).toHaveBeenCalledWith(0)); }); }); -it.each(["", ""])( - "opens an empty Word document with %s", - async (body) => { - const { default: JSZip } = await import("jszip"); - const { prepareOffice } = await import("./officeArchive"); - const archive = new JSZip().file( - "word/document.xml", - `${body}`, - ); - const prepared = await prepareOffice( - await archive.generateAsync({ type: "arraybuffer" }), - "docx", - ); - const result = await JSZip.loadAsync(prepared.bytes); - const xml = new DOMParser().parseFromString( - await result.file("word/document.xml")!.async("string"), - "application/xml", - ); - expect(xml.querySelector("parsererror")).toBeNull(); - expect( - xml.getElementsByTagNameNS( - "http://schemas.openxmlformats.org/wordprocessingml/2006/main", - "body", - ), - ).toHaveLength(1); - }, -); - -it.each([false, true])( - "rejects excessive archive entries before JSZip (directories: %s)", - async (directories) => { - const { default: JSZip } = await import("jszip"); - const { prepareOffice } = await import("./officeArchive"); - const archive = new JSZip(); - for (let i = 0; i < 4096; i++) - archive.file(`entry-${i}${directories ? "/" : ".txt"}`, "", { dir: directories }); - const bytes = await archive.generateAsync({ type: "arraybuffer" }); - const load = vi.spyOn(JSZip, "loadAsync"); - try { - await expect(prepareOffice(bytes, "docx")).rejects.toThrow("2,048 entries"); - expect(load).not.toHaveBeenCalled(); - // The actual central-directory records must also be bounded if EOCD lies. - const view = new DataView(bytes); - view.setUint16(bytes.byteLength - 22 + 8, 1, true); - view.setUint16(bytes.byteLength - 22 + 10, 1, true); - await expect(prepareOffice(bytes, "docx")).rejects.toThrow("2,048 entries"); - expect(load).not.toHaveBeenCalled(); - } finally { - load.mockRestore(); - } - }, -); - -it("rejects oversized embedded images before decoding pixels and retains ordinary images", async () => { - const { officeImageBitmap } = await import("./office/image-bitmap"); - const canvas = new OffscreenCanvas(2, 3); - canvas.getContext("2d"); - const blob = await canvas.convertToBlob({ type: "image/png" }); - const bytes = await blob.arrayBuffer(); - new DataView(bytes).setUint32(16, 12000); - new DataView(bytes).setUint32(20, 12000); - const decode = vi.spyOn(globalThis, "createImageBitmap"); - try { - await expect(officeImageBitmap(new Blob([bytes]))).rejects.toThrow("pixel limit"); - expect(decode).not.toHaveBeenCalled(); - const bitmap = await officeImageBitmap(blob); - expect([bitmap.width, bitmap.height]).toEqual([2, 3]); - bitmap.close(); - } finally { - decode.mockRestore(); - } -}); - -it.each(["utf-16le", "utf-16be", "windows-1252"])( - "preserves %s Office XML and safe hyperlink targets", - async (encoding) => { - const { default: JSZip } = await import("jszip"); - const { prepareOffice } = await import("./officeArchive"); - const archive = new JSZip(); - const encode = (text: string) => { - const xml = `${text}`; - if (encoding === "windows-1252") return Uint8Array.from(xml, (char) => char.charCodeAt(0)); - const bytes = new Uint8Array(2 + xml.length * 2); - const view = new DataView(bytes.buffer); - const little = encoding === "utf-16le"; - view.setUint16(0, 0xfeff, little); - for (let i = 0; i < xml.length; i++) view.setUint16(2 + i * 2, xml.charCodeAt(i), little); - return bytes; - }; - archive.file( - "word/document.xml", - encode( - 'café', - ), - ); - archive.file( - "word/_rels/document.xml.rels", - encode( - `${["https://example.com/page", "http://example.com", "mailto:maria@example.com", "javascript:alert(1)", "file:///tmp/private"].map((target, i) => ``).join("")}`, - ), - ); - const prepared = await prepareOffice( - await archive.generateAsync({ type: "arraybuffer" }), - "docx", - ); - const result = await JSZip.loadAsync(prepared.bytes); - expect(await result.file("word/document.xml")!.async("string")).toContain("café"); - const links = await result.file("word/_rels/document.xml.rels")!.async("string"); - expect(links).toContain('encoding="UTF-8"'); - expect(links).toContain("https://example.com/page"); - expect(links).toContain("http://example.com"); - expect(links).toContain("mailto:maria@example.com"); - expect(links).not.toContain("javascript:"); - expect(links).not.toContain("file:"); - }, -); - -it.each([ - ["", "worksheets/./sheet1.xml", false, false], - ["x:", "../xl/worksheets/sheet1.xml", false, false], - ["", "/xl/worksheets/sheet1.xml", true, false], - ["", "worksheets/sheet1.xml", false, true], - ["", "worksheets/sheet1.xml", true, true], -])( - "preserves %sworksheet metadata with relationship target %s", - async (prefix, target, empty, localPrefix) => { - const { default: JSZip } = await import("jszip"); - const archive = new JSZip(); - const ns = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"; - archive.file( - "xl/workbook.xml", - '', - ); - archive.file( - "xl/_rels/workbook.xml.rels", - ``, - ); - archive.file( - "xl/worksheets/sheet1.xml", - `<${prefix}worksheet xmlns${prefix ? ":x" : ""}="${ns}"><${prefix}dimension ref="XFD1048576"/><${prefix}sheetViews><${prefix}sheetView showGridLines="0"/><${prefix}sheetFormatPr defaultColWidth="30"/><${prefix}cols><${prefix}col min="${OFFICE_PREVIEW_MAX_COLUMNS - 1}" max="${OFFICE_PREVIEW_MAX_COLUMNS + 10}" width="20"/><${prefix}col min="${OFFICE_PREVIEW_MAX_COLUMNS + 1}" max="${OFFICE_PREVIEW_MAX_COLUMNS + 30}" hidden="1"/><${prefix}sheetData><${prefix}row ht="42" customHeight="1" hidden="1"><${prefix}c r="A50" t="inlineStr"><${prefix}is><${prefix}t>${"value".repeat(1000)}<${prefix}mergeCells><${prefix}mergeCell ref="XFD1048576"/>` - .replace( - /<((?:x:)?(?:col|dimension|mergeCell))\b([^>]*)\/>/g, - prefix ? "<$1$2>" : "$&", - ) - .replace(/[\s\S]*?<\/sheetData>/, (data) => (empty ? "" : data)) - .replace(/<(\/?)sheetData\b/g, (tag, closing: string) => - localPrefix ? `<${closing}x:sheetData${closing ? "" : ` xmlns:x="${ns}"`}` : tag, - ) - .replace(/="([^"]*)"/g, (attribute, value: string) => - prefix || empty - ? ` = '${value.replace(/\d/g, (digit) => `&#${digit.charCodeAt(0)};`)}'` - : attribute, - ), - ); - const bytes = await archive.generateAsync({ type: "arraybuffer" }); - const worker = new Worker(new URL("./officePreview.worker.ts", import.meta.url), { - type: "module", - }); - try { - const result = await new Promise((resolve) => { - worker.addEventListener( - "message", - (event: MessageEvent) => resolve(event.data), - { once: true }, - ); - worker.postMessage({ type: "open", kind: "xlsx", bytes }, [bytes]); - }); - expect(result.type).toBe("xlsx"); - if (result.type !== "xlsx") throw new Error("Expected bounded workbook"); - // The engine counts the chart sheet before it. - expect(result.view?.hiddenGridlines).toEqual([1]); - const bounded = await JSZip.loadAsync(result.bytes); - const sheet = new DOMParser().parseFromString( - await bounded.file("xl/worksheets/sheet1.xml")!.async("string"), - "application/xml", - ); - expect(sheet.querySelector("parsererror")).toBeNull(); - const row = sheet.getElementsByTagNameNS(ns, "row")[0]; - if (empty) expect(row).toBeUndefined(); - else { - expect(row!.getAttribute("ht")).toBe("42"); - expect(row!.getAttribute("hidden")).toBe("1"); - expect(row!.getElementsByTagNameNS(ns, "c")[0]!.getAttribute("r")).toBe("A50"); - expect(row!.getElementsByTagNameNS(ns, "t")[0]!.textContent).toHaveLength(4096); - } - expect(sheet.getElementsByTagNameNS(ns, "dimension")).toHaveLength(0); - expect(sheet.getElementsByTagNameNS(ns, "mergeCell")).toHaveLength(0); - const columns = [...sheet.getElementsByTagNameNS(ns, "col")].map((column) => - ["min", "max", "width"].map((name) => column.getAttribute(name)), - ); - // The engine ignores `defaultColWidth`, so the columns it sizes are spelled out. - expect(columns).toEqual([ - [String(OFFICE_PREVIEW_MAX_COLUMNS - 1), String(OFFICE_PREVIEW_MAX_COLUMNS), "20"], - // Hidden columns keep their span past the limit, so they stay hidden. - [String(OFFICE_PREVIEW_MAX_COLUMNS + 1), String(OFFICE_PREVIEW_MAX_COLUMNS + 30), null], - ["1", String(OFFICE_PREVIEW_MAX_COLUMNS - 2), "30"], - ]); - } finally { - worker.terminate(); - } - }, -); - -it("reports the final slide at maximum scroll after page visibility changes", async () => { - const { OfficePages } = await import("./office/OfficePages"); - const changed = vi.fn(); - const container = document.createElement("div"); - container.style.cssText = "display:flex;flex-direction:column;width:400px;height:400px"; - await using screen = await render( - ({ width: 400, height: 240 }))} - label="Slide" - paint={async () => undefined} - onPageChange={changed} - onError={(error) => { - throw error; - }} - />, - { container }, - ); - const scroller = screen.container.querySelector("[data-office-pages]")!; - await vi.waitFor(() => expect(scroller.scrollHeight).toBeGreaterThan(scroller.clientHeight)); - await act(() => { - scroller.scrollTop = scroller.scrollHeight; - }); - await vi.waitFor(() => expect(changed).toHaveBeenLastCalledWith(5)); - await act(() => { - scroller.scrollTop = 10; - }); - await vi.waitFor(() => expect(changed).toHaveBeenLastCalledWith(0)); -}); - -it("preserves merged text across tile clears", async () => { - const { paintFrame } = await import("./office/sheet-frame"); - const canvas = new OffscreenCanvas(400, 80); - const context = canvas.getContext("2d")!; - paintFrame( - context, - { - viewport: { x: 0, y: 0, width: 400, height: 80 }, - list: { width: 400, height: 80, commands: [] }, - tiles: [ - { - x: 0, - y: 0, - clipX: 0, - clipY: 0, - list: { - width: 208, - height: 80, - commands: [ - { - op: "text", - x: 2, - y: 40, - text: "MMMMMMMMMMMMMMMMMMMMMMMM", - fontSize: 20, - color: "#000", - clip: { x: 0, y: 0, w: 400, h: 80 }, - }, - ], - }, - }, - { x: 192, y: 0, clipX: 200, clipY: 0, list: { width: 208, height: 80, commands: [] } }, - ], - }, - { ratio: 1, origin: { x: 0, y: 0 } }, - ); - const pixels = context.getImageData(240, 15, 100, 35).data; - expect(pixels.some((value, index) => index % 4 === 3 && value > 0)).toBe(true); -}); - -it.each([ - "paragraphs", - "runs", - "tables", - "auxiliary XML", - "XML bytes", - "renamed XML", - "section after prefix", - "oversized section", -])("bounds excessive Word %s before rendering DOM", async (scenario) => { - const { default: JSZip } = await import("jszip"); - const archive = new JSZip(); - const body = ["paragraphs", "section after prefix", "oversized section"].includes(scenario) - ? "x".repeat(200_000) - : scenario === "runs" - ? `${"x".repeat(50_001)}` - : scenario === "tables" - ? "".repeat(2 * WORD_LIMITS.bodyElements) - : scenario === "XML bytes" - ? `${"x".repeat(2 * 1024 * 1024)}` - : ""; - const section = - ''; - archive.file( - "word/document.xml", - `${body}${scenario === "section after prefix" ? '' : ""}${scenario === "oversized section" ? `${"".repeat(2_001)}` : section}`, - ); - if (scenario === "auxiliary XML" || scenario === "renamed XML") - archive.file( - scenario === "renamed XML" ? "word/footnotes.dat" : "word/footnotes.xml", - `${"".repeat(WORD_LIMITS.auxiliaryMarkup + 1)}`, - ); - const file = await archive.generateAsync({ type: "blob", compression: "DEFLATE" }); - const src = URL.createObjectURL(file); - try { - const worker = new Worker(new URL("./officePreview.worker.ts", import.meta.url), { - type: "module", - }); - try { - const result = await new Promise((resolve) => { - worker.addEventListener( - "message", - (event: MessageEvent) => resolve(event.data), - { once: true }, - ); - void file - .arrayBuffer() - .then((bytes) => worker.postMessage({ type: "open", kind: "docx", bytes }, [bytes])); - }); - if ( - scenario === "auxiliary XML" || - scenario === "renamed XML" || - scenario === "oversized section" - ) { - expect(result).toMatchObject({ - type: "error", - message: expect.stringContaining("Preview limited"), - }); - } else { - expect(result.type).toBe("docx"); - if (result.type !== "docx") throw new Error("Expected a Word prefix"); - expect(result.truncated).toBe(true); - const bounded = await JSZip.loadAsync(result.bytes); - const markup = await bounded.file("word/document.xml")!.async("string"); - { - const xml = markup; - const document = new DOMParser().parseFromString(xml, "application/xml"); - expect(document.querySelector("parsererror")).toBeNull(); - const bodies = document.getElementsByTagNameNS("*", "body"); - expect(bodies).toHaveLength(1); - const section = bodies[0]!.lastElementChild!; - expect(section.localName).toBe("sectPr"); - const size = section.getElementsByTagNameNS("*", "pgSz")[0]!; - expect(size.getAttributeNS(section.namespaceURI, "w")).toBe( - scenario === "section after prefix" ? "11906" : "16838", - ); - expect(size.getAttributeNS(section.namespaceURI, "orient")).toBe( - scenario === "section after prefix" ? null : "landscape", - ); - } - expect(markup.length).toBeLessThan(body.length); - expect(markup.length).toBeLessThanOrEqual(WORD_LIMITS.bodyBytes); - expect(markup.match(/<(?![!?/])/g)!.length).toBeLessThanOrEqual(WORD_LIMITS.bodyTags); - } - } finally { - worker.terminate(); - } - } finally { - URL.revokeObjectURL(src); - } -}); - -it.each([ - "relationship markup", - "relationship bytes", - "content type", - "metadata type", - "deep XML", - "query path", -])("bounds XML hidden in media by %s", async (scenario) => { - const { default: JSZip } = await import("jszip"); - const archive = new JSZip(); - const path = `word/media/payload.png${scenario === "query path" ? "?xml" : ""}`; - archive.file( - "word/document.xml", - '', - ); - archive.file( - "[Content_Types].xml", - `${scenario === "metadata type" ? '' : ""}`, - ); - archive.file( - "word/_rels/document.xml.rels", - `${scenario === "content type" ? "" : ``}${scenario === "metadata type" ? "".repeat(WORD_LIMITS.auxiliaryMarkup + 1) : ""}`, - ); - archive.file( - path, - scenario === "relationship bytes" - ? `${"x".repeat(WORD_LIMITS.auxiliaryPartBytes)}` - : scenario === "deep XML" - ? `${"".repeat(65)}${"".repeat(65)}` - : `${" + +
+
+ `); + await page.getByRole("button", { name: "Open" }).click(); + const frame = await ServerBrowserPage.captureFinalRecordingFrame( + await context.newCDPSession(page), + 80, + ); + expect(frame).toBeDefined(); + const pixel = (await page.evaluate(`(async () => { + const bytes = Uint8Array.from(atob(${JSON.stringify(frame)}), char => char.charCodeAt(0)); + const bitmap = await createImageBitmap(new Blob([bytes], { type: "image/jpeg" })); + const canvas = document.createElement("canvas"); + canvas.width = bitmap.width; + canvas.height = bitmap.height; + const context = canvas.getContext("2d"); + context.drawImage(bitmap, 0, 0); + return Array.from(context.getImageData(bitmap.width / 2, bitmap.height / 2, 1, 1).data); + })()`)) as [number, number, number, number]; + expect(colorOf(pixel)).toBe("red"); + }); + it("shrinks the screenshot of an XML document, whose createElement makes no canvas", async () => { const page = await context.newPage(); await page.goto("data:text/xml,
"); diff --git a/apps/server/src/preview/ServerBrowserPage.ts b/apps/server/src/preview/ServerBrowserPage.ts index 9b0f782d3d4ab..29ba05c21b7ea 100644 --- a/apps/server/src/preview/ServerBrowserPage.ts +++ b/apps/server/src/preview/ServerBrowserPage.ts @@ -475,6 +475,24 @@ export const captureViewport = async ( return data; }; +export const captureFinalRecordingFrame = (cdp: CDPSession, quality: number) => + answered( + cdp + .send("Runtime.evaluate", { + // Capture the destination of finite transitions, not their initially transparent frame. + expression: `(async () => { + const paint = () => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); + await paint(); + await Promise.allSettled(document.getAnimations() + .filter(animation => animation.playState === "running" && animation.effect?.getComputedTiming().iterations !== Infinity) + .map(animation => animation.finished)); + await paint(); + })()`, + awaitPromise: true, + }) + .then(() => captureViewport(cdp, { format: "jpeg", quality })), + ); + /** Runs in the page's own isolated world, as the server has no image library. */ const SHRINK_PNG = `async function (png, width, height) { const bytes = Uint8Array.from(atob(png), (char) => char.charCodeAt(0)); From fe5b51e206997c765167f3e8f3f3c25cfd8897ea Mon Sep 17 00:00:00 2001 From: Nik <64274427+eyeveil@users.noreply.github.com> Date: Thu, 12 Nov 2026 00:17:02 +0100 Subject: [PATCH 0190/1165] fix(server): an interrupted provider update ends failed instead of running forever (#33975) --- .../providerMaintenanceRunner.test.ts | 60 +++++++++++++++++++ .../src/provider/providerMaintenanceRunner.ts | 25 +++++++- 2 files changed, 83 insertions(+), 2 deletions(-) diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index 8726ba3d08eec..057f5b0839d7d 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -893,6 +893,66 @@ describe("providerMaintenanceRunner", () => { ); }); + it.effect("an interrupted update ends failed instead of running forever", () => { + const started = Deferred.makeUnsafe(); + return Effect.gen(function* () { + const { registry, updateStatesRef } = yield* makeRegistry(baseNativeCliProvider); + const updater = yield* makeTestRunner(registry); + const update = yield* updater.updateProvider(NATIVE_CLI_DRIVER).pipe(Effect.forkScoped); + yield* Deferred.await(started); + yield* Fiber.interrupt(update); + const last = (yield* Ref.get(updateStatesRef)).at(-1); + assert.deepStrictEqual( + [last?.status, last?.message], + ["failed", "Update stopped before it finished."], + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + layerNonWindowsPlatform, + layerLatestVersionHttpClient("1.0.0"), + layerMockSpawner(() => ({ + exitCode: Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)), + })), + ), + ), + ); + }); + + it.effect("a request dropped while the final state is written still records it", () => { + const writing = Deferred.makeUnsafe(); + const release = Deferred.makeUnsafe(); + return Effect.gen(function* () { + const { registry, updateStatesRef } = yield* makeRegistry(baseNativeCliProvider); + const updater = yield* makeTestRunner({ + ...registry, + setProviderMaintenanceActionState: (input) => + input.state?.status === "succeeded" + ? Deferred.succeed(writing, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.andThen(registry.setProviderMaintenanceActionState(input)), + ) + : registry.setProviderMaintenanceActionState(input), + }); + + const update = yield* updater.updateProvider(NATIVE_CLI_DRIVER).pipe(Effect.forkScoped); + yield* Deferred.await(writing); + update.interruptUnsafe(); + yield* Deferred.succeed(release, undefined); + yield* Fiber.await(update); + + assert.strictEqual((yield* Ref.get(updateStatesRef)).at(-1)?.status, "succeeded"); + }).pipe( + Effect.provide( + Layer.mergeAll( + layerNonWindowsPlatform, + layerLatestVersionHttpClient("1.0.0"), + layerMockSpawner(() => ({ stdout: "updated" })), + ), + ), + ); + }); + it.effect("prevents concurrent updates for the same provider", () => { const startedLatch: { resolve: () => void } = { resolve: () => {} }; const releaseLatch: { resolve: () => void } = { resolve: () => {} }; diff --git a/apps/server/src/provider/providerMaintenanceRunner.ts b/apps/server/src/provider/providerMaintenanceRunner.ts index d13825d15a5cf..99e2eb8ddab34 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.ts @@ -419,6 +419,8 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { }); } + const startedAtRef = yield* Ref.make(null); + const settled = yield* Ref.make(false); const setUpdateState = (state: ServerProviderUpdateState | null) => providerRegistry.setProviderMaintenanceActionState({ instanceId, @@ -436,9 +438,13 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { const runProviderUpdate = Effect.fn("ProviderMaintenanceRunner.runProviderUpdate")( function* () { + // Uninterruptible, so a dropped request cannot leave settled set without the state. const finish = (state: ServerProviderUpdateState) => - setUpdateState(state).pipe(Effect.map((providers) => ({ providers }))); - const startedAtRef = yield* Ref.make(null); + Ref.set(settled, true).pipe( + Effect.andThen(setUpdateState(state)), + Effect.map((providers) => ({ providers })), + Effect.uninterruptible, + ); const runCommandAndVerify = Effect.fn("ProviderMaintenanceRunner.runCommandAndVerify")( function* () { @@ -655,6 +661,21 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { run: runProviderUpdate(), }) .pipe( + // A dropped request interrupts the update; a "running" state would spin until restart. + Effect.onInterrupt(() => + Effect.gen(function* () { + // A finished state stays, even if the request drops while it is being written. + if (yield* Ref.get(settled)) return; + yield* setUpdateState( + makeUpdateState({ + status: "failed", + startedAt: yield* Ref.get(startedAtRef), + finishedAt: yield* nowIso, + message: "Update stopped before it finished.", + }), + ); + }).pipe(Effect.ignore), + ), Effect.mapError((error) => isServerProviderUpdateError(error) ? new ServerProviderUpdateError({ From ddbc525f1c8667ecb42500b3be2254df5b5bed4a Mon Sep 17 00:00:00 2001 From: Etan Heyman <93338119+EtanHey@users.noreply.github.com> Date: Thu, 12 Nov 2026 01:17:21 +0200 Subject: [PATCH 0191/1165] fix(speech): removing filler words no longer joins paragraphs (#34101) Co-authored-by: GPT-6.1 Sol --- apps/server/src/speech/SpeechService.test.ts | 18 ++++++++++++++++++ apps/server/src/speech/fillerWords.ts | 5 ++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/apps/server/src/speech/SpeechService.test.ts b/apps/server/src/speech/SpeechService.test.ts index 8d980f2846271..c47174e46f1cb 100644 --- a/apps/server/src/speech/SpeechService.test.ts +++ b/apps/server/src/speech/SpeechService.test.ts @@ -533,6 +533,24 @@ it.effect("unloads after the chosen idle period and reloads on the next recordin ); it.effect.each([ + { + cue: undefined, + remove: true, + text: "First paragraph.\n\nSecond paragraph.", + expected: "First paragraph.\n\nSecond paragraph.", + }, + { + cue: undefined, + remove: true, + text: "First um\n\nuh Second.", + expected: "First\n\nSecond.", + }, + { + cue: undefined, + remove: true, + text: "First um\r\n\r\nuh Second.", + expected: "First\r\n\r\nSecond.", + }, { cue: "um", remove: true, text: "Make it red, um, blue.", expected: "Make it red, um, blue." }, { cue: "uh no", diff --git a/apps/server/src/speech/fillerWords.ts b/apps/server/src/speech/fillerWords.ts index 5061341833c06..e939e275fbc0e 100644 --- a/apps/server/src/speech/fillerWords.ts +++ b/apps/server/src/speech/fillerWords.ts @@ -38,5 +38,8 @@ export function removeSpeechFillerWords( "giu", ); const filtered = text.replace(pattern, (_match, cue: string | undefined) => cue ?? ""); - return filtered.replace(/\s{2,}/g, " ").trim(); + return filtered + .replace(/[^\S\r\n]{2,}/g, " ") + .replace(/[^\S\r\n]*([\r\n])[^\S\r\n]*/g, "$1") + .trim(); } From 86c379d07ad8d94ec2e1742e29e3d3201315e272 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 15:17:33 -0800 Subject: [PATCH 0192/1165] fix(server): drop a finished child's queued question wake (#25680) --- .../DelegatedTaskQuestionReactor.ts | 88 +++++-- .../Orchestrator.control-reads.test.ts | 228 ++++++++++++++++++ .../src/orchestration-v2/Orchestrator.ts | 94 ++++++++ 3 files changed, 396 insertions(+), 14 deletions(-) diff --git a/apps/server/src/orchestration-v2/DelegatedTaskQuestionReactor.ts b/apps/server/src/orchestration-v2/DelegatedTaskQuestionReactor.ts index f6eeb9a00ede3..9fd17580e333e 100644 --- a/apps/server/src/orchestration-v2/DelegatedTaskQuestionReactor.ts +++ b/apps/server/src/orchestration-v2/DelegatedTaskQuestionReactor.ts @@ -4,6 +4,8 @@ import { type OrchestrationV2DomainEvent, type OrchestrationV2RuntimeRequest, type OrchestrationV2Subagent, + type RuntimeRequestId, + type ThreadId, } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -58,6 +60,37 @@ const make = Effect.gen(function* () { }); }); + const cancelQueuedWakes = Effect.fnUntraced(function* ( + parentThreadId: ThreadId, + requestIds: ReadonlyArray, + ) { + const parent = yield* projections.getThreadRecords(parentThreadId, ["runs"]); + for (const requestId of requestIds) { + const key = `${parentThreadId}:${requestId}`; + const messageId = MessageId.make(`message:delegated-task-question:${key}`); + const queued = parent.runs.find( + (run) => run.status === "queued" && run.userMessageId === messageId, + ); + if (queued === undefined) continue; + yield* engine + .dispatch({ + type: "queued-run.cancel", + commandId: CommandId.make(`server:delegated-task-question-cancel:${key}`), + threadId: parentThreadId, + runId: queued.id, + }) + .pipe( + Effect.catchCause((cause) => + Effect.logWarning("cancelling a delegated task question wake failed", { + threadId: parentThreadId, + requestId, + cause, + }), + ), + ); + } + }); + // Runs for every domain event; most events return before any work is needed. const handle: DelegatedTaskQuestionReactor["Service"]["handle"] = Effect.fnUntraced( function* (event) { @@ -100,6 +133,46 @@ const make = Effect.gen(function* () { } return; } + if (event.type === "subagent.updated") { + const task = event.payload; + if ( + task.origin !== "app_owned" || + task.childThreadId === null || + !["completed", "failed", "cancelled", "interrupted"].includes(task.status) + ) { + return; + } + // A settled task can no longer use an answer to its still-pending questions. + const { runtimeRequests } = yield* projections.getThreadRecords(task.childThreadId, [ + "runtimeRequests", + ]); + const questions = runtimeRequests.filter((request) => request.kind === "user_input"); + if (questions.length === 0) return; + const child = yield* projections.getThreadRecords(task.childThreadId, ["runs", "nodes"]); + const taskRunIds = new Set( + child.runs + .filter( + (run) => + run.delegatedTaskId === task.id || + (run.delegatedTaskId === undefined && + child.thread.forkedFrom?.type === "node" && + child.thread.forkedFrom.nodeId === task.id), + ) + .map((run) => run.id), + ); + const taskNodeIds = new Set( + child.nodes + .filter((node) => node.runId !== null && taskRunIds.has(node.runId)) + .map((node) => node.id), + ); + yield* cancelQueuedWakes( + task.threadId, + questions + .filter((request) => taskNodeIds.has(request.nodeId)) + .map((request) => request.id), + ); + return; + } if (event.type !== "runtime-request.updated" || event.payload.kind !== "user_input") { return; } @@ -107,21 +180,8 @@ const make = Effect.gen(function* () { const parentThreadId = child.lineage.parentThreadId; if (parentThreadId === null) return; const request = yield* projections.getRuntimeRequest(event.threadId, event.payload.id); - const key = `${parentThreadId}:${event.payload.id}`; - const messageId = MessageId.make(`message:delegated-task-question:${key}`); if (event.payload.status !== "pending" || request?.status !== "pending") { - const parent = yield* projections.getThreadRecords(parentThreadId, ["runs"]); - const queued = parent.runs.find( - (run) => run.status === "queued" && run.userMessageId === messageId, - ); - if (queued !== undefined) { - yield* engine.dispatch({ - type: "queued-run.cancel", - commandId: CommandId.make(`server:delegated-task-question-cancel:${key}`), - threadId: parentThreadId, - runId: queued.id, - }); - } + yield* cancelQueuedWakes(parentThreadId, [event.payload.id]); return; } const parent = yield* projections.getThreadRecords(parentThreadId, ["subagents", "runs"]); 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 d79d1a2d9ca97..870e0d24c9238 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts @@ -1574,6 +1574,234 @@ it.effect("wakes the parent once when its delegated task asks a question", () => }).pipe(Effect.provide(DelegatedTaskQuestionReactor.layer.pipe(Layer.provideMerge(layerTest)))), ); +it.effect.each([false, true])( + "drops a stale question wake (missed terminal event: %s)", + (missedEvent) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const reactor = yield* DelegatedTaskQuestionReactor.DelegatedTaskQuestionReactor; + const eventSink = yield* EventSink.EventSinkV2; + const parentThreadId = ThreadId.make("thread:stale-question-parent"); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("create-stale-question-parent"), + threadId: parentThreadId, + projectId: ProjectId.make("project:stale-question"), + title: "Parent", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdBy: "user", + creationSource: "web", + }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("start-stale-question-parent"), + threadId: parentThreadId, + messageId: MessageId.make("message:stale-question-parent"), + text: "Delegate the proof", + attachments: [], + dispatchMode: { type: "start_immediately" }, + createdBy: "user", + creationSource: "web", + }); + const parentRun = (yield* projections.getThreadProjection(parentThreadId)).runs[0]!; + yield* orchestrator.dispatch({ + type: "delegated_task.request", + commandId: CommandId.make("delegate-stale-question-task"), + parentThreadId, + parentRunId: parentRun.id, + parentNodeId: parentRun.rootNodeId!, + task: "Capture the proof", + completionWake: "settled_only", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + createdBy: "agent", + creationSource: "mcp", + }); + const task = (yield* projections.getThreadProjection(parentThreadId)).subagents[0]!; + const childRun = (yield* projections.getThreadRecords(task.childThreadId!, ["runs"])) + .runs[0]!; + const now = yield* DateTime.now; + const question = { + id: EventId.make("event:stale-question"), + type: "runtime-request.updated", + threadId: task.childThreadId!, + runId: childRun.id, + occurredAt: now, + payload: { + id: RuntimeRequestId.make("request:stale-question"), + nodeId: childRun.rootNodeId!, + providerTurnId: null, + nativeRequestRef: null, + kind: "user_input", + status: "pending", + responseCapability: { type: "message" }, + createdAt: now, + resolvedAt: null, + }, + } satisfies OrchestrationV2DomainEvent; + yield* eventSink.write({ events: [question] }); + yield* reactor.handle(question); + const wake = (yield* projections.getThreadRecords(parentThreadId, [ + "messages", + ])).messages.find((message) => message.notification !== undefined)!; + const wakeRun = () => + projections + .getThreadRecords(parentThreadId, ["runs"]) + .pipe(Effect.map(({ runs }) => runs.find((run) => run.userMessageId === wake.id))); + assert.equal((yield* wakeRun())?.status, "queued"); + + // The child finishes without ever resolving its message-capability question. + const childCompleted = { + id: EventId.make("event:stale-question-child-completed"), + type: "run.updated", + threadId: task.childThreadId!, + runId: childRun.id, + occurredAt: now, + payload: { ...childRun, status: "completed", completedAt: now }, + } satisfies OrchestrationV2DomainEvent; + const taskCompleted = { + id: EventId.make("event:stale-question-task-completed"), + type: "subagent.updated", + threadId: parentThreadId, + occurredAt: now, + payload: { ...task, status: "completed", result: "Proof captured.", completedAt: now }, + } satisfies OrchestrationV2DomainEvent; + yield* eventSink.write({ + commandId: CommandId.make("command:runtime-reconcile:stale-question"), + events: [childCompleted, taskCompleted], + }); + yield* reactor.handle(childCompleted); + if (missedEvent) { + // An acknowledged result drops the task from the parent's command projection. + yield* orchestrator.dispatch({ + type: "delegated_task.completion-delivery.acknowledge", + commandId: CommandId.make("ack-stale-question-task"), + parentThreadId, + taskId: task.id, + observedByRunId: parentRun.id, + }); + // Persisted terminal state can predate the reactor subscription after restart. + yield* projections.apply({ + id: EventId.make("event:stale-question-parent-finished"), + type: "run.updated", + threadId: parentThreadId, + occurredAt: now, + payload: { ...parentRun, status: "completed", completedAt: now }, + }); + yield* orchestrator.dispatch({ + type: "queue.resume", + commandId: CommandId.make("resume-stale-question"), + threadId: parentThreadId, + }); + } else { + yield* reactor.handle(taskCompleted); + } + assert.equal((yield* wakeRun())?.status, "cancelled"); + if (missedEvent) { + const before = (yield* projections.getThreadRecords(parentThreadId, ["runs"])).runs.length; + const delayedQuestion = { + ...question, + id: EventId.make("event:delayed-question"), + payload: { ...question.payload, id: RuntimeRequestId.make("request:delayed-question") }, + }; + yield* eventSink.write({ events: [delayedQuestion] }); + yield* reactor.handle(delayedQuestion); + // A stale delivery is accepted without starting work, not rejected. + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("stale-question-redelivery"), + threadId: parentThreadId, + messageId: MessageId.make("message:stale-question-redelivery"), + text: "Answer the delegated question", + notification: { + source: { + kind: "delegated_task", + taskIds: [task.id], + childThreadId: task.childThreadId!, + }, + outcome: "updated", + summary: "Delegated task has a question", + }, + attachments: [], + dispatchMode: { type: "queue_after_active" }, + createdBy: "agent", + creationSource: "server", + }); + assert.equal( + (yield* projections.getThreadRecords(parentThreadId, ["runs"])).runs.length, + before, + ); + return; + } + + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("stale-question-followup"), + threadId: task.childThreadId!, + messageId: MessageId.make("message:stale-question-followup"), + text: "Capture another proof", + attachments: [], + dispatchMode: { type: "start_immediately" }, + createdBy: "user", + creationSource: "web", + }); + const followup = (yield* projections.getThreadRecords(task.childThreadId!, [ + "runs", + ])).runs.find((run) => run.userMessageId === "message:stale-question-followup")!; + const followupQuestion = { + ...question, + id: EventId.make("event:stale-question-followup"), + runId: followup.id, + payload: { + ...question.payload, + id: RuntimeRequestId.make("request:stale-question-followup"), + nodeId: followup.rootNodeId!, + }, + }; + yield* eventSink.write({ events: [followupQuestion] }); + yield* reactor.handle(followupQuestion); + const followupWake = (yield* projections.getThreadRecords(parentThreadId, [ + "runs", + ])).runs.find( + (run) => + run.userMessageId === + `message:delegated-task-question:${parentThreadId}:${followupQuestion.payload.id}`, + )!; + assert.equal(followupWake.status, "queued"); + yield* reactor.handle(taskCompleted); + assert.equal( + (yield* projections.getThreadRecords(parentThreadId, ["runs"])).runs.find( + (run) => run.id === followupWake.id, + )?.status, + "queued", + ); + yield* projections.apply({ + id: EventId.make("event:followup-question-parent-finished"), + type: "run.updated", + threadId: parentThreadId, + occurredAt: now, + payload: { ...parentRun, status: "completed", completedAt: now }, + }); + yield* orchestrator.dispatch({ + type: "queue.resume", + commandId: CommandId.make("resume-followup-question"), + threadId: parentThreadId, + }); + assert.equal( + (yield* projections.getThreadRecords(parentThreadId, ["runs"])).runs.find( + (run) => run.id === followupWake.id, + )?.status, + "starting", + ); + }).pipe(Effect.provide(DelegatedTaskQuestionReactor.layer.pipe(Layer.provideMerge(layerTest)))), +); + it.effect.each([false, true])("rejects thread.create for an existing id (deleted: %s)", (deleted) => 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 24335188545f9..6e38afbf6430e 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -1987,6 +1987,85 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ]); }); + const isStaleQuestionWake = Effect.fn("Orchestrator.isStaleQuestionWake")(function* ( + projection: Pick, + messageId: MessageId, + notification: OrchestrationV2ThreadProjection["messages"][number]["notification"], + ) { + if (notification?.source.kind !== "delegated_task" || notification.outcome !== "updated") + return false; + const source = notification.source; + const threadId = projection.thread.id; + const task = + projection.subagents.find((candidate) => source.taskIds.includes(candidate.id)) ?? + // Command projections omit settled tasks whose result was already handed over. + (yield* projectionStore + .getThreadRecords(threadId, ["subagents"], { subagentIds: source.taskIds }) + .pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause })))) + .subagents[0]; + // An unknown task gives no evidence the question is obsolete. + if (task === undefined) return false; + const childThreadId = task.childThreadId; + if ( + childThreadId === null || + ["completed", "failed", "cancelled", "interrupted"].includes(task.status) + ) + return true; + const child = yield* projectionStore + .getThreadRecords(childThreadId, ["runtimeRequests"]) + .pipe( + Effect.mapError( + (cause) => new OrchestratorProjectionError({ threadId: childThreadId, cause }), + ), + ); + return !child.runtimeRequests.some( + (request) => + request.kind === "user_input" && + request.status === "pending" && + messageId === `message:delegated-task-question:${threadId}:${request.id}`, + ); + }); + + const cancelStaleQuestionWakes = Effect.fn("Orchestrator.cancelStaleQuestionWakes")(function* ( + projection: OrchestrationV2ThreadProjection, + ) { + const events = yield* Ref.make>([]); + for (const run of projection.runs) { + if (run.status !== "queued") continue; + const message = projection.messages.find((candidate) => candidate.id === run.userMessageId); + if ( + message === undefined || + !(yield* isStaleQuestionWake(projection, message.id, message.notification).pipe( + // A child thread read must not fail the parent's unrelated next queued run. + Effect.catchTags({ + OrchestratorProjectionError: (cause) => + Effect.logWarning("Failed to check a queued task question wake", { + runId: run.id, + cause, + }).pipe(Effect.as(false)), + }), + )) + ) + continue; + yield* emitQueuedRunCancellation({ + command: { + type: "queued-run.cancel", + commandId: CommandId.make(`command:system:stale-question:${run.id}`), + threadId: projection.thread.id, + runId: run.id, + }, + events, + projection, + run, + now: yield* DateTime.now, + }); + } + const cancellations = yield* Ref.get(events); + if (cancellations.length === 0) return false; + yield* writeSystemEvents(cancellations); + return true; + }); + const startNextQueuedRun = Effect.fn("Orchestrator.startNextQueuedRun")( function* ( threadId: ThreadId, @@ -2007,6 +2086,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio return; } + if (yield* cancelStaleQuestionWakes(projection)) { + projection = yield* readCommandProjection(threadId); + } let storedQueuedRun = options?.queuedRunId === undefined ? nextQueuedRun(projection) @@ -6085,6 +6167,18 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio effects: Ref.Ref>, ) { let projection = yield* getProjectionWithPendingEvents(command.threadId, events); + if (yield* isStaleQuestionWake(projection, command.messageId, command.notification)) { + yield* emit( + events, + command, + )({ + type: "thread.metadata-updated", + threadId: command.threadId, + occurredAt: yield* DateTime.now, + payload: projection.thread, + }); + return; + } if (command.manualContinuationOfRunId !== undefined) { const source = projection.runs.find((run) => run.id === command.manualContinuationOfRunId); const limited = latestRootProviderFailure(source ?? null, projection.turnItems); From 3dfc1a0d42f1da5985f947baadc20f239bc00f30 Mon Sep 17 00:00:00 2001 From: "M.Umais Adeed" <165276382+Umais-Adeed@users.noreply.github.com> Date: Thu, 12 Nov 2026 04:17:44 +0500 Subject: [PATCH 0193/1165] fix(server): T3 Connect remembers when the relay client falls back to HTTP/2 on its own (#34167) Co-authored-by: Claude Opus 5.5 --- .../src/cloud/ManagedEndpointRuntime.test.ts | 51 +++++++++++++++++++ .../src/cloud/ManagedEndpointRuntime.ts | 24 ++++++--- 2 files changed, 69 insertions(+), 6 deletions(-) diff --git a/apps/server/src/cloud/ManagedEndpointRuntime.test.ts b/apps/server/src/cloud/ManagedEndpointRuntime.test.ts index 2916c8f965386..ba6d4a635c280 100644 --- a/apps/server/src/cloud/ManagedEndpointRuntime.test.ts +++ b/apps/server/src/cloud/ManagedEndpointRuntime.test.ts @@ -516,6 +516,57 @@ describe("CloudManagedEndpointRuntime", () => { }), ); + it.effect("remembers an HTTP/2 fallback the relay client made on its own", () => + Effect.gen(function* () { + const context = yield* Layer.build( + ServerSecretStore.layer.pipe( + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-transport-test-" })), + Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), + ), + ); + const secretStoreLayer = Layer.succeed( + ServerSecretStore.ServerSecretStore, + context.pipe(Context.get(ServerSecretStore.ServerSecretStore)), + ); + const start = (spawner: ReturnType) => + buildCloudManagedEndpointRuntime( + spawner, + layerRelayClientAvailable, + unusedHttpClient, + secretStoreLayer, + ); + const registered = (protocol: string) => + `2026-11-10T01:43:17Z INF Registered tunnel connection connIndex=0 connection=c1 event=0 ip=198.41.192.7 location=fra08 protocol=${protocol}\n`; + + yield* Effect.gen(function* () { + const fixture = yield* makeTransportSpawner(); + const runtime = yield* start(fixture.spawner); + yield* runtime.applyConfig(tunnelConfig); + yield* fixture.emit(0, registered("quic")); + yield* Stream.runHead(runtime.tunnelConnected); + }).pipe(Effect.scoped); + yield* Effect.gen(function* () { + const fixture = yield* makeTransportSpawner(); + const runtime = yield* start(fixture.spawner); + yield* runtime.applyConfig(tunnelConfig); + expect(fixture.spawned[0]!.args).not.toContain("--protocol"); + // cloudflared, started with `auto`, gave up on QUIC itself. + yield* fixture.emit(0, registered("http2")); + yield* Stream.runHead(runtime.tunnelConnected); + // The connector already runs on HTTP/2, so it is not restarted. + expect(fixture.spawned).toHaveLength(1); + expect(fixture.killed).toEqual([]); + }).pipe(Effect.scoped); + + yield* Effect.gen(function* () { + const fixture = yield* makeTransportSpawner(); + const runtime = yield* start(fixture.spawner); + yield* runtime.applyConfig(tunnelConfig); + expect(fixture.spawned[0]!.args.slice(-3)).toEqual(["--protocol", "http2", "run"]); + }).pipe(Effect.scoped); + }), + ); + it.effect( "ignores invalid or unreadable fallback state and still falls back if persistence fails", () => diff --git a/apps/server/src/cloud/ManagedEndpointRuntime.ts b/apps/server/src/cloud/ManagedEndpointRuntime.ts index 6e86f87ffef95..c018bbf6c2f1f 100644 --- a/apps/server/src/cloud/ManagedEndpointRuntime.ts +++ b/apps/server/src/cloud/ManagedEndpointRuntime.ts @@ -571,10 +571,7 @@ export const make = Effect.gen(function* () { } }); - const switchToHttp2 = Effect.fnUntraced(function* (connector: ActiveConnector) { - const active = yield* Ref.get(activeRef); - const desiredConfig = yield* Ref.get(desiredConfigRef); - if ((yield* Ref.get(http2Ref)) || active !== connector || !desiredConfig) return; + const rememberHttp2Fallback = Effect.fnUntraced(function* () { yield* Ref.set(http2Ref, true); yield* secrets .set( @@ -586,6 +583,13 @@ export const make = Effect.gen(function* () { .pipe( Effect.catch(() => Effect.logWarning("Could not persist the relay client HTTP/2 fallback")), ); + }); + + const switchToHttp2 = Effect.fnUntraced(function* (connector: ActiveConnector) { + const active = yield* Ref.get(activeRef); + const desiredConfig = yield* Ref.get(desiredConfigRef); + if ((yield* Ref.get(http2Ref)) || active !== connector || !desiredConfig) return; + yield* rememberHttp2Fallback(); yield* Effect.logWarning("Relay client keeps losing QUIC connections; switching to HTTP/2", { pid: Number(connector.child.pid), tunnelId: connector.config.tunnelId, @@ -630,11 +634,18 @@ export const make = Effect.gen(function* () { output, }; switch (classifyRelayClientOutput(line)) { - case "connected": + case "connected": { rejectedRegistrations = 0; + const protocol = RELAY_CLIENT_PROTOCOL.exec(line)?.[1] ?? null; return Deferred.succeed(connector.registered, undefined).pipe( + Effect.andThen(Ref.set(connector.protocol, protocol)), + // cloudflared started with `auto` fell back on its own; later starts skip QUIC too. Effect.andThen( - Ref.set(connector.protocol, RELAY_CLIENT_PROTOCOL.exec(line)?.[1] ?? null), + protocol === "http2" && !transportChosenByUser + ? Ref.get(http2Ref).pipe( + Effect.flatMap((http2) => (http2 ? Effect.void : rememberHttp2Fallback())), + ) + : Effect.void, ), Effect.andThen( Effect.logInfo("Relay client tunnel connection registered", attributes), @@ -652,6 +663,7 @@ export const make = Effect.gen(function* () { ), Effect.asVoid, ); + } case "unregistered": return Effect.logInfo("Relay client tunnel connection unregistered", attributes); case "warning": From 5a3e368c1b3d2729e51b16e17c92e73a043cb015 Mon Sep 17 00:00:00 2001 From: Romil Date: Thu, 12 Nov 2026 10:18:03 +1100 Subject: [PATCH 0194/1165] fix(server): detach errored provider sessions when deleting threads (#34199) --- apps/server/src/orchestration-v2/ThreadDeletion.test.ts | 8 +++++++- apps/server/src/orchestration-v2/ThreadDeletion.ts | 3 ++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/server/src/orchestration-v2/ThreadDeletion.test.ts b/apps/server/src/orchestration-v2/ThreadDeletion.test.ts index ef7c23bf75120..a9f20246eb000 100644 --- a/apps/server/src/orchestration-v2/ThreadDeletion.test.ts +++ b/apps/server/src/orchestration-v2/ThreadDeletion.test.ts @@ -279,7 +279,7 @@ it.effect("queues provider and resource cleanup and preserves an earlier deletio assert.deepEqual(deleted.thread.deletedAt, createdAt); assert.deepEqual( deleted.providerSessions.map((session) => session.status), - ["stopped", "error"], + ["stopped"], ); assert.deepEqual( plan.effects.map((effect) => effect.request), @@ -290,6 +290,12 @@ it.effect("queues provider and resource cleanup and preserves an earlier deletio detail: "Thread deleted.", revokeMcpCredential: true, }, + { + type: "provider-session.detach", + providerSessionId: ProviderSessionId.make("session:delete-plan:error"), + detail: "Thread deleted.", + revokeMcpCredential: true, + }, { type: "scheduled-tasks.cleanup" }, { type: "terminal.cleanup" }, { type: "preview.cleanup" }, diff --git a/apps/server/src/orchestration-v2/ThreadDeletion.ts b/apps/server/src/orchestration-v2/ThreadDeletion.ts index 67a27fcf76962..265f59008d87f 100644 --- a/apps/server/src/orchestration-v2/ThreadDeletion.ts +++ b/apps/server/src/orchestration-v2/ThreadDeletion.ts @@ -183,7 +183,8 @@ export const planThreadDeletion = Effect.fn("ThreadDeletion.planThreadDeletion") } for (const session of projection.providerSessions) { - if (session.status === "stopped" || session.status === "error") continue; + // An errored session can still own a reusable live runtime. + if (session.status === "stopped") continue; yield* emitEvent({ type: "provider-session.detached", threadId: command.threadId, From 9b6c77fb0de9d8943aaf14c8a4aa1f84634e0c69 Mon Sep 17 00:00:00 2001 From: Mohamed Sayed Ali Date: Thu, 12 Nov 2026 01:18:21 +0200 Subject: [PATCH 0195/1165] fix(opencode): pass --method curl to the native updater (#34169) Co-authored-by: Mohamed Sayed Co-authored-by: maria-rcks --- .../src/server/driver.test.ts | 6 +++--- .../provider-opencode/src/server/driver.ts | 19 ++++++++++++++++++- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/packages/provider-opencode/src/server/driver.test.ts b/packages/provider-opencode/src/server/driver.test.ts index a3e7024426023..d19068dedc1f9 100644 --- a/packages/provider-opencode/src/server/driver.test.ts +++ b/packages/provider-opencode/src/server/driver.test.ts @@ -815,9 +815,9 @@ it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( const native = executable(".opencode", "bin", "opencode"); for (const generation of ["v1", "v2"] as const) { assert.isNull((yield* resolveUpdate(generation, launcher)).update); - assert.deepStrictEqual((yield* resolveUpdate(generation, native)).update?.args, [ - "upgrade", - ]); + const update = (yield* resolveUpdate(generation, native)).update; + assert.deepStrictEqual(update?.args, ["upgrade", "--method", "curl"]); + assert.strictEqual(update?.env?.HOME, root); } }), ); diff --git a/packages/provider-opencode/src/server/driver.ts b/packages/provider-opencode/src/server/driver.ts index fe7118062fd85..694d50b1f4df3 100644 --- a/packages/provider-opencode/src/server/driver.ts +++ b/packages/provider-opencode/src/server/driver.ts @@ -93,15 +93,32 @@ function isOpenCodeNativeCommandPath(commandPath: string): boolean { ); } +function openCodeNativeHome(commandPath: string): string | null { + const slashed = commandPath.replaceAll("\\", "/"); + const match = /\/\.opencode\/bin\/opencode(?:\.exe)?$/i.exec(slashed); + if (!match) return null; + return match.index === 0 ? "/" : slashed.slice(0, match.index); +} + /** Update only within the installed package generation; 2.x converts the shared database in place. */ export const openCodeUpdateFor = (generation: ProbedOpenCode["generation"]) => makePackageManagedProviderMaintenanceResolver({ provider: DRIVER_KIND, npmPackageName: generation === "v2" ? "@opencode/cli" : "opencode-ai", nativeUpdate: { - args: ["upgrade"], + // `isOpenCodeNativeCommandPath` is the curl installer's own binary. Name the + // method so OpenCode does not have to infer it from `process.execPath`, which + // fails when the binary is launched through a wrapper or shim. + args: ["upgrade", "--method", "curl"], isCommandPath: isOpenCodeNativeCommandPath, requiresCommandPath: true, + // The curl installer writes to `$HOME/.opencode/bin`, so point HOME at the install it replaces. + env: (context) => { + const home = + openCodeNativeHome(context.realCommandPath) ?? + openCodeNativeHome(context.resolvedCommandPath); + return home ? { HOME: home } : {}; + }, }, // mise's `opencode` follows releases into 2.x, so it would upgrade 1.x across that line. mise: { tool: "opencode", upgrade: generation === "v2" }, From b43a1b4b93b023586e4519c5f67e16b8b141f845 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Wed, 11 Nov 2026 18:19:07 -0500 Subject: [PATCH 0196/1165] fix(server): a Claude tool call interrupted by a new message shows as stopped, not failed (#34183) --- .../src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts | 7 +++++-- .../src/orchestration-v2/Adapters/ClaudeAdapterV2.ts | 4 ++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index ee1b475bd30d5..b1787e678d223 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -6667,7 +6667,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); // regression: #15892 non-execution metadata is carried through, not guessed from result text - it.effect.each(["cancelled", "denied", "permission_denied", undefined])( + it.effect.each(["cancelled", "interrupted", "denied", "permission_denied", undefined])( "preserves native tool non-execution metadata %s without inferring a denial from text", (kind) => Effect.scoped( @@ -6722,7 +6722,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { : [], ); const failed = items.findLast((item) => item.nativeItemRef?.nativeId === "tool-error")!; - assert.equal(failed.status, kind === "cancelled" ? "cancelled" : "failed"); + assert.equal( + failed.status, + kind === "cancelled" || kind === "interrupted" ? kind : "failed", + ); assert.equal(failed.toolNonExecutionKind, kind); const ok = items.findLast((item) => item.nativeItemRef?.nativeId === "tool-ok")!; assert.equal(ok.status, "completed"); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index b8e6e3d90b760..bfe5250b1bb2b 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -7739,8 +7739,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ordinal: toolCall.ordinal, output: input.output, status: - toolNonExecutionKind === "cancelled" - ? "cancelled" + toolNonExecutionKind === "cancelled" || toolNonExecutionKind === "interrupted" + ? toolNonExecutionKind : isClaudeToolResultError(input.toolResult) ? "failed" : "completed", From 05e835378faa1ff425d4e284f668ff386d5d90c9 Mon Sep 17 00:00:00 2001 From: Edouard Andrei Date: Thu, 12 Nov 2026 01:19:38 +0200 Subject: [PATCH 0197/1165] fix(acp-registry): model refresh picks up models an agent adds upstream (#34246) Co-authored-by: Claude Opus 5.5 Co-authored-by: Julius Marminge --- .../src/server/driver.test.ts | 123 ++++++++++++++++++ .../src/server/driver.ts | 4 + 2 files changed, 127 insertions(+) diff --git a/packages/provider-acp-registry/src/server/driver.test.ts b/packages/provider-acp-registry/src/server/driver.test.ts index fbb4f16e97c75..2783f336ec370 100644 --- a/packages/provider-acp-registry/src/server/driver.test.ts +++ b/packages/provider-acp-registry/src/server/driver.test.ts @@ -1,18 +1,32 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import * as NodeServices from "@effect/platform-node/NodeServices"; import { AcpRegistryOperationError, ProviderInstanceId } from "@t3tools/contracts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; +import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; +import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost"; import { AcpRegistrySettings } from "../settings.ts"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as AcpRegistryRuntimeCoordinator from "./AcpRegistryRuntimeCoordinator.ts"; import * as AcpRegistrySupport from "./AcpRegistrySupport.ts"; import { + AcpRegistryDriver, applyAcpRegistryAvailableCommands, applyAcpRegistryLiveConfiguration, buildCheckedAcpRegistrySnapshot, checkAcpRegistryProviderReadiness, checkAcpRegistryProviderStatus, } from "./driver.ts"; +import { normalizeAcpRegistryLiveConfiguration } from "./probe.ts"; const decodeSettings = Schema.decodeSync(AcpRegistrySettings); const identity = { @@ -432,3 +446,112 @@ describe("acpRegistrySnapshotReadiness", () => { }), ); }); + +const fakeAgentSource = ` +import { readFileSync } from "node:fs"; +import { createInterface } from "node:readline"; +const send = (message) => process.stdout.write(JSON.stringify({ jsonrpc: "2.0", ...message }) + "\\n"); +createInterface({ input: process.stdin }).on("line", (line) => { + const { id, method } = JSON.parse(line); + if (id === undefined) return; + if (method === "initialize") { + send({ id, result: { protocolVersion: 1, agentCapabilities: {}, authMethods: [] } }); + } else if (method === "session/new") { + const modelIds = JSON.parse(readFileSync(process.argv[2], "utf8")); + send({ + id, + result: { + sessionId: "probe-session", + models: { + currentModelId: modelIds[0], + availableModels: modelIds.map((modelId) => ({ modelId, name: modelId })), + }, + }, + }); + send({ + method: "session/update", + params: { + sessionId: "probe-session", + update: { sessionUpdate: "available_commands_update", availableCommands: [] }, + }, + }); + } else { + send({ id, result: {} }); + } +}); +`; + +const driverLayer = TestProviderHost.layer({ runBackgroundWork: false }).pipe( + Layer.provideMerge(AcpRegistryRuntimeCoordinator.layer), + Layer.provideMerge(IdAllocator.layer), + Layer.provideMerge(McpProviderSessions.layer), + Layer.provideMerge( + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + ), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), +); + +it.layer(driverLayer, { excludeTestServices: true })("AcpRegistryDriver", (it) => { + it.effect("drops cached and session-reported models when its caches are invalidated", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "acp-driver-" }); + const agentPath = path.join(directory, "agent.mjs"); + const modelsPath = path.join(directory, "models.json"); + yield* fileSystem.writeFileString(agentPath, fakeAgentSource); + yield* fileSystem.writeFileString(modelsPath, JSON.stringify(["old-model"])); + const coordinator = yield* AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator; + + const instance = yield* AcpRegistryDriver.create({ + instanceId: identity.instanceId, + displayName: identity.displayName, + enabled: true, + environment: [], + config: decodeSettings({ agentId: "test-agent" }), + }).pipe( + Effect.provideService(AcpRegistrySupport.AcpRegistryCatalog, { + ...catalogWithInspection({ + status: "ready", + agentId: "test-agent", + version: "1.0.0", + distribution: "npx", + }), + resolve: () => + Effect.succeed({ + distribution: "npx" as const, + spawn: { command: process.execPath, args: [agentPath, modelsPath] }, + }), + }), + ); + const modelSlugs = instance.snapshot.streamChanges.pipe( + Stream.map((snapshot) => snapshot.models.map((model) => model.slug)), + ); + const waitForModels = (expected: ReadonlyArray) => + modelSlugs.pipe( + Stream.filter((slugs) => slugs.join() === expected.join()), + Stream.runHead, + ); + + const probed = yield* waitForModels(["old-model"]).pipe(Effect.forkScoped); + yield* instance.snapshot.refresh; + yield* Fiber.join(probed); + yield* coordinator.publishLiveConfiguration( + identity.instanceId, + normalizeAcpRegistryLiveConfiguration([], undefined, { + currentModelId: "old-model", + availableModels: [{ modelId: "old-model", name: "old-model" }], + }), + ); + yield* fileSystem.writeFileString(modelsPath, JSON.stringify(["old-model", "new-model"])); + + const refreshed = yield* waitForModels(["old-model", "new-model"]).pipe(Effect.forkScoped); + yield* instance.invalidateCaches ?? Effect.void; + yield* instance.snapshot.refresh; + expect(Option.isSome(yield* Fiber.join(refreshed))).toBe(true); + }), + ); +}); diff --git a/packages/provider-acp-registry/src/server/driver.ts b/packages/provider-acp-registry/src/server/driver.ts index ef3e0c3e7416c..3bb96f5d4327e 100644 --- a/packages/provider-acp-registry/src/server/driver.ts +++ b/packages/provider-acp-registry/src/server/driver.ts @@ -825,6 +825,10 @@ export const AcpRegistryDriver: ProviderDriver controller.refreshMethods ?? Effect.void)), }, + invalidateCaches: (Option.isSome(runtimeCoordinator) + ? runtimeCoordinator.value.clearLiveConfiguration(instanceId) + : Effect.void + ).pipe(Effect.andThen(liveSnapshotSemaphore.withPermit(invalidateEnrichmentCache))), orchestrationAdapter, textGeneration: makeUnsupportedTextGeneration(), acpSessionManagement: { From 10128e592567a14e0ce21a3d461a95ef72d1d91d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 15:19:48 -0800 Subject: [PATCH 0198/1165] fix(server): repeated session imports reach transcripts behind the scan budget (#27215) --- .../src/orchestration-v2/runtimeLayer.ts | 7 +- .../src/project/AgentSessionImporter.ts | 9 +- .../src/project/AgentSessionScanner.test.ts | 189 +++++++++++++++++- .../server/src/project/AgentSessionScanner.ts | 84 ++++++-- apps/server/src/ws.ts | 1 - 5 files changed, 254 insertions(+), 36 deletions(-) diff --git a/apps/server/src/orchestration-v2/runtimeLayer.ts b/apps/server/src/orchestration-v2/runtimeLayer.ts index 8434de806bf0d..990bfc776f5b6 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.ts @@ -236,10 +236,14 @@ const layerOrchestratorProvided = Orchestrator.layer.pipe( ), ); +// One scanner serves the wizard's scan and imports, so imports can reuse its candidates. +const layerAgentSessionScannerProvided = AgentSessionScanner.layer.pipe( + Layer.provide(ProviderSessionRuntime.layer), +); const layerAgentSessionImporterProvided = AgentSessionImporter.layer.pipe( Layer.provide( Layer.mergeAll( - AgentSessionScanner.layer, + layerAgentSessionScannerProvided, layerProjectService, layerOrchestratorProvided, layerEventSink, @@ -395,6 +399,7 @@ export const layerProduction = Layer.mergeAll( ), ), layerProviderContinuationWorkerProvided, + layerAgentSessionScannerProvided, layerAgentSessionImporterProvided, EffectOutbox.layerPruneWorker.pipe(Layer.provide(EffectOutbox.layer)), ).pipe( diff --git a/apps/server/src/project/AgentSessionImporter.ts b/apps/server/src/project/AgentSessionImporter.ts index ec3e9e3bbe21a..de699840c0b88 100644 --- a/apps/server/src/project/AgentSessionImporter.ts +++ b/apps/server/src/project/AgentSessionImporter.ts @@ -6,7 +6,6 @@ import { isRuntimeModeAbove, AgentSessionImportProjectChangedError, AgentSessionImportProjectNotFoundError, - AgentSessionImportSource, AgentSessionScanError, AgentSessionSource, EventId, @@ -46,12 +45,6 @@ import * as ProjectService from "./ProjectService.ts"; const IMPORT_EVENT_PREFIX = "agent-session-import:v2"; const CLAUDE_SESSION_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; -const decodeImportedTranscriptPayload = Schema.decodeUnknownOption( - Schema.Struct({ - cwd: Schema.optional(Schema.String), - importedTranscripts: Schema.optional(Schema.Array(AgentSessionImportSource)), - }), -); class AgentSessionUnresumableSessionError extends Schema.TaggedError()( "AgentSessionUnresumableSessionError", @@ -250,7 +243,7 @@ const make = Effect.gen(function* () { ), ); const completedSources = runtimeRows.flatMap((runtime) => { - const payload = decodeImportedTranscriptPayload(runtime.runtimePayload); + const payload = AgentSessionScanner.decodeImportedTranscriptPayload(runtime.runtimePayload); if ( Option.isNone(payload) || payload.value.cwd === undefined || diff --git a/apps/server/src/project/AgentSessionScanner.test.ts b/apps/server/src/project/AgentSessionScanner.test.ts index 956a65976369f..ad7d6cfff4d4a 100644 --- a/apps/server/src/project/AgentSessionScanner.test.ts +++ b/apps/server/src/project/AgentSessionScanner.test.ts @@ -13,6 +13,7 @@ import { ProviderDriverKind, ProviderInstanceId, type ServerSettings as ContractServerSettings, + ThreadId, } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; @@ -31,6 +32,7 @@ import * as TestClock from "effect/testing/TestClock"; import * as ServerConfig from "../config.ts"; import * as ProcessRunner from "../processRunner.ts"; import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; +import * as ProviderSessionRuntime from "../persistence/ProviderSessionRuntime.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as AgentSessionScanner from "./AgentSessionScanner.ts"; @@ -59,9 +61,33 @@ interface ScannerTestInput { readonly importedWorkspaceRoots?: ReadonlyArray; readonly configBaseDir?: string; readonly providerInstances?: ContractServerSettings["providerInstances"]; + readonly importedTranscripts?: { + readonly cwd: string; + readonly sources: ReadonlyArray; + }; readonly processRunner?: Layer.Layer; } +const layerRuntimeRepository = (imported: ScannerTestInput["importedTranscripts"]) => + Layer.mock(ProviderSessionRuntime.ProviderSessionRuntimeRepository)({ + list: () => + Effect.sync(() => + (imported?.sources ?? []).map((source): ProviderSessionRuntime.ProviderSessionRuntime => ({ + threadId: ThreadId.make( + `import:${source.providerInstanceId}:${source.providerSessionId}`, + ), + providerName: source.provider, + providerInstanceId: source.providerInstanceId, + adapterKey: source.provider, + runtimeMode: "full-access", + status: "stopped", + lastSeenAt: "2026-01-01T00:00:00.000Z", + resumeCursor: null, + runtimePayload: { cwd: imported?.cwd, importedTranscripts: [source] }, + })), + ), + }); + const layerScannerTest = (input: ScannerTestInput) => (input.processRunner === undefined ? AgentSessionScanner.layer @@ -103,6 +129,7 @@ const layerScannerTest = (input: ScannerTestInput) => input.configBaseDir ?? { prefix: "t3code-scanner-config-" }, ), layerProjectStore(input.importedWorkspaceRoots ?? []), + layerRuntimeRepository(input.importedTranscripts), ), ), ); @@ -1619,16 +1646,20 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { null, { custom: "launcher" }, ]; + const fileSystem = yield* FileSystem.FileSystem; + // Rollouts imported before subagents were excluded still have import records. + const legacyImports: Array = []; for (const [index, source] of sources.entries()) { + const filePath = path.join( + codexHomePath, + "sessions", + "2026", + "08", + "24", + `rollout-${index}.jsonl`, + ); yield* writeTranscript({ - filePath: path.join( - codexHomePath, - "sessions", - "2026", - "08", - "24", - `rollout-${index}.jsonl`, - ), + filePath, contents: [ { type: "session_meta", payload: { id: `session-${index}`, cwd: workspace, source } }, // A later cwd must not make an excluded rollout eligible again. @@ -1642,6 +1673,21 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { .join("\n"), mtimeMs: nowMs - index * 60_000, }); + if (index >= 100) continue; + const stats = yield* fileSystem.stat(filePath); + legacyImports.push( + decodeImportSource({ + provider: "codex", + providerInstanceId: "codex", + providerSessionId: `session-${index}`, + filePath, + size: Number(stats.size), + mtimeMs: Option.getOrNull(Option.map(stats.mtime, (date) => date.getTime())), + device: Number(stats.dev), + inode: Option.getOrNull(Option.map(stats.ino, String)), + birthtimeMs: Option.getOrNull(Option.map(stats.birthtime, (date) => date.getTime())), + }), + ); } yield* Effect.gen(function* () { @@ -1654,7 +1700,15 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { outcome._tag === "Importable" ? outcome.thread.providerSessionId : outcome._tag, ), ).toEqual(["session-100", "session-101", "session-102", "session-103"]); - }).pipe(Effect.provide(layerScannerTest({ claudeHomePath, codexHomePath }))); + }).pipe( + Effect.provide( + layerScannerTest({ + claudeHomePath, + codexHomePath, + importedTranscripts: { cwd: workspace, sources: legacyImports }, + }), + ), + ); }), ); @@ -1790,6 +1844,123 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { }), ); + it.effect("repeated imports reach older transcripts behind already-imported ones", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-repeat-import-claude-"); + const codexHomePath = yield* makeTempDir("t3code-repeat-import-codex-"); + const workspace = yield* makeTempDir("t3code-repeat-import-workspace-"); + const sessionIds = Array.from( + { length: 102 }, + (_, index) => `00000000-0000-4000-8000-${String(index).padStart(12, "0")}`, + ); + for (const [index, sessionId] of sessionIds.entries()) { + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "p", `${sessionId}.jsonl`), + // 999 cwd-less records cost 1,000 metadata records, so 100 newer transcripts + // exhaust MAX_METADATA_RECORDS_PER_SOURCE. + contents: + "{}\n".repeat(999) + + `${encodeTranscriptRecord({ + type: "user", + cwd: workspace, + sessionId, + timestamp: "2026-08-23T12:00:00.000Z", + message: { role: "user", content: `Prompt ${index}` }, + })}\n`, + mtimeMs: nowMs - (index + 1) * 60_000, + }); + } + + const completed: Array = []; + const imported = yield* Effect.gen(function* () { + const scanner = yield* AgentSessionScanner.AgentSessionScanner; + while (true) { + const outcomes = yield* scanner + .recentThreads(workspace, completed) + .pipe(Stream.runCollect); + const added = Array.from(outcomes).flatMap((outcome) => + outcome._tag === "Importable" ? [outcome.source] : [], + ); + if (added.length === 0) return completed; + completed.push(...added); + } + }).pipe( + Effect.provide( + layerScannerTest({ + claudeHomePath, + codexHomePath, + importedTranscripts: { cwd: workspace, sources: completed }, + }), + ), + ); + + expect(imported.map((source) => source.providerSessionId).toSorted()).toEqual(sessionIds); + }), + ); + + it.effect("lists projects whose transcripts sit behind already-imported ones", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-hidden-project-claude-"); + const codexHomePath = yield* makeTempDir("t3code-hidden-project-codex-"); + const imported = yield* makeTempDir("t3code-hidden-project-imported-"); + const hidden = yield* makeTempDir("t3code-hidden-project-hidden-"); + const hiddenSessionId = "00000000-0000-4000-8000-000000000100"; + for (const index of Array.from({ length: 101 }, (_, value) => value)) { + const sessionId = `00000000-0000-4000-8000-${String(index).padStart(12, "0")}`; + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "p", `${sessionId}.jsonl`), + contents: + "{}\n".repeat(999) + + `${encodeTranscriptRecord({ + type: "user", + cwd: index === 100 ? hidden : imported, + sessionId, + message: { role: "user", content: `Prompt ${index}` }, + })}\n`, + mtimeMs: nowMs - (index + 1) * 60_000, + }); + } + + const completed: Array = []; + const scans = yield* Effect.gen(function* () { + const scanner = yield* AgentSessionScanner.AgentSessionScanner; + const before = yield* scanner.scan; + const outcomes = yield* scanner.recentThreads(imported).pipe(Stream.runCollect); + const sources = Array.from(outcomes).flatMap((outcome) => + outcome._tag === "Importable" ? [outcome.source] : [], + ); + // A record whose file identity no longer matches must not lend its cwd. + const newest = sources[0]!; + completed.push(...sources, { + ...newest, + providerSessionId: hiddenSessionId, + filePath: path.join(claudeHomePath, "projects", "p", `${hiddenSessionId}.jsonl`), + }); + return { before, after: yield* scanner.scan }; + }).pipe( + Effect.provide( + layerScannerTest({ + claudeHomePath, + codexHomePath, + importedTranscripts: { cwd: imported, sources: completed }, + }), + ), + ); + + expect(scans.before.candidates.map((candidate) => candidate.path)).toEqual([imported]); + expect(scans.after.candidates.map((candidate) => candidate.path)).toEqual([ + imported, + hidden, + ]); + }), + ); + for (const source of ["claudeAgent", "codex"] as const) { for (const replacement of [ "same root", diff --git a/apps/server/src/project/AgentSessionScanner.ts b/apps/server/src/project/AgentSessionScanner.ts index 6c8410241a3f5..fe206ac465133 100644 --- a/apps/server/src/project/AgentSessionScanner.ts +++ b/apps/server/src/project/AgentSessionScanner.ts @@ -2,13 +2,13 @@ import * as NodeOS from "node:os"; import * as NodeSqlite from "node:sqlite"; import { + AgentSessionImportSource, AgentSessionScanError, ClaudeSettings, CodexSettings, ProviderDriverKind, ProviderInstanceId, resolveProviderInstanceEnabled, - type AgentSessionImportSource, type AgentSessionProjectCandidate, type AgentSessionProjectGit, type AgentSessionScanResult, @@ -34,6 +34,7 @@ import { repositoryPathFromRemoteUrl } from "@t3tools/source-control-core/server import * as ServerConfig from "../config.ts"; import * as ProcessRunner from "../processRunner.ts"; import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; +import * as ProviderSessionRuntime from "../persistence/ProviderSessionRuntime.ts"; import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; @@ -165,6 +166,12 @@ const decodePiEntryLinks = Schema.decodeUnknownOption( ); const selectTranscriptPath = createTranscriptJsonSelector(TranscriptRecord); const decodeCodexTurnMetadata = Schema.decodeUnknownOption(CodexTurnMetadata); +export const decodeImportedTranscriptPayload = Schema.decodeUnknownOption( + Schema.Struct({ + cwd: Schema.optional(Schema.String), + importedTranscripts: Schema.optional(Schema.Array(AgentSessionImportSource)), + }), +); const decodeCodexHistoryBase = Schema.decodeUnknownOption( Schema.Struct({ thread_id: Schema.String, end_byte_offset: Schema.Number }), ); @@ -246,7 +253,7 @@ interface TranscriptCandidate { readonly mtimeMs: number; readonly providerInstanceId: ProviderInstanceId; readonly homePath: string; - readonly size: number; + readonly identity: TranscriptIdentity; readonly projectRoots?: ReadonlyArray | undefined; } @@ -814,6 +821,7 @@ export const make = Effect.gen(function* () { const serverConfig = yield* ServerConfig.ServerConfig; const serverSettings = yield* ServerSettings.ServerSettingsService; const projectStore = yield* ProjectStore.ProjectStoreV2; + const runtimes = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; const baseDir = path.resolve(serverConfig.baseDir); const worktreesDir = path.resolve(serverConfig.worktreesDir); const isWindows = (yield* HostProcess.Platform) === "win32"; @@ -987,7 +995,7 @@ export const make = Effect.gen(function* () { transcript: TranscriptCandidate, budget: MetadataReadBudget, ) { - if (transcript.size === 0) return null; + if (transcript.identity.size === 0) return null; if ( budget.bytesRemaining === 0 || budget.operationsRemaining < 2 || @@ -1005,7 +1013,7 @@ export const make = Effect.gen(function* () { let remaining = ""; let bytesRead = 0; let recordsRead = 0; - const maxBytes = Math.min(MAX_TRANSCRIPT_SCAN_BYTES, transcript.size); + const maxBytes = Math.min(MAX_TRANSCRIPT_SCAN_BYTES, transcript.identity.size); const reserveRecord = () => { if ( recordsRead === MAX_METADATA_RECORDS_PER_TRANSCRIPT || @@ -1054,7 +1062,7 @@ export const make = Effect.gen(function* () { } } - if (bytesRead < transcript.size) { + if (bytesRead < transcript.identity.size) { budget.truncated = true; return null; } @@ -1277,7 +1285,7 @@ export const make = Effect.gen(function* () { mtimeMs: stats.value.mtime.value.getTime(), providerInstanceId, homePath, - size: Number(stats.value.size), + identity: transcriptIdentity(filePath, stats.value), }); } } @@ -1380,7 +1388,7 @@ export const make = Effect.gen(function* () { mtimeMs: stats.value.mtime.value.getTime(), providerInstanceId, homePath, - size: Number(stats.value.size), + identity: transcriptIdentity(filePath, stats.value), }); } } @@ -1445,7 +1453,7 @@ export const make = Effect.gen(function* () { mtimeMs: stats.value.mtime.value.getTime(), providerInstanceId, homePath: sessionsDir, - size: Number(stats.value.size), + identity: transcriptIdentity(filePath, stats.value), }); } } @@ -1457,6 +1465,10 @@ export const make = Effect.gen(function* () { source: AgentSessionSource, transcripts: ReadonlyArray, budget: MetadataReadBudget, + importedByFile: ReadonlyMap< + string, + ReadonlyArray<{ readonly cwd: string; readonly source: AgentSessionImportSource }> + >, ) { const byOwnerAndCwd = new Map< string, @@ -1470,7 +1482,18 @@ export const make = Effect.gen(function* () { >(); for (const transcript of transcripts) { - const rolloutCwd = yield* readCwd(source, transcript, budget); + // Unchanged imported transcripts keep their import cwd, so they never spend the read budget. + // Codex is excluded: its first record carries the cwd and the subagent marker, and + // rollouts imported before subagents were skipped must still be excluded by it. + const rolloutCwd = + (source === "codex" + ? undefined + : importedByFile.get(`${transcript.providerInstanceId}\0${transcript.filePath}`) + )?.find( + (imported) => + imported.source.provider === source && + sameTranscriptIdentity(imported.source, transcript.identity), + )?.cwd ?? (yield* readCwd(source, transcript, budget)); if (rolloutCwd === null || rolloutCwd === false) continue; const cwd = projectCwd(rolloutCwd, transcript.projectRoots); const key = `${transcript.providerInstanceId}\0${cwd}`; @@ -1505,8 +1528,25 @@ export const make = Effect.gen(function* () { Effect.mapError((cause) => new AgentSessionScanError({ operation: "read-settings", cause })), ); + const runtimeRows = yield* runtimes + .list() + .pipe( + Effect.mapError( + (cause) => new AgentSessionScanError({ operation: "read-projects", cause }), + ), + ); + const importedByFile = Map.groupBy( + runtimeRows.flatMap((runtime) => { + const payload = decodeImportedTranscriptPayload(runtime.runtimePayload); + if (Option.isNone(payload) || payload.value.cwd === undefined) return []; + const cwd = payload.value.cwd; + return (payload.value.importedTranscripts ?? []).map((source) => ({ cwd, source })); + }), + ({ source }) => `${source.providerInstanceId}\0${source.filePath}`, + ); const raw: Array = []; let truncated = false; + let metadataTruncated = false; for (const source of ["claudeAgent", "codex", "pi"] as const) { const instances: Array<{ @@ -1617,18 +1657,25 @@ export const make = Effect.gen(function* () { recordsRemaining: MAX_METADATA_RECORDS_PER_SOURCE, truncated: false, }; - raw.push(...(yield* groupTranscriptsByCwd(source, selectedTranscripts, metadataBudget))); - truncated ||= metadataBudget.truncated; + raw.push( + ...(yield* groupTranscriptsByCwd( + source, + selectedTranscripts, + metadataBudget, + importedByFile, + )), + ); + metadataTruncated ||= metadataBudget.truncated; } - return { candidates: raw, truncated }; + return { candidates: raw, truncated: truncated || metadataTruncated, metadataTruncated }; }); - let cachedCandidates: ReadonlyArray | null = null; + let cachedCandidates: Effect.Success> | null = null; const scan: AgentSessionScanner["Service"]["scan"] = Effect.gen(function* () { - const { candidates: raw, truncated } = yield* collectCandidates(); - cachedCandidates = raw; + cachedCandidates = yield* collectCandidates(); + const { candidates: raw, truncated } = cachedCandidates; const merged = new Map< string, @@ -1768,8 +1815,11 @@ export const make = Effect.gen(function* () { const nowMs = DateTime.toEpochMillis(yield* DateTime.now); const cutoffMs = nowMs - RECENT_THREAD_WINDOW_MS; - const candidates = cachedCandidates ?? (yield* collectCandidates()).candidates; - cachedCandidates = candidates; + // Imports recorded since a budget-truncated collection can free budget for transcripts it cut off. + if (cachedCandidates === null || cachedCandidates.metadataTruncated) { + cachedCandidates = yield* collectCandidates(); + } + const { candidates } = cachedCandidates; const eligibleTranscripts: Array<{ readonly candidate: RawCandidate; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 46d3f5c3ea5e9..5257da599c4f2 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -3509,7 +3509,6 @@ export const layer = Layer.unwrap( // Handler-context reporters see only request defects, not server-wide defects. Layer.provide(DefectReporter.layer), Layer.provide(Layer.succeed(SqlClient.SqlClient, sql)), - Layer.provide(AgentSessionScanner.layer), Layer.provide(AcpRegistrySessions.layer), Layer.provide(ProviderMaintenanceRunner.layer), Layer.provide(Layer.succeed(NpmRelease.NpmRelease, npmRelease)), From 5c9a01b747421f019487da13bc3b55f5b8746f52 Mon Sep 17 00:00:00 2001 From: Bin Jamshid Date: Thu, 12 Nov 2026 03:19:57 +0400 Subject: [PATCH 0199/1165] fix(server): archiving or deleting a thread releases its devices (#34175) --- .../src/guest/GuestShareService.test.ts | 3 +- .../src/orchestration-v2/EffectWorker.ts | 13 ++- .../ResourceCleanupService.ts | 22 +++- .../src/orchestration-v2/runtimeLayer.test.ts | 108 ++++++++++++++++++ .../src/provider/userInputAttachments.test.ts | 2 + 5 files changed, 145 insertions(+), 3 deletions(-) diff --git a/apps/server/src/guest/GuestShareService.test.ts b/apps/server/src/guest/GuestShareService.test.ts index 7140222478720..cf2f9e3f70782 100644 --- a/apps/server/src/guest/GuestShareService.test.ts +++ b/apps/server/src/guest/GuestShareService.test.ts @@ -90,7 +90,7 @@ const layerProviders = Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]), }); const layerConfig = ServerConfig.layerTest(process.cwd(), { prefix: "t3-guest-share-" }); -// The real cleanup also closes terminals and previews; these tests only need the files gone. +// The real cleanup also closes terminals, previews and devices; these tests only need the files gone. const layerCleanup = Layer.effect( ResourceCleanupService.ResourceCleanupService, Effect.gen(function* () { @@ -100,6 +100,7 @@ const layerCleanup = Layer.effect( cleanupScheduledTasks: () => Effect.void, cleanupTerminals: () => Effect.void, cleanupPreviews: () => Effect.void, + cleanupDevices: () => Effect.void, cleanupAttachments: (attachmentIds) => Effect.forEach( attachmentIds, diff --git a/apps/server/src/orchestration-v2/EffectWorker.ts b/apps/server/src/orchestration-v2/EffectWorker.ts index 3ec8193d3ef7b..0013735f3b4f3 100644 --- a/apps/server/src/orchestration-v2/EffectWorker.ts +++ b/apps/server/src/orchestration-v2/EffectWorker.ts @@ -526,8 +526,19 @@ export const layerExecutor: Layer.Layer< }), ), ); + // Archive and delete queue this one per-thread teardown, so it also releases the + // thread's device claims; the devices stay booted for other threads. case "terminal.cleanup": - return resourceCleanup.cleanupTerminals(effect.threadId).pipe( + return Effect.partition( + [ + resourceCleanup.cleanupTerminals(effect.threadId), + resourceCleanup.cleanupDevices(effect.threadId), + ], + (cleanup) => cleanup, + ).pipe( + Effect.flatMap(([, [failure]]) => + failure === undefined ? Effect.void : Effect.fail(failure), + ), Effect.mapError( (cause) => new OrchestrationEffectExecutionError({ diff --git a/apps/server/src/orchestration-v2/ResourceCleanupService.ts b/apps/server/src/orchestration-v2/ResourceCleanupService.ts index ff56b42c492c1..fd45ecac667ef 100644 --- a/apps/server/src/orchestration-v2/ResourceCleanupService.ts +++ b/apps/server/src/orchestration-v2/ResourceCleanupService.ts @@ -8,6 +8,7 @@ import * as Schema from "effect/Schema"; import { resolveAttachmentRelativePath } from "../attachmentPaths.ts"; import { parseAttachmentFileExtension, resolveAttachmentPathById } from "../attachmentStore.ts"; import * as ServerConfig from "../config.ts"; +import * as DeviceService from "../device/DeviceService.ts"; import * as PreviewManager from "../preview/Manager.ts"; import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; import * as TerminalManager from "../terminal/Manager.ts"; @@ -15,7 +16,7 @@ import * as TerminalManager from "../terminal/Manager.ts"; export class ResourceCleanupError extends Schema.TaggedError()( "ResourceCleanupError", { - operation: Schema.Literals(["terminal", "preview", "attachment", "scheduled-tasks"]), + operation: Schema.Literals(["terminal", "preview", "device", "attachment", "scheduled-tasks"]), threadId: Schema.optional(Schema.String), attachmentId: Schema.optional(Schema.String), cause: Schema.Defect(), @@ -26,6 +27,8 @@ export class ResourceCleanupService extends Context.Reference<{ readonly cleanupScheduledTasks: (threadId: ThreadId) => Effect.Effect; readonly cleanupTerminals: (threadId: string) => Effect.Effect; readonly cleanupPreviews: (threadId: string) => Effect.Effect; + /** Releases the thread's device claims, as `device_close` does; the devices stay booted. */ + readonly cleanupDevices: (threadId: ThreadId) => Effect.Effect; readonly cleanupAttachments: ( attachmentIds: ReadonlyArray, ) => Effect.Effect; @@ -34,6 +37,7 @@ export class ResourceCleanupService extends Context.Reference<{ cleanupScheduledTasks: () => Effect.void, cleanupTerminals: () => Effect.void, cleanupPreviews: () => Effect.void, + cleanupDevices: () => Effect.void, cleanupAttachments: () => Effect.void, }), }) {} @@ -43,6 +47,7 @@ export const layer = Layer.effect( Effect.gen(function* () { const terminals = yield* TerminalManager.TerminalManager; const previews = yield* PreviewManager.PreviewManager; + const devices = yield* DeviceService.DeviceService; const fileSystem = yield* FileSystem.FileSystem; const config = yield* ServerConfig.ServerConfig; const scheduledTasks = yield* ScheduledTaskService.ScheduledTaskService; @@ -81,6 +86,21 @@ export const layer = Layer.effect( ), ); }), + cleanupDevices: Effect.fn("ResourceCleanupService.cleanupDevices")(function* ( + threadId: ThreadId, + ) { + // One close per session, so a reconnecting host does not keep the other hosts' claims. + const sessions = yield* devices.sessionsForThread(threadId); + const [, failures] = yield* Effect.partition( + sessions, + (session) => + devices.close({ threadId, hostId: session.hostId, deviceId: session.deviceId }), + { concurrency: 1 }, + ); + const [failure] = failures; + if (failure !== undefined) + return yield* new ResourceCleanupError({ operation: "device", threadId, cause: failure }); + }), cleanupAttachments: Effect.fn("ResourceCleanupService.cleanupAttachments")(function ( attachmentIds: ReadonlyArray, ) { diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index a56616340050c..2d466c837ea5c 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -88,6 +88,8 @@ import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts import * as ManagedPolicy from "../policy/ManagedPolicy.ts"; import * as Scheduler from "../scheduling/Scheduler.ts"; import * as SecretRequests from "../secrets/SecretRequests.ts"; +import * as DeviceHost from "../device/DeviceHost.ts"; +import * as DeviceService from "../device/DeviceService.ts"; import * as PreviewManager from "../preview/Manager.ts"; import * as TerminalManager from "../terminal/Manager.ts"; import * as ResourceCleanupService from "./ResourceCleanupService.ts"; @@ -368,8 +370,59 @@ const layerScheduledTasksTest = ScheduledTaskService.layer.pipe( ), ), ); +const bootedSimulatorReady: DeviceHost.DeviceHostAgentReady = { + nodePath: process.execPath, + hub: { origin: "http://device.test" }, + agentDevice: null, + helpers: { serveSimAxSettings: null, serveSimCli: null }, + run: () => Effect.die("Thread cleanup must not run host commands"), + releaseAgentSession: () => Effect.void, + devices: { + list: Effect.succeed({ + devices: [ + { + id: "phone", + name: "iPhone", + platform: "ios", + version: "26", + booted: true, + physical: false, + }, + ], + detail: undefined, + }), + boot: () => Effect.die("The simulator is already booted"), + attach: () => Effect.void, + shutdown: () => Effect.die("Thread cleanup must keep the simulator booted"), + screenshot: () => Effect.die("unused screenshot"), + }, +}; +const bootedSimulatorHost = DeviceHost.DeviceHost.of({ + id: "local", + label: "Local", + summary: Effect.succeed({ + id: "local", + label: "Local", + kind: "local", + hubInstalled: true, + agentDeviceInstalled: true, + platforms: [{ platform: "ios", available: true }], + }), + platformAvailability: (platform) => Effect.succeed({ platform, available: true }), + ensureReady: () => Effect.succeed(bootedSimulatorReady), + ensureAgentReady: () => Effect.succeed(bootedSimulatorReady), + current: Effect.succeed(bootedSimulatorReady), + stopAgent: Effect.void, + stop: Effect.void, +}); const layerResourceCleanupTest = ResourceCleanupService.layer.pipe( Layer.provideMerge(layerScheduledTasksTest), + Layer.provideMerge( + Layer.effect( + DeviceService.DeviceService, + DeviceService.makeWithHosts(new Map([[bootedSimulatorHost.id, bootedSimulatorHost]])), + ), + ), Layer.provide( Layer.mergeAll( Layer.mock(PreviewManager.PreviewManager)({ close: () => Effect.void }), @@ -545,6 +598,61 @@ it.layer(layerProjectDeletionTest)("project deletion during thread commands", (i }), ); + it.effect.each(["archive", "thread delete", "project delete"] as const)( + "%s releases the thread's devices and keeps them booted", + (lifecycle) => + Effect.gen(function* () { + const projects = yield* ProjectService.ProjectService; + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const devices = yield* DeviceService.DeviceService; + yield* devices.configure({ enabled: true, agentAccessEnabled: true }); + const projectId = ProjectId.make(`device-cleanup-project:${lifecycle}`); + const threadId = ThreadId.make(`device-cleanup-thread:${lifecycle}`); + const otherThreadId = ThreadId.make(`device-surviving-thread:${lifecycle}`); + yield* projects.create({ + commandId: CommandId.make(`device-cleanup-create-project:${lifecycle}`), + projectId, + title: "Device cleanup", + workspaceRoot: `/work/device-cleanup/${lifecycle}`, + }); + for (const id of [threadId, otherThreadId]) { + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make(`device-cleanup-create:${id}`), + createdBy: "user", + creationSource: "web", + threadId: id, + projectId: id === threadId ? projectId : ProjectId.make("device-cleanup-other-project"), + title: "Device user", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + yield* devices.open({ threadId: id, deviceId: "phone", platform: "ios" }); + } + const otherSessions = yield* devices.sessionsForThread(otherThreadId); + + const commandId = CommandId.make(`device-cleanup:${lifecycle}`); + if (lifecycle === "project delete") { + yield* projects.delete({ commandId, projectId, force: true }); + } else { + yield* orchestrator.dispatch({ + type: lifecycle === "archive" ? "thread.archive" : "thread.delete", + commandId, + threadId, + }); + } + yield* worker.drain(); + + assert.deepEqual(yield* devices.sessionsForThread(threadId), []); + assert.deepEqual(yield* devices.sessionsForThread(otherThreadId), otherSessions); + assert.isTrue((yield* devices.state).devices[0]?.booted); + }), + ); + it.effect("waits for an in-flight thread update before planning deletion", () => Effect.gen(function* () { const projects = yield* ProjectService.ProjectService; diff --git a/apps/server/src/provider/userInputAttachments.test.ts b/apps/server/src/provider/userInputAttachments.test.ts index f9b41babaea2f..aacc345b3cfef 100644 --- a/apps/server/src/provider/userInputAttachments.test.ts +++ b/apps/server/src/provider/userInputAttachments.test.ts @@ -16,6 +16,7 @@ import * as ServerConfig from "../config.ts"; import * as DocumentText from "./DocumentText.ts"; import { extractDocumentText } from "./documentTextWorker.ts"; import * as Cleanup from "../orchestration-v2/ResourceCleanupService.ts"; +import * as DeviceService from "../device/DeviceService.ts"; import * as TerminalManager from "../terminal/Manager.ts"; import * as PreviewManager from "../preview/Manager.ts"; import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; @@ -328,6 +329,7 @@ describe("question answer paths", () => { Layer.mergeAll( Layer.mock(TerminalManager.TerminalManager)({}), Layer.mock(PreviewManager.PreviewManager)({}), + Layer.mock(DeviceService.DeviceService)({}), Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), ), ), From b859968cabd38023d30cd3a7b1a5b3603355b725 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 15:20:39 -0800 Subject: [PATCH 0200/1165] fix(web): a pending approval is announced once, not once per queued message (#25866) --- ...eadNotificationCoordinator.badge.browser.test.ts | 13 ++++++++++--- .../ThreadNotificationCoordinator.logic.ts | 2 +- .../components/ThreadNotificationCoordinator.tsx | 10 +++++++--- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts b/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts index aab1de3db6726..e2202254c91fd 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts +++ b/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts @@ -243,12 +243,19 @@ it.each(["hasPendingApprovals", "hasPendingUserInput"] as const)( ); it.each(["hasPendingApprovals", "hasPendingUserInput"] as const)( - "notifies again when a new %s request replaces the pending one in the same run", + "notifies once per %s request, not again as queued runs start behind it", async (flag) => { await using app = setup(); app.start(); - state.shells.set("one", shell({ [flag]: true, pendingRequestId: "request-1" })); - state.shells.set("one", shell({ [flag]: true, pendingRequestId: "request-2" })); + const waiting = (turnId: string, pendingRequestId: string) => + shell({ + [flag]: true, + pendingRequestId, + latestTurn: { turnId, state: "running", completedAt: null }, + }); + state.shells.set("one", waiting("turn-1", "request-1")); + state.shells.set("one", waiting("turn-2", "request-1")); + state.shells.set("one", waiting("turn-2", "request-2")); const title = flag === "hasPendingApprovals" ? "Approval needed" : "Input needed"; expect(TestNotification.sent.map(({ title }) => title)).toEqual([title, title]); }, diff --git a/apps/web/src/components/ThreadNotificationCoordinator.logic.ts b/apps/web/src/components/ThreadNotificationCoordinator.logic.ts index c8f0ca7df7a9a..81002179ea9c7 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.logic.ts +++ b/apps/web/src/components/ThreadNotificationCoordinator.logic.ts @@ -1,7 +1,7 @@ export type ThreadNotificationKind = "input" | "completion"; export interface ThreadNotificationObservation { - /** Run, status and request key while the thread needs attention, otherwise null. */ + /** Status and request key, or status and run without a request, while the thread needs attention. */ readonly attention: string | null; /** The pending request's id, when it has one; null whenever attention is. */ readonly requestId: string | null; diff --git a/apps/web/src/components/ThreadNotificationCoordinator.tsx b/apps/web/src/components/ThreadNotificationCoordinator.tsx index 2051d4ad633a8..1de9cfcf87ee5 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.tsx @@ -261,9 +261,13 @@ export function startThreadNotifications( status === "failed" || status === "limited"; const requestId = needsAttention ? resolveBackgroundThreadRequest(thread).requestId : null; - const attention = needsAttention - ? `${thread.latestRun?.runId ?? ""}:${status}:${requestId ?? ""}` - : null; + // Queued messages become the latest run while a request stays pending, so a request + // with an id is keyed by that id alone and announces once. + const attention = !needsAttention + ? null + : requestId !== null + ? `${status}:${requestId}` + : `${thread.latestRun?.runId ?? ""}:${status}`; const completed = status === "ready" && thread.latestRun?.status === "completed"; const settled = completed && !monitorHoldsWakeCompletion(rawThread); const parsedCompletedAt = Date.parse(thread.latestRun?.completedAt ?? ""); From 905311d37e9d9242545cf6f213404dc3c68ea61b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Poskin?= Date: Thu, 12 Nov 2026 00:20:45 +0100 Subject: [PATCH 0201/1165] test(server): keep the OpenZFS zed launch test off host system bins (#34277) Co-authored-by: Claude Opus 5.5 --- .../src/process/externalLauncher.test.ts | 45 +++++++++++-------- 1 file changed, 26 insertions(+), 19 deletions(-) diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index 26253e383761d..2282e6ff5f70a 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -59,6 +59,7 @@ function makeMockDetachedHandle(input: MockSpawnResult & { readonly onUnref?: () const layerTest = (input: { readonly platform: NodeJS.Platform; readonly env?: Record; + readonly path?: Path.Path; readonly resolveExecutable?: (command: string) => string | undefined; readonly onSpawn?: (command: ChildProcess.StandardCommand) => void; readonly onUnref?: () => void; @@ -84,7 +85,15 @@ const layerTest = (input: { ); return Layer.mergeAll( - ExternalLauncher.layer.pipe(Layer.provide(Layer.merge(NodeServices.layer, layerSpawner))), + ExternalLauncher.layer.pipe( + Layer.provide( + Layer.mergeAll( + NodeServices.layer, + layerSpawner, + input.path ? Layer.succeed(Path.Path, input.path) : Layer.empty, + ), + ), + ), Layer.succeed(HostProcess.Platform, input.platform), Layer.succeed( SpawnExecutableResolution, @@ -503,6 +512,18 @@ for (const { layout, pathNames, fallbackNames, expected } of [ ); } +// Linux editor discovery also probes fixed system directories; tests point them at a temp +// directory so an editor installed on the host cannot satisfy the lookup. +const withSystemBin = (path: Path.Path, systemBin: string): Path.Path => ({ + ...path, + join: (...parts) => + path.join( + ...parts.map((part) => + ["/usr/local/bin", "/usr/bin", "/snap/bin"].includes(part) ? systemBin : part, + ), + ), +}); + it.effect.skipIf(windowsHost)("prefers zeditor in a later Linux fallback directory", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -522,15 +543,7 @@ it.effect.skipIf(windowsHost)("prefers zeditor in a later Linux fallback directo const editor = EDITORS.find((editor) => editor.id === "zed")!; const resolve = resolveEditorCommand(editor, { HOME: home, PATH: "" }).pipe( Effect.provideService(HostProcess.Platform, "linux"), - Effect.provideService(Path.Path, { - ...path, - join: (...parts) => - path.join( - ...parts.map((part) => - ["/usr/local/bin", "/usr/bin", "/snap/bin"].includes(part) ? systemBin : part, - ), - ), - }), + Effect.provideService(Path.Path, withSystemBin(path, systemBin)), ); assert.deepEqual( yield* resolve, @@ -563,15 +576,7 @@ it.effect.skipIf(windowsHost)("does not offer the OpenZFS zed daemon as Zed on L const editor = EDITORS.find((editor) => editor.id === "zed")!; const resolve = resolveEditorCommand(editor, { HOME: home, PATH: binDir }).pipe( Effect.provideService(HostProcess.Platform, "linux"), - Effect.provideService(Path.Path, { - ...path, - join: (...parts) => - path.join( - ...parts.map((part) => - ["/usr/local/bin", "/usr/bin", "/snap/bin"].includes(part) ? systemBin : part, - ), - ), - }), + Effect.provideService(Path.Path, withSystemBin(path, systemBin)), ); assert.deepEqual(yield* resolve, Option.none()); @@ -589,6 +594,7 @@ it.effect.skipIf(windowsHost)("does not open Zed with the OpenZFS zed daemon on const path = yield* Path.Path; const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-zed-zfs-launch-" }); const binDir = path.join(home, "bin"); + const systemBin = path.join(home, "system-bin"); yield* fs.makeDirectory(binDir, { recursive: true }); yield* fs.writeFileString(path.join(binDir, "zed"), "#!/bin/sh\nexit 1\n"); yield* fs.chmod(path.join(binDir, "zed"), 0o755); @@ -602,6 +608,7 @@ it.effect.skipIf(windowsHost)("does not open Zed with the OpenZFS zed daemon on layerTest({ platform: "linux", env: { HOME: home, PATH: binDir }, + path: withSystemBin(path, systemBin), onSpawn: (command) => spawned.push(command), spawnResult: (command) => (command.args[0] === "--version" ? { exitCode: 1 } : undefined), }), From 96a2f0a7763f800e11905f421ad172524ec3b515 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 15:21:16 -0800 Subject: [PATCH 0202/1165] fix(shared): relay client install survives antivirus-slowed first launch (#27426) --- packages/shared/src/relayClient.test.ts | 41 +++++++++++++++++++++++++ packages/shared/src/relayClient.ts | 30 ++++++++++-------- 2 files changed, 59 insertions(+), 12 deletions(-) diff --git a/packages/shared/src/relayClient.test.ts b/packages/shared/src/relayClient.test.ts index 5319fe5596379..88559182143bf 100644 --- a/packages/shared/src/relayClient.test.ts +++ b/packages/shared/src/relayClient.test.ts @@ -471,6 +471,47 @@ describe("RelayClient", () => { ), ); + it.effect.skipIf(windowsHost)( + "installs when the downloaded binary's first launch is held by an antivirus scan", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-cloudflared-test-", + }); + const scanning = yield* Deferred.make(); + const manager = yield* RelayClient.makeCloudflaredRelayClient({ + baseDir, + releaseAsset: testReleaseAsset, + }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Deferred.succeed(scanning, undefined).pipe( + Effect.andThen(Effect.sleep("25 seconds")), + Effect.as( + makeHandle( + 0, + `cloudflared version ${RelayClient.CLOUDFLARED_VERSION} (built 2026-01-01-00:00 UTC)\n`, + ), + ), + ), + ), + ), + ); + + const installing = yield* manager.install.pipe(Effect.forkChild); + yield* Deferred.await(scanning); + yield* TestClock.adjust("25 seconds"); + + expect(yield* Fiber.join(installing)).toMatchObject({ + status: "available", + source: "managed", + version: RelayClient.CLOUDFLARED_VERSION, + }); + }).pipe(Effect.scoped, Effect.provide(layerInstallRuntime("linux"))), + ); + it.effect("rejects downloads whose checksum does not match the pinned manifest", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/packages/shared/src/relayClient.ts b/packages/shared/src/relayClient.ts index ae48676e6e171..e3cf71abbe1bf 100644 --- a/packages/shared/src/relayClient.ts +++ b/packages/shared/src/relayClient.ts @@ -141,6 +141,8 @@ const INSTALL_LOCK_RETRY_DELAY = "100 millis"; // Legacy locks have no owner; allow both ten-minute download stages and the install tail. const INSTALL_LOCK_LEGACY_STALE_MS = 30 * 60 * 1_000; const VERSION_PROBE_TIMEOUT = "10 seconds"; +// Antivirus scans can hold the first launch of a freshly downloaded binary for 20-30 s. +const INSTALL_VALIDATION_TIMEOUT = "2 minutes"; const ACTIVATE_RETRY_COUNT = 40; const ACTIVATE_RETRY_DELAY = "250 millis"; @@ -299,19 +301,23 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function return platform === "win32" || (info.value.mode & 0o111) !== 0; }); + const runVersionProbe = Effect.fn("cloudflared.runVersionProbe")(function* ( + executablePath: string, + timeout: Duration.Input, + ) { + return yield* spawner + .string(ChildProcess.make(executablePath, ["version"], { stdin: "ignore", stderr: "ignore" })) + .pipe( + Effect.map(parseCloudflaredVersionOutput), + Effect.timeoutOption(timeout), + Effect.map(Option.getOrNull), + Effect.orElseSucceed(() => null), + ); + }); + // Cache successful version probes per file revision; do not let transient failures hide valid binaries. const versionCache = yield* Cache.makeWith( - (key: VersionProbeKey) => - spawner - .string( - ChildProcess.make(key.executablePath, ["version"], { stdin: "ignore", stderr: "ignore" }), - ) - .pipe( - Effect.map(parseCloudflaredVersionOutput), - Effect.timeoutOption(VERSION_PROBE_TIMEOUT), - Effect.map(Option.getOrNull), - Effect.orElseSucceed(() => null), - ), + (key: VersionProbeKey) => runVersionProbe(key.executablePath, VERSION_PROBE_TIMEOUT), { capacity: 16, timeToLive: (exit) => @@ -648,7 +654,7 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function } yield* report("validating"); // Verify the pin so mislabeled assets cannot cause repeated redownloads. - const installedVersion = yield* probeVersion(executablePath); + const installedVersion = yield* runVersionProbe(executablePath, INSTALL_VALIDATION_TIMEOUT); if (installedVersion !== CLOUDFLARED_VERSION) { return yield* new RelayClientInstallError({ reason: "validation_failed", From 2597756eecd5c672007625ffe7245015c4cbf740 Mon Sep 17 00:00:00 2001 From: Invix <74636900+InvixGG@users.noreply.github.com> Date: Thu, 12 Nov 2026 00:21:20 +0100 Subject: [PATCH 0203/1165] fix(server): re-probe Codex when its auth.json changes (#34320) Co-authored-by: Julius Marminge --- .../src/provider/Drivers/CodexDriver.test.ts | 52 +++++++++++++++++++ .../src/provider/Drivers/CodexDriver.ts | 13 +++++ 2 files changed, 65 insertions(+) diff --git a/apps/server/src/provider/Drivers/CodexDriver.test.ts b/apps/server/src/provider/Drivers/CodexDriver.test.ts index 868dc9c0af03b..0c4fb617a27c5 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.test.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.test.ts @@ -17,9 +17,11 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; +import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import { HttpClient, HttpClientResponse } from "effect/http"; import * as ChildProcess from "effect/process/ChildProcess"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; @@ -725,4 +727,54 @@ it.layer(layerTest)("CodexDriver", (it) => { }).pipe(Effect.scoped), { skip: windowsHost }, ); + + it.effect("probes again when auth.json in the Codex home changes", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const codexHome = yield* fs.makeTempDirectoryScoped({ prefix: "t3-codex-auth-watch-" }); + const watchedPath = yield* Deferred.make(); + const homeEvents = yield* Queue.unbounded(); + const probes = yield* Queue.unbounded(); + yield* CodexDriver.create({ + instanceId: ProviderInstanceId.make("codex-auth-watch"), + displayName: "Codex auth watch", + enabled: true, + environment: [], + config: { ...CodexDriver.defaultConfig(), homePath: codexHome }, + }).pipe( + // OS watchers can miss events written right after they start, so the test supplies them. + Effect.provideService(FileSystem.FileSystem, { + ...fs, + watch: (path) => + Stream.unwrap( + Deferred.succeed(watchedPath, path).pipe(Effect.as(Stream.fromQueue(homeEvents))), + ), + }), + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => + (ChildProcess.isStandardCommand(command) && command.args.includes("app-server") + ? Queue.offer(probes, undefined) + : Effect.void + ).pipe( + Effect.andThen( + PlatformError.badArgument({ + module: "ChildProcessSpawner", + method: "spawn", + description: "The fixture has no Codex executable", + }), + ), + ), + ), + ), + ); + expect(yield* Deferred.await(watchedPath)).toBe(codexHome); + yield* Queue.take(probes); + + yield* Queue.offer(homeEvents, { _tag: "Update", path: "auth.json" }); + yield* TestClock.adjust("250 millis"); + + yield* Queue.take(probes); + }).pipe(Effect.scoped), + ); }); diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index dee8a04aee9fa..58ef7d27142c9 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -2,10 +2,12 @@ import { CodexSettings, ProviderDriverKind } from "@t3tools/contracts"; import * as Digest from "effect/crypto/Digest"; import * as SecureRandom from "effect/crypto/SecureRandom"; import type * as Signature from "effect/crypto/Signature"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; import { HttpClient } from "effect/http"; import { ChildProcessSpawner } from "effect/process"; @@ -371,6 +373,17 @@ export const CodexDriver: ProviderDriver< ), ); + // `codex login` and `codex logout` rewrite auth.json outside T3. + if (effectiveConfig.enabled) { + yield* fileSystem.watch(accountKey).pipe( + Stream.filter((event) => pathService.basename(event.path) === "auth.json"), + Stream.debounce(Duration.millis(250)), + Stream.runForEach(() => snapshot.refresh.pipe(Effect.ignoreCause({ log: true }))), + Effect.catchReason("PlatformError", "NotFound", () => Effect.void), + Effect.ignoreCause({ log: true }), + Effect.forkScoped, + ); + } return { instanceId, driverKind: DRIVER_KIND, From a4ab7dd7079cb9c2507fd9943211a80077f3258b Mon Sep 17 00:00:00 2001 From: Romil Date: Thu, 12 Nov 2026 10:21:39 +1100 Subject: [PATCH 0204/1165] fix(server): close scratch browser contexts when page creation fails (#34310) --- .../src/preview/ServerBrowserContexts.test.ts | 20 +++++++++++++++++++ .../src/preview/ServerBrowserContexts.ts | 11 +++++++--- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/apps/server/src/preview/ServerBrowserContexts.test.ts b/apps/server/src/preview/ServerBrowserContexts.test.ts index 99504e86cb1b2..8a5a280d1f1b8 100644 --- a/apps/server/src/preview/ServerBrowserContexts.test.ts +++ b/apps/server/src/preview/ServerBrowserContexts.test.ts @@ -108,6 +108,26 @@ describe("ServerBrowserContexts", () => { } }); + it("closes a scratch context when creating its page fails", async () => { + const browser = makeBrowser(); + launches.launch.mockResolvedValue(browser as unknown as Browser); + const pool = new ServerBrowserContexts(options()); + const peer = await pool.contextFor("default", "peer"); + const context = await browser.newContext(); + const failure = new Error("newPage failed"); + browser.created[1]!.newPage.mockRejectedValueOnce(failure); + browser.newContext.mockResolvedValueOnce(context); + try { + await expect(pool.scratchPage()).rejects.toBe(failure); + expect(context.close).toHaveBeenCalledOnce(); + expect(browser.contexts()).toEqual([peer]); + expect(peer.close).not.toHaveBeenCalled(); + expect(browser.close).not.toHaveBeenCalled(); + } finally { + await pool.close(); + } + }); + it("lazily shares one browser across concurrent isolated sessions and reuses each context", async () => { const browser = makeBrowser(); const launch = Promise.withResolvers(); diff --git a/apps/server/src/preview/ServerBrowserContexts.ts b/apps/server/src/preview/ServerBrowserContexts.ts index 365add7565c69..06c2d4983ea3d 100644 --- a/apps/server/src/preview/ServerBrowserContexts.ts +++ b/apps/server/src/preview/ServerBrowserContexts.ts @@ -227,9 +227,14 @@ export class ServerBrowserContexts { async scratchPage() { if (this.closing) throw new Error("The preview browser is closed."); const context = await this.sharedContext(); - const page = await context.newPage(); - page.once("close", () => void context.close().catch(constVoid)); - return page; + try { + const page = await context.newPage(); + page.once("close", () => void context.close().catch(constVoid)); + return page; + } catch (cause) { + await context.close().catch(constVoid); + throw cause; + } } async connectDesktopPage(endpoint: string) { From 4a075c966798a8a5cd584be681080ad4dabb048e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 15:21:56 -0800 Subject: [PATCH 0205/1165] fix(desktop): keep AppImage mounted while t3 shuts down (#24421) --- apps/desktop/src/app/DesktopCliShim.test.ts | 83 ++++++++++++++++++++- apps/desktop/src/app/DesktopCliShim.ts | 7 +- 2 files changed, 84 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/app/DesktopCliShim.test.ts b/apps/desktop/src/app/DesktopCliShim.test.ts index 50fcb08d9c64a..e2f97c7d2ff71 100644 --- a/apps/desktop/src/app/DesktopCliShim.test.ts +++ b/apps/desktop/src/app/DesktopCliShim.test.ts @@ -41,10 +41,10 @@ const run = (shim: string, args: ReadonlyArray, env: NodeJS.ProcessEnv = const REPORTER = '#!/bin/sh\necho "node=$ELECTRON_RUN_AS_NODE cli=$T3CODE_CLI_PATH home=$T3CODE_HOME"\nprintf "%s\\n" "$@"\nexit 3\n'; -const fakeAppImage = (canMount = true) => { +const fakeAppImage = (canMount = true, app = REPORTER) => { const root = tempRoot(); const image = NodePath.join(root, "image"); - writeExecutable(NodePath.join(image, "t3code"), REPORTER); + writeExecutable(NodePath.join(image, "t3code"), app); const appImage = NodePath.join(root, "T3 Code.AppImage"); writeExecutable( appImage, @@ -53,8 +53,11 @@ const fakeAppImage = (canMount = true) => { `if [ "$1" = --appimage-extract ]; then cp -R '${image}' squashfs-root; exit 0; fi`, '[ "$1" = --appimage-mount ] || exit 9', '[ -n "$APPIMAGE_EXTRACT_AND_RUN" ] && exit 8', + `echo $$ >'${root}/mounter.pid'`, canMount ? `echo '${image}'` : 'echo "Cannot mount AppImage"; exit 1', - `trap 'touch "${root}/unmounted"; exit 0' TERM`, + // Like the FUSE runtime, any of these unmounts. fd 3 reports to the test when it is open. + `trap 'echo unmounted >&3 2>/dev/null; touch "${root}/unmounted"; exit 0' TERM HUP INT`, + "trap 'echo mounted >&3 2>/dev/null' WINCH", "while :; do sleep 0.05; done", "", ].join("\n"), @@ -63,7 +66,81 @@ const fakeAppImage = (canMount = true) => { return { root, image, appImage, shim }; }; +// Stays up after a signal until the test lets it finish, like a server shutting down. +const SLOW_SERVER = [ + "#!/bin/sh", + "trap 'echo stopping >&3; read go; echo stopped >&3; exit 0' TERM HUP", + "echo ready", + "while :; do sleep 0.05; done", + "", +].join("\n"); + +// The launcher mounts only under setsid, which stock macOS and Windows lack; there it extracts instead. +const hasSetsid = NodeChildProcess.spawnSync("setsid", ["true"]).status === 0; + +const lines = (stream: NodeJS.ReadableStream) => { + const seen: Array = []; + const waiters: Array<() => void> = []; + let buffer = ""; + stream.setEncoding("utf8"); + stream.on("data", (chunk: string) => { + buffer += chunk; + const parts = buffer.split("\n"); + buffer = parts.pop()!; + seen.push(...parts); + for (const wake of waiters.splice(0)) wake(); + }); + const until = async (match: (line: string) => boolean) => { + for (;;) { + const line = seen.find(match); + if (line !== undefined) return line; + await new Promise((resolve) => waiters.push(resolve)); + } + }; + return { seen, until }; +}; + describe("renderCliShim", () => { + it.skipIf(!hasSetsid).each(["SIGTERM", "SIGHUP"] as const)( + "keeps the AppImage mounted until the server exits after %s to the process group", + async (signal) => { + const { root, shim } = fakeAppImage(true, SLOW_SERVER); + const child = NodeChildProcess.spawn(shim, [], { + detached: true, + env: { PATH: process.env.PATH }, + stdio: ["pipe", "pipe", "ignore", "pipe"], + }); + let mounter: number | undefined; + try { + const stdout = lines(child.stdout!); + const events = lines(child.stdio[3] as NodeJS.ReadableStream); + const exited = new Promise((resolve) => child.on("close", resolve)); + await stdout.until((line) => line === "ready"); + mounter = Number(NodeFS.readFileSync(NodePath.join(root, "mounter.pid"), "utf8")); + + // Ctrl-C in a terminal, a closed SSH session, or a service manager signals the whole group. + process.kill(-child.pid!, signal); + // The shell runs pending traps in ascending signal number, and WINCH outnumbers TERM and + // HUP, so a mounter that also got the group signal unmounts before it answers this probe. + process.kill(mounter, "SIGWINCH"); + const mount = await events.until((line) => line === "mounted" || line === "unmounted"); + expect(mount).toBe("mounted"); + + await events.until((line) => line === "stopping"); + child.stdin!.end("go\n"); + await exited; + expect(events.seen.slice(-2)).toEqual(["stopped", "unmounted"]); + } finally { + for (const pid of [-child.pid!, mounter]) { + if (pid === undefined) continue; + try { + process.kill(pid, "SIGKILL"); + } catch {} + } + } + }, + ); + it("extracts the AppImage where it cannot be mounted", () => { const { shim } = fakeAppImage(false); const result = run(shim, ["--version"], { APPIMAGE_EXTRACT_AND_RUN: "1" }); diff --git a/apps/desktop/src/app/DesktopCliShim.ts b/apps/desktop/src/app/DesktopCliShim.ts index f8bd8b5f91deb..eba61f561f526 100644 --- a/apps/desktop/src/app/DesktopCliShim.ts +++ b/apps/desktop/src/app/DesktopCliShim.ts @@ -90,12 +90,13 @@ export const renderCliShim = (input: { ...header, // Extract-and-run would make the runtime start the app instead of mounting it. "unset APPIMAGE_EXTRACT_AND_RUN", - // The runtime stays mounted until killed; traps unmount it because background jobs ignore Ctrl-C. + // Its own session keeps a group signal (Ctrl-C, SSH hangup) from unmounting it under the + // running server; the traps unmount it after. Without setsid the extract fallback runs. "work=$(mktemp -d) || exit 1", - '"$app" --appimage-mount >"$work/mount" 2>/dev/null &', + 'setsid "$app" --appimage-mount >"$work/mount" 2>/dev/null &', "mounter=$!", `trap 'kill "$mounter" 2>/dev/null; rm -rf "$work"' EXIT`, - "trap 'exit 130' INT TERM", + "trap 'exit 130' INT TERM HUP", "mount=", 'while kill -0 "$mounter" 2>/dev/null; do', ' mount=$(head -n 1 "$work/mount")', From b9381c257d506f1f76f72a391bc96c6fd95a840d Mon Sep 17 00:00:00 2001 From: Aaron Zingerle Date: Thu, 12 Nov 2026 00:22:00 +0100 Subject: [PATCH 0206/1165] fix(server): Claude Plan mode runs t3_background_wait without asking (#34343) Co-authored-by: Aaron Zingerle Co-authored-by: Claude Opus 5.5 Co-authored-by: Julius Marminge --- apps/server/src/mcp/toolkits/orchestrator/tools.ts | 6 +++++- .../src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts | 2 ++ .../server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts | 1 + 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index 2ba8a5f2dd5fc..d1c916f2caefb 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -154,7 +154,11 @@ const BackgroundWaitTool = Tool.make("t3_background_wait", { failure: OrchestratorMcpFailure, failureMode: "return", dependencies, -}).annotate(Tool.Title, "Wait on background work"); +}) + .annotate(Tool.Title, "Wait on background work") + // Only marks which tasks keep the thread Working; Claude Plan mode blocks tools without this. + .annotate(Tool.Readonly, true) + .annotate(Tool.Destructive, false); const ScheduleTaskTool = Tool.make("schedule_task", { description: diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index b1787e678d223..39dcd39f158c3 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -611,6 +611,8 @@ describe("ClaudeAdapterV2 MCP query overrides", () => { .map((tool) => `mcp__t3-code__${tool.name}`) .sort(); + // regression: #32022 Claude Plan mode asks before every non-read-only MCP call + assert.include(readOnlyToolNames, "mcp__t3-code__t3_background_wait"); assert.deepEqual( [...ClaudeAdapterV2.CLAUDE_READ_ONLY_T3_MCP_ALLOWED_TOOLS].sort(), readOnlyToolNames, diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index bfe5250b1bb2b..9df52082eacca 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -2023,6 +2023,7 @@ export const CLAUDE_T3_MCP_TOOL_WILDCARD = "mcp__t3-code__*"; export const CLAUDE_READ_ONLY_T3_MCP_ALLOWED_TOOLS: ReadonlyArray = [ "mcp__t3-code__orchestrator_capabilities", "mcp__t3-code__list_scheduled_tasks", + "mcp__t3-code__t3_background_wait", "mcp__t3-code__t3_thread_list", "mcp__t3-code__t3_thread_wait", "mcp__t3-code__t3_worktree_status", From 1b42b169a332046fdcb50a573ef269f9b73b2358 Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Thu, 12 Nov 2026 04:52:19 +0530 Subject: [PATCH 0207/1165] fix(server): preserve spaces in Git remote URLs (#34324) --- apps/server/src/vcs/GitVcsDriver.test.ts | 77 ++++++++++++-------- apps/server/src/vcs/GitVcsDriver.ts | 10 +-- apps/server/src/vcs/GitVcsDriverCore.test.ts | 55 ++++++++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 7 +- 4 files changed, 109 insertions(+), 40 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriver.test.ts b/apps/server/src/vcs/GitVcsDriver.test.ts index 128c8afcd3b4c..89580e0937af7 100644 --- a/apps/server/src/vcs/GitVcsDriver.test.ts +++ b/apps/server/src/vcs/GitVcsDriver.test.ts @@ -102,35 +102,54 @@ runVcsDriverContractSuite({ }, }); -it.effect("lists the fetch and push URLs of a partial clone remote", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const cwd = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-partial-clone-remotes-", - }); - const driver = yield* GitVcsDriver.makeVcsDriverShape(); - yield* runGit(cwd, ["init"]); - yield* runGit(cwd, ["remote", "add", "origin", "https://github.com/pingdotgg/t3code.git"]); - yield* runGit(cwd, [ - "remote", - "set-url", - "--push", - "origin", - "git@github.com:octocat/t3code.git", - ]); - yield* runGit(cwd, ["config", "remote.origin.promisor", "true"]); - yield* runGit(cwd, ["config", "remote.origin.partialclonefilter", "blob:none"]); - - const result = yield* driver.listRemotes(cwd); - assert.deepEqual(result.remotes, [ - { - name: "origin", - url: "https://github.com/pingdotgg/t3code.git", - pushUrl: Option.some("git@github.com:octocat/t3code.git"), - isPrimary: true, - }, - ]); - }).pipe(Effect.scoped, Effect.provide(layerGitContract)), +it.effect.each([ + { + fetchUrl: "https://github.com/pingdotgg/t3code.git", + pushUrl: "git@github.com:octocat/t3code.git", + lineEnding: "\n", + }, + { fetchUrl: "../team repos/shared.git", pushUrl: "../team repos/push.git", lineEnding: "\n" }, + { + fetchUrl: " ../team repos/shared (fetch) [blob:none].git ", + pushUrl: "../team repos/push (push).git\t", + lineEnding: "\r\n", + }, +])( + "lists the fetch and push URLs of a partial clone remote (%j)", + ({ fetchUrl, pushUrl, lineEnding }) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-partial-clone-remotes-", + }); + const process = yield* VcsProcess.VcsProcess; + const driver = yield* GitVcsDriver.makeVcsDriverShape().pipe( + Effect.provideService(VcsProcess.VcsProcess, { + run: (input) => + process.run(input).pipe( + Effect.map((result) => ({ + ...result, + stdout: result.stdout.replace(/\r?\n/g, lineEnding), + })), + ), + }), + ); + yield* runGit(cwd, ["init"]); + yield* runGit(cwd, ["remote", "add", "origin", fetchUrl]); + yield* runGit(cwd, ["remote", "set-url", "--push", "origin", pushUrl]); + yield* runGit(cwd, ["config", "remote.origin.promisor", "true"]); + yield* runGit(cwd, ["config", "remote.origin.partialclonefilter", "blob:none"]); + + const result = yield* driver.listRemotes(cwd); + assert.deepEqual(result.remotes, [ + { + name: "origin", + url: fetchUrl, + pushUrl: Option.some(pushUrl), + isPrimary: true, + }, + ]); + }).pipe(Effect.scoped, Effect.provide(layerGitContract)), ); const makeCheckpointFixture = Effect.fn("makeCheckpointFixture")(function* ( diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index a5dd821d4a8b4..dbc87e088d905 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -517,14 +517,10 @@ function parseGitRemoteVerboseOutput( output: string, ): Map { const remotes = new Map(); - for (const line of output.split("\n")) { - const trimmed = line.trim(); - if (trimmed.length === 0) { - continue; - } - + for (const line of output.split(/\r?\n/)) { + // Git uses a tab before the URL and one space before the direction; both can occur in URLs. // Partial clones append the promisor filter, e.g. `(fetch) [blob:none]`. - const match = /^(\S+)\s+(\S+)\s+\((fetch|push)\)(?:\s+\[[^\]]+\])?$/.exec(trimmed); + const match = /^(\S+)\t(.+) \((fetch|push)\)(?:\s+\[[^\]]+\])?$/.exec(line); if (!match) { continue; } diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 0f393f215e4df..5b84236db14c0 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -5724,6 +5724,61 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); + it.effect.each(["\n", "\r\n"])( + "ensureRemote preserves spaces in fetch URLs and reserves their names (%j)", + (lineEnding) => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const fetchUrl = " ../team repos/shared (fetch) [blob:none].git "; + const pushUrl = "../team repos/push (push).git"; + yield* git(cwd, ["init"]); + yield* git(cwd, ["remote", "add", "origin", fetchUrl]); + yield* git(cwd, ["remote", "set-url", "--push", "origin", pushUrl]); + yield* git(cwd, ["config", "remote.origin.promisor", "true"]); + yield* git(cwd, ["config", "remote.origin.partialclonefilter", "blob:none"]); + const liveDriver = yield* GitVcsDriver.GitVcsDriver; + const listing = yield* liveDriver.execute({ + cwd, + args: ["remote", "-v"], + operation: "test", + }); + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const spawner = ChildProcessSpawner.make((command) => + ChildProcess.isStandardCommand(command) && + command.args[0] === "remote" && + command.args[1] === "-v" + ? Effect.succeed(makeSuccessfulHandle(listing.stdout.replace(/\r?\n/g, lineEnding))) + : delegate.spawn(command), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provide(layerServerConfig), + ); + + assert.equal( + yield* driver.ensureRemote({ cwd, preferredName: "origin", url: fetchUrl }), + "origin", + ); + assert.equal( + yield* driver.ensureRemote({ cwd, preferredName: "origin", url: pushUrl }), + "origin-1", + ); + const fetch = yield* liveDriver.execute({ + cwd, + args: ["config", "--get", "remote.origin.url"], + operation: "test", + }); + const push = yield* liveDriver.execute({ + cwd, + args: ["config", "--get", "remote.origin.pushurl"], + operation: "test", + }); + assert.equal(fetch.stdout, `${fetchUrl}\n`); + assert.equal(push.stdout, `${pushUrl}\n`); + assert.equal(yield* git(cwd, ["config", "--get", "remote.origin-1.url"]), pushUrl); + }), + ); + it.effect("ensureRemote returns the remote a parallel call added for the same URL", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 7fe6ed9da3374..12ce999f25e18 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -353,11 +353,10 @@ function sanitizeRemoteName(value: string): string { function parseRemoteFetchUrls(stdout: string): Map { const remotes = new Map(); - for (const line of stdout.split("\n")) { - const trimmed = line.trim(); - if (trimmed.length === 0) continue; + for (const line of stdout.split(/\r?\n/)) { + // Git uses a tab before the URL and one space before the direction; both can occur in URLs. // Partial clones append the promisor filter, e.g. `(fetch) [blob:none]`. - const match = /^(\S+)\s+(\S+)\s+\((fetch|push)\)(?:\s+\[[^\]]+\])?$/.exec(trimmed); + const match = /^(\S+)\t(.+) \((fetch|push)\)(?:\s+\[[^\]]+\])?$/.exec(line); if (!match) continue; const [, remoteName = "", remoteUrl = "", direction = ""] = match; if (direction !== "fetch" || remoteName.length === 0 || remoteUrl.length === 0) { From 8e89fe191808d3595c3d047f63fe63c9598a2e22 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 15:22:33 -0800 Subject: [PATCH 0208/1165] fix(server): usage no longer waits on Muse home discovery (#30859) --- apps/server/src/usage/UsageService.test.ts | 64 +++++++- apps/server/src/usage/UsageService.ts | 25 ++- packages/provider-core/src/server/usage.ts | 8 +- .../provider-muse/src/server/MuseUsageHome.ts | 36 ++++- .../provider-muse/src/server/usage.test.ts | 151 ++++++++++++------ packages/provider-muse/src/server/usage.ts | 14 +- 6 files changed, 233 insertions(+), 65 deletions(-) diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 1c03c05e13727..4e898799e992c 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -12,6 +12,7 @@ import { vi } from "vite-plus/test"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import { SpawnExecutableResolution } from "@t3tools/shared/shell"; import { mergeUsage } from "@t3tools/shared/usageMerge"; import { EnvironmentId, @@ -1120,7 +1121,7 @@ describe("UsageService", () => { }; const muse = (summary: UsageSummary) => summary.buckets.filter((bucket) => bucket.provider === "muse"); - const first = yield* service.readSummary(window); + const first = yield* service.readSummary({ ...window, awaitRefresh: true }); const buckets = muse(first); assert.strictEqual( buckets.reduce((sum, b) => sum + b.records, 0), @@ -1202,6 +1203,7 @@ describe("UsageService", () => { assert.closeTo(repriced?.costUsd ?? NaN, 0.000486, 1e-12); }).pipe( Effect.scoped, + Effect.provideService(SpawnExecutableResolution, (command: string) => command), Effect.provide( layerService({ prefix: "usage-muse", @@ -1235,6 +1237,66 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); + it.live("returns other providers' usage while Muse home discovery is still pending", () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5))); + const museStarted = yield* Deferred.make(); + const museReady = yield* Deferred.make(); + const museHome = NodePath.join(home, "muse"); + yield* Effect.promise(() => + NodeFSP.mkdir(NodePath.join(museHome, "sessions"), { recursive: true }), + ); + using _create = vi + .spyOn(MuseSdk, "createMuseSdkHostEffect") + .mockImplementation(() => + Deferred.succeed(museStarted, undefined).pipe(Effect.andThen(Deferred.await(museReady))), + ); + const museSources = (summary: UsageSummary) => + summary.sources + .filter((source) => source.fingerprint.provider === "muse") + .map((source) => [source.fingerprint.resolvedHomePath, source.status, source.refreshing]); + const [pending, refreshed] = yield* Effect.gen(function* () { + const service = yield* UsageService.make; + const read = yield* Effect.forkChild(service.readSummary(WINDOW)); + yield* Deferred.await(museStarted); + const pending = yield* Fiber.join(read); + const refresh = yield* Effect.forkChild( + service.readSummary({ ...WINDOW, awaitRefresh: true }), + ); + yield* Deferred.succeed(museReady, { + initializeResult: { grantedCapabilities: [], museHome }, + close: async () => {}, + } as unknown as MuseSdk.MuseSdkHost); + return [pending, yield* Fiber.join(refresh)] as const; + }).pipe( + Effect.scoped, + Effect.provideService(SpawnExecutableResolution, (command: string) => command), + Effect.provide( + layerService({ + prefix: "usage-muse-pending", + home, + settings: { + ...settings, + providerInstances: { + ...settings.providerInstances, + [ProviderInstanceId.make("muse")]: { + driver: ProviderDriverKind.make("muse"), + enabled: false, + }, + }, + }, + }), + ), + ); + assert.strictEqual(totalOutputTokens(pending), 5); + assert.deepStrictEqual(museSources(pending), [["muse", "ok", true]]); + assert.deepStrictEqual(museSources(refreshed), [ + [NodePath.join(museHome, "sessions"), "ok", undefined], + ]); + }).pipe(Effect.scoped), + ); + it.live("reports missing Pi history and skips malformed records without losing valid usage", () => Effect.gen(function* () { const { home, settings } = yield* setup; diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 771393dac2579..341b14d8c6280 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -330,16 +330,25 @@ export const make = Effect.gen(function* () { const resolveTranscriptDirs = Effect.fn("UsageService.resolveTranscriptDirs")(function* ( settings: ServerSettingsValue, retentionCutoffMs: number, + awaitRefresh: boolean, ) { const dirs: Array = []; + const pending: Array = []; const seen = new Set(); for (const { driver, reader } of transcriptReaders) { const { provider, format } = reader; const directories = yield* Effect.forEach( yield* usageInstances(driver, settings), - (instance) => reader.directories(instance), + (instance) => reader.directories(instance, { awaitRefresh }), ); - for (const { dir: directory, fileName } of directories.flat()) { + for (const { dir: directory, fileName, refreshing } of directories.flat()) { + if (refreshing) { + const key = `${provider}\0${directory}`; + if (seen.has(key)) continue; + seen.add(key); + pending.push({ provider, dir: directory, volumeId: "", files: [], refreshing }); + continue; + } const sourceKey = provider + "\0" + directory; const previous = sourceCache.get(sourceKey); const dir = yield* fileSystem @@ -375,7 +384,7 @@ export const make = Effect.gen(function* () { }); } } - return dirs; + return { dirs, pending }; }); // Share first loads so concurrent readers cannot scan against a still-empty cache. @@ -654,9 +663,11 @@ export const make = Effect.gen(function* () { retentionCutoffMs: number, awaitRefresh: boolean, ) { - const dirs = yield* resolveTranscriptDirs(settings, retentionCutoffMs).pipe( - Effect.provideContext(readerContext), - ); + const { dirs, pending } = yield* resolveTranscriptDirs( + settings, + retentionCutoffMs, + awaitRefresh, + ).pipe(Effect.provideContext(readerContext)); const scans = Effect.forEach( scanReaders, @@ -690,7 +701,7 @@ export const make = Effect.gen(function* () { [Effect.forEach(dirs, (dir) => scanTranscriptDir(dir, windowStartMs)), scans], { concurrency: "unbounded" }, ); - const scanned: readonly ScannedDir[] = [...transcripts, ...scanDirs.flat()]; + const scanned: readonly ScannedDir[] = [...transcripts, ...pending, ...scanDirs.flat()]; return scanned; }); diff --git a/packages/provider-core/src/server/usage.ts b/packages/provider-core/src/server/usage.ts index 3915ce7b2d9fa..eaed1d06ece4f 100644 --- a/packages/provider-core/src/server/usage.ts +++ b/packages/provider-core/src/server/usage.ts @@ -111,10 +111,16 @@ export type ProviderUsageReader = readonly resolveServiceTiers?: ( records: readonly UsageRecord[], ) => Effect.Effect, never, R>; + /** A `refreshing` entry is not scanned; it reports its source as still being located. */ readonly directories: ( instance: ProviderUsageInstance, + options: { readonly awaitRefresh: boolean }, ) => Effect.Effect< - ReadonlyArray<{ readonly dir: string; readonly fileName?: string }>, + ReadonlyArray<{ + readonly dir: string; + readonly fileName?: string; + readonly refreshing?: true; + }>, never, R >; diff --git a/packages/provider-muse/src/server/MuseUsageHome.ts b/packages/provider-muse/src/server/MuseUsageHome.ts index a0c5c16261c0e..1ced0d2578b01 100644 --- a/packages/provider-muse/src/server/MuseUsageHome.ts +++ b/packages/provider-muse/src/server/MuseUsageHome.ts @@ -4,24 +4,32 @@ import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import * as HostProcess from "@t3tools/shared/HostProcess"; +import { SpawnExecutableResolution } from "@t3tools/shared/shell"; import { createMuseSdkHostEffect, MuseSdkError } from "./sdk.ts"; export class MuseUsageHome extends Context.Service< MuseUsageHome, { + /** None while discovery is still running and `awaitRefresh` is false. */ readonly resolve: (input: { readonly binaryPath: string; readonly environment: NodeJS.ProcessEnv; - }) => Effect.Effect; + readonly awaitRefresh: boolean; + }) => Effect.Effect>; } >()("@t3tools/provider-muse/server/MuseUsageHome") {} const make = Effect.gen(function* () { + const scope = yield* Effect.scope; const cache = yield* Cache.makeWith( - Effect.fn("MuseUsageHome.initialize")(function* ( - input: Parameters[0], - ) { + Effect.fn("MuseUsageHome.initialize")(function* (input: { + readonly binaryPath: string; + readonly environment: NodeJS.ProcessEnv; + }) { return yield* Effect.scoped( Effect.gen(function* () { const host = yield* Effect.acquireRelease( @@ -35,7 +43,7 @@ const make = Effect.gen(function* () { ); return host.initializeResult.museHome; }), - ); + ).pipe(Effect.orElseSucceed(() => undefined)); }), { capacity: 32, @@ -45,15 +53,27 @@ const make = Effect.gen(function* () { }, ); const resolve: MuseUsageHome["Service"]["resolve"] = Effect.fn("MuseUsageHome.resolve")( - function* ({ binaryPath, environment }) { + function* ({ binaryPath, environment, awaitRefresh }) { // Cache keys compare structurally. Omitted and undefined variables both // disappear from the child process environment and must share an entry. - return yield* Cache.get(cache, { + const key = { binaryPath, environment: Object.fromEntries( Object.entries(environment).filter(([, value]) => value !== undefined), ), - }).pipe(Effect.orElseSucceed(() => undefined)); + }; + const settled = yield* Cache.getSuccess(cache, key); + if (Option.isSome(settled)) return settled; + // Spawn failures surface asynchronously; without this a missing CLI would report as pending. + const resolveExecutable = yield* SpawnExecutableResolution; + if (resolveExecutable(binaryPath, yield* HostProcess.Platform, environment) === undefined) { + return Option.some(undefined); + } + if (awaitRefresh) return Option.some(yield* Cache.get(cache, key)); + // Awaited in the layer scope so a departing caller cannot cancel discovery. + yield* Effect.forkIn(Cache.get(cache, key), scope, { startImmediately: true }); + // A lookup that settled synchronously needs no refresh. + return yield* Cache.getSuccess(cache, key); }, ); return MuseUsageHome.of({ resolve }); diff --git a/packages/provider-muse/src/server/usage.test.ts b/packages/provider-muse/src/server/usage.test.ts index 4ca72c5682488..c90906aa80b66 100644 --- a/packages/provider-muse/src/server/usage.test.ts +++ b/packages/provider-muse/src/server/usage.test.ts @@ -2,6 +2,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { ProviderInstanceId } from "@t3tools/contracts"; import { totalTokens } from "@t3tools/provider-core/server/usage"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import { SpawnExecutableResolution } from "@t3tools/shared/shell"; import * as Effect from "effect/Effect"; import * as Deferred from "effect/Deferred"; import * as Fiber from "effect/Fiber"; @@ -190,6 +191,9 @@ it("handles malformed and partial data and rejects unsupported timestamps or sch ).toEqual([]); }); +const AWAIT_REFRESH = { awaitRefresh: true }; +const onPath = Effect.provideService(SpawnExecutableResolution, (command: string) => command); + describe("Muse history discovery", () => { it.effect.each([undefined, "/custom/xdg-data"])( "uses the initialized home with XDG_DATA_HOME=%s and closes without starting a session", @@ -204,12 +208,15 @@ describe("Muse history discovery", () => { } as unknown as MuseSdk.MuseSdkHost), ); if (museUsageReader.kind !== "transcripts") throw new Error("Expected a transcript reader"); - const directories = yield* museUsageReader.directories({ - instanceId: ProviderInstanceId.make("muse-other"), - configured: true, - config: decodeSettings({ binaryPath: "~/bin/custom-muse" }), - environment: { XDG_DATA_HOME: xdg, MUSE_NO_AUTO_UPDATE: "0" }, - }); + const directories = yield* museUsageReader.directories( + { + instanceId: ProviderInstanceId.make("muse-other"), + configured: true, + config: decodeSettings({ binaryPath: "~/bin/custom-muse" }), + environment: { XDG_DATA_HOME: xdg, MUSE_NO_AUTO_UPDATE: "0" }, + }, + AWAIT_REFRESH, + ); expect(directories).toEqual([ { dir: path.join("/native/configured-home", "sessions"), fileName: "session.jsonl" }, ]); @@ -226,6 +233,7 @@ describe("Muse history discovery", () => { expect(close).toHaveBeenCalledOnce(); }).pipe( Effect.provide([NodeServices.layer, MuseUsageHome.layer]), + onPath, Effect.provideService(HostProcess.HomeDirectory, "/test-home"), Effect.provideService(HostProcess.Environment, { PATH: "/host/bin", @@ -234,6 +242,27 @@ describe("Muse history discovery", () => { ), ); + it.effect("reports no history without starting Muse when its CLI is not installed", () => + Effect.gen(function* () { + using create = vi.spyOn(MuseSdk, "createMuseSdkHostEffect"); + if (museUsageReader.kind !== "transcripts") throw new Error("Expected a transcript reader"); + const directories = yield* museUsageReader.directories( + { + instanceId: ProviderInstanceId.make("muse"), + configured: false, + config: undefined, + environment: {}, + }, + { awaitRefresh: false }, + ); + expect(directories).toEqual([]); + expect(create).not.toHaveBeenCalled(); + }).pipe( + Effect.provide([NodeServices.layer, MuseUsageHome.layer]), + Effect.provideService(SpawnExecutableResolution, () => undefined), + ), + ); + it.effect("does not guess a directory when initialization fails", () => Effect.gen(function* () { using _create = vi @@ -244,14 +273,17 @@ describe("Muse history discovery", () => { ), ); if (museUsageReader.kind !== "transcripts") throw new Error("Expected a transcript reader"); - const directories = yield* museUsageReader.directories({ - instanceId: ProviderInstanceId.make("muse"), - configured: false, - config: undefined, - environment: {}, - }); + const directories = yield* museUsageReader.directories( + { + instanceId: ProviderInstanceId.make("muse"), + configured: false, + config: undefined, + environment: {}, + }, + AWAIT_REFRESH, + ); expect(directories).toEqual([]); - }).pipe(Effect.provide([NodeServices.layer, MuseUsageHome.layer])), + }).pipe(Effect.provide([NodeServices.layer, MuseUsageHome.layer]), onPath), ); it.effect( @@ -273,21 +305,24 @@ describe("Muse history discovery", () => { config: decodeSettings({ binaryPath: "~/bin/muse", enabled: false }), environment: { PATH: "/bin", XDG_DATA_HOME: "/data" }, }; - const first = yield* museUsageReader.directories(instance); - const second = yield* museUsageReader.directories({ - ...instance, - instanceId: ProviderInstanceId.make("another-account"), - config: decodeSettings({ - binaryPath: path.join("/test-home", "bin/muse"), - enabled: true, - }), - environment: { - XDG_DATA_HOME: "/data", - PATH: "/bin", - UNUSED: undefined, - META_API_KEY: "ignored", + const first = yield* museUsageReader.directories(instance, AWAIT_REFRESH); + const second = yield* museUsageReader.directories( + { + ...instance, + instanceId: ProviderInstanceId.make("another-account"), + config: decodeSettings({ + binaryPath: path.join("/test-home", "bin/muse"), + enabled: true, + }), + environment: { + XDG_DATA_HOME: "/data", + PATH: "/bin", + UNUSED: undefined, + META_API_KEY: "ignored", + }, }, - }); + AWAIT_REFRESH, + ); expect(second).toEqual(first); expect(first).toEqual([ { dir: path.join("/native/home", "sessions"), fileName: "session.jsonl" }, @@ -296,6 +331,7 @@ describe("Muse history discovery", () => { expect(close).toHaveBeenCalledOnce(); }).pipe( Effect.provide([NodeServices.layer, MuseUsageHome.layer]), + onPath, Effect.provideService(HostProcess.HomeDirectory, "/test-home"), Effect.provideService(HostProcess.Environment, {}), ), @@ -318,20 +354,26 @@ describe("Muse history discovery", () => { config: undefined, environment: {}, }; - const first = yield* museUsageReader.directories(instance); + const first = yield* museUsageReader.directories(instance, AWAIT_REFRESH); expect( - yield* museUsageReader.directories({ - ...instance, - config: decodeSettings({ binaryPath: "custom-muse" }), - }), + yield* museUsageReader.directories( + { + ...instance, + config: decodeSettings({ binaryPath: "custom-muse" }), + }, + AWAIT_REFRESH, + ), ).toEqual(first); - const changed = yield* museUsageReader.directories({ - ...instance, - environment: { XDG_DATA_HOME: "/other-data" }, - }); + const changed = yield* museUsageReader.directories( + { + ...instance, + environment: { XDG_DATA_HOME: "/other-data" }, + }, + AWAIT_REFRESH, + ); expect(changed).not.toEqual(first); const inherited = yield* museUsageReader - .directories(instance) + .directories(instance, AWAIT_REFRESH) .pipe( Effect.provideService(HostProcess.Environment, { XDG_DATA_HOME: "/new-host-data" }), ); @@ -340,11 +382,12 @@ describe("Muse history discovery", () => { expect(create).toHaveBeenCalledTimes(4); }).pipe( Effect.provide([NodeServices.layer, MuseUsageHome.layer]), + onPath, Effect.provideService(HostProcess.Environment, { XDG_DATA_HOME: "/host-data" }), ), ); - it.effect("shares pending initialization across concurrent directory reads", () => + it.effect("reports pending discovery without waiting and shares it with awaiting reads", () => Effect.gen(function* () { const started = yield* Deferred.make(); const ready = yield* Deferred.make(); @@ -361,11 +404,21 @@ describe("Muse history discovery", () => { config: undefined, environment: {}, }; + const pending = [{ dir: "muse", refreshing: true }]; + expect(yield* museUsageReader.directories(instance, { awaitRefresh: false })).toEqual( + pending, + ); + yield* Deferred.await(started); const reads = yield* Effect.all( - [museUsageReader.directories(instance), museUsageReader.directories(instance)], + [ + museUsageReader.directories(instance, AWAIT_REFRESH), + museUsageReader.directories(instance, AWAIT_REFRESH), + ], { concurrency: "unbounded" }, ).pipe(Effect.forkChild); - yield* Deferred.await(started); + expect(yield* museUsageReader.directories(instance, { awaitRefresh: false })).toEqual( + pending, + ); yield* Deferred.succeed(ready, { initializeResult: { museHome: "/native/home" }, close, @@ -373,9 +426,17 @@ describe("Muse history discovery", () => { const [first, second] = yield* Fiber.join(reads); expect(first).toEqual(second); expect(first).toHaveLength(1); + expect(yield* museUsageReader.directories(instance, { awaitRefresh: false })).toEqual(first); + const uninstalled = Effect.provideService(SpawnExecutableResolution, () => undefined); + expect( + yield* museUsageReader.directories(instance, { awaitRefresh: false }).pipe(uninstalled), + ).toEqual(first); + expect(yield* museUsageReader.directories(instance, AWAIT_REFRESH).pipe(uninstalled)).toEqual( + first, + ); expect(create).toHaveBeenCalledOnce(); expect(close).toHaveBeenCalledOnce(); - }).pipe(Effect.provide([NodeServices.layer, MuseUsageHome.layer])), + }).pipe(Effect.provide([NodeServices.layer, MuseUsageHome.layer]), onPath), ); it.effect("briefly caches failed discovery and retries after installation recovers", () => @@ -400,12 +461,12 @@ describe("Muse history discovery", () => { config: undefined, environment: {}, }; - expect(yield* museUsageReader.directories(instance)).toEqual([]); - expect(yield* museUsageReader.directories(instance)).toEqual([]); + expect(yield* museUsageReader.directories(instance, AWAIT_REFRESH)).toEqual([]); + expect(yield* museUsageReader.directories(instance, { awaitRefresh: false })).toEqual([]); expect(create).toHaveBeenCalledOnce(); yield* TestClock.adjust("30 seconds"); - expect(yield* museUsageReader.directories(instance)).toHaveLength(1); + expect(yield* museUsageReader.directories(instance, AWAIT_REFRESH)).toHaveLength(1); expect(create).toHaveBeenCalledTimes(2); - }).pipe(Effect.provide([NodeServices.layer, MuseUsageHome.layer])), + }).pipe(Effect.provide([NodeServices.layer, MuseUsageHome.layer]), onPath), ); }); diff --git a/packages/provider-muse/src/server/usage.ts b/packages/provider-muse/src/server/usage.ts index b495767c95712..64187d61556d8 100644 --- a/packages/provider-muse/src/server/usage.ts +++ b/packages/provider-muse/src/server/usage.ts @@ -121,7 +121,10 @@ export const museUsageReader: ProviderUsageReader< kind: "transcripts", provider: "muse", format: museUsageFormat, - directories: Effect.fn("museUsageReader.directories")(function* ({ config, environment }) { + directories: Effect.fn("museUsageReader.directories")(function* ( + { config, environment }, + { awaitRefresh }, + ) { const path = yield* Path.Path; const home = yield* HostProcess.HomeDirectory; const usageHome = yield* MuseUsageHome.MuseUsageHome; @@ -131,10 +134,15 @@ export const museUsageReader: ProviderUsageReader< ), yield* HostProcess.Environment, ); + const binaryPath = expandHomePath(config?.binaryPath ?? "muse", home); const museHome = yield* usageHome.resolve({ - binaryPath: expandHomePath(config?.binaryPath ?? "muse", home), + binaryPath, environment: makeMuseEnvironment(processEnvironment), + awaitRefresh, }); - return museHome ? [{ dir: path.join(museHome, "sessions"), fileName: "session.jsonl" }] : []; + if (Option.isNone(museHome)) return [{ dir: binaryPath, refreshing: true }]; + return museHome.value + ? [{ dir: path.join(museHome.value, "sessions"), fileName: "session.jsonl" }] + : []; }), }; From 214f1baf71079c13018737536fac674f17b8ab76 Mon Sep 17 00:00:00 2001 From: Romil Date: Thu, 12 Nov 2026 10:22:38 +1100 Subject: [PATCH 0209/1165] fix(server): capture device screenshots through their selected host (#34313) --- apps/server/src/device/DeviceHubProxy.test.ts | 70 +++++++++++++++++-- apps/server/src/device/DeviceHubProxy.ts | 5 +- 2 files changed, 67 insertions(+), 8 deletions(-) diff --git a/apps/server/src/device/DeviceHubProxy.test.ts b/apps/server/src/device/DeviceHubProxy.test.ts index 72c8f9e0e5d56..3daa305bdfb79 100644 --- a/apps/server/src/device/DeviceHubProxy.test.ts +++ b/apps/server/src/device/DeviceHubProxy.test.ts @@ -27,7 +27,10 @@ const fixture = ( ) => { let finalized = 0; const requests: string[] = []; + const screenshots: Array[0]> = + []; const headers: Array>> = []; + const unexpectedOperation = Effect.die("Unexpected device operation"); const client = HttpClient.make((request, _url, signal) => Effect.gen(function* () { requests.push(request.url); @@ -45,7 +48,7 @@ const fixture = ( const { handler, dispose } = HttpRouter.toWebHandler( DeviceHubProxy.layer.pipe( Layer.provideMerge( - Layer.succeed(EnvironmentAuth.EnvironmentAuth, { + Layer.mock(EnvironmentAuth.EnvironmentAuth)({ authenticateWebSocketUpgrade: () => authError ? Effect.fail(authError) @@ -55,24 +58,50 @@ const fixture = ( method: "bearer-access-token", scopes, }), - } as unknown as EnvironmentAuth.EnvironmentAuth["Service"]), + }), ), Layer.provideMerge( - Layer.succeed(DeviceService.DeviceService, { - currentReadiness: () => + Layer.mock(DeviceService.DeviceService)({ + currentReadiness: (hostId) => Effect.succeed({ - hostId: LOCAL_DEVICE_HOST_ID, + hostId: hostId ?? LOCAL_DEVICE_HOST_ID, + nodePath: "node", hub: { origin: "http://hub.test" }, + devices: { + list: unexpectedOperation, + boot: () => unexpectedOperation, + attach: () => unexpectedOperation, + shutdown: () => unexpectedOperation, + screenshot: () => unexpectedOperation, + }, + run: () => unexpectedOperation, + helpers: { serveSimAxSettings: null, serveSimCli: null }, ...(upstream ? { upstream } : {}), }), - } as DeviceService.DeviceService["Service"]), + screenshot: (input) => + Effect.sync(() => { + screenshots.push(input); + return { + device: { + hostId: input.hostId ?? LOCAL_DEVICE_HOST_ID, + id: input.deviceId, + platform: "ios", + name: "Phone", + version: "27.0", + booted: true, + physical: false, + }, + png: new Uint8Array([137, 80, 78, 71]), + }; + }), + }), ), Layer.provideMerge(Layer.succeed(HttpClient.HttpClient, client)), ), { disableLogger: true }, ); disposers.push(dispose); - return { handler, requests, headers, finalized: () => finalized }; + return { handler, requests, screenshots, headers, finalized: () => finalized }; }; describe("device hub proxy", () => { @@ -239,6 +268,33 @@ describe("device hub proxy", () => { ).toBe(404); expect(requests).toEqual([]); }); + + it.each(["serve-sim", "serve-emu"])( + "captures %s screenshots through hosts with their own stream routing", + async (vendor) => { + const { handler, requests, screenshots } = fixture( + [AuthOrchestrationReadScope], + false, + undefined, + () => Effect.succeed(null), + ); + const response = await handler( + new Request( + `http://t3.test/api/device-hub/vendor/${vendor}/api/screenshot?device=udid-1&hostId=pool`, + { + method: "POST", + }, + ), + ); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("image/png"); + expect(new Uint8Array(await response.arrayBuffer())).toEqual( + new Uint8Array([137, 80, 78, 71]), + ); + expect(requests).toEqual([]); + expect(screenshots).toEqual([{ hostId: "pool", deviceId: "udid-1" }]); + }, + ); }); it.each([ diff --git a/apps/server/src/device/DeviceHubProxy.ts b/apps/server/src/device/DeviceHubProxy.ts index ff7d292564fba..56132433c8869 100644 --- a/apps/server/src/device/DeviceHubProxy.ts +++ b/apps/server/src/device/DeviceHubProxy.ts @@ -214,7 +214,10 @@ const handler = Effect.gen(function* () { upstreamSearch.delete("hostId"); const search = upstreamSearch.size > 0 ? `?${upstreamSearch.toString()}` : ""; const screenshotDevice = upstreamSearch.get("device"); - if (!ready.upstream && hubPath === "/vendor/serve-sim/api/screenshot" && screenshotDevice) { + if ( + screenshotDevice && + ["/vendor/serve-sim/api/screenshot", "/vendor/serve-emu/api/screenshot"].includes(hubPath) + ) { const { png } = yield* devices.screenshot({ hostId: ready.hostId, deviceId: screenshotDevice }); return HttpServerResponse.uint8Array(png, { contentType: "image/png", From f89af364ec11eabba65295492db0b10ad20a3b37 Mon Sep 17 00:00:00 2001 From: Romil Date: Thu, 12 Nov 2026 10:22:58 +1100 Subject: [PATCH 0210/1165] fix(web): avoid false errors when cancelling preview popouts (#34315) --- .../serverPictureInPicture.browser.test.ts | 55 +++++++++++++++++++ .../web/src/browser/serverPictureInPicture.ts | 1 + 2 files changed, 56 insertions(+) create mode 100644 apps/web/src/browser/serverPictureInPicture.browser.test.ts diff --git a/apps/web/src/browser/serverPictureInPicture.browser.test.ts b/apps/web/src/browser/serverPictureInPicture.browser.test.ts new file mode 100644 index 0000000000000..d1f439fae786c --- /dev/null +++ b/apps/web/src/browser/serverPictureInPicture.browser.test.ts @@ -0,0 +1,55 @@ +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { expect, it, vi } from "vite-plus/test"; + +const mocks = vi.hoisted(() => ({ readAccess: vi.fn() })); + +vi.mock(import("~/state/previewStream"), () => ({ + readPreviewStreamAccess: mocks.readAccess, +})); + +import { closeServerPictureInPicture, openServerPictureInPicture } from "./serverPictureInPicture"; + +function setup() { + return { + [Symbol.dispose]() { + closeServerPictureInPicture(); + mocks.readAccess.mockReset(); + }, + }; +} + +it("cancels pending playback without reporting a failed popout", async () => { + using _cleanup = setup(); + const opening = openServerPictureInPicture({ + environmentId: EnvironmentId.make("environment"), + threadId: ThreadId.make("thread"), + tabId: "tab", + seed: null, + }); + const video = document.querySelector("video")!; + const tracks = (video.srcObject as MediaStream).getTracks(); + closeServerPictureInPicture(); + await expect(opening).resolves.toBeUndefined(); + expect(video.isConnected).toBe(false); + expect(tracks.length).toBeGreaterThan(0); + expect(tracks.every((track) => track.readyState === "ended")).toBe(true); + expect(mocks.readAccess).not.toHaveBeenCalled(); +}); + +it("still reports playback failures for an active popout", async () => { + using _cleanup = setup(); + const error = new Error("Playback failed"); + using _play = vi.spyOn(HTMLMediaElement.prototype, "play").mockRejectedValue(error); + const opening = openServerPictureInPicture({ + environmentId: EnvironmentId.make("environment"), + threadId: ThreadId.make("thread"), + tabId: "tab", + seed: null, + }); + const video = document.querySelector("video")!; + const tracks = (video.srcObject as MediaStream).getTracks(); + await expect(opening).rejects.toBe(error); + expect(video.isConnected).toBe(false); + expect(tracks.every((track) => track.readyState === "ended")).toBe(true); + expect(mocks.readAccess).not.toHaveBeenCalled(); +}); diff --git a/apps/web/src/browser/serverPictureInPicture.ts b/apps/web/src/browser/serverPictureInPicture.ts index fda9ec7fa7294..04b6d3ddd8dbd 100644 --- a/apps/web/src/browser/serverPictureInPicture.ts +++ b/apps/web/src/browser/serverPictureInPicture.ts @@ -175,6 +175,7 @@ export async function openServerPictureInPicture(input: { video.webkitSetPresentationMode?.("picture-in-picture"); } } catch (error) { + if (stopped) return; onLeave(); throw error; } From e9df5914d4e882ef30af9ba95ac508781184d2e2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 15:23:16 -0800 Subject: [PATCH 0211/1165] fix(cursor): a completed catalog refresh drops models it no longer lists (#28018) --- .../src/provider/ProviderRegistry.test.ts | 41 +++++++++++++++++++ apps/server/src/provider/ProviderRegistry.ts | 9 ++-- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index 5c53f76237b6a..170b2e72b6d5e 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -773,6 +773,7 @@ it.layer( } as const satisfies ServerProvider; const refreshedProvider = { ...previousProvider, + status: "warning", checkedAt: "2026-04-14T00:01:00.000Z", models: [], slashCommands: [], @@ -918,6 +919,45 @@ it.layer( ); }); + it("drops Cursor models a completed catalog read no longer returns", () => { + const previousProvider = { + instanceId: ProviderInstanceId.make("cursor"), + driver: ProviderDriverKind.make("cursor"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-10-30T00:00:00.000Z", + version: "1.0.0", + models: [ + { slug: "composer-2", name: "Composer 2", isCustom: false, capabilities: null }, + { slug: "retired-model-x", name: "Retired", isCustom: false, capabilities: null }, + ], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const refreshedProvider = { + ...previousProvider, + checkedAt: "2026-10-30T00:01:00.000Z", + models: [previousProvider.models[0]], + } satisfies ServerProvider; + const failedProvider = { + ...refreshedProvider, + status: "error", + auth: { status: "unknown" }, + message: "Cursor SDK model discovery timed out.", + } satisfies ServerProvider; + + assert.deepStrictEqual( + ProviderRegistry.mergeProviderSnapshot(previousProvider, refreshedProvider).models, + [previousProvider.models[0]], + ); + assert.deepStrictEqual( + ProviderRegistry.mergeProviderSnapshot(previousProvider, failedProvider).models, + [...previousProvider.models], + ); + }); + it("retains stale OpenCode models when a refresh fails", () => { const previousProvider = { instanceId: ProviderInstanceId.make("opencode"), @@ -2338,6 +2378,7 @@ it.layer( } as const satisfies ServerProvider; const refreshedProvider = { ...initialProvider, + status: "warning", checkedAt: "2026-04-14T00:01:00.000Z", models: [], } satisfies ServerProvider; diff --git a/apps/server/src/provider/ProviderRegistry.ts b/apps/server/src/provider/ProviderRegistry.ts index a0ea5c78fbc61..fe5375136dc25 100644 --- a/apps/server/src/provider/ProviderRegistry.ts +++ b/apps/server/src/provider/ProviderRegistry.ts @@ -195,9 +195,12 @@ const shouldRetainMissingProviderModels = (provider: ServerProvider): boolean => ); } - if (provider.driver === ProviderDriverKind.make("grok")) { - // Grok reports ready only after it discovers a model list; the pending, - // ACP-failed, empty-inventory, and logged-out probes are warning/error. + if ( + provider.driver === ProviderDriverKind.make("grok") || + provider.driver === ProviderDriverKind.make("cursor") + ) { + // Grok and Cursor report ready only after they discover a model list; the pending, + // failed, empty-inventory, and logged-out probes are warning/error. return !(provider.installed && provider.status === "ready"); } From 62e68f3166880362e54b4d277e66da901f629a34 Mon Sep 17 00:00:00 2001 From: Romil Date: Thu, 12 Nov 2026 10:23:38 +1100 Subject: [PATCH 0212/1165] fix(provider-core): isolate npm version caches by lookup environment (#34309) --- .../src/server/maintenanceResolver.test.ts | 51 +++++++++++++++++++ .../src/server/maintenanceResolver.ts | 16 ++++++ 2 files changed, 67 insertions(+) diff --git a/packages/provider-core/src/server/maintenanceResolver.test.ts b/packages/provider-core/src/server/maintenanceResolver.test.ts index e5298def7f0da..da67da0404771 100644 --- a/packages/provider-core/src/server/maintenanceResolver.test.ts +++ b/packages/provider-core/src/server/maintenanceResolver.test.ts @@ -421,6 +421,57 @@ it.layer( { skip: !symlinksSupported || windowsHost }, ); + it.effect.skipIf(!symlinksSupported || windowsHost)( + "keeps configured npm registries' latest versions apart for one install", + () => + Effect.gen(function* () { + const tempDir = yield* makeTempDir("t3-npm-registry-isolation"); + yield* Effect.addFinalizer(() => + Effect.sync(() => NodeFS.rmSync(tempDir, { recursive: true, force: true })), + ); + const npmBinDir = NodePath.join(tempDir, "npm-bin"); + writeExecutable(NodePath.join(npmBinDir, "npm")); + const binaryPath = linkIntoPackage(tempDir, "package-tool", [ + "lib", + "node_modules", + "@example", + "package-tool", + ]); + let probes = 0; + const latest = (registry: string, version: string) => + resolveProviderMaintenanceCapabilitiesEffect(packageToolUpdate, { + binaryPath, + env: { PATH: npmBinDir, NPM_CONFIG_REGISTRY: registry }, + }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + stdoutSpawner(() => { + probes++; + return JSON.stringify(version); + }), + ), + Effect.tap((capabilities) => + Effect.sync(() => { + expect(capabilities.latestVersionCacheKey ?? "").not.toContain("private-token"); + }), + ), + Effect.flatMap(resolveLatestProviderVersion), + ); + const first = "https://user:private-token@first.example.test"; + const second = "https://second.example.test"; + expect(yield* latest(first, "1.1.0")).toBe("1.1.0"); + expect(yield* latest(second, "2.0.0")).toBe("2.0.0"); + expect(yield* latest(first, "1.2.0")).toBe("1.1.0"); + expect(probes).toBe(2); + }).pipe( + Effect.provide(ProviderLatestVersions.layer), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("configured registries must not use public npm")), + ), + ), + ); + it("pins the advertised stable version in a pnpm-global update command", () => { const advisoryFor = ( maintenanceCapabilities: ReturnType, diff --git a/packages/provider-core/src/server/maintenanceResolver.ts b/packages/provider-core/src/server/maintenanceResolver.ts index bb60e8e865714..920574ef50d7b 100644 --- a/packages/provider-core/src/server/maintenanceResolver.ts +++ b/packages/provider-core/src/server/maintenanceResolver.ts @@ -938,6 +938,14 @@ const decodeNpmViewVersion = Schema.decodeUnknownOption( Schema.fromJsonString(Schema.Union([Schema.String, Schema.Tuple([Schema.String])])), ); +function environmentCacheKey(env: NodeJS.ProcessEnv): string { + return NodeCrypto.createHash("sha256") + .update( + JSON.stringify(Object.entries(env).toSorted(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))), + ) + .digest("hex"); +} + const withConfiguredNpmVersionLookup = Effect.fn("withConfiguredNpmVersionLookup")(function* ( capabilities: ProviderMaintenanceCapabilities, ) { @@ -981,6 +989,14 @@ const withConfiguredNpmVersionLookup = Effect.fn("withConfiguredNpmVersionLookup ...capabilities, // npm owns registry, scoped auth and proxy configuration; failure must not retry public npm. latestVersion: null, + latestVersionCacheKey: [ + "configured-npm", + npmPath, + packageName, + prefix ?? "", + environmentCacheKey(env), + installerLatestVersionKey(capabilities) ?? "", + ].join("\0"), lookupLatestVersion: capabilities.lookupLatestVersion ? capabilities.lookupLatestVersion.pipe( Effect.filterOrElse( From a308715773b66f1de9403039b6f359bab1961388 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 15:23:52 -0800 Subject: [PATCH 0213/1165] fix(provider-acp): Hermes html_render pages show in the thread (#29141) --- .../src/server/runtimeModel.test.ts | 23 +++++++++++++++++++ .../provider-acp/src/server/runtimeModel.ts | 9 ++++++++ 2 files changed, 32 insertions(+) diff --git a/packages/provider-acp/src/server/runtimeModel.test.ts b/packages/provider-acp/src/server/runtimeModel.test.ts index 1495d40cc8a18..5b0e3327d9819 100644 --- a/packages/provider-acp/src/server/runtimeModel.test.ts +++ b/packages/provider-acp/src/server/runtimeModel.test.ts @@ -1366,6 +1366,29 @@ describe("extractMcpToolCallIdentity", () => { } }); + it("recovers T3 identity from Hermes editor-renamed MCP tool titles", () => { + const identityFor = (title: string) => + extractMcpToolCallIdentity( + toolCallFromUpdate({ + sessionUpdate: "tool_call", + toolCallId: "hermes-1", + kind: "other", + title, + status: "pending", + rawInput: { title: "Chart", html: "

hi

", height: 320 }, + }), + ); + + expect(identityFor("mcp__editor_t3_code_f6129534__html_render")).toEqual({ + server: "t3-code", + tool: "html_render", + }); + expect(identityFor("mcp__editor_github_1234abcd__html_render")).toEqual({ + server: "editor_github_1234abcd", + tool: "html_render", + }); + }); + it("recovers T3 identity from Kiro's @-scoped titles", () => { for (const tool of ["html_render", "t3_thread_read", "delegate_task"]) { const toolCall = toolCallFromUpdate({ diff --git a/packages/provider-acp/src/server/runtimeModel.ts b/packages/provider-acp/src/server/runtimeModel.ts index 454fa3011ed39..6b9f0a01dde43 100644 --- a/packages/provider-acp/src/server/runtimeModel.ts +++ b/packages/provider-acp/src/server/runtimeModel.ts @@ -1161,6 +1161,15 @@ export function extractMcpToolCallIdentity( for (const candidate of candidates) { const trimmed = candidate.trim(); const qualified = /^mcp__(.+?)__(.+)$/i.exec(trimmed); + // Hermes renames client MCP servers to editor__<8 hex of a per-session hash>. + if ( + qualified?.[1] && + qualified[2] && + /^editor_t3_?code_[0-9a-f]{8}$/.test(qualified[1]) && + T3_MCP_TOOL_NAMES.has(qualified[2]) + ) { + return { server: "t3-code", tool: qualified[2] }; + } if (qualified?.[1] && qualified[2] && !/^t3[-_ ]?code$/i.test(qualified[1])) return { server: qualified[1], tool: qualified[2] }; const match = From c2ff84a4bbafe5b86422e27d985165cd7220baff Mon Sep 17 00:00:00 2001 From: Romil Date: Thu, 12 Nov 2026 10:23:56 +1100 Subject: [PATCH 0214/1165] fix(client): trim model-reference delimiters in linear time (#34311) --- .../src/composerModelMentions.test.ts | 14 +++++++++---- .../src/composerModelMentions.ts | 21 ++++++++++++------- 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/packages/client-runtime/src/composerModelMentions.test.ts b/packages/client-runtime/src/composerModelMentions.test.ts index b6a51a71301fe..dfd0f6ac9acb5 100644 --- a/packages/client-runtime/src/composerModelMentions.test.ts +++ b/packages/client-runtime/src/composerModelMentions.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import { collectComposerModelMentions, @@ -85,10 +85,16 @@ describe("collectComposerModelMentions", () => { ["model:codex/gpt-6.1,model:claudeAgent/sonnet", ["codex/gpt-6.1", "claudeAgent/sonnet"]], ["model:codex/gpt-6.1's output", ["codex/gpt-6.1"]], ["model:opencode/foo(bar)", ["opencode/foo(bar)"]], + ["(model:codex/GPT-6.1-Sol[1m]).", ["codex/GPT-6.1-Sol[1m]"]], + ["model:codex/gpt-6.1" + ")".repeat(128), ["codex/gpt-6.1"]], ])("bounds the reference in %s", (text, expected) => { - expect( - collectComposerModelMentions(text).map(({ instanceId, model }) => `${instanceId}/${model}`), - ).toEqual(expected); + const { mentions, rescans } = (() => { + using split = vi.spyOn(String.prototype, "split"); + const mentions = collectComposerModelMentions(text); + return { mentions, rescans: split.mock.calls.length }; + })(); + expect(mentions.map(({ instanceId, model }) => `${instanceId}/${model}`)).toEqual(expected); + expect(rescans).toBeLessThanOrEqual(expected.length * 4); }); it.each(["remodel:codex/gpt", "model:codex", "model:/gpt", "model:codex/.", "model:1x/gpt"])( diff --git a/packages/client-runtime/src/composerModelMentions.ts b/packages/client-runtime/src/composerModelMentions.ts index 29af1b8deb73b..140786d778ee2 100644 --- a/packages/client-runtime/src/composerModelMentions.ts +++ b/packages/client-runtime/src/composerModelMentions.ts @@ -68,15 +68,22 @@ function countOf(text: string, char: string): number { // Slugs like `foo(bar)` or `GPT-6.1-Sol[1m]` keep balanced brackets; prose wrapping them does not. function trimModelSlug(slug: string): string { - let trimmed = slug; - while (trimmed.length > 0) { - const last = trimmed.at(-1)!; + let end = slug.length; + const unmatched = new Map(); + while (end > 0) { + const last = slug[end - 1]!; + if (SENTENCE_PUNCTUATION.includes(last)) { + end -= 1; + continue; + } const opening = CLOSING_BRACKETS[last]; - const unbalanced = opening !== undefined && countOf(trimmed, opening) < countOf(trimmed, last); - if (!SENTENCE_PUNCTUATION.includes(last) && !unbalanced) break; - trimmed = trimmed.slice(0, -1); + if (opening === undefined) break; + const excess = unmatched.get(last) ?? countOf(slug, last) - countOf(slug, opening); + if (excess <= 0) break; + unmatched.set(last, excess - 1); + end -= 1; } - return trimmed; + return slug.slice(0, end); } /** Finds the `model:/` references that `formatComposerModelMention` writes. */ From c7618f6c10120bc7e684df6a0d7275679d8c260e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 15:24:30 -0800 Subject: [PATCH 0215/1165] fix(server): a steer that cuts off a streaming Claude reply marks it interrupted (#34597) --- .../Adapters/ClaudeAdapterV2.test.ts | 22 +++++++ .../Adapters/ClaudeAdapterV2.ts | 64 +++++++++++++++---- .../components/chat/TimelineAssistantRows.tsx | 17 ++++- 3 files changed, 89 insertions(+), 14 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 39dcd39f158c3..84910fd4baaec 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -6861,6 +6861,15 @@ describe("ClaudeAdapterV2 background wake turns", () => { }); assert.equal(harness.offeredMessages[1]?.priority, "now"); } + const partialText = "The audit covers the `integ"; + if (!queryExits) + yield* Queue.offer( + harness.sdkMessages, + makeAssistantTextFrame({ + uuid: "00000000-0000-4000-8000-000000000900", + text: partialText, + }), + ); yield* harness.offerAndWait( makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000901", @@ -6897,6 +6906,19 @@ describe("ClaudeAdapterV2 background wake turns", () => { terminal.status, queryExits ? "failed" : steered ? "completed" : "interrupted", ); + if (steered && !queryExits && terminalReason === "aborted_streaming") { + // The steer cut this reply off mid-stream; it must not read as a finished answer. + const partial = harness.events.findLast( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "assistant_message" && + event.turnItem.text === partialText, + ); + assert.equal( + partial?.type === "turn_item.updated" ? partial.turnItem.status : undefined, + "interrupted", + ); + } if (steered) { if (!queryExits) assert.isTrue( diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 9df52082eacca..e98276b898e7b 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -4271,6 +4271,7 @@ function buildAssistantArtifacts(input: { readonly nativeItemId: string; readonly text: string; readonly ordinal: number; + readonly status?: "completed" | "interrupted"; readonly startedAt: DateTime.Utc; readonly completedAt: DateTime.Utc; }): { @@ -4304,7 +4305,7 @@ function buildAssistantArtifacts(input: { parentNodeId: input.turnInput.rootNodeId, rootNodeId: input.turnInput.rootNodeId, kind: "assistant_message", - status: "completed", + status: input.status ?? "completed", countsForRun: false, providerThreadId: input.turnInput.providerThread.id, providerTurnId: input.providerTurnId, @@ -4338,7 +4339,7 @@ function buildAssistantArtifacts(input: { nativeItemRef, parentItemId: null, ordinal: input.ordinal, - status: "completed", + status: input.status ?? "completed", title: null, startedAt: input.startedAt, completedAt: input.completedAt, @@ -4408,6 +4409,12 @@ interface ActiveClaudeTurnContext { fallbackText: string; fallbackNativeItemId: string; emittedNativeItemIds: Set; + // The root reply the latest assistant frame carried; a steer's aborted_streaming cuts it off. + lastReply: { + readonly nativeItemId: string; + readonly text: string; + readonly startedAt: DateTime.Utc; + } | null; // The CLI repeats a notice that ends a prompt as the turn's result text. noticeTexts: Set; }; @@ -8518,16 +8525,14 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }); }); - const emitAssistantTextArtifacts = Effect.fnUntraced(function* (input: { + const publishAssistantArtifacts = Effect.fnUntraced(function* (input: { readonly context: ActiveClaudeTurnContext; readonly nativeItemId: string; readonly text: string; + readonly status: "completed" | "interrupted"; + readonly startedAt: DateTime.Utc; + readonly completedAt: DateTime.Utc; }) { - if (input.context.assistant.emittedNativeItemIds.has(input.nativeItemId)) { - return; - } - input.context.assistant.emittedNativeItemIds.add(input.nativeItemId); - const now = yield* DateTime.now; const ordinal = yield* resolveItemOrdinal(input.context, input.nativeItemId); const artifacts = buildAssistantArtifacts({ idAllocator, @@ -8536,8 +8541,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( nativeItemId: input.nativeItemId, text: input.text, ordinal, - startedAt: now, - completedAt: now, + status: input.status, + startedAt: input.startedAt, + completedAt: input.completedAt, }); yield* Effect.all( [ @@ -8561,6 +8567,25 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ); }); + const emitAssistantTextArtifacts = Effect.fnUntraced(function* (input: { + readonly context: ActiveClaudeTurnContext; + readonly nativeItemId: string; + readonly text: string; + }) { + if (input.context.assistant.emittedNativeItemIds.has(input.nativeItemId)) { + return undefined; + } + input.context.assistant.emittedNativeItemIds.add(input.nativeItemId); + const now = yield* DateTime.now; + yield* publishAssistantArtifacts({ + ...input, + status: "completed", + startedAt: now, + completedAt: now, + }); + return now; + }); + const endSubagentsOfClosedQuery = Effect.fnUntraced(function* (input: { readonly closedQuery: ClaudeLiveQueryContext; readonly context: ActiveClaudeTurnContext; @@ -10189,6 +10214,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( } } const assistantText = assistantTextFromSdkMessage(message); + if (message.type === "assistant" && assistantParentToolUseId === null) { + context.assistant.lastReply = null; + } if ( assistantText !== null && assistantText.text.length > 0 && @@ -10237,11 +10265,14 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( return; } if (assistantText !== null && assistantText.text.length > 0) { - yield* emitAssistantTextArtifacts({ + const startedAt = yield* emitAssistantTextArtifacts({ context, nativeItemId: assistantText.nativeItemId, text: assistantText.text, }); + if (startedAt !== undefined) { + context.assistant.lastReply = { ...assistantText, startedAt }; + } return; } @@ -10314,6 +10345,16 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const interrupted = (yield* Ref.get(interruptedTurns)).has(context.providerTurnId); const wasSteered = (yield* Ref.get(steeredTurns)).has(context.providerTurnId); if (!interrupted && wasSteered && isClaudeActiveSteeringAbortResult(message)) { + const cutOffReply = context.assistant.lastReply; + context.assistant.lastReply = null; + if (message.terminal_reason === "aborted_streaming" && cutOffReply !== null) { + yield* publishAssistantArtifacts({ + context, + ...cutOffReply, + status: "interrupted", + completedAt, + }); + } context.steeredSegmentUsage.push(message.usage); context.steeredLedgerUsage = addClaudeLedgerUsage( context.steeredLedgerUsage, @@ -11578,6 +11619,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( fallbackText: "", fallbackNativeItemId: `assistant:${turnInput.runId}`, emittedNativeItemIds: new Set(), + lastReply: null, noticeTexts: new Set(), }, reasoning: { diff --git a/apps/web/src/components/chat/TimelineAssistantRows.tsx b/apps/web/src/components/chat/TimelineAssistantRows.tsx index 732efb40897f0..316f852ae9c6d 100644 --- a/apps/web/src/components/chat/TimelineAssistantRows.tsx +++ b/apps/web/src/components/chat/TimelineAssistantRows.tsx @@ -70,6 +70,11 @@ export function AssistantTimelineRow({ row }: { row: Extract ) : null} + {!row.showAssistantMeta && row.projectedItem?.item.status === "interrupted" ? ( +
+ +
+ ) : null} {row.showAssistantMeta ? ( {projectedItem && projectedItem.item.status !== "completed" ? ( - - {projectedItem.item.status} - + ) : null} + {status} + + ); +} + function AssistantCopyButton({ message, showCopyButton, From 7a64505a21e755498e6b85794124f3754265db38 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 15:24:33 -0800 Subject: [PATCH 0216/1165] fix(server): preserve message order across lineage reads (#24442) --- .../Orchestrator.control-reads.test.ts | 75 +++++++++++++++++++ .../src/orchestration-v2/Orchestrator.ts | 37 +++++---- .../testkit/ProviderReplayHarness.ts | 16 +++- 3 files changed, 113 insertions(+), 15 deletions(-) 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 870e0d24c9238..6cb8356889e3b 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts @@ -19,7 +19,9 @@ import { TurnItemId, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Metric from "effect/Metric"; import * as Result from "effect/Result"; @@ -2083,3 +2085,76 @@ it.effect("dispatch records the command outcome and its duration", () => assert.strictEqual((yield* durations).count - durationsBefore, 1); }).pipe(Effect.provide(layerTest)), ); + +it.effect("a user message dispatched first stays run 1 when its lineage read is slow", () => { + const threadId = ThreadId.make("thread:dispatch-order"); + // A file database serves projection reads from a reader worker, so the lineage read a user + // dispatch makes before taking the thread lock can yield. This gate holds it open instead. + const lineageReadEntered = Deferred.makeUnsafe(); + const lineageReadRelease = Deferred.makeUnsafe(); + let gateArmed = false; + const layerGatedLineageRead = ProviderReplayHarness.layerWithRegistry( + { name: "dispatch-order" }, + ProviderAdapterRegistry.layerFromAdapters([adapter]), + { + runEffectWorker: false, + wrapProjectionStore: (store) => ({ + ...store, + getThread: (id) => { + if (!gateArmed || id !== threadId) return store.getThread(id); + gateArmed = false; + return Deferred.succeed(lineageReadEntered, undefined).pipe( + Effect.andThen(Deferred.await(lineageReadRelease)), + Effect.andThen(store.getThread(id)), + ); + }, + }), + }, + ); + return Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("create-dispatch-order"), + threadId, + projectId: ProjectId.make("project:dispatch-order"), + title: "Dispatch order", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdBy: "user", + creationSource: "web", + }); + const message = (id: string, createdBy: "user" | "agent") => + orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(id), + threadId, + messageId: MessageId.make(id), + text: id, + attachments: [], + dispatchMode: { type: "defer_start" }, + createdBy, + creationSource: createdBy === "user" ? "web" : "server", + }); + + gateArmed = true; + const user = yield* message("message:user-first", "user").pipe(Effect.forkChild); + yield* Deferred.await(lineageReadEntered); + // Agent messages skip the lineage read, so this one would take a free thread lock first. + const agent = yield* message("message:agent-second", "agent").pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* Deferred.succeed(lineageReadRelease, undefined); + yield* Fiber.join(agent); + yield* Fiber.join(user); + + const { runs } = yield* orchestrator.getThreadProjection(threadId); + assert.deepEqual( + runs.toSorted((left, right) => left.ordinal - right.ordinal).map((run) => run.userMessageId), + [MessageId.make("message:user-first"), MessageId.make("message:agent-second")], + ); + }).pipe(Effect.provide(layerGatedLineageRead)); +}); diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 6e38afbf6430e..44fe331d05161 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -77,6 +77,7 @@ import { TrimmedNonEmptyString, managedRuntimeModeCeilingMessage, } from "@t3tools/contracts"; +import * as KeyedLock from "@t3tools/shared/KeyedLock"; import { modelSelectionsEqual } from "@t3tools/shared/model"; import { modelSwitchSince } from "./modelSwitch.ts"; import { recordThreadOpen } from "@t3tools/shared/paletteSearch"; @@ -1313,6 +1314,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const runtimePolicy = yield* RuntimePolicyV2; const threadForkService = yield* ThreadForkServiceV2; const threadDispatch = yield* ThreadCommandExecutor.ThreadCommandExecutor; + const messageIntake = yield* KeyedLock.make(); // Read once: the policy is fixed for the server's life, and dispatch takes it as an input. const { managedPolicy } = serverSettings; const runtimeModeCeiling: RuntimeModeCeiling | undefined = @@ -14674,6 +14676,15 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio } satisfies OrchestratorV2DispatchResult; }, withCommandMetrics); + // Follow-up intake and Stop serialize on the parent before taking the child lock. + const withSubagentParentLock = (threadId: ThreadId, dispatch: Effect.Effect) => + appOwnedSubagentParentThreadId(threadId).pipe( + Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause })), + Effect.flatMap((parentThreadId) => + parentThreadId === undefined ? dispatch : threadDispatch.withLock(parentThreadId, dispatch), + ), + ); + // dispatchWithReceiptEffect owns the dispatch span; this wrapper selects the thread locks. const dispatchWithReceipt = Effect.fnUntraced(function* (command: OrchestrationV2ServerCommand) { const dispatch = threadDispatch.withLock( @@ -14706,21 +14717,19 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ), ), ); + // Taken before the lineage read, so a message whose read yields keeps its + // place ahead of later messages to the same thread. + if (command.type === "message.dispatch") + return yield* messageIntake.withLock( + command.threadId, + mayStartSubagentFollowup(command) + ? withSubagentParentLock(command.threadId, dispatch) + : dispatch, + ); // Answering a message-mode question can also start a follow-up. - if ( - command.type !== "runtime-request.respond" && - (command.type !== "message.dispatch" || !mayStartSubagentFollowup(command)) - ) - return yield* dispatch; - const parentThreadId = yield* appOwnedSubagentParentThreadId(command.threadId).pipe( - Effect.mapError( - (cause) => new OrchestratorProjectionError({ threadId: command.threadId, cause }), - ), - ); - // Follow-up intake and Stop serialize on the parent before taking the child lock. - return yield* parentThreadId === undefined - ? dispatch - : threadDispatch.withLock(parentThreadId, dispatch); + return yield* command.type === "runtime-request.respond" + ? withSubagentParentLock(command.threadId, dispatch) + : dispatch; }); const handleRunUpdate = Effect.fn("Orchestrator.handleRunUpdate")( diff --git a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts index 363164b688043..0939d46b17bf4 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts @@ -282,6 +282,10 @@ export function layerWithRegistry( readonly runContinuationWorker?: boolean; readonly recoverOnStartup?: boolean; readonly continueThreadsAfterServerUpdate?: boolean; + /** Lets a test hold a projection read open to order concurrent dispatches. */ + readonly wrapProjectionStore?: ( + store: ProjectionStore.ProjectionStoreV2Shape, + ) => ProjectionStore.ProjectionStoreV2Shape; readonly managedPolicy?: ManagedPolicyDocument; } = {}, ): Layer.Layer< @@ -322,9 +326,19 @@ export function layerWithRegistry( ), ), ).pipe(Layer.orDie); + const wrapProjectionStore = options.wrapProjectionStore; + const layerProjectionStore = + wrapProjectionStore === undefined + ? ProjectionStore.layer + : Layer.effect( + ProjectionStore.ProjectionStoreV2, + Effect.gen(function* () { + return wrapProjectionStore(yield* ProjectionStore.ProjectionStoreV2); + }), + ).pipe(Layer.provide(ProjectionStore.layer)); const layerStores = Layer.mergeAll( EventStore.layer, - ProjectionStore.layer, + layerProjectionStore, ProjectStore.layer, CommandReceiptStore.layer, EffectOutbox.layer, From 3de3c9ffff678a0c54d324e9d0ef7b5767a95e54 Mon Sep 17 00:00:00 2001 From: Romil Date: Thu, 12 Nov 2026 10:24:50 +1100 Subject: [PATCH 0217/1165] fix(web): keep a leading blank line selected when indenting code (#34307) --- apps/web/src/composer-code-block.test.ts | 10 +++++++--- apps/web/src/composer-code-block.ts | 2 +- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/web/src/composer-code-block.test.ts b/apps/web/src/composer-code-block.test.ts index 567e6c0759532..0affbe76dcf88 100644 --- a/apps/web/src/composer-code-block.test.ts +++ b/apps/web/src/composer-code-block.test.ts @@ -144,11 +144,15 @@ describe("indentCodeBlock", () => { expect(textOf(editor)).toBe("one\ntwo"); }); - it("keeps the selection across the shifted lines", () => { - const editor = codeEditor("one\ntwo", { from: 0, to: 7 }); + it.each([ + ["one\ntwo", " one\n two", 5], + ["\none\ntwo", "\n one\n two", 1], + ])("keeps the selection across the shifted lines in %s", (code, expected, from) => { + const editor = codeEditor(code, { from: 0, to: code.length }); indentCodeBlock(editor.state, "in", (tr) => editor.view.dispatch(tr)); indentCodeBlock(editor.state, "in", (tr) => editor.view.dispatch(tr)); - expect(textOf(editor)).toBe(" one\n two"); + expect(textOf(editor)).toBe(expected); + expect(editor.state.selection.from).toBe(from); }); it("reports handled when outdenting can go no further, so Tab never escapes the editor", () => { diff --git a/apps/web/src/composer-code-block.ts b/apps/web/src/composer-code-block.ts index bf220d1715662..30c7630473160 100644 --- a/apps/web/src/composer-code-block.ts +++ b/apps/web/src/composer-code-block.ts @@ -137,7 +137,7 @@ export function indentCodeBlock( return true; } - const lineStart = block.text.lastIndexOf("\n", Math.max(0, startOffset - 1)) + 1; + const lineStart = startOffset === 0 ? 0 : block.text.lastIndexOf("\n", startOffset - 1) + 1; const newlineAfter = block.text.indexOf("\n", Math.max(startOffset, endOffset - 1)); const lineEnd = newlineAfter === -1 ? block.text.length : newlineAfter; From eb964beccceae25113e0f954613bbcd2e43f7131 Mon Sep 17 00:00:00 2001 From: Matthew Kulina Date: Wed, 11 Nov 2026 18:25:24 -0500 Subject: [PATCH 0218/1165] fix(server): prevent HTML preview navigation timeouts (#34403) --- apps/server/src/htmlRender/HtmlRender.test.ts | 1 + apps/server/src/htmlRender/headlessChrome.ts | 9 ++++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/server/src/htmlRender/HtmlRender.test.ts b/apps/server/src/htmlRender/HtmlRender.test.ts index 859491e800029..9d84d29e43e1a 100644 --- a/apps/server/src/htmlRender/HtmlRender.test.ts +++ b/apps/server/src/htmlRender/HtmlRender.test.ts @@ -453,6 +453,7 @@ process.on("SIGTERM", () => {}); const quit = () => { fs.writeFileSync(${JSON.stringify(exitRecord)}, "asked to close"); process.exit(0); }; const reply = (message) => fs.writeSync(4, JSON.stringify(message) + "\\0"); const results = { + "Target.createBrowserContext": { browserContextId: "context" }, "Target.createTarget": { targetId: "page" }, "Target.attachToTarget": { sessionId: "session" }, "Runtime.evaluate": { result: { value: 100 } }, diff --git a/apps/server/src/htmlRender/headlessChrome.ts b/apps/server/src/htmlRender/headlessChrome.ts index ea50cce46cd3b..6a2cb37743472 100644 --- a/apps/server/src/htmlRender/headlessChrome.ts +++ b/apps/server/src/htmlRender/headlessChrome.ts @@ -418,10 +418,17 @@ const launchBrowser = Effect.fnUntraced(function* (input: { ); }); + // Chrome's default context can stall Fetch interception during navigation. + const { browserContextId } = yield* send( + "Target.createBrowserContext", + {}, + Schema.Struct({ browserContextId: Schema.String }), + ); + const openPage = Effect.fnUntraced(function* (width: number, appearance: ThemeAppearance) { const { targetId } = yield* send( "Target.createTarget", - { url: "about:blank" }, + { url: "about:blank", browserContextId }, Schema.Struct({ targetId: Schema.String }), ); const { sessionId } = yield* send( From 3d12c90ea826f55ef4d975d1b8bed9d640287d10 Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Thu, 12 Nov 2026 04:56:16 +0530 Subject: [PATCH 0219/1165] perf(shared): avoid quadratic file link labels (#34340) --- packages/shared/src/fileLinks.test.ts | 76 ++++++++++++++++++++++++++- packages/shared/src/fileLinks.ts | 23 ++++---- 2 files changed, 84 insertions(+), 15 deletions(-) diff --git a/packages/shared/src/fileLinks.test.ts b/packages/shared/src/fileLinks.test.ts index ca6ab06e58c66..541ac6506f8b4 100644 --- a/packages/shared/src/fileLinks.test.ts +++ b/packages/shared/src/fileLinks.test.ts @@ -1,11 +1,85 @@ -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import { + buildFileLinkParentSuffixByPath, fileLinkLabel, parseFileUrlHref, splitFilePathPosition, resolvePathLinkTarget, } from "./fileLinks.ts"; +describe("buildFileLinkParentSuffixByPath", () => { + it.each([ + { + paths: ["", "/", "index.ts", "/repo/src/index.ts"], + suffixes: [["/repo/src/index.ts", "repo/src"]], + }, + { paths: ["/repo/a.ts", "/repo/b.ts", "/repo/a.ts"], suffixes: [] }, + { + paths: ["/repo/src/index.ts", "/repo/test/index.ts"], + suffixes: [ + ["/repo/src/index.ts", "repo/src"], + ["/repo/test/index.ts", "repo/test"], + ], + }, + { + paths: ["/repo/a/src/index.ts", "/repo/b/src/index.ts"], + suffixes: [ + ["/repo/a/src/index.ts", "a/src"], + ["/repo/b/src/index.ts", "b/src"], + ], + }, + { + paths: ["/a/pkg/src/index.ts", "/b/pkg/src/index.ts"], + suffixes: [ + ["/a/pkg/src/index.ts", "a/pkg/src"], + ["/b/pkg/src/index.ts", "b/pkg/src"], + ], + }, + { + paths: ["src/index.ts", "/repo/src/index.ts"], + suffixes: [ + ["src/index.ts", "src"], + ["/repo/src/index.ts", "repo/src"], + ], + }, + { + paths: ["/repo//src/index.ts", "/repo/src/index.ts"], + suffixes: [ + ["/repo//src/index.ts", "repo/src"], + ["/repo/src/index.ts", "repo/src"], + ], + }, + { paths: [String.raw`C:\repo\src\index.ts`, "C:/repo/src/index.ts"], suffixes: [] }, + { + paths: [String.raw`C:\repo\src\index.ts`, String.raw`D:\repo\src\index.ts`], + suffixes: [ + ["C:/repo/src/index.ts", "C:/repo/src"], + ["D:/repo/src/index.ts", "D:/repo/src"], + ], + }, + { + paths: [String.raw`\\one\share\src\index.ts`, String.raw`\\two\share\src\index.ts`], + suffixes: [ + ["//one/share/src/index.ts", "one/share/src"], + ["//two/share/src/index.ts", "two/share/src"], + ], + }, + ])("preserves suffixes for $paths", ({ paths, suffixes }) => { + expect([...buildFileLinkParentSuffixByPath(paths)]).toEqual(suffixes); + }); + + it("bounds suffix construction by path count for a same-basename group", () => { + const paths = Array.from({ length: 128 }, (_, i) => `/repo/packages/pkg-${i}/src/index.ts`); + using joins = vi.spyOn(Array.prototype, "join"); + const result = buildFileLinkParentSuffixByPath(paths); + const suffixConstructions = joins.mock.calls.length; + + expect(result).toEqual(new Map(paths.map((path, i) => [path, `pkg-${i}/src`]))); + // Count work instead of elapsed time so a pairwise scan fails on every machine. + expect(suffixConstructions).toBeLessThan(paths.length * 20); + }); +}); + describe("parseFileUrlHref", () => { it.each([ [ diff --git a/packages/shared/src/fileLinks.ts b/packages/shared/src/fileLinks.ts index 2db6905c34a49..1ea5bad895c86 100644 --- a/packages/shared/src/fileLinks.ts +++ b/packages/shared/src/fileLinks.ts @@ -135,31 +135,26 @@ export function buildFileLinkParentSuffixByPath( const parentSegmentsByPath = new Map( uniquePaths.map((filePath) => [filePath, pathParentSegments(filePath)]), ); - const minUniqueDepthByPath = new Map(); + const suffixCounts = new Map(); + for (const segments of parentSegmentsByPath.values()) { + for (let depth = 1; depth <= segments.length; depth += 1) { + const suffix = segments.slice(-depth).join("/"); + suffixCounts.set(suffix, (suffixCounts.get(suffix) ?? 0) + 1); + } + } for (const filePath of uniquePaths) { const segments = parentSegmentsByPath.get(filePath) ?? []; let resolvedDepth = segments.length; for (let depth = 1; depth <= segments.length; depth += 1) { const candidate = segments.slice(-depth).join("/"); - const collision = uniquePaths.some((otherPath) => { - if (otherPath === filePath) return false; - const otherSegments = parentSegmentsByPath.get(otherPath) ?? []; - return otherSegments.slice(-depth).join("/") === candidate; - }); - if (!collision) { + if (suffixCounts.get(candidate) === 1) { resolvedDepth = depth; break; } } - minUniqueDepthByPath.set(filePath, resolvedDepth); - } - - for (const filePath of uniquePaths) { - const segments = parentSegmentsByPath.get(filePath) ?? []; if (segments.length === 0) continue; - const minUniqueDepth = minUniqueDepthByPath.get(filePath) ?? 1; - const suffixDepth = Math.min(segments.length, Math.max(minUniqueDepth, 2)); + const suffixDepth = Math.min(segments.length, Math.max(resolvedDepth, 2)); suffixByPath.set(filePath, segments.slice(-suffixDepth).join("/")); } } From 860fa7c7b637bab209c38a14189cf42d0bb5f193 Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Thu, 12 Nov 2026 04:56:33 +0530 Subject: [PATCH 0220/1165] fix(shared): keep HTML bootstrap outside quoted attributes (#34344) --- packages/shared/src/htmlRender.test.ts | 16 ++++++++++++++++ packages/shared/src/htmlRender.ts | 4 ++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/htmlRender.test.ts b/packages/shared/src/htmlRender.test.ts index 8d4dd225626f3..816f27d059e97 100644 --- a/packages/shared/src/htmlRender.test.ts +++ b/packages/shared/src/htmlRender.test.ts @@ -21,6 +21,22 @@ describe("injectHtmlRenderBootstrap", () => { expect(injected).toContain('name="viewport"'); }); + it.each([ + "", + '', + "", + '', + ])("keeps the bootstrap after the complete opening tag: %s", (opening) => { + const prefix = `${opening}`; + const head = opening.startsWith(""; + const content = `${head ? "" : "Report"}

Report

`; + const injected = injectHtmlRenderBootstrap(prefix + content); + expect(injected.startsWith(`${prefix}${head}`)).toBe(true); + expect(injected.indexOf('
+ + + +
+ +
`); + const src = URL.createObjectURL(new Blob([html], { type: "text/html" })); + using _url = { [Symbol.dispose]: () => URL.revokeObjectURL(src) }; + const scroller = document.createElement("div"); + scroller.style.cssText = "height: 400px; overflow: auto; width: 728px;"; + const changed = vi.fn(); + const messages = new AbortController(); + using _messages = { [Symbol.dispose]: () => messages.abort() }; + window.addEventListener( + "message", + (event) => { + if (event.data?.type === "radio-changed") changed(event.data.id); + }, + { signal: messages.signal }, + ); + const contentHeight = vi.fn((height: number) => { + scroller.style.setProperty("--content-height", `${height}px`); + }); + await using screen = await render( + <> + +
+ +
+ , + { container: scroller }, + ); + + const frame = screen.getByTitle("Toggle").element() as HTMLIFrameElement; + await vi.waitFor(() => expect(frame.getBoundingClientRect().height).toBeGreaterThan(1400)); + scroller.scrollTop = scroller.scrollHeight; + const scrollTop = scroller.scrollTop; + + await page.frameLocator(screen.getByTitle("Toggle")).getByText("B", { exact: true }).click(); + + await vi.waitFor(() => expect(changed).toHaveBeenCalledWith("opt-b")); + expect(scroller.scrollTop).toBe(scrollTop); +}); + it("keeps the first and last selected card rings inside the fitted renderer frame", async () => { const html = injectHtmlRenderBootstrap(`${PATH}`; + +describe("resolveProjectFaviconColorScheme", () => { + it("gives the root the dark color the stylesheet picks in dark mode", () => { + const resolved = decode(resolveProjectFaviconColorScheme(svgDataUrl(ISSUE_ICON), "dark")); + + expect(resolved).toBe( + `${PATH}`, + ); + }); + + it("gives the root the light color in light mode", () => { + const resolved = decode(resolveProjectFaviconColorScheme(svgDataUrl(ISSUE_ICON), "light")); + + expect(resolved).toBe( + `${PATH}`, + ); + }); + + it("keeps the rules of the matching color-scheme block and drops the other", () => { + const icon = ``; + + expect(decode(resolveProjectFaviconColorScheme(svgDataUrl(icon), "dark"))).toBe( + ``, + ); + expect(decode(resolveProjectFaviconColorScheme(svgDataUrl(icon), "light"))).toBe( + ``, + ); + }); + + it("replaces an existing root color attribute", () => { + const icon = ``; + + expect(decode(resolveProjectFaviconColorScheme(svgDataUrl(icon), "light"))).toBe( + ``, + ); + }); + + it("takes the last root color declaration outside other at-rules", () => { + const icon = ``; + + expect(decode(resolveProjectFaviconColorScheme(svgDataUrl(icon), "light"))).toBe( + ``, + ); + const variable = icon.replace("color:blue", "color:var(--icon)"); + expect(resolveProjectFaviconColorScheme(svgDataUrl(variable), "dark")).toBe( + svgDataUrl(variable), + ); + const media = ``; + expect(decode(resolveProjectFaviconColorScheme(svgDataUrl(media), "light"))).toBe( + ``, + ); + expect(decode(resolveProjectFaviconColorScheme(svgDataUrl(media), "dark"))).toBe( + ``, + ); + }); + + it("lets an important root color beat later normal ones", () => { + const icon = ``; + + expect(decode(resolveProjectFaviconColorScheme(svgDataUrl(icon), "dark"))).toBe( + ``, + ); + expect(decode(resolveProjectFaviconColorScheme(svgDataUrl(icon), "light"))).toBe( + ``, + ); + }); + + it("keeps an important root color across style elements", () => { + const icon = ``; + + expect(decode(resolveProjectFaviconColorScheme(svgDataUrl(icon), "dark"))).toBe( + ``, + ); + }); + + it("ignores braces and color-scheme blocks inside CSS comments", () => { + const icon = ``; + + expect(decode(resolveProjectFaviconColorScheme(svgDataUrl(icon), "light"))).toBe( + ``, + ); + expect(decode(resolveProjectFaviconColorScheme(svgDataUrl(icon), "dark"))).toBe( + ``, + ); + }); + + it("colors the root element, not an svg tag in an earlier comment", () => { + const icon = ``; + + expect(decode(resolveProjectFaviconColorScheme(svgDataUrl(icon), "dark"))).toBe( + ``, + ); + }); + + it("leaves other media queries for the renderer", () => { + const icon = ``; + + expect(resolveProjectFaviconColorScheme(svgDataUrl(icon), "dark")).toBe(svgDataUrl(icon)); + }); + + it("ignores a root color that is not a plain color value", () => { + const icon = ``; + + expect(resolveProjectFaviconColorScheme(svgDataUrl(icon), "dark")).toBe(svgDataUrl(icon)); + const padded = ``; + expect(resolveProjectFaviconColorScheme(svgDataUrl(padded), "dark")).toBe(svgDataUrl(padded)); + }); + + it("ignores style elements inside XML comments", () => { + const icon = ``; + + expect(decode(resolveProjectFaviconColorScheme(svgDataUrl(icon), "dark"))).toBe( + icon.replace(" { + const icon = ``; + + expect(decode(resolveProjectFaviconColorScheme(svgDataUrl(icon), "dark"))).toBe( + icon.replace(" { + const png = "data:image/png;base64,iVBORw0KGgo="; + const remote = "https://host/api/assets/token/icon.svg"; + + expect(resolveProjectFaviconColorScheme(png, "dark")).toBe(png); + expect(resolveProjectFaviconColorScheme(remote, "dark")).toBe(remote); + }); +}); diff --git a/apps/mobile/src/lib/projectFaviconColorScheme.ts b/apps/mobile/src/lib/projectFaviconColorScheme.ts new file mode 100644 index 0000000000000..cd45b90b65392 --- /dev/null +++ b/apps/mobile/src/lib/projectFaviconColorScheme.ts @@ -0,0 +1,120 @@ +import * as Base64 from "effect/encoding/Base64"; +import * as Result from "effect/Result"; + +import type { MobileThemeAppearance } from "./mobileTheme"; + +const SVG_DATA_URL_PREFIX = "data:image/svg+xml;base64,"; +const STYLE_ELEMENT_RE = /|(]*>)([\s\S]*?)(<\/style>)/gi; +const CSS_COMMENT_RE = /\/\*[\s\S]*?\*\//g; +const COLOR_SCHEME_MEDIA_RE = /@media\s*\(\s*prefers-color-scheme\s*:\s*(light|dark)\s*\)\s*\{/gi; +const AT_RULE_BLOCK_RE = /@[^{};]*\{/g; +const RULE_RE = /([^{}]*)\{([^{}]*)\}/g; +const IMPORTANT_RE = /!\s*important$/i; +const PLAIN_COLOR_RE = /^(?:#[\da-f]{3,8}|[a-z]+|(?:rgba?|hsla?)\([\d\s.,%/]+\))$/i; +const COMMENT_OR_SVG_TAG_RE = /|]*>/gi; +const COLOR_ATTRIBUTE_RE = /\s+color\s*=\s*(?:"[^"]*"|'[^']*')/i; +const MEDIA_ATTRIBUTE_RE = /\s+media\s*=\s*(?:"([^"]*)"|'([^']*)')/i; +const COLOR_SCHEME_CONDITION_RE = /^\(\s*prefers-color-scheme\s*:\s*(light|dark)\s*\)$/i; + +// CoreSVG ignores root stylesheet colors and misreads @media; resolve both before decoding. +export function resolveProjectFaviconColorScheme(url: string, scheme: MobileThemeAppearance) { + if (!url.startsWith(SVG_DATA_URL_PREFIX)) return url; + const decoded = Base64.decodeString(url.slice(SVG_DATA_URL_PREFIX.length)); + if (Result.isFailure(decoded)) return url; + const svg = decoded.success; + + let stylesheet = ""; + const styled = svg.replace( + STYLE_ELEMENT_RE, + (match, open: string, css: string | undefined, close: string) => { + if (css === undefined) return match; + const mediaAttribute = open.match(MEDIA_ATTRIBUTE_RE); + const media = (mediaAttribute?.[1] ?? mediaAttribute?.[2])?.trim(); + if (media) { + const condition = media.match(COLOR_SCHEME_CONDITION_RE); + if (!condition) return match; + if (condition[1]?.toLowerCase() !== scheme) return ""; + open = open.replace(MEDIA_ATTRIBUTE_RE, ""); + } + // Comments can hold braces or a commented-out `@media`, which the block matcher would count. + const resolvedCss = replaceBlocks( + css.replace(CSS_COMMENT_RE, ""), + COLOR_SCHEME_MEDIA_RE, + (match, body) => (match[1]?.toLowerCase() === scheme ? body : ""), + ); + stylesheet += `${resolvedCss}\n`; + return `${open}${resolvedCss}${close}`; + }, + ); + const rootColor = lastRootColor(stylesheet); + const resolved = rootColor === undefined ? styled : withRootColor(styled, rootColor); + return resolved === svg ? url : `${SVG_DATA_URL_PREFIX}${Base64.encode(resolved)}`; +} + +function withRootColor(svg: string, color: string) { + let applied = false; + return svg.replace(COMMENT_OR_SVG_TAG_RE, (match) => { + if (applied || match.startsWith(" 4 fail()\n", + "Cell In[1], line 2, in fail()\n----> 2 raise ValueError()\n", + "ValueError: preview proof", + ], + }, + { + output_type: "error", + traceback: [ + "Traceback (most recent call last):\n", + " Cell In[2], line 5\n fail()\n", + "ValueError: preview proof\n", + ], + }, + { + output_type: "error", + traceback: [ + "\u001b[31mTraceback (most recent call last):\n\u001b[0m", + "\u001b[32mCell In[3], line 1\n----> 1 fail()\n\u001b[0m\u001b[39m", + "\u001b[31mValueError: preview proof\u001b[0m", + "final entry", + ], + }, + ], + }, + ], + }), + ), + ); + if (failed.type !== "notebook") throw new Error("Expected notebook"); + expect(failed.cells[0]!.outputs).toEqual([ + { + kind: "text", + text: "-----\nValueError Traceback (most recent call last)\nCell In[1], line 4\n----> 4 fail()\nCell In[1], line 2, in fail()\n----> 2 raise ValueError()\nValueError: preview proof", + }, + { + kind: "text", + text: "Traceback (most recent call last):\n Cell In[2], line 5\n fail()\nValueError: preview proof\n", + }, + { + kind: "text", + text: "\u001b[31mTraceback (most recent call last):\n\u001b[0m\u001b[32mCell In[3], line 1\n----> 1 fail()\n\u001b[0m\u001b[39m\u001b[31mValueError: preview proof\u001b[0m\nfinal entry", + }, + ]); expect(() => openNotebook(bytes('{"nbformat":3,"cells":[]}'))).toThrow("version 4"); expect(() => openNotebook( diff --git a/apps/web/src/components/files/extendedDocumentPreview.ts b/apps/web/src/components/files/extendedDocumentPreview.ts index abd07afb23c68..5f30fe77c34b9 100644 --- a/apps/web/src/components/files/extendedDocumentPreview.ts +++ b/apps/web/src/components/files/extendedDocumentPreview.ts @@ -423,6 +423,14 @@ function notebookText(value: unknown, limit = 256_000): string { if (result.length > limit) throw new Error("A notebook cell is too large to preview."); return result; } +// IPython traceback frames may omit a final newline or follow it with ANSI SGR sequences. +function tracebackLines(value: unknown): unknown { + if (!Array.isArray(value)) return value; + const frames = value.filter((frame): frame is string => typeof frame === "string"); + return frames.map((frame, index) => + index && !/\n(?:\u001b\[[\d;:]*m)*$/.test(frames[index - 1]!) ? `\n${frame}` : frame, + ); +} export function openNotebook(bytes: ArrayBuffer): OfficeWorkerResult { if (bytes.byteLength > 8 * 1024 * 1024) throw new Error("Notebook preview limited to files under 8 MB."); @@ -454,7 +462,9 @@ export function openNotebook(bytes: ArrayBuffer): OfficeWorkerResult { if (html.trim() && htmlImagesFit(html)) return { kind: "html" as const, text: html }; return { kind: "text" as const, - text: notebookText(output.text ?? data["text/plain"] ?? output.traceback), + text: notebookText( + output.text ?? data["text/plain"] ?? tracebackLines(output.traceback), + ), }; }) .filter((output) => output.text.trim().length > 0), From d6244ac5a337b6c71154715d197b3d915b8584a8 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sat, 14 Nov 2026 10:33:33 +0100 Subject: [PATCH 1150/1165] fix(web): preserve literal entities in EPUB CDATA (#36595) Co-authored-by: Julius Marminge --- .../src/components/files/FilePreviewPanel.test.ts | 7 ++++--- .../src/components/files/extendedDocumentPreview.ts | 12 ++++++++++-- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/files/FilePreviewPanel.test.ts b/apps/web/src/components/files/FilePreviewPanel.test.ts index 384a3ca80683c..d9361ba21244a 100644 --- a/apps/web/src/components/files/FilePreviewPanel.test.ts +++ b/apps/web/src/components/files/FilePreviewPanel.test.ts @@ -221,12 +221,12 @@ Third}`; it("resolves scoped ODF namespaces and explicit spreadsheet whitespace", async () => { const archive = new JSZip().file( "content.xml", - '

AccountNameABC

', + '

AccountNameABC &

', ); const result = await openDocument(archive, "ods"); if (result.type !== "ods") throw new Error("Expected workbook"); expect(result.sheets[0]!.name).toBe("Accounts & names"); - expect(result.sheets[0]!.rows[0]).toEqual(["Account Name A\tB\nC"]); + expect(result.sheets[0]!.rows[0]).toEqual(["Account Name A\tB\nC & &"]); }); it("stops repeated slide images before encoding beyond the output budget", async () => { @@ -439,7 +439,7 @@ Third}`; ) .file( "Book/two&&&.xhtml", - 'Second

Second chapter

A B © — ≂̸ &lt;

© ≂̸', + 'Second

Second chapter

A B © — ≂̸ &lt;

© ≂̸', ) .file( "Book/cover.png", @@ -453,6 +453,7 @@ Third}`; if (result.type !== "document") throw new Error("Expected document"); expect(result.pages.map((page) => page.title)).toEqual(["Second", "First"]); expect(result.pages[0]!.html).toContain("A\u00a0B © — ≂̸ &lt;"); + expect(result.pages[0]!.html).toContain("
Use &amp; and &lt; literally
"); expect(result.pages[0]!.html).toContain('alt="© ≂̸"'); expect(result.pages[1]!.html).toContain('src="data:image/png;base64,'); expect(result.pages[1]!.html).not.toMatch(/https:|script|alert/); diff --git a/apps/web/src/components/files/extendedDocumentPreview.ts b/apps/web/src/components/files/extendedDocumentPreview.ts index 5f30fe77c34b9..6b840a00543a6 100644 --- a/apps/web/src/components/files/extendedDocumentPreview.ts +++ b/apps/web/src/components/files/extendedDocumentPreview.ts @@ -12,6 +12,7 @@ import { type OfficeWorkerResult, type NotebookCell } from "./officePreviewProto const ODS_PREVIEW_MAX_ROWS = 500; const ODS_PREVIEW_MAX_COLUMNS = 50; +// `text` and `attrs` hold decoded values: escaped text is decoded once, CDATA stays literal. type XmlNode = { name: string; attrs: Record; children: XmlNode[]; text: string }; type OrderedXml = { [key: string]: OrderedXml[] | Record | string }; const escape = (value: string) => @@ -47,6 +48,7 @@ function parseXml(source: string): XmlNode { processEntities: false, trimValues: false, parseTagValue: false, + cdataPropName: "#cdata", }); const ordered = parser.parse(source) as OrderedXml[]; let count = 0; @@ -60,6 +62,12 @@ function parseXml(source: string): XmlNode { if (++count > 100_000) throw new Error("This document has too much content to preview."); const name = Object.keys(item).find((key) => key !== ":@") ?? "#text"; const value = item[name]; + if (name === "#cdata") { + const literal = Array.isArray(value) + ? value.map((part) => part["#text"] ?? "").join("") + : ""; + return { name: "#text", attrs: {}, children: [], text: literal }; + } const attrs = Object.fromEntries( Object.entries((item[":@"] ?? {}) as Record).map(([key, value]) => [ key, @@ -91,7 +99,7 @@ function parseXml(source: string): XmlNode { Object.entries(attrs).map(([key, value]) => [expanded(key, true), value]), ), children: Array.isArray(value) ? convert(value, depth + 1, namespaces) : [], - text: typeof value === "string" ? value : "", + text: typeof value === "string" ? decodeEntities(value) : "", }; }); } @@ -116,7 +124,7 @@ function text(node: XmlNode): string { if (node.name === "text:s") return " ".repeat(repeat(node.attrs["text:c"], 100)); if (node.name === "text:tab") return "\t"; if (node.name === "text:line-break") return "\n"; - return node.name === "#text" ? decodeEntities(node.text) : node.children.map(text).join(""); + return node.name === "#text" ? node.text : node.children.map(text).join(""); } function archivePath(base: string, href: string): string | null { if (/^(?:[a-z][a-z\d+.-]*:|\/|\\)/i.test(href)) return null; From 953ce60d12348948ceb12da92cc9271a2cfa9975 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sat, 14 Nov 2026 10:35:07 +0100 Subject: [PATCH 1151/1165] fix(web): changed root files with a leading space open in the diff panel (#36606) Co-authored-by: Julius Marminge --- .../src/components/DiffPanel.browser.test.tsx | 34 +++++++++++++++++++ apps/web/src/components/DiffPanel.tsx | 4 ++- apps/web/src/diffPanelStore.ts | 2 +- .../contracts/src/orchestrationV2.test.ts | 11 ++++++ packages/contracts/src/orchestrationV2.ts | 3 +- 5 files changed, 51 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/DiffPanel.browser.test.tsx b/apps/web/src/components/DiffPanel.browser.test.tsx index 51ba65933dff7..503f9b60c1b5d 100644 --- a/apps/web/src/components/DiffPanel.browser.test.tsx +++ b/apps/web/src/components/DiffPanel.browser.test.tsx @@ -302,3 +302,37 @@ it("says a turn diff cut off at the server's size limit is incomplete", async () .element(screen.getByText("This preview exceeds the size limit. Changes shown are incomplete.")) .toBeVisible(); }); + +// Paths as git's numstat reports them; the parsed patch trims the trailing space from its name. +it.each([ + { path: " leading.ts", name: " leading.ts" }, + { path: "src/trailing.ts ", name: "src/trailing.ts" }, +])("reveals the turn file $path picked from the changed files", async ({ path, name }) => { + using context = setup("origin/orphan"); + state.diffFilesCollapsed = true; + state.checkpoints = [ + { + runId: "run", + checkpointTurnCount: 1, + checkpointRef: "ref", + status: "ready", + files: [], + assistantMessageId: null, + completedAt: "2026-01-01T00:00:00Z", + }, + ]; + state.checkpointDiff = { + diff: `diff --git a/${path} b/${path}\nnew file mode 100644\n--- /dev/null\n+++ b/${path}\t\n@@ -0,0 +1 @@\n+revealed\n`, + }; + useDiffPanelStore.getState().selectTurn(context.threadRef, RunId.make("run"), path); + await using screen = await render( + +
+ +
+
, + ); + await expect + .element(screen.getByRole("button", { name: `Collapse ${name}`, exact: true })) + .toHaveAttribute("aria-expanded", "true"); +}); diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 2b0a6a78d4ec2..9dad875a2a8a6 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -336,7 +336,9 @@ export default function DiffPanel({ diffSelection.kind === "branch" || diffSelection.kind === "commit" ? diffSelection.baseRef : null; - const selectedFilePath = diffSelection.kind === "turn" ? diffSelection.filePath : null; + // @pierre/diffs trims trailing whitespace from parsed file names; leading whitespace survives. + const selectedFilePath = + diffSelection.kind === "turn" ? diffSelection.filePath?.trimEnd() || null : null; const selectedFileRevealRequestId = diffSelection.kind === "turn" ? diffSelection.revealRequestId : 0; const selectedTurn = diff --git a/apps/web/src/diffPanelStore.ts b/apps/web/src/diffPanelStore.ts index 2f685005c1e74..ddf454395443d 100644 --- a/apps/web/src/diffPanelStore.ts +++ b/apps/web/src/diffPanelStore.ts @@ -105,7 +105,7 @@ export const useDiffPanelStore = create()( [threadKey]: { kind: "turn", turnId, - filePath: filePath?.trim() || null, + filePath: filePath ?? null, revealRequestId: previous?.kind === "turn" ? previous.revealRequestId + 1 : 1, }, }, diff --git a/packages/contracts/src/orchestrationV2.test.ts b/packages/contracts/src/orchestrationV2.test.ts index 3782307171e87..76f78f536d7c5 100644 --- a/packages/contracts/src/orchestrationV2.test.ts +++ b/packages/contracts/src/orchestrationV2.test.ts @@ -5,6 +5,7 @@ import * as Schema from "effect/Schema"; import { NodeId, RunAttemptId, TrimmedNonEmptyString } from "./index.ts"; import { latestProviderTurnForAttempt, + OrchestrationV2CheckpointFileSummary, OrchestrationV2ProviderCapabilities, OrchestrationV2ProviderThread, OrchestrationV2ProviderThreadJson, @@ -22,6 +23,7 @@ const now = DateTime.makeUnsafe("2026-04-20T00:00:00.000Z"); const decodeOrchestrationV2TurnItemJson = Schema.decodeUnknownSync(OrchestrationV2TurnItemJson); const encodeOrchestrationV2TurnItemJson = Schema.encodeSync(OrchestrationV2TurnItemJson); const decodeOrchestrationV2Subagent = Schema.decodeUnknownSync(OrchestrationV2Subagent); +const decodeCheckpointFileSummary = Schema.decodeSync(OrchestrationV2CheckpointFileSummary); const encodeOrchestrationV2SubagentJson = Schema.encodeSync(OrchestrationV2SubagentJson); const decodeOrchestrationV2ProviderThreadJson = Schema.decodeUnknownSync( OrchestrationV2ProviderThreadJson, @@ -397,6 +399,15 @@ describe("orchestration V2 contracts", () => { }, ); + it.each([" leading.ts", "src/trailing.ts "])( + "decodes the checkpoint file path %j exactly", + (path) => { + expect( + decodeCheckpointFileSummary({ path, kind: "added", additions: 1, deletions: 0 }).path, + ).toBe(path); + }, + ); + it("decodes historical provider-thread JSON without pendingBackgroundTasks as empty roster", () => { const providerThread = decodeOrchestrationV2ProviderThreadJson({ id: "provider-thread-1", diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index d1b0dca92206a..053fc644fe1c0 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -1455,7 +1455,8 @@ export const OrchestrationV2PlanArtifact = Schema.Union([ export type OrchestrationV2PlanArtifact = typeof OrchestrationV2PlanArtifact.Type; export const OrchestrationV2CheckpointFileSummary = Schema.Struct({ - path: TrimmedNonEmptyString, + // Exact, as git reports it: file names may start or end with whitespace. + path: Schema.NonEmptyString, kind: TrimmedNonEmptyString, additions: NonNegativeInt, deletions: NonNegativeInt, From 43ba62b9fcf9e7bb34e921e76e7b2f609bb5d40a Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sat, 14 Nov 2026 10:35:52 +0100 Subject: [PATCH 1152/1165] fix(server): thread reads wait for pending background work (#36610) Co-authored-by: Julius Marminge --- .../OrchestratorMcpService.activity.test.ts | 41 ++++++++++++++----- apps/server/src/mcp/OrchestratorMcpService.ts | 17 +++++--- 2 files changed, 43 insertions(+), 15 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts b/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts index 2ff2125a51800..cd8c5064476b2 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts @@ -105,7 +105,7 @@ function baseThread(input: { function makeRun(input: { readonly id: RunId; readonly ordinal: number; - readonly status: "running" | "waiting" | "cancelled" | "queued" | "completed"; + readonly status: "running" | "waiting" | "cancelled" | "queued" | "completed" | "failed"; readonly instanceId?: ProviderInstanceId; }) { return { @@ -125,6 +125,15 @@ function makeRun(input: { it("readThread preserves caller access errors and prefers activity-run status over a newer cancelled queued run", async () => { let callerUnavailable = false; + let pendingBackgroundTasks: OrchestrationV2ThreadShell["pendingBackgroundTasks"] = [ + { + taskId: "task-subagent", + kind: "subagent", + parentTaskId: "task-parent", + providerInstanceId: parentInstanceId, + childThreadId, + }, + ]; const pullRequestLink = (number: number, state: "open" | "merged", watched: boolean) => ({ host: "github.com", repository: "acme/web", @@ -202,15 +211,7 @@ it("readThread preserves caller access errors and prefers activity-run status ov ? ({ ...projection.thread, blockedOnThreadWait: true, - pendingBackgroundTasks: [ - { - taskId: "task-subagent", - kind: "subagent", - parentTaskId: "task-parent", - providerInstanceId: parentInstanceId, - childThreadId, - }, - ], + pendingBackgroundTasks, } as unknown as OrchestrationV2ThreadShell) : null, ), @@ -263,6 +264,26 @@ it("readThread preserves caller access errors and prefers activity-run status ov expect(laterPage.thread.pullRequests).toBeUndefined(); expect(laterPage.thread.pullRequestCount).toBe(8); + Object.assign(projection, { + runs: [makeRun({ id: activeRunId, ordinal: 1, status: "completed" })], + }); + const awaiting = yield* service.readThread(makeScope(), { threadId: parentThreadId }); + expect(awaiting.thread.status).toBe("waiting"); + + Object.assign(projection, { + runs: [makeRun({ id: activeRunId, ordinal: 1, status: "failed" })], + }); + const failed = yield* service.readThread(makeScope(), { threadId: parentThreadId }); + expect(failed.thread.status).toBe("failed"); + + Object.assign(projection, { + runs: [makeRun({ id: activeRunId, ordinal: 1, status: "completed" })], + }); + // An unawaited command (a dev server) does not hold the thread once its turn ends. + pendingBackgroundTasks = [{ taskId: "dev-server", kind: "command" }]; + const released = yield* service.readThread(makeScope(), { threadId: parentThreadId }); + expect(released.thread.status).toBe("completed"); + callerUnavailable = true; const denied = yield* service .readThread({ ...makeScope(), capabilities: new Set() }, { threadId: parentThreadId }) diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 82948f7c5b055..d7ef5f788f0ba 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -865,11 +865,18 @@ function threadPullRequests( } // Awaited background work keeps a thread live after its turn ends, as auto-settle and the sidebar treat it. +function threadStatus( + activeStatus: OrchestratorMcpThreadListItem["status"] | null | undefined, + settledStatus: OrchestratorMcpThreadListItem["status"], + pendingBackgroundTasks: OrchestrationV2ThreadShell["pendingBackgroundTasks"], +) { + if (activeStatus != null) return activeStatus; + if (settledStatus === "failed") return settledStatus; + return backgroundWorkHoldsCompletion(pendingBackgroundTasks ?? []) ? "waiting" : settledStatus; +} + function listedThreadStatus(shell: OrchestrationV2ThreadShell) { - if (shell.activityRunStatus != null) return shell.activityRunStatus; - return backgroundWorkHoldsCompletion(shell.pendingBackgroundTasks ?? []) - ? "waiting" - : shell.status; + return threadStatus(shell.activityRunStatus, shell.status, shell.pendingBackgroundTasks); } function listItemFromShell( @@ -920,7 +927,7 @@ function threadDetail( link: threadLinkFor(scope, projection.thread.id, projection.thread.title), createdBy: projection.thread.createdBy, creationSource: projection.thread.creationSource, - status: active?.status ?? latest?.status ?? "idle", + status: threadStatus(active?.status, latest?.status ?? "idle", shell.pendingBackgroundTasks), latestRunId: latest?.id ?? null, activeRunId: active?.id ?? null, providerInstanceId: projection.thread.modelSelection.instanceId, From 91fa913be9c280dcf9da6ad655aa6bac7c8ce74a Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sat, 14 Nov 2026 10:36:05 +0100 Subject: [PATCH 1153/1165] fix(server): partial import retries no longer certify missing messages (#36604) Co-authored-by: Julius Marminge --- .../src/project/AgentSessionImporter.test.ts | 33 +++++++++++++++++++ .../src/project/AgentSessionImporter.ts | 21 ++++++------ .../server/src/project/AgentSessionScanner.ts | 5 +-- 3 files changed, 45 insertions(+), 14 deletions(-) diff --git a/apps/server/src/project/AgentSessionImporter.test.ts b/apps/server/src/project/AgentSessionImporter.test.ts index 4378a7fde8470..23264b2eeefe6 100644 --- a/apps/server/src/project/AgentSessionImporter.test.ts +++ b/apps/server/src/project/AgentSessionImporter.test.ts @@ -453,6 +453,39 @@ it.effect( }).pipe(Effect.scoped, Effect.provide(persistence)), ); +it.effect("does not certify new native history after a partial import retry", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const runtimes = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; + const original = candidate(CODEX_MODEL_SELECTION, "partial-retry"); + const threadId = importId(original); + const importer = yield* makeImporter([original]); + yield* sql`CREATE TEMP TRIGGER fail_runtime BEFORE INSERT ON provider_session_runtime BEGIN SELECT RAISE(ABORT, 'test runtime failure'); END`; + expect(yield* importer.run).toEqual({ importedCount: 0, skippedCount: 1 }); + yield* sql`DROP TRIGGER fail_runtime`; + expect(yield* runtimes.list()).toEqual([]); + const continued = candidate(CODEX_MODEL_SELECTION, original.source.providerSessionId); + continued.source.size = 200; + continued.source.mtimeMs = 3; + continued.thread.messages.push( + { role: "user", text: "Continue outside T3", createdAt: timestamp }, + { role: "assistant", text: "Native continuation", createdAt: timestamp }, + ); + // The thread holds only the original messages, so the continued transcript stays unrecorded. + expect(yield* (yield* makeImporter([continued])).run).toEqual({ + importedCount: 0, + skippedCount: 1, + }); + expect(yield* runtimes.list()).toEqual([]); + expect( + (yield* projections.getThreadRecords(threadId, ["messages"])).messages.map( + (message) => message.text, + ), + ).toEqual(["Fix it", "Fixed"]); + }).pipe(Effect.scoped, Effect.provide(persistence)), +); + it.effect("does not record a changed transcript as imported without its new messages", () => Effect.gen(function* () { const projections = yield* ProjectionStore.ProjectionStoreV2; diff --git a/apps/server/src/project/AgentSessionImporter.ts b/apps/server/src/project/AgentSessionImporter.ts index 6200fc11188fb..6f88b929260a6 100644 --- a/apps/server/src/project/AgentSessionImporter.ts +++ b/apps/server/src/project/AgentSessionImporter.ts @@ -313,7 +313,9 @@ const make = Effect.gen(function* () { }, { onConflict: "ignore" }, ); - const existing = yield* Effect.option(orchestrator.getThreadRecords(threadId, [])); + const existing = yield* Effect.option( + orchestrator.getThreadRecords(threadId, ["messages"]), + ); if (Option.isSome(existing)) { if (existing.value.thread.projectId !== input.projectId) { return yield* new AgentSessionThreadProjectConflictError({ @@ -325,16 +327,15 @@ const make = Effect.gen(function* () { if (existing.value.thread.historyOrigin !== "v1_import") { return yield* new AgentSessionThreadModifiedError({ threadId }); } - // Other recorded transcripts mean this one changed after the import, and the - // thread does not hold its new messages, so it must stay unrecorded. - const recorded = Option.flatMap(yield* runtimes.getByThreadId({ threadId }), (row) => - AgentSessionScanner.decodeImportedTranscriptPayload(row.runtimePayload), - ).pipe(Option.flatMapNullishOr((payload) => payload.importedTranscripts)); + // A transcript can change after the import, so record it only when the + // thread already holds exactly its messages. + const stored = existing.value.messages; if ( - Option.isSome(recorded) && - recorded.value.length > 0 && - !recorded.value.some((imported) => - AgentSessionScanner.sameTranscriptIdentity(imported, source), + stored.length !== thread.messages.length || + stored.some( + (message, index) => + message.role !== thread.messages[index]?.role || + message.text !== thread.messages[index]?.text, ) ) { return false; diff --git a/apps/server/src/project/AgentSessionScanner.ts b/apps/server/src/project/AgentSessionScanner.ts index 0de2e84b1f6e3..92ccc4efcd746 100644 --- a/apps/server/src/project/AgentSessionScanner.ts +++ b/apps/server/src/project/AgentSessionScanner.ts @@ -802,10 +802,7 @@ type TranscriptIdentity = Pick< const fileIdKey = (inode: TranscriptIdentity["inode"]) => (inode === null ? null : String(inode)); -export function sameTranscriptIdentity( - left: TranscriptIdentity, - right: TranscriptIdentity, -): boolean { +function sameTranscriptIdentity(left: TranscriptIdentity, right: TranscriptIdentity): boolean { // Unsafe legacy numbers may already have lost file ID precision. if ( (typeof left.inode === "number" && !Number.isSafeInteger(left.inode)) || From 4b99420d8e13d9cbbdfd60374583202ecff2dc0f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 01:36:17 -0800 Subject: [PATCH 1154/1165] fix(server): background subagents stop running when their Claude session is released (#36569) --- apps/server/src/orchestration-v2/EventSink.ts | 38 +++++++ .../src/orchestration-v2/ProjectionStore.ts | 2 +- .../ProviderSessionManager.test.ts | 60 ++++++++++- .../ProviderSessionManager.ts | 102 +++++++++++++++++- 4 files changed, 197 insertions(+), 5 deletions(-) diff --git a/apps/server/src/orchestration-v2/EventSink.ts b/apps/server/src/orchestration-v2/EventSink.ts index edae3077912ec..6b27f90aa6d5c 100644 --- a/apps/server/src/orchestration-v2/EventSink.ts +++ b/apps/server/src/orchestration-v2/EventSink.ts @@ -3,6 +3,7 @@ import { type OrchestrationV2Run, OrchestrationV2DomainEvent, type OrchestrationV2ProviderThread, + type OrchestrationV2Subagent, type OrchestrationV2TurnItem, OrchestrationV2StoredEvent, ProviderThreadId, @@ -180,6 +181,17 @@ export interface EventSinkV2Shape { }, EventSinkV2Error >; + /** Commits only if the stored subagent still equals expected. */ + readonly writeIfSubagentUnchanged: (input: { + readonly expected: OrchestrationV2Subagent; + readonly events: ReadonlyArray; + }) => Effect.Effect< + { + readonly committed: boolean; + readonly storedEvents: ReadonlyArray; + }, + EventSinkV2Error + >; readonly commitCommand: (input: { readonly commandId: CommandId; readonly threadId: ThreadId; @@ -712,6 +724,26 @@ const layerBase: Layer.Layer< ); }); + const writeIfSubagentUnchangedEffect = Effect.fn( + "orchestrationV2.EventSink.writeIfSubagentUnchanged", + )(function* (input: Parameters[0]) { + yield* Effect.annotateCurrentSpan({ + "orchestration_v2.event_count": input.events.length, + "orchestration_v2.subagent_id": input.expected.id, + }); + const expectedJson = yield* ProjectionStore.encodeSubagentPayload(input.expected); + return yield* writeIfStoredPayloadMatches( + sql<{ readonly payload_json: string }>` + SELECT payload_json + FROM orchestration_v2_projection_subagents + WHERE subagent_id = ${input.expected.id} + LIMIT 1 + `, + expectedJson, + input.events, + ); + }); + const existingCommandResult = /* This helper runs for individual orchestration events; the enclosing operation owns the span. */ Effect.fnUntraced(function* (commandId: CommandId) { @@ -1118,6 +1150,12 @@ const layerBase: Layer.Layer< (cause) => new EventSinkWriteError({ eventCount: input.events.length, cause }), ), ), + writeIfSubagentUnchanged: (input) => + writeIfSubagentUnchangedEffect(input).pipe( + Effect.mapError( + (cause) => new EventSinkWriteError({ eventCount: input.events.length, cause }), + ), + ), commitCommand: (input) => commitCommandEffect(input).pipe( Effect.mapError( diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 5043b274941c1..6d845450c79da 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -1205,7 +1205,7 @@ const encodeRunAttemptPayload = Schema.encodeEffect( const encodeNodePayload = Schema.encodeEffect( Schema.fromJsonString(OrchestrationV2ExecutionNodeJsonSchema), ); -const encodeSubagentPayload = Schema.encodeEffect( +export const encodeSubagentPayload = Schema.encodeEffect( Schema.fromJsonString(OrchestrationV2SubagentJsonSchema), ); const encodeProviderSessionPayload = Schema.encodeEffect( diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts index 5740faf8f8757..09441e2659166 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts @@ -3309,7 +3309,53 @@ const openSessionWithBackgroundCommand = (fixtureName: string) => }); }); yield* Effect.forEach(Object.values(seededToolCalls), writeToolCall); + // A background native subagent that outlived its completed run; Claude records no provider thread on it. + const nativeSubagentRunId = idAllocator.derive.run({ threadId, ordinal: 1 }); + const nativeSubagentId = idAllocator.derive.nodeFromProviderItem({ + driver: CODEX_DRIVER, + nativeItemId: "background-agent", + }); + yield* eventSink.write({ + events: [ + { + id: yield* idAllocator.allocate.event({ threadId }), + type: "subagent.updated", + threadId, + occurredAt: now, + payload: { + id: nativeSubagentId, + threadId, + runId: nativeSubagentRunId, + parentNodeId: idAllocator.derive.rootNode({ runId: nativeSubagentRunId }), + origin: "provider_native", + createdBy: "agent", + driver: CODEX_DRIVER, + providerInstanceId: modelSelection.instanceId, + providerThreadId: null, + childThreadId: null, + nativeTaskRef: { + driver: CODEX_DRIVER, + nativeId: "background-agent", + strength: "strong", + }, + prompt: "sleep 150; echo done", + title: "Background agent", + model: null, + status: "running", + result: null, + startedAt: now, + completedAt: null, + updatedAt: now, + }, + }, + ], + }); yield* manager.open({ threadId, providerSessionId, modelSelection, runtimePolicy }); + const nativeSubagentStatus = Effect.gen(function* () { + const projectionStore = yield* ProjectionStore.ProjectionStoreV2; + const projection = yield* projectionStore.getThreadProjection(threadId); + return projection.subagents.find((subagent) => subagent.id === nativeSubagentId)?.status; + }); const toolCallStatus = (id: OrchestrationV2TurnItem["id"]) => Effect.gen(function* () { const projectionStore = yield* ProjectionStore.ProjectionStoreV2; @@ -3345,6 +3391,7 @@ const openSessionWithBackgroundCommand = (fixtureName: string) => writeToolCall, toolCalls, toolCallStatus, + nativeSubagentStatus, }; }); @@ -3356,8 +3403,15 @@ it.effect.each(["detach", "stream failure", "provider stop", "early close"] as c const state = yield* Ref.make(emptyState); const effect = Effect.gen(function* () { const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; - const { threadId, providerSessionId, rosters, toolCalls, seededToolCalls, writeToolCall } = - yield* openSessionWithBackgroundCommand("provider-session-manager-detach-roster"); + const { + threadId, + providerSessionId, + rosters, + toolCalls, + seededToolCalls, + writeToolCall, + nativeSubagentStatus, + } = yield* openSessionWithBackgroundCommand("provider-session-manager-detach-roster"); const runtime = Option.getOrThrow(yield* manager.get(providerSessionId)); const subscription = yield* runtime.subscribeEvents!; const consuming = yield* Deferred.make(); @@ -3433,6 +3487,8 @@ it.effect.each(["detach", "stream failure", "provider stop", "early close"] as c otherSession: "running", replacement: "running", }); + // regression: #36548 a native subagent the dead process ran stayed running after its run completed + assert.notInclude(["pending", "running", "waiting"], yield* nativeSubagentStatus); }); yield* effect.pipe( diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.ts index eb56df9d049ac..ca54c14b2cb95 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.ts @@ -59,6 +59,10 @@ import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderEventIngestor from "./ProviderEventIngestor.ts"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; +import { + endOrphanedNativeSubagent, + endRunlessRootTurns, +} from "@t3tools/provider-core/server/subagentProjection"; import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; @@ -995,7 +999,7 @@ export const layerWithOptions = ( // Tool calls the dead process started never report an end either, and run finalization // keeps them open for a late result. Items started after the release belong to a - // replacement session. Subagent rows are left out: run finalization ends run-owned ones. + // replacement session. const writeReleasedToolCallEvents = Effect.fn( "ProviderSessionManager.writeReleasedToolCallEvents", )(function* (released: ReleasedProviderThread, releasedAt: DateTime.Utc) { @@ -1047,6 +1051,96 @@ export const layerWithOptions = ( }); }); + // Background native subagents die with the process too, often after their run completed, + // so run finalization does not end them. One started or resumed after the release belongs + // to a replacement session. + const writeReleasedSubagentEvents = Effect.fn( + "ProviderSessionManager.writeReleasedSubagentEvents", + )(function* (released: ReleasedProviderThread, releasedAt: DateTime.Utc) { + const { threadId, providerSessionId, providerThreadId, driver } = released; + const readCutOff = Effect.gen(function* () { + const { subagents, turnItems } = yield* projectionStore.getThreadRecords( + threadId, + ["subagents", "turnItems"], + { + omitSettledSubagents: true, + turnItemTypes: ["subagent"], + turnItemStatuses: activeTurnItemStatuses, + }, + ); + const cutOff = subagents.filter((subagent) => { + if ( + subagent.origin !== "provider_native" || + subagent.driver !== driver || + !isOrchestrationV2WorkActive(subagent.status) || + DateTime.isGreaterThan(subagent.startedAt ?? subagent.updatedAt, releasedAt) + ) { + return false; + } + // Record and item are written together; a record without a live item lost its end. + const item = turnItems.find( + (candidate) => candidate.type === "subagent" && candidate.subagentId === subagent.id, + ); + return item === undefined || item.providerThreadId === providerThreadId; + }); + return { subagents: cutOff, turnItems }; + }); + let pending = yield* readCutOff; + for (let pass = 0; pass < 3 && pending.subagents.length > 0; pass++) { + const { nodes } = yield* projectionStore.getThreadRecords(threadId, ["nodes"], { + nodeIds: pending.subagents.map((subagent) => subagent.id), + }); + const projection = { ...pending, nodes }; + for (const subagent of pending.subagents) { + const now = yield* DateTime.now; + const allocateEventId = () => + idAllocator.allocate.event({ threadId, providerSessionId }); + const events = yield* endOrphanedNativeSubagent({ + projection, + subagentId: subagent.id, + status: "interrupted", + now, + emitted: [], + allocateEventId, + }); + if (subagent.childThreadId !== null) { + const child = yield* projectionStore + .getThreadRecords(subagent.childThreadId, ["nodes", "providerTurns", "turnItems"], { + turnItemRunIds: [null], + turnItemStatuses: activeTurnItemStatuses, + }) + .pipe( + Effect.catchTags({ + ProjectionStoreThreadNotFoundError: () => Effect.succeed(null), + }), + ); + if (child !== null) { + events.push( + ...(yield* endRunlessRootTurns({ + threadId: subagent.childThreadId, + providerInstanceId: subagent.providerInstanceId, + projection: child, + status: "interrupted", + now, + emitted: events, + allocateEventId, + })), + ); + } + } + // A late update from the dead session's queued events or a resume rejects only this write. + yield* eventSink.writeIfSubagentUnchanged({ expected: subagent, events }); + } + pending = yield* readCutOff; + } + if (pending.subagents.length === 0) return; + yield* Effect.logWarning("orchestration-v2.provider-session-release-subagent-conflict", { + providerSessionId, + providerThreadId, + openSubagents: pending.subagents.length, + }); + }); + const writeReleasedBackgroundWorkEvents = ( released: ReadonlyArray, releasedAt: DateTime.Utc, @@ -1056,6 +1150,7 @@ export const layerWithOptions = ( (providerThread) => writeReleasedRosterEvents(providerThread).pipe( Effect.andThen(writeReleasedToolCallEvents(providerThread, releasedAt)), + Effect.andThen(writeReleasedSubagentEvents(providerThread, releasedAt)), ), { discard: true }, ); @@ -1269,7 +1364,10 @@ export const layerWithOptions = ( // Queued events can persist tool calls after the release-time scan. const afterDrain = Effect.forEach( releasedProviderThreads, - (thread) => writeReleasedToolCallEvents(thread, releasedAt), + (thread) => + writeReleasedToolCallEvents(thread, releasedAt).pipe( + Effect.andThen(writeReleasedSubagentEvents(thread, releasedAt)), + ), { discard: true }, ).pipe(logReleaseFailure(input.providerSessionId)); if (input.gracefulSubscribers === true) { From b37c959669ca3e4757bdd84053cdb2a06c03f0ac Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sat, 14 Nov 2026 10:38:57 +0100 Subject: [PATCH 1155/1165] fix(server): open colon-number project folders correctly in VS Code (#36613) Co-authored-by: Julius Marminge --- apps/server/src/process/externalLauncher.test.ts | 4 ++++ apps/server/src/process/externalLauncher.ts | 5 +++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index a610a3b1709a3..4f6cabb783be9 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -195,6 +195,8 @@ it.effect.skipIf(windowsHost)( const codePath = path.join(binDir, "code"); yield* fileSystem.writeFileString(codePath, "#!/bin/sh\n"); yield* fileSystem.chmod(codePath, 0o755); + const colonProject = path.join(project, "build:2026"); + yield* fileSystem.makeDirectory(colonProject); const file = `${path.join(project, "file.ts")}:3:7`; const spawned: ChildProcess.StandardCommand[] = []; @@ -202,6 +204,7 @@ it.effect.skipIf(windowsHost)( const launcher = yield* ExternalLauncher.ExternalLauncher; yield* launcher.launchEditor({ editor: "vscode", cwd: project }); yield* launcher.launchEditor({ editor: "vscode", cwd: file }); + yield* launcher.launchEditor({ editor: "vscode", cwd: colonProject }); }).pipe( Effect.provide( layerTest({ @@ -218,6 +221,7 @@ it.effect.skipIf(windowsHost)( [ ["--new-window", project], ["--goto", file], + ["--new-window", colonProject], ], ); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index 4eecaf2c5cb2c..bb38a9d56afd4 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -149,9 +149,10 @@ function resolveCommandEditorArgs( case "direct-path": return [target]; case "goto": - if (Option.isSome(parsedTarget)) return ["--goto", target]; + // An existing folder wins over a position: `build:2026` is a legal folder name. // Without a window flag, VS Code's window settings may replace the user's last active window. - return isDirectory ? ["--new-window", target] : [target]; + if (isDirectory) return ["--new-window", target]; + return Option.isSome(parsedTarget) ? ["--goto", target] : [target]; case "line-column": return Option.match(parsedTarget, { onNone: () => [target], From 4121b30f78cd68c8aa033c949075ffcd9832be97 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Sat, 14 Nov 2026 20:44:11 +1100 Subject: [PATCH 1156/1165] fix(mobile): the open button no longer covers inline page headings (#25858) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: maria-rcks Co-authored-by: SAPHID Co-authored-by: Julius Marminge --- .../features/threads/HtmlRenderWebView.tsx | 57 ++++++++++--------- 1 file changed, 30 insertions(+), 27 deletions(-) diff --git a/apps/mobile/src/features/threads/HtmlRenderWebView.tsx b/apps/mobile/src/features/threads/HtmlRenderWebView.tsx index bf22cd01a1966..b13704196468b 100644 --- a/apps/mobile/src/features/threads/HtmlRenderWebView.tsx +++ b/apps/mobile/src/features/threads/HtmlRenderWebView.tsx @@ -22,10 +22,11 @@ import { useAppearancePreferences } from "../settings/appearance/AppearancePrefe import { webViewDecelerationRate } from "../../lib/webViewDecelerationRate"; const ROW_BOTTOM_MARGIN = 8; +const ACTION_ROW_HEIGHT = 32; const FULL_SCREEN_GUTTER = 16; export function htmlRenderRowHeight(frameHeight: number) { - return frameHeight + ROW_BOTTOM_MARGIN; + return ACTION_ROW_HEIGHT + frameHeight + ROW_BOTTOM_MARGIN; } function useHtmlRenderTheme() { @@ -213,6 +214,34 @@ export function ThreadHtmlRender(props: { return ( + + {uri !== null && !failed ? ( + + navigation.navigate("ThreadAttachment", { + environmentId: String(props.environmentId), + threadId: String(props.threadId), + attachmentId, + name: fileName, + mimeType: "text/html", + sizeBytes: "0", + htmlRender: "1", + }) + } + > + + + ) : null} + {uri !== null && !failed ? ( )} - {uri !== null && !failed ? ( - - navigation.navigate("ThreadAttachment", { - environmentId: String(props.environmentId), - threadId: String(props.threadId), - attachmentId, - name: fileName, - mimeType: "text/html", - sizeBytes: "0", - htmlRender: "1", - }) - } - > - - - ) : null} ); From a778a1e916ea53d444242720997cc550971c7774 Mon Sep 17 00:00:00 2001 From: getBoolean <19920697+getBoolean@users.noreply.github.com> Date: Sat, 14 Nov 2026 03:45:33 -0600 Subject: [PATCH 1157/1165] fix(server): create the hooks directory in the fork's rejected-push test Upstream's GitManager tests now clone repos from a fixture template that has no .git/hooks directory, so the fork's rejected-credentials push test could not write its pre-push hook. --- apps/server/src/git/GitManager.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 0d9dd25b053e0..d3a3fd4c100eb 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -3421,6 +3421,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const remoteDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); const hook = NodePath.join(repoDir, ".git", "hooks", "pre-push"); + // Template-cloned fixture repos have no hooks directory. + NodeFS.mkdirSync(NodePath.dirname(hook), { recursive: true }); NodeFS.writeFileSync( hook, "#!/bin/sh\necho \"fatal: Authentication failed for 'https://github.com/acme/web.git/'\" >&2\nexit 1\n", From 02e29ce8fa57eff37b85afef0e9077658118c276 Mon Sep 17 00:00:00 2001 From: Hieu Le <78847613+hieu-lee@users.noreply.github.com> Date: Sat, 14 Nov 2026 10:48:10 +0100 Subject: [PATCH 1158/1165] perf(usage): show saved totals before scanning history (#25711) Co-authored-by: maria-rcks Co-authored-by: Julius Marminge --- apps/server/src/usage/UsageService.test.ts | 67 ++++++++++++++++++++++ apps/server/src/usage/UsageService.ts | 3 +- 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 9c616eb02ac1b..ed71046036c6d 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -522,6 +522,73 @@ describe("Codex usage history", () => { }); describe("UsageService", () => { + it.effect("keeps pricing metadata paired with the rates used during a concurrent refresh", () => + Effect.gen(function* () { + const { home, settings } = yield* setup; + const sessions = NodePath.join(home, "codex", "sessions"); + yield* Effect.promise(async () => { + await NodeFSP.mkdir(sessions, { recursive: true }); + await NodeFSP.writeFile( + NodePath.join(sessions, "session.jsonl"), + [ + { type: "session_meta", payload: { id: "session", model_provider: "openai" } }, + { type: "turn_context", payload: { model: "gpt-6-astra", turn_id: "turn" } }, + { + type: "event_msg", + timestamp: "2026-08-01T10:00:00Z", + payload: { + type: "token_count", + info: { last_token_usage: { input_tokens: 0, output_tokens: 1 } }, + }, + }, + ] + .map((record) => encodeUnknownJsonString(record)) + .join("\n") + "\n", + ); + }); + let rate = 0; + yield* Effect.gen(function* () { + const resolving = yield* Deferred.make(); + const release = yield* Deferred.make(); + const service = yield* UsageService.make.pipe( + Effect.provideService(CodexUsageHistory.CodexUsageHistory, { + resolve: () => + Deferred.succeed(resolving, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.as(new Map()), + ), + }), + ); + const initialPricing = yield* service.refreshRates; + yield* TestClock.adjust(61_000); + const pending = yield* service.readSummary(WINDOW).pipe(Effect.forkChild); + yield* Deferred.await(resolving); + yield* service.refreshRates; + yield* Deferred.succeed(release, undefined); + const original = yield* Fiber.join(pending); + assert.strictEqual(original.buckets[0]?.costUsd, 1); + assert.strictEqual(original.pricing.fetchedAt, initialPricing.fetchedAt); + const updated = yield* service.readSummary(WINDOW); + assert.strictEqual(updated.buckets[0]?.costUsd, 2); + assert.notStrictEqual(updated.pricing.fetchedAt, initialPricing.fetchedAt); + }).pipe( + Effect.provide( + layerService({ + prefix: "concurrent-rates", + home, + settings, + ratesResponse: () => + Effect.succeed( + Response.json({ + "gpt-6-astra": { input_cost_per_token: 0, output_cost_per_token: ++rate }, + }), + ), + }), + ), + ); + }).pipe(Effect.scoped), + ); + it.effect("counts compressed Codex rollouts across refresh, rewrites, and cache restarts", () => Effect.gen(function* () { const { home, settings } = yield* setup; diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index fbbc1205868ee..638d2fe2d2d97 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -881,6 +881,7 @@ export const make = Effect.gen(function* () { { concurrency: 2 }, ); + const summaryPricing = pricing(); const aggregator = new UsageAggregator({ timeZone: input.timeZone, sinceDay: input.sinceDay, @@ -1116,7 +1117,7 @@ export const make = Effect.gen(function* () { untilDay: input.untilDay, buckets: aggregated.buckets, sources, - pricing: pricing(), + pricing: summaryPricing, scanDurationMs: Math.max(0, finishedAtMs - startedAtMs), ...(input.modelFilter === undefined ? {} : { modelFilter: input.modelFilter }), } satisfies UsageSummary; From 89f1acb0f0a9f5d6ce12b7c80c510da099366b26 Mon Sep 17 00:00:00 2001 From: MarcoDotIO Date: Sat, 14 Nov 2026 04:48:22 -0500 Subject: [PATCH 1159/1165] fix(pi): text generation uses the thinking level picked in Settings (#26069) Co-authored-by: maria-rcks Co-authored-by: Julius Marminge --- apps/server/src/provider/model-manifest.json | 4 +- docs/user/providers-pi.md | 2 +- .../src/server/modelCapabilities.ts | 4 + .../provider-pi/src/server/status.test.ts | 13 +- packages/provider-pi/src/server/status.ts | 24 ++-- .../src/server/textGeneration.test.ts | 118 +++++++++++++++++- .../provider-pi/src/server/textGeneration.ts | 30 +++++ 7 files changed, 160 insertions(+), 35 deletions(-) diff --git a/apps/server/src/provider/model-manifest.json b/apps/server/src/provider/model-manifest.json index 4906358bab70b..e13e640aa1616 100644 --- a/apps/server/src/provider/model-manifest.json +++ b/apps/server/src/provider/model-manifest.json @@ -95,8 +95,8 @@ "t3CodeRange": ">=0.0.46", "recommendedRange": ">=1.0.0", "ranges": [ - { "range": ">=0.80.5", "status": "supported" }, - { "range": "<0.80.5", "status": "unsupported" } + { "range": ">=0.84.3", "status": "supported" }, + { "range": "<0.84.3", "status": "unsupported" } ] }, { diff --git a/docs/user/providers-pi.md b/docs/user/providers-pi.md index e6a7b7d244023..b434250e9a12d 100644 --- a/docs/user/providers-pi.md +++ b/docs/user/providers-pi.md @@ -7,7 +7,7 @@ extensions, skills, context files, and native session history. 1. Install [Pi](https://pi.dev) on the machine running the T3 Code server. Pi requires Node.js 22.19 or newer; its platform installers can install Node if needed. T3 Code supports Pi - 0.80.5 or newer. + 0.84.3 or newer. 2. Run `pi` in a terminal, then `/login` to connect your model provider or configure its API key. 3. Open T3 Code Settings, enable Pi, and refresh the provider. diff --git a/packages/provider-pi/src/server/modelCapabilities.ts b/packages/provider-pi/src/server/modelCapabilities.ts index 2d379286c36f5..682ff2d3ed077 100644 --- a/packages/provider-pi/src/server/modelCapabilities.ts +++ b/packages/provider-pi/src/server/modelCapabilities.ts @@ -11,6 +11,10 @@ const PI_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", export type PiThinkingLevel = (typeof PI_THINKING_LEVELS)[number]; +export function isPiThinkingLevel(value: string): value is PiThinkingLevel { + return PI_THINKING_LEVELS.some((level) => level === value); +} + const PI_THINKING_LEVEL_LABELS: Record = { off: "Off", minimal: "Minimal", diff --git a/packages/provider-pi/src/server/status.test.ts b/packages/provider-pi/src/server/status.test.ts index 78d16ebd31c62..37fa28c00e42a 100644 --- a/packages/provider-pi/src/server/status.test.ts +++ b/packages/provider-pi/src/server/status.test.ts @@ -153,13 +153,13 @@ const settings = { } as const; describe("PiProvider", () => { - it.effect("requires the first published Pi version with entries and settlement hooks", () => + it.effect("requires session-scoped thinking changes", () => Effect.gen(function* () { const snapshot = yield* checkPiProviderStatus(settings).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, piProbeSpawner("0.80.3")), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, piProbeSpawner("0.84.2")), ); assert.equal(snapshot.status, "error"); - assert.equal(snapshot.version, "0.80.3"); + assert.equal(snapshot.version, "0.84.2"); assert.include(snapshot.message ?? "", `Pi ${MINIMUM_PI_VERSION} or newer`); }).pipe(Effect.provide(NodeServices.layer)), ); @@ -259,13 +259,6 @@ describe("PiProvider", () => { authCheck: '{"status":"not_ready","provider":"openai","reason":"provider_not_found"}', message: "Pi default (openai/gpt-5) uses openai, which has no available models in Pi.", }, - { - label: "a pinned model on Pi without auth check", - version: MINIMUM_PI_VERSION, - activeModel: { provider: "openai", id: "gpt-5" }, - authCheck: undefined, - message: "Pi default (openai/gpt-5) uses openai, which has no available models in Pi.", - }, { label: "an uncatalogued model id of a provider with credentials", version: "1.4.0", diff --git a/packages/provider-pi/src/server/status.ts b/packages/provider-pi/src/server/status.ts index 9361bc204eba5..43fa8f60c08a5 100644 --- a/packages/provider-pi/src/server/status.ts +++ b/packages/provider-pi/src/server/status.ts @@ -60,13 +60,8 @@ const PI_PRESENTATION = { const VERSION_PROBE_TIMEOUT_MS = 10_000; const PI_RPC_DISCOVERY_TIMEOUT_MS = 30_000; const PI_TEXT_GENERATION_DISCOVERY_TIMEOUT_MS = 5_000; -/** - * get_entries arrived in 0.80.3 and agent_settled landed in source at 0.80.4. - * Version 0.80.5 was the first published package containing both hooks. - */ -export const MINIMUM_PI_VERSION = "0.80.5"; -/** `pi auth check --json --no-refresh` arrived in 0.84.1. */ -const PI_AUTH_CHECK_VERSION = "0.84.1"; +// Earlier releases persist background set_thinking_level requests as the user's global default. +export const MINIMUM_PI_VERSION = "0.84.3"; const decodePiAuthCheck = Schema.decodeUnknownOption( Schema.fromJsonString(Schema.Struct({ reason: Schema.optional(Schema.String) })), @@ -290,9 +285,10 @@ export const discoverPiCommandsForCwd = Effect.fn("discoverPiCommandsForCwd")( }, ); -const runPiVersionCommand = Effect.fn("PiProviderStatus.runPiVersionCommand")(function* ( +export const runPiVersionCommand = Effect.fn("PiProviderStatus.runPiVersionCommand")(function* ( piSettings: PiSettings, environment: NodeJS.ProcessEnv, + cwd?: string, ) { const command = piSettings.binaryPath || "pi"; const spawnCommand = yield* resolveSpawnCommand(command, ["--version"], { @@ -303,13 +299,14 @@ const runPiVersionCommand = Effect.fn("PiProviderStatus.runPiVersionCommand")(fu ChildProcess.make(spawnCommand.command, spawnCommand.args, { env: environment, shell: spawnCommand.shell, + ...(cwd === undefined ? {} : { cwd }), }), ); }); /** - * Explains why Pi's active model cannot start a turn. On Pi versions with `auth check`, asks it - * whether credentials are missing, without refreshing or printing them. That command cannot see + * Explains why Pi's active model cannot start a turn. Asks `pi auth check` whether credentials + * are missing, without refreshing or printing them. That command cannot see * extension providers or a runtime --api-key, so it only refines Pi's own unavailable verdict, * and is skipped when launch args supply the key. */ @@ -317,16 +314,12 @@ const describeUnavailablePiModel = Effect.fn("PiProviderStatus.describeUnavailab function* ( piSettings: PiSettings, environment: NodeJS.ProcessEnv, - version: string, launchArgs: ReadonlyArray, model: { readonly provider: string; readonly id: string }, ) { const label = `Pi default (${model.provider}/${model.id})`; const unavailable = `${label} uses ${model.provider}, which has no available models in Pi. Choose another model, or check that provider's setup and the Pi launch arguments.`; - if ( - compareSemverVersions(version, PI_AUTH_CHECK_VERSION) < 0 || - launchArgs.includes("--api-key") - ) { + if (launchArgs.includes("--api-key")) { return unavailable; } const command = piSettings.binaryPath || "pi"; @@ -583,7 +576,6 @@ export const checkPiProviderStatus = Effect.fn("checkPiProviderStatus")(function : yield* describeUnavailablePiModel( piSettings, probeEnvironment, - version, resolvedLaunchArgs.args, discovery.unavailableActiveModel, ); diff --git a/packages/provider-pi/src/server/textGeneration.test.ts b/packages/provider-pi/src/server/textGeneration.test.ts index eda86cd2e62a4..6c977eee3cfa7 100644 --- a/packages/provider-pi/src/server/textGeneration.test.ts +++ b/packages/provider-pi/src/server/textGeneration.test.ts @@ -20,6 +20,8 @@ const makeFakePi = ( setModelError?: string, turnEvents: ReadonlyArray> = [], models?: ReadonlyArray>, + version = "0.84.3", + thinkingError?: string, ) => Effect.gen(function* () { const stdout = yield* Queue.unbounded(); @@ -46,29 +48,35 @@ const makeFakePi = ( yield* emit({ ...response, success: true, data: { models } }); } else if (record["type"] === "set_model" && setModelError !== undefined) { yield* emit({ ...response, success: false, error: setModelError }); + } else if (record["type"] === "set_thinking_level" && thinkingError !== undefined) { + yield* emit({ ...response, success: false, error: thinkingError }); } else { yield* emit({ ...response, success: true }); } } }); - const spawner = ChildProcessSpawner.make(() => - Effect.succeed( + const spawner = ChildProcessSpawner.make((command) => { + const versionProbe = + ChildProcess.isStandardCommand(command) && command.args.includes("--version"); + return Effect.succeed( ChildProcessSpawner.makeHandle({ // Outside the valid pid range, so PiRpc's process-group kill never lands. pid: ChildProcessSpawner.ProcessId(999_999_999), - exitCode: Effect.never, + exitCode: versionProbe ? Effect.succeed(ChildProcessSpawner.ExitCode(0)) : Effect.never, isRunning: Effect.succeed(true), kill: () => Effect.void, unref: Effect.succeed(Effect.void), stdin: Sink.forEach(onStdin), - stdout: Stream.fromQueue(stdout), + stdout: versionProbe + ? Stream.make(new TextEncoder().encode(version)) + : Stream.fromQueue(stdout), stderr: Stream.empty, all: Stream.empty, getInputFd: () => Sink.drain, getOutputFd: () => Stream.empty, }), - ), - ); + ); + }); return { spawner, received }; }); @@ -223,3 +231,101 @@ it.effect("drops an earlier error once a later attempt succeeds", () => assert.equal(error.detail, "Pi returned empty output."); }), ); + +it.effect.each([ + { model: "demo/demo-model", thinking: "off", expected: "off", version: "0.84.3" }, + { model: "demo/demo-model", thinking: "max", expected: "max", version: "1.4.0" }, + { model: "demo/demo-model", thinking: "invalid", expected: undefined, version: "0.84.3" }, + { model: "demo/demo-model", thinking: undefined, expected: undefined, version: "0.84.3" }, + { model: "default", thinking: "off", expected: "off", version: "0.84.3" }, + { model: "demo/demo-model", thinking: "low", expected: undefined, version: "0.84.2" }, +])( + "handles thinking=$thinking for $model on Pi $version", + ({ model, thinking, expected, version }) => + Effect.gen(function* () { + const pi = yield* makeFakePi( + JSON.stringify({ subject: "Update README", body: "" }), + undefined, + [], + undefined, + version, + ); + const textGeneration = yield* makePiTextGeneration( + { enabled: true, binaryPath: "pi", launchArgs: "", customModels: [] }, + {}, + ).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, pi.spawner)); + + const generation = textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "main", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: createModelSelection( + ProviderInstanceId.make("pi"), + model, + thinking === undefined ? [] : [{ id: "thinking", value: thinking }], + ), + }); + if (version === "0.84.2") { + const error = yield* generation.pipe(Effect.flip); + assert.include(error.detail, "Pi 0.84.3 or newer"); + assert.deepEqual(pi.received, []); + return; + } + const result = yield* generation; + + assert.equal(result.subject, "Update README"); + assert.deepEqual( + pi.received.map((record) => record["type"]), + [ + ...(model === "default" ? [] : ["get_available_models", "set_model"]), + ...(expected === undefined ? [] : ["set_thinking_level"]), + "prompt", + "get_last_assistant_text", + ], + ); + assert.equal( + pi.received.find((record) => record["type"] === "set_thinking_level")?.["level"], + expected, + ); + }), +); + +it.effect("generates at Pi's default level when Pi rejects the saved thinking level", () => + Effect.gen(function* () { + const pi = yield* makeFakePi( + JSON.stringify({ subject: "Update README", body: "" }), + undefined, + [], + undefined, + "1.4.0", + 'Thinking level "max" is not supported by demo/demo-model', + ); + const textGeneration = yield* makePiTextGeneration( + { enabled: true, binaryPath: "pi", launchArgs: "", customModels: [] }, + {}, + ).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, pi.spawner)); + + const result = yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "main", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: createModelSelection(ProviderInstanceId.make("pi"), "demo/demo-model", [ + { id: "thinking", value: "max" }, + ]), + }); + + assert.equal(result.subject, "Update README"); + assert.deepEqual( + pi.received.map((record) => record["type"]), + [ + "get_available_models", + "set_model", + "set_thinking_level", + "prompt", + "get_last_assistant_text", + ], + ); + }), +); diff --git a/packages/provider-pi/src/server/textGeneration.ts b/packages/provider-pi/src/server/textGeneration.ts index 8b75b3c3c0bad..dbc80cdfb4f6f 100644 --- a/packages/provider-pi/src/server/textGeneration.ts +++ b/packages/provider-pi/src/server/textGeneration.ts @@ -5,11 +5,16 @@ import * as Schema from "effect/Schema"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import { TextGenerationError } from "@t3tools/contracts"; +import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; +import { compareSemverVersions } from "@t3tools/shared/semver"; +import { parseGenericCliVersion } from "@t3tools/provider-core/server/snapshotProbe"; import type { PiSettings } from "../settings.ts"; import { makePiRpcConnection, piRecordField, piRecordString, resolvePiModelSlug } from "./rpc.ts"; import { buildPiRpcLaunch, resolvePiLaunchArgs } from "./mcpInjection.ts"; import * as TextGenerationOperations from "@t3tools/provider-core/server/textGenerationOperations"; +import { isPiThinkingLevel } from "./modelCapabilities.ts"; +import { MINIMUM_PI_VERSION, runPiVersionCommand } from "./status.ts"; const PI_TIMEOUT_MS = 180_000; @@ -33,6 +38,23 @@ export const makePiTextGeneration = Effect.fn("makePiTextGeneration")(function* const runPiJson: TextGenerationOperations.Runner = Effect.fn("PiTextGeneration.runPiJson")( function* (request) { const { operation, cwd, prompt, modelSelection } = request; + const thinking = getModelSelectionStringOptionValue(modelSelection, "thinking"); + if (thinking !== undefined && isPiThinkingLevel(thinking)) { + const output = yield* runPiVersionCommand(piSettings, environment, cwd).pipe( + Effect.timeoutOption(4_000), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + const version = + Option.isSome(output) && output.value.code === 0 + ? parseGenericCliVersion(`${output.value.stdout}\n${output.value.stderr}`) + : null; + if (version === null || compareSemverVersions(version, MINIMUM_PI_VERSION) < 0) { + return yield* new TextGenerationError({ + operation, + detail: `Pi ${MINIMUM_PI_VERSION} or newer is required for text generation thinking options. Update Pi and try again.`, + }); + } + } const resolvedLaunchArgs = resolvePiLaunchArgs(piSettings.launchArgs); if (!resolvedLaunchArgs.ok) { @@ -91,6 +113,14 @@ export const makePiTextGeneration = Effect.fn("makePiTextGeneration")(function* ); } + if (thinking !== undefined && isPiThinkingLevel(thinking)) { + // Pi rejects xhigh/max for models without a thinkingLevelMap entry; a stale saved + // level falls back to Pi's default instead of failing the whole generation. + yield* connection + .request({ type: "set_thinking_level", level: thinking }) + .pipe(Effect.catchTags({ PiRpcError: () => Effect.void })); + } + yield* connection.request({ type: "prompt", message: prompt }); // Provider failures (bad key, rate limit, 5xx) arrive on the assistant // `message_end`. Keep only the latest, since Pi can retry and still answer. From 0fee26d5bf69084e17ee8869c48e428f84a1e378 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sat, 14 Nov 2026 10:49:15 +0100 Subject: [PATCH 1160/1165] fix(mobile): launch terminal commands after the measured resize (#36584) Co-authored-by: Julius Marminge --- .../client-runtime/src/state/terminal.test.ts | 141 ++++++++++++++++-- packages/client-runtime/src/state/terminal.ts | 49 ++++-- 2 files changed, 161 insertions(+), 29 deletions(-) diff --git a/packages/client-runtime/src/state/terminal.test.ts b/packages/client-runtime/src/state/terminal.test.ts index 6fb2d21b19ea1..f3b2a58365d94 100644 --- a/packages/client-runtime/src/state/terminal.test.ts +++ b/packages/client-runtime/src/state/terminal.test.ts @@ -1,4 +1,12 @@ -import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { + EnvironmentId, + ThreadId, + type TerminalOpenInput, + type TerminalResizeInput, + type TerminalWriteInput, + type TerminalCloseInput, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; import { expect, it } from "vite-plus/test"; @@ -9,6 +17,7 @@ import { type TerminalLifecycleEvent, type TerminalSessionMode, } from "./terminal.ts"; +import { runAtomCommand } from "./runtime.ts"; import { EMPTY_TERMINAL_BUFFER_STATE, type TerminalBufferState } from "./terminalSession.ts"; function run(events: ReadonlyArray) { @@ -59,7 +68,19 @@ it("leaves the history a viewer was reading when operate is granted", () => { const environmentId = EnvironmentId.make("environment"); const threadId = ThreadId.make("thread"); -function setup(options: { readonly initialInput?: string } = {}) { +type CommandInput = + | TerminalOpenInput + | TerminalResizeInput + | TerminalWriteInput + | TerminalCloseInput; + +function setup( + options: { + readonly initialInput?: string; + readonly beforeCommand?: (name: string, input: CommandInput) => Promise; + readonly resizeFailure?: () => boolean; + } = {}, +) { // Tests flush the registry's scheduled work to control when driver finalizers run. const scheduled: Array<() => void> = []; const registry = AtomRegistry.make({ @@ -78,14 +99,30 @@ function setup(options: { readonly initialInput?: string } = {}) { AsyncResult.initial(true), ); const access = { canOperate: true }; - const calls: Array<{ readonly command: string; readonly input: unknown }> = []; + const calls: Array<{ readonly command: string; readonly input: CommandInput }> = []; + const pending: Promise[] = []; const command = (name: string) => ({ label: name, - run: async (_registry: AtomRegistry.AtomRegistry, value: { readonly input: unknown }) => { - calls.push({ command: name, input: value.input }); - return AsyncResult.success(undefined); + run: (_registry: AtomRegistry.AtomRegistry, value: { readonly input: CommandInput }) => { + const result = (async () => { + calls.push({ command: name, input: value.input }); + if (options.beforeCommand) await options.beforeCommand(name, value.input); + return name === "resize" && options.resizeFailure?.() + ? AsyncResult.failure(Cause.fail("resize rejected")) + : AsyncResult.success(undefined); + })(); + pending.push(result); + return result; }, }); + const write = command("write"); + // Await command completion, then a normal input receipt through the same command runner. + const completeCommands = async (count = pending.length) => { + await Promise.all(pending.slice(0, count)); + await runAtomCommand(registry, write, { + input: { threadId, terminalId: "term-1", data: "typed" }, + }); + }; const exits: Array = []; const live: Array = []; const session = createTerminalSession( @@ -94,7 +131,7 @@ function setup(options: { readonly initialInput?: string } = {}) { observe: () => observation, open: command("open"), resize: command("resize"), - write: command("write"), + write, close: command("close"), }, { @@ -126,7 +163,21 @@ function setup(options: { readonly initialInput?: string } = {}) { registry.set(observation, snapshot(state)); const mount = (mode: TerminalSessionMode) => registry.mount(session.driver(mode)); const of = (name: string) => calls.filter((call) => call.command === name).map((c) => c.input); - return { session, attached, access, flush, detach, emit, observe, mount, calls, of, exits, live }; + return { + session, + attached, + access, + flush, + detach, + emit, + observe, + mount, + calls, + of, + exits, + live, + completeCommands, + }; } it("replays the measured grid on every new attachment generation", () => { @@ -154,17 +205,79 @@ it("replays the measured grid on every new attachment generation", () => { ]); }); -it("writes the launch's initial input once the first snapshot lands", () => { - const { attached, emit, mount, calls, of } = setup({ initialInput: "pnpm dev\r" }); +it("writes the launch's initial input once, after the measured resize lands", async () => { + const resize = Promise.withResolvers(); + const latestResize = Promise.withResolvers(); + const written = Promise.withResolvers(); + let shellCols = 80; + let resizes = 0; + const commandWidths: number[] = []; + const { session, attached, emit, mount, calls, of, completeCommands } = setup({ + initialInput: "stty size\r", + beforeCommand: async (name, input) => { + if (name === "resize") { + await (++resizes === 1 ? resize : latestResize).promise; + if ("cols" in input && input.cols !== undefined) shellCols = input.cols; + } + if (name === "write" && "data" in input && input.data === "stty size\r") { + commandWidths.push(shellCols); + written.resolve(); + } + }, + }); mount(attached); - expect(of("write")).toEqual([]); emit({ status: "running", version: 1 }); emit({ status: "running", version: 2 }); - expect(of("write")).toEqual([{ threadId, terminalId: "term-1", data: "pnpm dev\r" }]); - // The shell gets the measured grid before the launched command runs. - expect(calls.map((call) => call.command)).toEqual(["resize", "write"]); + session.reportGridSize({ cols: 100, rows: 30 }); + resize.resolve(); + await completeCommands(1); + expect(of("write")).toEqual([{ threadId, terminalId: "term-1", data: "typed" }]); + latestResize.resolve(); + await written.promise; + expect(commandWidths).toEqual([100]); + expect(of("write")).toEqual([ + { threadId, terminalId: "term-1", data: "typed" }, + { threadId, terminalId: "term-1", data: "stty size\r" }, + ]); + expect(calls.map((call) => call.command)).toEqual(["resize", "resize", "write", "write"]); }); +it.each(["failed resize", "starting snapshot", "unmeasured grid"] as const)( + "keeps launch input pending after a %s until a running shell is successfully resized", + async (scenario) => { + let failResize = scenario === "failed resize"; + const written = Promise.withResolvers(); + const { session, attached, emit, mount, of, completeCommands } = setup({ + initialInput: "pnpm dev\r", + resizeFailure: () => failResize, + beforeCommand: async (name, input) => { + if (name === "write" && "data" in input && input.data === "pnpm dev\r") written.resolve(); + }, + }); + // The seeded grid is 120x40; only "unmeasured grid" never hears from the surface. + if (scenario !== "unmeasured grid") session.reportGridSize({ cols: 120, rows: 40 }); + mount(attached); + emit({ status: scenario === "starting snapshot" ? "starting" : "running", version: 1 }); + await completeCommands(); + expect(of("write")).toEqual([{ threadId, terminalId: "term-1", data: "typed" }]); + expect(of("resize")).toHaveLength(scenario === "starting snapshot" ? 0 : 1); + + failResize = false; + session.reportGridSize( + scenario === "unmeasured grid" ? { cols: 120, rows: 40 } : { cols: 100, rows: 30 }, + ); + emit({ status: "running", version: 2 }); + await written.promise; + emit({ status: "running", version: 3 }); + await completeCommands(); + expect(of("write")).toEqual([ + { threadId, terminalId: "term-1", data: "typed" }, + { threadId, terminalId: "term-1", data: "pnpm dev\r" }, + { threadId, terminalId: "term-1", data: "typed" }, + ]); + }, +); + it("closes and reports a shell that exits while attached", () => { const { attached, emit, mount, of, exits, live } = setup(); mount(attached); diff --git a/packages/client-runtime/src/state/terminal.ts b/packages/client-runtime/src/state/terminal.ts index f62c2549daf1d..7e87ef41f5edc 100644 --- a/packages/client-runtime/src/state/terminal.ts +++ b/packages/client-runtime/src/state/terminal.ts @@ -226,7 +226,7 @@ export interface TerminalSessionOptions { readonly terminalId: string; }; readonly gridSize: TerminalGridSize; - /** Written once, as soon as the first attachment snapshot lands. */ + /** Written once, after the first attachment snapshot lands and the shell has the measured grid. */ readonly initialInput?: string | undefined; /** Read right before every command, so revoking operate stops them at once. */ readonly canOperate: () => boolean; @@ -252,7 +252,7 @@ export interface TerminalSession { * Owns one shown terminal's lifecycle: it reopens an ended shell on an explicit * visit, closes and reports a shell that exits while attached, replays the * measured grid whenever the server starts a new output generation, and writes - * the launch's initial input once. + * the launch's initial input once that grid has reached the shell. */ export function createTerminalSession( atoms: TerminalSessionAtoms, @@ -261,19 +261,43 @@ export function createTerminalSession( const { environmentId, threadId, terminalId } = options.target; let lifecycle = INITIAL_TERMINAL_LIFECYCLE; let gridSize = options.gridSize; + // The seeded grid is a cache or default guess; launch input waits for the surface's own measurement. + let gridMeasured = false; let generation: number | null = null; let liveLifecycleVersion: number | null = null; let initialInputPending = Boolean(options.initialInput); let lastStatus: TerminalBufferState["status"] | null = null; let loggedFirstOutput = false; - let resizeRegistry: AtomRegistry.AtomRegistry | null = null; + let commandRegistry: AtomRegistry.AtomRegistry | null = null; + // Only a successful resize of the current measured grid may release initial input. + let shellResized: ReturnType | null = null; const sendResize = () => { - if (generation === null || resizeRegistry === null || !options.canOperate()) return; - void runAtomCommand(resizeRegistry, atoms.resize, { + const registry = commandRegistry; + if (generation === null || registry === null || !options.canOperate()) return; + const resized = runAtomCommand(registry, atoms.resize, { environmentId, input: { threadId, terminalId, cols: gridSize.cols, rows: gridSize.rows }, }); + shellResized = resized; + void resized.then((result) => { + const data = options.initialInput; + if ( + result._tag !== "Success" || + resized !== shellResized || + !gridMeasured || + generation === null || + !initialInputPending || + !data || + !options.canOperate() + ) + return; + initialInputPending = false; + void runAtomCommand(registry, atoms.write, { + environmentId, + input: { threadId, terminalId, data }, + }); + }); }; const setLive = (next: number | null) => { @@ -326,7 +350,7 @@ export function createTerminalSession( input: TerminalAttachInput, result: AsyncResult.AsyncResult, ) => { - resizeRegistry = registry; + commandRegistry = registry; const buffer = AsyncResult.getOrElse(result, () => EMPTY_TERMINAL_BUFFER_STATE); const status = result._tag === "Failure" ? "error" : buffer.status; if (status !== lastStatus) { @@ -357,17 +381,10 @@ export function createTerminalSession( buffer.version > 0 && status === "running" ? buffer.output.generation : null; if (nextGeneration !== generation) { generation = nextGeneration; + shellResized = null; sendResize(); } - if (initialInputPending && buffer.version > 0 && options.initialInput && options.canOperate()) { - initialInputPending = false; - void runAtomCommand(registry, atoms.write, { - environmentId, - input: { threadId, terminalId, data: options.initialInput }, - }); - } - apply({ type: "status", status, version: buffer.version }, registry, input); }; @@ -391,6 +408,7 @@ export function createTerminalSession( const registry = get.registry; get.addFinalizer(() => { generation = null; + shellResized = null; lastStatus = null; setLive(null); lifecycle = transitionTerminalLifecycle(lifecycle, { @@ -414,7 +432,8 @@ export function createTerminalSession( return attached; }, reportGridSize: (size) => { - if (size.cols === gridSize.cols && size.rows === gridSize.rows) return; + if (gridMeasured && size.cols === gridSize.cols && size.rows === gridSize.rows) return; + gridMeasured = true; gridSize = size; sendResize(); }, From 13f89ae0a7fcecbdcd06ac1551d5ff549bb76926 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sat, 14 Nov 2026 10:55:06 +0100 Subject: [PATCH 1161/1165] fix(server): repeated imports reach older worktree history (#36608) Co-authored-by: Julius Marminge --- .../src/project/AgentSessionImporter.test.ts | 148 +++++++++++++++++- .../src/project/AgentSessionImporter.ts | 32 ++-- 2 files changed, 164 insertions(+), 16 deletions(-) diff --git a/apps/server/src/project/AgentSessionImporter.test.ts b/apps/server/src/project/AgentSessionImporter.test.ts index 23264b2eeefe6..4c6109412d961 100644 --- a/apps/server/src/project/AgentSessionImporter.test.ts +++ b/apps/server/src/project/AgentSessionImporter.test.ts @@ -1,3 +1,4 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; import { type AgentSessionSource, @@ -22,17 +23,21 @@ import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import * as Stream from "effect/Stream"; import * as SqlClient from "effect/sql/SqlClient"; import * as TestClock from "effect/testing/TestClock"; +import * as ServerConfig from "../config.ts"; import * as EventSink from "../orchestration-v2/EventSink.ts"; import * as EventStore from "../orchestration-v2/EventStore.ts"; import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; +import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts"; import * as ThreadCommandExecutor from "../orchestration-v2/ThreadCommandExecutor.ts"; import { @@ -188,6 +193,10 @@ const makeImporter = Effect.fn(function* ( ServerSettings.ServerSettingsService, ServerSettingsError > = ServerSettings.layerTest(), + realScan?: { + readonly workspaceRoot: string; + readonly scanner: AgentSessionScanner.AgentSessionScanner["Service"]; + }, ) { const sql = yield* SqlClient.SqlClient; const events = yield* EventStore.EventStoreV2; @@ -229,15 +238,17 @@ const makeImporter = Effect.fn(function* ( ProviderSessionRuntime.ProviderSessionRuntimeRepository, yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository, ), - Layer.mock(AgentSessionScanner.AgentSessionScanner)({ - recentThreads: () => Stream.fromIterable(candidates), - }), + realScan === undefined + ? Layer.mock(AgentSessionScanner.AgentSessionScanner)({ + recentThreads: () => Stream.fromIterable(candidates), + }) + : Layer.succeed(AgentSessionScanner.AgentSessionScanner, realScan.scanner), Layer.mock(ProjectService.ProjectService)({ getById: () => Effect.succeedSome({ id: projectId, title: "Project", - workspaceRoot: "/workspace/project", + workspaceRoot: realScan?.workspaceRoot ?? "/workspace/project", defaultModelSelection: null, scripts: [], createdAt: timestamp, @@ -377,6 +388,43 @@ it.effect( }).pipe(Effect.scoped, Effect.provide(persistence)), ); +it.effect("tolerates a missing transcript owner but propagates failed ownership reads", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const runtimes = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; + const source = candidate(CODEX_MODEL_SELECTION, "ownership-read"); + const threadId = importId(source); + yield* runtimes.upsert({ + threadId: ThreadId.make("missing-owner"), + providerName: "codex", + providerInstanceId: source.source.providerInstanceId, + adapterKey: "codex", + runtimeMode: "full-access", + status: "stopped", + lastSeenAt: timestamp, + resumeCursor: null, + runtimePayload: { importedTranscripts: [source.source] }, + }); + yield* runtimes.recordImportedTranscript({ + threadId: ThreadId.make("missing-owner"), + source: source.source, + }); + const importer = yield* makeImporter([source]); + expect(yield* importer.run).toEqual({ importedCount: 1, skippedCount: 0 }); + + yield* sql`UPDATE orchestration_v2_projection_threads SET payload_json = '{}' WHERE thread_id = ${threadId}`; + expect(yield* Effect.flip(importer.run)).toMatchObject({ + _tag: "AgentSessionScanError", + operation: "read-projects", + cause: { + _tag: "OrchestratorProjectionError", + threadId, + cause: { _tag: "ProjectionStoreReadError" }, + }, + }); + }).pipe(Effect.scoped, Effect.provide(persistence)), +); + it.effect("rechecks ownership after the import reaches the commit transaction", () => Effect.gen(function* () { const enteringTransaction = yield* Deferred.make(); @@ -869,3 +917,95 @@ it.effect("imports under a policy ceiling in the project's default, kept under t ); }).pipe(Effect.scoped, Effect.provide(persistence)), ); + +it.effect("repeated imports reach linked-worktree sessions behind the scan budget", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-import-worktree-budget-" }); + const repo = path.join(root, "repo"); + const worktree = path.join(root, "feature"); + const worktreeGitDir = path.join(repo, ".git", "worktrees", "feature"); + yield* fs.makeDirectory(worktreeGitDir, { recursive: true }); + yield* fs.makeDirectory(worktree); + yield* fs.writeFileString(path.join(repo, ".git", "config"), "[core]\n"); + yield* fs.writeFileString(path.join(worktreeGitDir, "commondir"), "../..\n"); + yield* fs.writeFileString(path.join(worktreeGitDir, "HEAD"), "ref: refs/heads/feature\n"); + yield* fs.writeFileString(path.join(worktree, ".git"), `gitdir: ${worktreeGitDir}\n`); + + const claudeHome = path.join(root, "claude"); + const codexHome = path.join(root, "codex"); + const sessions = path.join(codexHome, "sessions", "2026", "09", "01"); + yield* fs.makeDirectory(sessions, { recursive: true }); + const nowMs = DateTime.toEpochMillis(now); + yield* TestClock.setTime(nowMs); + // One more session than the 100-transcript scan budget, all run in the linked worktree. + for (let index = 0; index < 101; index++) { + const file = path.join(sessions, `rollout-${String(index).padStart(3, "0")}.jsonl`); + const records = [ + { type: "session_meta", payload: { id: `session-${index}`, cwd: worktree } }, + { type: "event_msg", payload: { type: "user_message", message: `Request ${index}` } }, + { + type: "response_item", + payload: { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "Done" }], + }, + }, + ]; + yield* fs.writeFileString( + file, + records.map((record) => JSON.stringify(record) + "\n").join(""), + ); + const modifiedAtSeconds = nowMs / 1000 - (index + 1); + yield* fs.utimes(file, modifiedAtSeconds, modifiedAtSeconds); + } + + const scannerContext = yield* Layer.build( + AgentSessionScanner.layer.pipe( + Layer.provide( + Layer.mergeAll( + ServerConfig.layerTest(claudeHome, { prefix: "t3-import-budget-config-" }), + ServerSettings.layerTest({ + providerInstances: { + [ProviderInstanceId.make("claudeAgent")]: { + driver: ProviderDriverKind.make("claudeAgent"), + config: { homePath: claudeHome }, + }, + [ProviderInstanceId.make("codex")]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath: codexHome }, + }, + // Keeps the default Pi instance off the developer's real ~/.pi. + [ProviderInstanceId.make("pi")]: { + driver: ProviderDriverKind.make("pi"), + enabled: false, + config: {}, + }, + }, + }), + Layer.mock(ProjectStore.ProjectStoreV2)({ listShells: () => Effect.succeed([]) }), + Layer.succeed( + ProviderSessionRuntime.ProviderSessionRuntimeRepository, + yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository, + ), + ), + ), + ), + ); + const importer = yield* makeImporter([], Effect.void, undefined, undefined, undefined, { + workspaceRoot: repo, + scanner: Context.get(scannerContext, AgentSessionScanner.AgentSessionScanner), + }); + + expect(yield* importer.run).toEqual({ importedCount: 100, skippedCount: 1 }); + expect(yield* importer.run).toEqual({ importedCount: 101, skippedCount: 0 }); + const oldest = yield* ProjectionStore.ProjectionStoreV2.pipe( + Effect.flatMap((projections) => + projections.getThread(ThreadId.make("import:codex:session-100")), + ), + ); + expect(oldest.worktreePath).toBe(worktree); + }).pipe(Effect.scoped, Effect.provide(Layer.merge(persistence, NodeServices.layer))), +); diff --git a/apps/server/src/project/AgentSessionImporter.ts b/apps/server/src/project/AgentSessionImporter.ts index 6f88b929260a6..0ebd6688a28be 100644 --- a/apps/server/src/project/AgentSessionImporter.ts +++ b/apps/server/src/project/AgentSessionImporter.ts @@ -35,6 +35,7 @@ import * as Stream from "effect/Stream"; import * as EventSink from "../orchestration-v2/EventSink.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; +import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts"; import * as ThreadCommandExecutor from "../orchestration-v2/ThreadCommandExecutor.ts"; import * as ProviderSessionRuntime from "../persistence/ProviderSessionRuntime.ts"; @@ -42,6 +43,10 @@ import * as ServerSettings from "../serverSettings.ts"; import * as AgentSessionScanner from "./AgentSessionScanner.ts"; import * as ProjectService from "./ProjectService.ts"; +const isProjectionStoreThreadNotFoundError = Schema.is( + ProjectionStore.ProjectionStoreThreadNotFoundError, +); + const IMPORT_EVENT_PREFIX = "agent-session-import:v2"; const CLAUDE_SESSION_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; @@ -242,18 +247,21 @@ const make = Effect.gen(function* () { (cause) => new AgentSessionScanError({ operation: "read-projects", cause }), ), ); - const completedSources = runtimeRows.flatMap((runtime) => { - const payload = AgentSessionScanner.decodeImportedTranscriptPayload(runtime.runtimePayload); - if ( - Option.isNone(payload) || - payload.value.cwd === undefined || - normalizeProjectPathForComparison(payload.value.cwd) !== - normalizeProjectPathForComparison(project.workspaceRoot) - ) { - return []; - } - return payload.value.importedTranscripts ?? []; - }); + // The thread's project owns its transcripts: a linked worktree import records the worktree as cwd. + const completedSources = (yield* Effect.forEach(runtimeRows, (runtime) => { + const imported = AgentSessionScanner.decodeImportedTranscriptPayload( + runtime.runtimePayload, + ).pipe(Option.flatMapNullishOr((payload) => payload.importedTranscripts)); + if (Option.isNone(imported) || imported.value.length === 0) return Effect.succeed([]); + return orchestrator.getThreadRecords(runtime.threadId, []).pipe( + Effect.map(({ thread }) => (thread.projectId === input.projectId ? imported.value : [])), + Effect.catch((cause) => + isProjectionStoreThreadNotFoundError(cause.cause) + ? Effect.succeed([]) + : Effect.fail(new AgentSessionScanError({ operation: "read-projects", cause })), + ), + ); + })).flat(); const outcomes = scanner.recentThreads(project.workspaceRoot, completedSources); const importedThreadIds = new Set(); let importedCount = 0; From 8a072ca51d21ac5ec4cd593ab6151ea2f2443deb Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 02:08:06 -0800 Subject: [PATCH 1162/1165] fix(ci): pin unenv so release smoke installs resolve its exact peer (#36638) --- pnpm-lock.yaml | 1 + pnpm-workspace.yaml | 3 +++ 2 files changed, 4 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1157878a144ad..99afb66977519 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -299,6 +299,7 @@ overrides: expo-modules-core: 58.0.30 '@exodus/bytes>@noble/hashes': 1.8.0 '@react-navigation/core': 7.24.0 + unenv: 2.0.0-rc.24 dbus-next>usocket: '-' '@anthropic-ai/claude-agent-sdk>@anthropic-ai/claude-agent-sdk-darwin-arm64': '-' '@anthropic-ai/claude-agent-sdk>@anthropic-ai/claude-agent-sdk-darwin-x64': '-' diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c7982f437914e..4742a77f937aa 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -137,6 +137,9 @@ overrides: # Move this with @react-navigation/native: an older core than it requires crashes # every native stack at launch. "@react-navigation/core": 7.24.0 + # @cloudflare/unenv-preset peers on this exact version, but alchemy's runtime + # takes a caret range, so lockfile-free installs drift to a newer prerelease. + unenv: 2.0.0-rc.24 # The dbus-next patch drops usocket from the package, but pnpm resolves from the # registry manifest, so the edge has to be cut here as well. "dbus-next>usocket": "-" From 38b3feeb75d6c02a3988d4ea2983ceebc4a1b66e Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sat, 14 Nov 2026 11:11:06 +0100 Subject: [PATCH 1163/1165] fix(server): cached refs include a newly checked-out external branch (#36626) Co-authored-by: Julius Marminge --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 39 ++++++++++++-------- apps/server/src/vcs/GitVcsDriverCore.ts | 22 ++++++++++- 2 files changed, 44 insertions(+), 17 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 802d0d463cfdf..ab0483124433e 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -722,24 +722,31 @@ it.effect("reads a branch whose name starts with a parenthesis as attached", () ).pipe(Effect.provide(layerTest)), ); -it.effect("follows a checkout made outside the driver after refs were cached", () => - Effect.scoped( - Effect.gen(function* () { - const cwd = yield* makeTmpDir(); - const { initialBranch } = yield* initRepoWithCommit(cwd); - yield* git(cwd, ["branch", "feature/outside"]); - const driver = yield* GitVcsDriver.GitVcsDriver; - yield* driver.listRefs({ cwd }); +it.effect.each(["an existing", "a new"] as const)( + "follows a checkout of %s branch made outside the driver after refs were cached", + (branch) => + Effect.scoped( + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + if (branch === "an existing") yield* git(cwd, ["branch", "feature/outside"]); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* driver.listRefs({ cwd }); - yield* git(cwd, ["switch", "feature/outside"]); + yield* git( + cwd, + branch === "an existing" + ? ["switch", "feature/outside"] + : ["switch", "-c", "feature/outside"], + ); - const refs = (yield* driver.listRefs({ cwd })).refs; - const previous = refs.find((ref) => ref.name === initialBranch); - assert.isTrue(refs.find((ref) => ref.name === "feature/outside")?.current); - assert.isFalse(previous?.current); - assert.isNull(previous?.worktreePath ?? null); - }), - ).pipe(Effect.provide(layerTest)), + const refs = (yield* driver.listRefs({ cwd })).refs; + const previous = refs.find((ref) => ref.name === initialBranch); + assert.isTrue(refs.find((ref) => ref.name === "feature/outside")?.current); + assert.isFalse(previous?.current); + assert.isNull(previous?.worktreePath ?? null); + }), + ).pipe(Effect.provide(layerTest)), ); it.effect.each(["on a branch", "detached", "unborn"] as const)( diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index b85416032e177..1dc33f9a2b141 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -194,6 +194,7 @@ interface GitRepositoryPaths { } interface GitRefsSnapshot { + readonly defaultBranch: string | null; readonly localBranches: ReadonlyArray; readonly remoteBranches: ReadonlyArray; readonly hasPrimaryRemote: boolean; @@ -4387,6 +4388,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* : left.ref.name.localeCompare(right.ref.name); return { + defaultBranch, localBranches: localBranches.toSorted(byRecencyThenName).map(({ ref }) => ref), remoteBranches: remoteBranches.toSorted(byRecencyThenName).map(({ ref }) => ref), hasPrimaryRemote: remoteNames.length > 0, @@ -4527,7 +4529,24 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* { concurrency: 2 }, ); const { worktreeRoot } = repositoryPaths; - const localBranches = snapshot.localBranches.map((ref) => { + // The snapshot misses a branch created outside T3 Code until it refreshes. An unborn HEAD + // names a branch with no ref yet, so the ref must exist before it is listed. + const currentBranchUnlisted = + currentBranch !== null && !snapshot.localBranches.some((ref) => ref.name === currentBranch); + const unlistedCurrentBranch: ReadonlyArray = + currentBranchUnlisted && + (yield* branchExists(input.cwd, currentBranch).pipe(Effect.orElseSucceed(() => false))) + ? [ + { + name: currentBranch, + current: true, + isRemote: false, + isDefault: currentBranch === snapshot.defaultBranch, + worktreePath: worktreeRoot, + }, + ] + : []; + const listedBranches = snapshot.localBranches.map((ref) => { const current = ref.name === currentBranch; return { ...ref, @@ -4542,6 +4561,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* current, }; }); + const localBranches = [...unlistedCurrentBranch, ...listedBranches]; const combinedBranches = input.includeMatchingRemoteRefs ? [...localBranches, ...snapshot.remoteBranches] : dedupeRemoteBranchesWithLocalMatches([...localBranches, ...snapshot.remoteBranches]); From 152d1aa587c8ffbc95eb9074f68f521a903bf688 Mon Sep 17 00:00:00 2001 From: getBoolean <19920697+getBoolean@users.noreply.github.com> Date: Sat, 14 Nov 2026 04:11:17 -0600 Subject: [PATCH 1164/1165] fix: reconcile fork tests with upstream's newer pane, wire-budget and setup-shell tests - thread-file-navigator-pane.test: mock the docked-inspector close contexts the fork's files pane reads, so upstream's new pane test does not load react-native. - transferBudgetV2: the fork's 25-row opening page ends inside a long finished turn, so its folded-work anchor is off the page; measure the older page clients load instead. - ProjectSetupScriptRunner.test: assert only nonzero for a syntax error, since the fork also runs this file on macOS, where sh exits 1 instead of 2. - ProjectionStore: use upstream's long_turn threshold; with the fork's 27-row opening window both thresholds select the same rows. --- .github/fork-manifest.txt | 11 +++++++++++ .../files/thread-file-navigator-pane.test.tsx | 7 +++++++ .../transferBudgetV2.integration.test.ts | 18 ++++++++++++------ .../src/orchestration-v2/ProjectionStore.ts | 2 +- .../project/ProjectSetupScriptRunner.test.ts | 7 +++++-- 5 files changed, 36 insertions(+), 9 deletions(-) diff --git a/.github/fork-manifest.txt b/.github/fork-manifest.txt index d97f1a0a9769e..d1559ee5b916d 100644 --- a/.github/fork-manifest.txt +++ b/.github/fork-manifest.txt @@ -1010,3 +1010,14 @@ apps/mobile/src/native/NativeHeaderToolbar.tsx Fork: apps/mobile/src/native/NativeHeaderToolbar.tsx committedHandlers.current.get(key) apps/mobile/src/native/NativeHeaderToolbar.test.tsx Fork: apps/mobile/src/native/NativeHeaderToolbar.test.tsx keeps a retained Preview action attached to Preview + +# Fork: Mock the docked-inspector close contexts the fork's files pane reads, so upstream's pane test does not load react-native. +apps/mobile/src/features/files/thread-file-navigator-pane.test.tsx Fork: +apps/mobile/src/features/files/thread-file-navigator-pane.test.tsx vi.mock("../layout/workspace-inspector-sheet" + +# Fork: The fork's 25-row opening page ends inside a long finished turn, leaving its folded-work anchor off the page; measure the older page clients load instead. +apps/server/integration/transferBudgetV2.integration.test.ts Fork: +apps/server/integration/transferBudgetV2.integration.test.ts : yield* readHistoryPage(longTurnColdOpen.cursor, CLIENT_HISTORY_QUERY) + +# Fork: The fork runs setup-script tests on macOS too, where a syntax error exits 1 rather than dash's 2. +apps/server/src/project/ProjectSetupScriptRunner.test.ts if (exitCode === null) assert.notEqual(result.exitCode, 0); diff --git a/apps/mobile/src/features/files/thread-file-navigator-pane.test.tsx b/apps/mobile/src/features/files/thread-file-navigator-pane.test.tsx index 21f64998a3be9..9b14b981719f7 100644 --- a/apps/mobile/src/features/files/thread-file-navigator-pane.test.tsx +++ b/apps/mobile/src/features/files/thread-file-navigator-pane.test.tsx @@ -38,6 +38,13 @@ vi.mock("../../components/ui/AppBar", () => ({ /> ), })); +// Fork: the pane reads the docked-inspector close contexts, whose modules load react-native. +vi.mock("../layout/inspector-bar-store", async () => ({ + InspectorCloseContext: (await import("react")).createContext(null), +})); +vi.mock("../layout/workspace-inspector-sheet", async () => ({ + InspectorSheetContext: (await import("react")).createContext(null), +})); import { ThreadFileNavigatorPane } from "./thread-file-navigator-pane"; diff --git a/apps/server/integration/transferBudgetV2.integration.test.ts b/apps/server/integration/transferBudgetV2.integration.test.ts index 4aaac3b9e30e9..ed93dc4f77c88 100644 --- a/apps/server/integration/transferBudgetV2.integration.test.ts +++ b/apps/server/integration/transferBudgetV2.integration.test.ts @@ -512,13 +512,19 @@ it.live("reports V2 thread HTTP and WebSocket transfer budgets", () => // Once it finishes, its work stays on the server until a group is expanded. yield* sink.write({ events: finishLongTurn! }); const longTurnColdOpen = yield* readColdOpen(CLIENT_BOUNDED_QUERY); + // Fork: the fork's 25-row opening page ends inside a long finished turn, so the turn's + // folded-work anchor is off the page and a client reaches its work through the next older + // page instead. Measure that page (remove with the fork's opening limits). const { workAnchor } = longTurnColdOpen; - if (workAnchor?.item.deferredWork === undefined) - return yield* Effect.die("Expected the finished long turn to defer its work"); - const longTurnWorkPage = yield* readHistoryPage( - workAnchor.item.deferredWork.cursor, - `view=work&throughEntryId=${encodeURIComponent(workAnchor.sourceItemId)}&compactIds=1`, - ); + const longTurnWorkPage = + workAnchor?.item.deferredWork === undefined + ? longTurnColdOpen.cursor === null + ? yield* Effect.die("Expected the finished long turn to defer its work") + : yield* readHistoryPage(longTurnColdOpen.cursor, CLIENT_HISTORY_QUERY) + : yield* readHistoryPage( + workAnchor.item.deferredWork.cursor, + `view=work&throughEntryId=${encodeURIComponent(workAnchor.sourceItemId)}&compactIds=1`, + ); // Mobile opens a thread over the socket alone. const socketOpen = yield* open({ thread: LONG_TURN_THREAD_ID }); const socketSnapshot = socketOpen.items.find( diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index edc06af926ac9..0d6911c7465d0 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -3393,7 +3393,7 @@ export const layer: Layer.Layer = ), turn AS ( SELECT ( SELECT COUNT(*) FROM visible_tail WHERE ordinal >= ${boundary.latest} - ) > ${turnItemLimit} AS long_turn -- Fork: per-window turn limit. + ) > ${THREAD_HISTORY_PAGE_POLICY.maxTurnItems} AS long_turn ), tail AS ( SELECT visible_tail.ordinal FROM visible_tail ORDER BY visible_tail.ordinal DESC diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index bdc6216aded9c..0e284818103d5 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -337,11 +337,14 @@ describe.skipIf(HostProcess.Platform.defaultValue() === "win32")("POSIX setup co it.live.each([ { name: "an explicit exit", command: "exit 7", exitCode: 7 }, - { name: "a syntax error", command: "if then", exitCode: 2 }, + // Fork: shells disagree on a syntax error's code (dash 2, zsh and macOS sh 1), and the fork + // runs this file on macOS too, so only nonzero is asserted for it. + { name: "a syntax error", command: "if then", exitCode: null }, ])("reports a nonzero completion for $name", ({ command, exitCode }) => Effect.gen(function* () { const result = yield* runObservedSetup(command); - assert.equal(result.exitCode, exitCode); + if (exitCode === null) assert.notEqual(result.exitCode, 0); + else assert.equal(result.exitCode, exitCode); }), ); }); From 820f620e3d63cfb03f3587aa2c6844b9c57295bb Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sat, 14 Nov 2026 11:11:19 +0100 Subject: [PATCH 1165/1165] =?UTF-8?q?fix(web):=20model=20picks=20no=20long?= =?UTF-8?q?er=20inherit=20another=20model=E2=80=99s=20traits=20(#36621)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Julius Marminge --- apps/web/src/composerDraftStore.test.ts | 28 +++++++++++++- apps/web/src/composerDraftStore.ts | 50 +++++++++++++++++-------- 2 files changed, 60 insertions(+), 18 deletions(-) diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index a6d4e1ecde013..43d61924a8c74 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -1877,14 +1877,14 @@ describe("composerDraftStore modelSelection", () => { const threadSelection = modelSelection(CODEX_DRIVER, "gpt-5.3-codex", { reasoningEffort: "low", }); - const effective = (threadStarted: boolean) => { + const effective = (threadStarted: boolean, baseSelection = threadSelection) => { const state = deriveEffectiveComposerModelState({ draft: useComposerDraftStore.getState().getComposerDraft(threadRef), threadStarted, providers, selectedProvider: CODEX_DRIVER, selectedInstanceId: CODEX_INSTANCE, - threadModelSelection: threadSelection, + threadModelSelection: baseSelection, projectModelSelection: null, settings: DEFAULT_UNIFIED_SETTINGS, }); @@ -1921,6 +1921,30 @@ describe("composerDraftStore modelSelection", () => { }); }); + it.each([false, true])( + "an explicit optionless pick overrides base traits when threadStarted=%s", + (threadStarted) => { + const store = useComposerDraftStore.getState(); + store.setModelSelection(threadRef, modelSelection(CODEX_DRIVER, "gpt-5.4"), { + explicit: true, + replaceOptions: true, + }); + expect(effective(threadStarted)).toEqual({ model: "gpt-5.4", options: [] }); + }, + ); + + it("restores saved traits when the base model uses an alias", () => { + useComposerDraftStore + .getState() + .setModelSelection(threadRef, modelSelection(CODEX_DRIVER, "gpt-5.4"), { + explicit: true, + replaceOptions: true, + }); + expect( + effective(true, modelSelection(CODEX_DRIVER, "5.4", { reasoningEffort: "low" })), + ).toEqual({ model: "gpt-5.4", options: toSelections({ reasoningEffort: "low" }) }); + }); + it("keeps the thread's model when traits change over a seed", () => { const store = useComposerDraftStore.getState(); store.setModelSelection(threadRef, modelSelection(CODEX_DRIVER, "gpt-5.4")); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index ee337af7b07e6..0bf2b690b4696 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -1235,6 +1235,26 @@ function legacyToModelSelectionByProvider( return result; } +// Static aliases are shared across instances; exact custom slugs and catalog aliases are not. +function composerModelIdentity( + input: { + providers: ReadonlyArray; + settings: UnifiedSettings; + selectedProvider: ProviderDriverKind; + }, + instanceId: ProviderInstanceId, + model: string | undefined, +) { + const isCustom = + input.providers + .find((provider) => provider.instanceId === instanceId) + ?.models.some((entry) => entry.slug === model && entry.isCustom) || + readInstanceCustomModels(input.settings, instanceId, input.selectedProvider).some( + (entry) => entry.slug === model, + ); + return isCustom ? model : normalizeModelSlug(model, input.selectedProvider); +} + export function deriveEffectiveComposerModelState(input: { draft: | Pick< @@ -1254,8 +1274,8 @@ export function deriveEffectiveComposerModelState(input: { settings: UnifiedSettings; }): EffectiveComposerModelState { const draft = draftModelSelectionApplies(input.draft, input.threadStarted) ? input.draft : null; - const baseModelCandidate = - input.threadModelSelection?.model ?? input.projectModelSelection?.model ?? null; + const baseSelection = input.threadModelSelection ?? input.projectModelSelection; + const baseModelCandidate = baseSelection?.model ?? null; const preserveThreadModel = input.selectedInstanceId !== null && input.selectedInstanceId !== undefined && @@ -1309,12 +1329,21 @@ export function deriveEffectiveComposerModelState(input: { activeSelection.model, )) : baseModel; + // Base options belong to the base model, so a draft pick of another model gets none of them. + const draftPicksOtherModel = + !!activeSelection?.model && + composerModelIdentity(input, activeSelectionInstanceId, activeSelection.model) !== + composerModelIdentity( + input, + baseSelection?.instanceId ?? activeSelectionInstanceId, + baseSelection?.model, + ); // Draft options win; otherwise an existing thread's options, even none, outrank the project's. // Selections stay with them, so a fallback instance can check which model the options belong to. const optionSelections = [ draft?.modelSelectionByProvider, - selectionsByInstance(input.threadModelSelection ?? input.projectModelSelection), + draftPicksOtherModel ? null : selectionsByInstance(baseSelection), ].find((selections) => modelSelectionByProviderToOptions(selections) !== null) ?? null; const modelOptions = modelSelectionByProviderToOptions(optionSelections); @@ -1359,21 +1388,10 @@ function withFallbackInstanceOptions(input: { (provider) => provider.instanceId === requestedInstanceId, ); const requestedModel = input.optionSelections?.[requestedInstanceId]?.model; - // Static aliases are shared across instances; exact custom slugs and catalog aliases are not. - const modelIdentity = (instanceId: ProviderInstanceId, model: string | undefined) => { - const isCustom = - input.providers - .find((provider) => provider.instanceId === instanceId) - ?.models.some((entry) => entry.slug === model && entry.isCustom) || - readInstanceCustomModels(input.settings, instanceId, input.selectedProvider).some( - (entry) => entry.slug === model, - ); - return isCustom ? model : normalizeModelSlug(model, input.selectedProvider); - }; const sameModel = requestedModel === input.selectedModel || - modelIdentity(requestedInstanceId, requestedModel) === - modelIdentity(selectedInstanceId, input.selectedModel); + composerModelIdentity(input, requestedInstanceId, requestedModel) === + composerModelIdentity(input, selectedInstanceId, input.selectedModel); if (!requestedOptions || requestedProvider?.driver !== input.selectedProvider || !sameModel) { return modelOptions; }