From 44a8e9541bc1dc2927944c241b8332125af61757 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 19:42:04 -0800 Subject: [PATCH 001/114] fix(web): model settings no longer re-render every row on each star, toggle or move (#34964) --- apps/web/src/components/settings/ProviderModelsSection.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/settings/ProviderModelsSection.tsx b/apps/web/src/components/settings/ProviderModelsSection.tsx index a68f10eb56505..39583592b7360 100644 --- a/apps/web/src/components/settings/ProviderModelsSection.tsx +++ b/apps/web/src/components/settings/ProviderModelsSection.tsx @@ -1,5 +1,5 @@ import { ArrowDownIcon, ArrowUpIcon, PencilIcon, PlusIcon, StarIcon, XIcon } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; +import { memo, useEffect, useRef, useState } from "react"; import { ProviderDriverKind, type ProviderInstanceId, @@ -447,7 +447,7 @@ export function ProviderModelsSection({ ); } -function ProviderModelRow({ +const ProviderModelRow = memo(function ProviderModelRow({ model, isHidden, isFavorite, @@ -633,4 +633,4 @@ function ProviderModelRow({ ); -} +}); From 3ea253f80f20b764bbdc2bb9322d38371f2cb622 Mon Sep 17 00:00:00 2001 From: Shaswat Raj <108283560+playlikeboy@users.noreply.github.com> Date: Sat, 14 Nov 2026 09:18:23 +0530 Subject: [PATCH 002/114] fix(server): suppress redundant keybindings change emission on unchanged revalidation (#23775) Signed-off-by: Shaswat Raj Co-authored-by: Julius Marminge --- apps/server/src/keybindings.test.ts | 40 +++++++++++++++++++++++++++ apps/server/src/keybindings.ts | 43 ++++++++++++++++++++++++----- 2 files changed, 76 insertions(+), 7 deletions(-) diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index cb92c24242d47..7bff1a6823df1 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -6,8 +6,10 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Logger from "effect/Logger"; +import * 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 ServerConfig from "./config.ts"; import * as Keybindings from "./keybindings.ts"; import * as SharedKeybindings from "@t3tools/shared/keybindings"; @@ -616,4 +618,42 @@ it.layer(NodeServices.layer)("keybindings", (it) => { } }).pipe(Effect.provide(layerKeybindings())), ); + + it.effect( + "suppresses change emission when revalidating unchanged keybindings configuration", + () => + Effect.gen(function* () { + const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig; + yield* writeKeybindingsConfig(keybindingsConfigPath, [ + { key: "mod+j", command: "terminal.toggle" }, + ]); + + const keybindings = yield* Keybindings.Keybindings; + yield* keybindings.start; + + const changes = yield* keybindings.subscribeChanges; + + // Revalidate when config on disk has not changed (e.g. atime bump on Windows). + // `revalidate` publishes before it returns, so a wrongly emitted event would be + // buffered in the subscription ahead of the real change observed below. + yield* keybindings.revalidate; + + yield* writeKeybindingsConfig(keybindingsConfigPath, [ + { key: "mod+j", command: "terminal.toggle" }, + { key: "mod+k", command: "threadPanel.toggle" }, + ]); + yield* keybindings.revalidate; + + // The first buffered event must be the real change, not the unchanged state. + const change = Option.getOrThrow(yield* Stream.runHead(changes)); + assert.isTrue( + change.keybindings.some( + (rule) => + rule.command === "threadPanel.toggle" && + rule.shortcut.key === "k" && + rule.shortcut.modKey, + ), + ); + }).pipe(Effect.scoped, Effect.provide(layerKeybindings())), + ); }); diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index 0a5bfdd4246ca..49a7d85d4fa36 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -8,7 +8,7 @@ import { ResolvedKeybindingsConfig, type ServerRemoveKeybindingInput, type ServerUpsertKeybindingInput, - type ServerConfigIssue, + ServerConfigIssue, } from "@t3tools/contracts"; import * as Array from "effect/Array"; import * as Cache from "effect/Cache"; @@ -180,6 +180,13 @@ export interface KeybindingsConfigState { readonly issues: readonly ServerConfigIssue[]; } +const isSameKeybindingsConfigState = Schema.toEquivalence( + Schema.Struct({ + keybindings: ResolvedKeybindingsConfig, + issues: Schema.Array(ServerConfigIssue), + }), +); + export interface KeybindingsChangeEvent { readonly keybindings: ResolvedKeybindingsConfig; readonly issues: readonly ServerConfigIssue[]; @@ -220,6 +227,8 @@ export class Keybindings extends Context.Service< readonly streamChanges: Stream.Stream; + readonly revalidate: Effect.Effect; + /** Acquire before reading a snapshot to buffer changes until the stream starts. */ readonly subscribeChanges: Effect.Effect< Stream.Stream, @@ -248,12 +257,17 @@ const make = Effect.gen(function* () { const upsertSemaphore = yield* Semaphore.make(1); const resolvedConfigCacheKey = "resolved" as const; const changesPubSub = yield* PubSub.unbounded(); + const lastEmittedStateRef = yield* Ref.make(null); const startedRef = yield* Ref.make(false); const startedDeferred = yield* Deferred.make(); const watcherScope = yield* Scope.make("sequential"); yield* Effect.addFinalizer(() => Scope.close(watcherScope, Exit.void)); - const emitChange = (configState: KeybindingsConfigState) => - PubSub.publish(changesPubSub, configState).pipe(Effect.asVoid); + const emitChange = Effect.fn("keybindings.emitChange")(function* ( + configState: KeybindingsConfigState, + ) { + yield* Ref.set(lastEmittedStateRef, configState); + yield* PubSub.publish(changesPubSub, configState); + }); const readConfigExists = fs.exists(keybindingsConfigPath).pipe( Effect.mapError( @@ -432,14 +446,27 @@ const make = Effect.gen(function* () { const loadConfigStateFromCacheOrDisk = Cache.get(resolvedConfigCache, resolvedConfigCacheKey); - const reloadAndEmit = Effect.gen(function* () { + const reloadConfigState = Effect.gen(function* () { yield* Cache.invalidate(resolvedConfigCache, resolvedConfigCacheKey); - const configState = yield* loadConfigStateFromCacheOrDisk; + return yield* loadConfigStateFromCacheOrDisk; + }); + + const reloadAndEmit = Effect.gen(function* () { + const configState = yield* reloadConfigState; yield* emitChange(configState); return configState; }); - const revalidateAndEmit = upsertSemaphore.withPermits(1)(reloadAndEmit); + const revalidateAndEmit = upsertSemaphore.withPermits(1)( + Effect.gen(function* () { + const previousState = yield* Ref.get(lastEmittedStateRef); + const configState = yield* reloadConfigState; + if (!(previousState && isSameKeybindingsConfigState(previousState, configState))) { + yield* emitChange(configState); + } + return configState; + }), + ); const syncDefaultKeybindingsOnStartup = upsertSemaphore.withPermits(1)( Effect.gen(function* () { @@ -606,7 +633,8 @@ const make = Effect.gen(function* () { yield* startWatcher; yield* syncDefaultKeybindingsOnStartup; yield* Cache.invalidate(resolvedConfigCache, resolvedConfigCacheKey); - yield* loadConfigStateFromCacheOrDisk; + const initialState = yield* loadConfigStateFromCacheOrDisk; + yield* Ref.set(lastEmittedStateRef, initialState); }); const startupExit = yield* Effect.exit(startup); @@ -624,6 +652,7 @@ const make = Effect.gen(function* () { syncDefaultKeybindingsOnStartup, loadConfigState: loadConfigStateFromCacheOrDisk, getSnapshot: loadConfigStateFromCacheOrDisk, + revalidate: revalidateAndEmit, get streamChanges() { return Stream.fromPubSub(changesPubSub); }, From 4c7685729f1926bc1f3089cc6c1c2f4f08b0dfc3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 19:57:28 -0800 Subject: [PATCH 003/114] fix(web): clicking a label in an inline HTML render no longer jumps the chat (#36497) --- .../BrowserDocumentFrame.browser.test.tsx | 55 +++++++++++++++++++ packages/shared/src/htmlRender.ts | 2 +- 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/files/BrowserDocumentFrame.browser.test.tsx b/apps/web/src/components/files/BrowserDocumentFrame.browser.test.tsx index 098c5c9cea119..cc3640fc2a166 100644 --- a/apps/web/src/components/files/BrowserDocumentFrame.browser.test.tsx +++ b/apps/web/src/components/files/BrowserDocumentFrame.browser.test.tsx @@ -59,6 +59,61 @@ describe("HtmlRenderDocument", () => { }); }); +it("keeps the chat in place when a label far from its hidden radio is clicked", async () => { + const html = injectHtmlRenderBootstrap(`
+ + + +
+ +
`); + 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 101/114] 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 102/114] 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 103/114] 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 104/114] 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 105/114] 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 106/114] 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 107/114] 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 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 108/114] 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 109/114] 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 110/114] 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 111/114] 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 112/114] 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 113/114] 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 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 114/114] =?UTF-8?q?fix(web):=20model=20picks=20no=20longer?= =?UTF-8?q?=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; }