From dd82cd8eb600e1a1fa2488880abb27e629ac3ecb Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:37:08 -0500 Subject: [PATCH 01/66] fix(server): bound Bitbucket API requests with timeout --- apps/server/src/sourceControl/BitbucketApi.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/server/src/sourceControl/BitbucketApi.ts b/apps/server/src/sourceControl/BitbucketApi.ts index 2404e6b497e3..ba76dcac89fd 100644 --- a/apps/server/src/sourceControl/BitbucketApi.ts +++ b/apps/server/src/sourceControl/BitbucketApi.ts @@ -40,6 +40,8 @@ const DEFAULT_API_BASE_URL = "https://api.bitbucket.org/2.0"; const DEFAULT_MAX_RESPONSE_BYTES = 8 * 1024 * 1024; /** Bitbucket redirects a diff once; this leaves room without following a chain forever. */ const MAX_REDIRECTS = 3; +/** A wedged endpoint parks the caller forever without this; Bitbucket's p99 is far below. */ +const BITBUCKET_API_REQUEST_TIMEOUT = "30 seconds"; const BitbucketApiEnvConfig = Config.all({ baseUrl: Config.String("T3CODE_BITBUCKET_API_BASE_URL").pipe( @@ -712,6 +714,7 @@ export const make = Effect.gen(function* () { ): Effect.Effect => withAuth(request.pipe(HttpClientRequest.acceptJson)).pipe( Effect.flatMap(httpClient.execute), + Effect.timeout(BITBUCKET_API_REQUEST_TIMEOUT), Effect.mapError( (cause) => new BitbucketRequestError({ @@ -906,6 +909,7 @@ export const make = Effect.gen(function* () { : base.pipe(HttpClientRequest.bodyText(input.body, "application/json")); return withAuth(withBody).pipe( Effect.flatMap(httpClient.execute), + Effect.timeout(BITBUCKET_API_REQUEST_TIMEOUT), Effect.mapError( (cause): BitbucketApiError => new BitbucketRequestError({ operation: "request", cause }), ), From 6968c451fb25f5de401990095f1cf19e9d93a081 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:37:11 -0500 Subject: [PATCH 02/66] test(server): cover stalled Bitbucket requests --- .../src/sourceControl/BitbucketApi.test.ts | 59 ++++++++++++++++++- 1 file changed, 56 insertions(+), 3 deletions(-) diff --git a/apps/server/src/sourceControl/BitbucketApi.test.ts b/apps/server/src/sourceControl/BitbucketApi.test.ts index 83965a38b21c..a73de4b72132 100644 --- a/apps/server/src/sourceControl/BitbucketApi.test.ts +++ b/apps/server/src/sourceControl/BitbucketApi.test.ts @@ -3,6 +3,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import * as ConfigProvider from "effect/ConfigProvider"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -64,13 +65,17 @@ function makeLayer(input: { readonly requestFailure?: ( request: HttpClientRequest.HttpClientRequest, ) => HttpClientError.HttpClientError; + /** The endpoint accepts the request but never answers. */ + readonly stall?: boolean; readonly git?: Partial; readonly env?: Record; }) { const execute = vi.fn((request: HttpClientRequest.HttpClientRequest) => - input.requestFailure - ? Effect.fail(input.requestFailure(request)) - : Effect.succeed(HttpClientResponse.fromWeb(request, input.response(request))), + input.stall + ? Effect.never + : input.requestFailure + ? Effect.fail(input.requestFailure(request)) + : Effect.succeed(HttpClientResponse.fromWeb(request, input.response(request))), ); const gitMock = { readConfigValue: vi.fn(() => @@ -984,3 +989,51 @@ it.effect("cuts a response short rather than reading an unbounded diff into memo ), ), ); + + +it.effect("fails a wedged JSON request after the request timeout", () => { + const { execute, layer } = makeLayer({ + response: () => Response.json({}), + stall: true, + }); + + return Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + const child = yield* Effect.forkChild( + Effect.flip( + bitbucket.getPullRequest({ + cwd: "/repo", + reference: "42", + }), + ), + ); + while (execute.mock.calls.length === 0) { + yield* Effect.yieldNow; + } + yield* TestClock.adjust("31 seconds"); + const error = yield* Fiber.join(child); + assert.instanceOf(error, BitbucketApi.BitbucketRequestError); + assert.strictEqual(error.operation, "getPullRequest"); + }).pipe(Effect.provide(layer)); +}); + +it.effect("fails a wedged raw request after the request timeout", () => { + const { execute, layer } = makeLayer({ + response: () => Response.json({}), + stall: true, + }); + + return Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + const child = yield* Effect.forkChild( + Effect.flip(bitbucket.request({ method: "GET", url: "/user" })), + ); + while (execute.mock.calls.length === 0) { + yield* Effect.yieldNow; + } + yield* TestClock.adjust("31 seconds"); + const error = yield* Fiber.join(child); + assert.instanceOf(error, BitbucketApi.BitbucketRequestError); + assert.strictEqual(error.operation, "request"); + }).pipe(Effect.provide(layer)); +}); From 778a450c39513b813a7fb3bfeb8a10429227312e Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:37:38 -0500 Subject: [PATCH 03/66] style(server): normalize Bitbucket timeout test spacing --- apps/server/src/sourceControl/BitbucketApi.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/server/src/sourceControl/BitbucketApi.test.ts b/apps/server/src/sourceControl/BitbucketApi.test.ts index a73de4b72132..673fa3c0ba5e 100644 --- a/apps/server/src/sourceControl/BitbucketApi.test.ts +++ b/apps/server/src/sourceControl/BitbucketApi.test.ts @@ -990,7 +990,6 @@ it.effect("cuts a response short rather than reading an unbounded diff into memo ), ); - it.effect("fails a wedged JSON request after the request timeout", () => { const { execute, layer } = makeLayer({ response: () => Response.json({}), From 5ce5a8f658b229c36fc4dc371bb4dc4157500c00 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 10 Oct 2026 20:20:33 -0700 Subject: [PATCH 04/66] fix(ssh): qualify runner script builders in tunnel test (#18078) --- packages/ssh/src/tunnel.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ssh/src/tunnel.test.ts b/packages/ssh/src/tunnel.test.ts index 001852f98da1..1ebb1ff5d3a3 100644 --- a/packages/ssh/src/tunnel.test.ts +++ b/packages/ssh/src/tunnel.test.ts @@ -233,9 +233,9 @@ describe("ssh tunnel scripts", () => { }); it("embeds the runner in the launch script byte for byte", () => { - const runner = buildRemoteT3RunnerScript(ARCHIVE); + const runner = SshTunnel.buildRemoteT3RunnerScript(ARCHIVE); assert.include(runner, 'printf \'%s\\n\' "$$" > "$T3_LOCK/pid.tmp"'); - assert.include(buildRemoteLaunchScript(ARCHIVE), runner); + assert.include(SshTunnel.buildRemoteLaunchScript(ARCHIVE), runner); }); it("uses the remote t3 runner for launch and pairing scripts", () => { From 6c130163941618480da333979bf6fc675d803e4f Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Sun, 11 Oct 2026 14:31:12 +1100 Subject: [PATCH 05/66] fix(web): keep thread links open until the shell is live (#14697) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 (1M context) --- apps/web/src/components/ThreadRouteView.tsx | 3 +- apps/web/src/threadRoutes.test.ts | 38 +++++++++++++++++++++ apps/web/src/threadRoutes.ts | 18 ++++++---- 3 files changed, 51 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/ThreadRouteView.tsx b/apps/web/src/components/ThreadRouteView.tsx index 934feac61a83..1040e9d70bc5 100644 --- a/apps/web/src/components/ThreadRouteView.tsx +++ b/apps/web/src/components/ThreadRouteView.tsx @@ -19,6 +19,7 @@ import { useEnvironmentQuery } from "../state/query"; import { environmentShell } from "../state/shell"; import { buildThreadRouteParams, + isThreadRouteSnapshotAuthoritative, resolveThreadRouteRenderState, type ThreadRouteTarget, } from "../threadRoutes"; @@ -72,7 +73,7 @@ export function ThreadRouteView({ target }: { target: ThreadRouteTarget }) { ); const serverThreadShell = serverThread; const environmentThreadRefs = useEnvironmentThreadRefs(serverThreadRef?.environmentId ?? null); - const bootstrapComplete = shell.data?.snapshot._tag === "Some"; + const bootstrapComplete = isThreadRouteSnapshotAuthoritative(shell.data?.status); const draftThread = useComposerDraftStore((store) => serverThreadRef ? store.getDraftThreadByRef(serverThreadRef) : null, ); diff --git a/apps/web/src/threadRoutes.test.ts b/apps/web/src/threadRoutes.test.ts index 34380d126689..4a9b00a42be6 100644 --- a/apps/web/src/threadRoutes.test.ts +++ b/apps/web/src/threadRoutes.test.ts @@ -6,6 +6,7 @@ import { DraftId } from "./composerDraftStore"; import { buildDraftThreadRouteParams, buildThreadRouteParams, + isThreadRouteSnapshotAuthoritative, resolveActiveThreadRouteRef, resolveThreadRouteRenderState, resolveThreadRouteRef, @@ -13,6 +14,13 @@ import { } from "./threadRoutes"; describe("threadRoutes", () => { + it("does not treat cached thread lists as authoritative for deep links", () => { + expect(isThreadRouteSnapshotAuthoritative("empty")).toBe(false); + expect(isThreadRouteSnapshotAuthoritative("cached")).toBe(false); + expect(isThreadRouteSnapshotAuthoritative("synchronizing")).toBe(false); + expect(isThreadRouteSnapshotAuthoritative("live")).toBe(true); + }); + it("builds canonical thread route params from a scoped ref", () => { const ref = scopeThreadRef("env-1" as never, ThreadId.make("thread-1")); @@ -124,6 +132,25 @@ describe("threadRoutes", () => { ).toBe("ready"); }); + it.each([ + { status: "cached", source: "server thread" }, + { status: "synchronizing", source: "server thread" }, + { status: "cached", source: "draft" }, + { status: "synchronizing", source: "draft" }, + ] as const)( + "keeps available $source visible while the shell is $status", + ({ status, source }) => { + expect( + resolveThreadRouteRenderState({ + bootstrapComplete: isThreadRouteSnapshotAuthoritative(status), + serverThreadExists: source === "server thread", + serverThreadDeleted: false, + draftThreadExists: source === "draft", + }), + ).toBe("ready"); + }, + ); + it("distinguishes bootstrap loading from a missing thread", () => { expect( resolveThreadRouteRenderState({ @@ -153,4 +180,15 @@ describe("threadRoutes", () => { }), ).toBe("missing"); }); + + it("waits for the live shell before redirecting a deleted thread", () => { + expect( + resolveThreadRouteRenderState({ + bootstrapComplete: isThreadRouteSnapshotAuthoritative("cached"), + serverThreadExists: true, + serverThreadDeleted: true, + draftThreadExists: false, + }), + ).toBe("loading"); + }); }); diff --git a/apps/web/src/threadRoutes.ts b/apps/web/src/threadRoutes.ts index 8d6c036669c6..9220141740d3 100644 --- a/apps/web/src/threadRoutes.ts +++ b/apps/web/src/threadRoutes.ts @@ -1,4 +1,5 @@ import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import type { EnvironmentShellStatus } from "@t3tools/client-runtime/state/shell"; import type { EnvironmentId, ScopedThreadRef, ThreadId } from "@t3tools/contracts"; import type { DraftId } from "./composerDraftStore"; @@ -20,22 +21,25 @@ type DraftThreadRouteState = { export type ThreadRouteRenderState = "loading" | "ready" | "missing"; +export function isThreadRouteSnapshotAuthoritative( + status: EnvironmentShellStatus | undefined, +): boolean { + return status === "live"; +} + export function resolveThreadRouteRenderState(input: { bootstrapComplete: boolean; serverThreadExists: boolean; serverThreadDeleted: boolean; draftThreadExists: boolean; }): ThreadRouteRenderState { - if (!input.bootstrapComplete) { - return "loading"; - } - if (input.draftThreadExists) { + if (input.draftThreadExists || (input.serverThreadExists && !input.serverThreadDeleted)) { return "ready"; } - if (input.serverThreadDeleted) { - return "missing"; + if (!input.bootstrapComplete) { + return "loading"; } - return input.serverThreadExists ? "ready" : "missing"; + return "missing"; } export function buildThreadRouteParams(ref: ScopedThreadRef): { From 75fca7d77cd4a5f72167606f248131edb633bb50 Mon Sep 17 00:00:00 2001 From: Adamulek123 Date: Sun, 11 Oct 2026 05:31:17 +0200 Subject: [PATCH 06/66] fix(web): scope PR title collapse to tab scrollers (#14644) --- .../pullRequest/PullRequestCodeTab.tsx | 7 +- .../PullRequestDetailPanel.test.tsx | 145 ++++++++++++++---- .../pullRequest/PullRequestDetailPanel.tsx | 49 ++++-- .../PullRequestSummaryTab.test.tsx | 1 + .../pullRequest/PullRequestSummaryTab.tsx | 4 +- .../pullRequest/PullRequestTimelineTab.tsx | 4 +- 6 files changed, 167 insertions(+), 43 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index d5f054dadab0..4b2a878a5ca5 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -196,6 +196,7 @@ function getReviewPositionAnchor(position: PullRequestReviewPosition): { * drafted rather than being posted as it is typed. */ function PullRequestCodeTab({ + scrollerRef, environmentId, reference, detail, @@ -208,6 +209,7 @@ function PullRequestCodeTab({ onRefresh, refreshToken = 0, }: { + scrollerRef: (node: HTMLDivElement | null) => void; environmentId: EnvironmentId; reference: PullRequestRef; detail: PullRequestDetailView; @@ -1394,7 +1396,9 @@ function PullRequestCodeTab({ const withToolbar = (body: ReactNode) => (
{toolbar} -
{body}
+
+ {body} +
); @@ -1574,6 +1578,7 @@ function PullRequestCodeTab({ // interaction, but its native host outline clips and competes with the focus // indicators on its actual controls. className="@container h-full overflow-auto [scrollbar-gutter:stable]" + containerRef={scrollerRef} viewerRef={setViewer} items={items} selectedLines={selectedLines} diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.test.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.test.tsx index 3f192f745fa8..176009e1a98a 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.test.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.test.tsx @@ -108,43 +108,49 @@ vi.mock("./PullRequestThreadLinks", () => ({ PullRequestThreadLinks: () => null vi.mock("./PullRequestSummaryTab", () => ({ PullRequestSummaryTab: ({ onFixFinding, + scrollerRef, }: ComponentProps) => ( - +
+ +
), })); vi.mock("./PullRequestCodeTab", () => ({ default: ({ onAddToAgentSelection, + scrollerRef, }: ComponentProps) => ( - +
+ +
), })); @@ -343,3 +349,82 @@ describe.each([ } }); }); + +it("changes the title only for the active tab's main scroller", async () => { + const scrollers = new Map(); + await act(async () => { + renderer = create( + ({ + terminalFocus: false, + terminalOpen: false, + previewFocus: false, + previewOpen: false, + isWeb: true, + isDesktop: false, + })} + />, + { + createNodeMock: (element) => { + const props = element.props as Record; + const name = props["data-test-scroll"] as string | undefined; + if (name) { + const scroller = { scrollTop: 0, scrollHeight: 300 }; + scrollers.set(name, scroller); + return scroller; + } + return { scrollHeight: props.inert === false ? 100 : 20 }; + }, + }, + ); + }); + + const mainScroll = renderer.root.findByProps({ + className: "relative flex min-h-0 flex-1 flex-col overflow-hidden", + }); + const titleFold = () => { + let node = renderer.root.findByType("h1"); + while (typeof node.props.inert !== "boolean") node = node.parent!; + return node; + }; + const summary = scrollers.get("summary")!; + const nested = { scrollTop: 200 }; + await act(async () => mainScroll.props.onScrollCapture({ target: nested })); + expect(titleFold().props.inert).toBe(false); + summary.scrollTop = 180; + nested.scrollTop = 0; + await act(async () => { + mainScroll.props.onScrollCapture({ target: summary }); + mainScroll.props.onScrollCapture({ target: nested }); + }); + expect(titleFold().props.inert).toBe(true); + expect(summary.scrollTop).toBe(100); + expect(nested.scrollTop).toBe(0); + + await act(async () => mainScroll.props.onScrollCapture({ target: { scrollTop: 0 } })); + expect(titleFold().props.inert).toBe(true); + summary.scrollTop = 0; + await act(async () => mainScroll.props.onScrollCapture({ target: summary })); + expect(titleFold().props.inert).toBe(false); + + await click("Code"); + const code = scrollers.get("code")!; + summary.scrollTop = 180; + await act(async () => mainScroll.props.onScrollCapture({ target: summary })); + expect(titleFold().props.inert).toBe(false); + code.scrollTop = 180; + await act(async () => mainScroll.props.onScrollCapture({ target: code })); + expect(titleFold().props.inert).toBe(true); + expect(code.scrollTop).toBe(100); + await click("Summary"); + expect(titleFold().props.inert).toBe(false); + await click("Code"); + expect(titleFold().props.inert).toBe(true); + code.scrollTop = 0; + await act(async () => mainScroll.props.onScrollCapture({ target: code })); + expect(titleFold().props.inert).toBe(false); +}); diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index e977c302be34..526a1ae9bfbc 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -526,18 +526,44 @@ export function PullRequestDetailPanel({ setChromeCondensed(chromeStateByTab.current[tab] ?? false); }, [tab]); const condensed = chromeCondensed; - const scrollerRef = useRef(null); + const scrollerByTab = useRef>>({}); + const scrollerRefs = useMemo( + () => ({ + summary: (node: HTMLDivElement | null) => { + scrollerByTab.current.summary = node; + }, + timeline: (node: HTMLDivElement | null) => { + scrollerByTab.current.timeline = node; + }, + code: (node: HTMLDivElement | null) => { + scrollerByTab.current.code = node; + }, + }), + [], + ); const foldRef = useRef(null); const condensedRowRef = useRef(null); // Refund after the fold commits so the content under the reader does not jump with its height. - const compensationRef = useRef(null); + const compensationRef = useRef<{ + tab: DetailTab; + scroller: HTMLDivElement; + delta: number; + } | null>(null); useLayoutEffect(() => { - if (compensationRef.current === null) return; - const scroller = scrollerRef.current; - const delta = compensationRef.current; + const compensation = compensationRef.current; + if (compensation === null) return; compensationRef.current = null; - if (scroller) scroller.scrollTop = Math.max(0, scroller.scrollTop + delta); - }, [condensed]); + if ( + !condensed || + compensation.tab !== tab || + scrollerByTab.current[tab] !== compensation.scroller + ) + return; + compensation.scroller.scrollTop = Math.max( + 0, + compensation.scroller.scrollTop + compensation.delta, + ); + }, [condensed, tab]); const lastSelectedMergeMethod = useUiStateStore((state) => state.pullRequestMergeMethod); const setLastSelectedMergeMethod = useUiStateStore((state) => state.setPullRequestMergeMethod); const resolveProjectDefaultMergeMethod = usePullRequestDefaultMergeMethodResolver( @@ -2737,8 +2763,8 @@ export function PullRequestDetailPanel({
{ - const scroller = event.target as HTMLElement; - scrollerRef.current = scroller; + const scroller = scrollerByTab.current[tab]; + if (event.target !== scroller || scroller === null || scroller === undefined) return; const top = scroller.scrollTop; setChromeCondensed((previous) => { let next = previous; @@ -2753,7 +2779,7 @@ export function PullRequestDetailPanel({ next = false; } } else if (foldHeight > 0 && top > foldHeight + 32) { - compensationRef.current = -chromeDelta; + compensationRef.current = { tab, scroller, delta: -chromeDelta }; next = true; } chromeStateByTab.current[tab] = next; @@ -2779,6 +2805,7 @@ export function PullRequestDetailPanel({ {mountedTabs.has("summary") ? (
) : ( }> { function render(value = detail) { return ( {}} environmentId={EnvironmentId.make("environment")} threadRef={null} reference={value} diff --git a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx index bfda1209f9cb..2b3de0893108 100644 --- a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx @@ -376,6 +376,7 @@ const CommentFilterSchema = Schema.Struct({ bots: Schema.Boolean, resolved: Sche const DEFAULT_COMMENT_FILTER: typeof CommentFilterSchema.Type = { bots: false, resolved: false }; export function PullRequestSummaryTab({ + scrollerRef, environmentId, threadRef, reference, @@ -390,6 +391,7 @@ export function PullRequestSummaryTab({ onRefresh, onRefreshChecks = onRefresh, }: { + scrollerRef: (node: HTMLDivElement | null) => void; environmentId: EnvironmentId; threadRef: ScopedThreadRef | null; reference: PullRequestRef; @@ -657,7 +659,7 @@ export function PullRequestSummaryTab({ }; return ( -
+
} label="Reviewers"> diff --git a/apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx b/apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx index 69421fefbbce..b45bd73a7ea9 100644 --- a/apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx @@ -538,6 +538,7 @@ function ReviewVerdictEvent({ } export function PullRequestTimelineTab({ + scrollerRef, detail, environmentId, threadRef = null, @@ -546,6 +547,7 @@ export function PullRequestTimelineTab({ onOpenCommit, onRefresh, }: { + scrollerRef: (node: HTMLDivElement | null) => void; detail: PullRequestDetailView; environmentId: EnvironmentId; threadRef?: ScopedThreadRef | null; @@ -577,7 +579,7 @@ export function PullRequestTimelineTab({ }; return ( -
+
From 6cf05264257093476a41f73439b1fa745427a007 Mon Sep 17 00:00:00 2001 From: Adamulek123 Date: Sun, 11 Oct 2026 05:40:53 +0200 Subject: [PATCH 07/66] fix(server): detect fork PRs for branches without upstreams (#13894) Co-authored-by: maria-rcks --- apps/server/src/git/GitManager.test.ts | 340 ++++++++++++++++++++++++- apps/server/src/git/GitManager.ts | 145 +++++++++-- docs/user/source-control.md | 4 + 3 files changed, 463 insertions(+), 26 deletions(-) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index cd176d8f938a..53bb49c346e3 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -272,6 +272,11 @@ function initRepo( yield* runGit(cwd, ["init", "--initial-branch=main"]); yield* runGit(cwd, ["config", "user.email", "test@example.com"]); yield* runGit(cwd, ["config", "user.name", "Test User"]); + // Hermetic line endings: machine-global `core.autocrlf=true` on Windows + // makes Git check out CRLF, which breaks exact-content assertions that + // write LF with Node. Local config overrides the global so the fixture + // (and its linked worktrees, which share config) always checks out LF. + yield* runGit(cwd, ["config", "core.autocrlf", "false"]); yield* fs.writeFileString(NodePath.join(cwd, "README.md"), "hello\n"); yield* runGit(cwd, ["add", "README.md"]); yield* runGit(cwd, ["commit", "-m", "Initial commit"]); @@ -1630,6 +1635,316 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); + it.effect("finds a fork PR for a branch pushed without an upstream", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originDir = yield* createBareRemote(); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", originDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["remote", "add", "fork", forkDir]); + yield* runGit(repoDir, ["checkout", "-b", "feature/no-upstream-fork"]); + yield* runGit(repoDir, ["push", "fork", "feature/no-upstream-fork"]); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "origin", + "git@github.com:pingdotgg/codething-mvp.git", + originDir, + ); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "fork", + "git@github.com:contributor/codething-mvp.git", + forkDir, + ); + + const ghScenario = { + prListByHeadSelector: { + "feature/no-upstream-fork": JSON.stringify([ + { + number: 40, + title: "Another fork's PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/40", + baseRefName: "main", + headRefName: "feature/no-upstream-fork", + state: "OPEN", + isCrossRepository: true, + headRepository: { nameWithOwner: "someone-else/codething-mvp" }, + headRepositoryOwner: { login: "someone-else" }, + }, + { + number: 41, + title: "Our fork's PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/41", + baseRefName: "main", + headRefName: "feature/no-upstream-fork", + state: "OPEN", + isCrossRepository: true, + headRepository: { nameWithOwner: "contributor/codething-mvp" }, + headRepositoryOwner: { login: "contributor" }, + }, + ]), + }, + }; + const { manager } = yield* makeManager({ ghScenario }); + + const pullRequest = yield* manager.branchPullRequest({ + cwd: repoDir, + branch: "feature/no-upstream-fork", + }); + expect(pullRequest?.number).toBe(41); + expect((yield* manager.status({ cwd: repoDir })).pr?.number).toBe(41); + + yield* runGit(repoDir, ["commit", "--allow-empty", "-m", "Continue feature"]); + const localHead = yield* runGit(repoDir, ["rev-parse", "HEAD"]); + const pushedHead = yield* runGit(repoDir, [ + "rev-parse", + "refs/remotes/fork/feature/no-upstream-fork", + ]); + expect(localHead.stdout.trim()).not.toBe(pushedHead.stdout.trim()); + const { manager: aheadManager } = yield* makeManager({ ghScenario }); + expect( + (yield* aheadManager.branchPullRequest({ + cwd: repoDir, + branch: "feature/no-upstream-fork", + }))?.number, + ).toBe(41); + expect((yield* aheadManager.status({ cwd: repoDir })).pr?.number).toBe(41); + + yield* runGit(repoDir, ["config", "branch.feature/no-upstream-fork.remote", "origin"]); + const upstream = yield* runGit(repoDir, [ + "for-each-ref", + "--format=%(upstream:short)", + "refs/heads/feature/no-upstream-fork", + ]); + expect(upstream.stdout.trim()).toBe(""); + const { manager: staleRemoteManager } = yield* makeManager({ ghScenario }); + expect( + (yield* staleRemoteManager.branchPullRequest({ + cwd: repoDir, + branch: "feature/no-upstream-fork", + }))?.number, + ).toBe(41); + + yield* runGit(repoDir, ["config", "--unset", "branch.feature/no-upstream-fork.remote"]); + yield* runGit(repoDir, ["checkout", "-b", "diverged-push", "main"]); + yield* runGit(repoDir, ["commit", "--allow-empty", "-m", "Rewrite fork head"]); + yield* runGit(repoDir, [ + "push", + "--force", + "fork", + "HEAD:refs/heads/feature/no-upstream-fork", + ]); + yield* runGit(repoDir, ["checkout", "feature/no-upstream-fork"]); + const { manager: divergedManager } = yield* makeManager({ ghScenario }); + expect( + yield* divergedManager.branchPullRequest({ + cwd: repoDir, + branch: "feature/no-upstream-fork", + }), + ).toBeNull(); + }), + ); + + it.effect("prefers an exact fork ref but does not guess between equal refs", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originDir = yield* createBareRemote(); + const firstForkDir = yield* createBareRemote(); + const secondForkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", originDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["remote", "add", "first-fork", firstForkDir]); + yield* runGit(repoDir, ["remote", "add", "second-fork", secondForkDir]); + yield* runGit(repoDir, ["checkout", "-b", "feature/ambiguous-fork"]); + yield* runGit(repoDir, ["push", "first-fork", "feature/ambiguous-fork"]); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "origin", + "git@github.com:pingdotgg/codething-mvp.git", + originDir, + ); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "first-fork", + "git@github.com:contributor/codething-mvp.git", + firstForkDir, + ); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "second-fork", + "git@github.com:someone-else/codething-mvp.git", + secondForkDir, + ); + + const ghScenario = { + prListByHeadSelector: { + "feature/ambiguous-fork": JSON.stringify([ + { + number: 41, + title: "One fork's PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/41", + baseRefName: "main", + headRefName: "feature/ambiguous-fork", + state: "OPEN", + isCrossRepository: true, + headRepository: { nameWithOwner: "contributor/codething-mvp" }, + headRepositoryOwner: { login: "contributor" }, + }, + ]), + }, + }; + const { manager: singleRemoteManager } = yield* makeManager({ ghScenario }); + expect( + (yield* singleRemoteManager.branchPullRequest({ + cwd: repoDir, + branch: "feature/ambiguous-fork", + }))?.number, + ).toBe(41); + + yield* runGit(repoDir, ["push", "second-fork", "feature/ambiguous-fork"]); + const { manager } = yield* makeManager({ ghScenario }); + expect( + yield* manager.branchPullRequest({ cwd: repoDir, branch: "feature/ambiguous-fork" }), + ).toBeNull(); + + yield* runGit(repoDir, ["commit", "--allow-empty", "-m", "Continue feature"]); + yield* runGit(repoDir, ["push", "first-fork", "feature/ambiguous-fork"]); + const { manager: exactManager } = yield* makeManager({ ghScenario }); + expect( + (yield* exactManager.branchPullRequest({ + cwd: repoDir, + branch: "feature/ambiguous-fork", + }))?.number, + ).toBe(41); + }), + ); + + it.effect("does not guess a fork when an ancestry probe fails", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originDir = yield* createBareRemote(); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", originDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["remote", "add", "fork", forkDir]); + yield* runGit(repoDir, ["checkout", "-b", "feature/failed-probe"]); + yield* runGit(repoDir, ["push", "fork", "feature/failed-probe"]); + yield* runGit(repoDir, ["push", "origin", "feature/failed-probe"]); + // Local work past both tips, so no exact ref matches and the ancestor + // tier decides. + yield* runGit(repoDir, ["commit", "--allow-empty", "-m", "Local work"]); + // Simulate a failed ancestry probe: point origin's tracking ref at a + // non-commit object so `merge-base --is-ancestor` exits 128 — neither + // a match (0) nor a negative result (1). + const treeOid = (yield* runGit(repoDir, ["rev-parse", "HEAD^{tree}"])).stdout.trim(); + yield* runGit(repoDir, ["update-ref", "refs/remotes/origin/feature/failed-probe", treeOid]); + const forkProbe = yield* runGit( + repoDir, + ["merge-base", "--is-ancestor", "refs/remotes/fork/feature/failed-probe", "HEAD"], + true, + ); + expect(forkProbe.exitCode).toBe(0); + const originProbe = yield* runGit( + repoDir, + ["merge-base", "--is-ancestor", "refs/remotes/origin/feature/failed-probe", "HEAD"], + true, + ); + expect(originProbe.exitCode).not.toBe(0); + expect(originProbe.exitCode).not.toBe(1); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "origin", + "git@github.com:pingdotgg/codething-mvp.git", + originDir, + ); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "fork", + "git@github.com:contributor/codething-mvp.git", + forkDir, + ); + + const ghScenario = { + prListByHeadSelector: { + "feature/failed-probe": JSON.stringify([ + { + number: 43, + title: "Failed probe PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/43", + baseRefName: "main", + headRefName: "feature/failed-probe", + state: "OPEN", + isCrossRepository: true, + headRepository: { nameWithOwner: "contributor/codething-mvp" }, + headRepositoryOwner: { login: "contributor" }, + }, + ]), + }, + }; + const { manager } = yield* makeManager({ ghScenario }); + // The fork probe matches, but the failed origin probe rejects the + // whole inference: unknown, invisible rather than wrong. + expect( + yield* manager.branchPullRequest({ cwd: repoDir, branch: "feature/failed-probe" }), + ).toBeNull(); + }), + ); + + it.effect("finds a fork PR when the remote name contains a slash", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originDir = yield* createBareRemote(); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", originDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["remote", "add", "team/fork", forkDir]); + yield* runGit(repoDir, ["checkout", "-b", "feature/slash-remote"]); + yield* runGit(repoDir, ["push", "team/fork", "feature/slash-remote"]); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "origin", + "git@github.com:pingdotgg/codething-mvp.git", + originDir, + ); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "team/fork", + "git@github.com:contributor/codething-mvp.git", + forkDir, + ); + + const { manager } = yield* makeManager({ + ghScenario: { + prListByHeadSelector: { + "feature/slash-remote": JSON.stringify([ + { + number: 42, + title: "Slash remote PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/42", + baseRefName: "main", + headRefName: "feature/slash-remote", + state: "OPEN", + isCrossRepository: true, + headRepository: { nameWithOwner: "contributor/codething-mvp" }, + headRepositoryOwner: { login: "contributor" }, + }, + ]), + }, + }, + }); + + expect( + (yield* manager.branchPullRequest({ cwd: repoDir, branch: "feature/slash-remote" })) + ?.number, + ).toBe(42); + }), + ); + it.effect("branch PR lookup does not reuse a cached PR after the remote is repointed", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -2096,9 +2411,11 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { ); it.effect.each([ - "git@gitlab.com:Group/Subgroup/Fork.git", - "https://gitlab.com/Group/Subgroup/Fork.git", - ])("matches nested GitLab forks through the adapter for %s", (remoteUrl) => + { remoteUrl: "git@gitlab.com:Group/Subgroup/Fork.git", setUpstream: true }, + { remoteUrl: "git@gitlab.com:Group/Subgroup/Fork.git", setUpstream: false }, + { remoteUrl: "https://gitlab.com/Group/Subgroup/Fork.git", setUpstream: true }, + { remoteUrl: "https://gitlab.com/Group/Subgroup/Fork.git", setUpstream: false }, + ] as const)("matches GitLab forks for $remoteUrl/$setUpstream", ({ remoteUrl, setUpstream }) => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); @@ -2109,7 +2426,13 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { yield* runGit(repoDir, ["push", "-u", "origin", "main"]); yield* runGit(repoDir, ["remote", "add", "fork", forkDir]); yield* runGit(repoDir, ["checkout", "-b", branch]); - yield* runGit(repoDir, ["push", "-u", "fork", branch]); + yield* runGit(repoDir, ["push", ...(setUpstream ? ["-u"] : []), "fork", branch]); + const upstream = yield* runGit(repoDir, [ + "for-each-ref", + "--format=%(upstream:short)", + `refs/heads/${branch}`, + ]); + expect(upstream.stdout.trim()).toBe(setUpstream ? `fork/${branch}` : ""); yield* configureVisibleRemoteUrlWithLocalRewrite( repoDir, "origin", @@ -5639,9 +5962,12 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { expect((yield* runGit(worktreePath, ["rev-parse", "HEAD"])).stdout.trim()).toBe( rewrittenHead, ); - expect(NodeFS.readFileSync(NodePath.join(worktreePath, "force-pushed.txt"), "utf8")).toBe( - "rewritten\n", - ); + expect( + NodeFS.readFileSync(NodePath.join(worktreePath, "force-pushed.txt"), "utf8").replace( + /\r\n/g, + "\n", + ), + ).toBe("rewritten\n"); }), ); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index e2ae3109b2fe..6079ee714f69 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -1337,8 +1337,21 @@ export const make = Effect.gen(function* () { ), ), Effect.map(({ pr }) => pr), - Effect.catch((error) => - Effect.logWarning("PR lookup failed; keeping last known PR state.").pipe( + Effect.catch((error) => { + // No prior PR means there is no badge to preserve or remote to verify. + const fallback = lastKnownPrByBranchKey.get(branchKey)?.pr + ? resolveLookupHeadContext(cwd, details).pipe( + Effect.map(({ headContext }) => + resolveLastKnownPr(branchKey, { + upstreamRef: details.upstreamRef, + headBranch: headContext.headBranch, + remoteName: headContext.remoteName, + headRemoteUrlKey: headContext.headRemoteUrlKey, + }), + ), + ) + : Effect.succeed(null); + return Effect.logWarning("PR lookup failed.").pipe( Effect.annotateLogs({ operation: "lookupStatusPr", branch: details.branch, @@ -1355,17 +1368,9 @@ export const make = Effect.gen(function* () { } : {}), }), - Effect.andThen(resolveLookupHeadContext(cwd, details)), - Effect.map(({ headContext }) => - resolveLastKnownPr(branchKey, { - upstreamRef: details.upstreamRef, - headBranch: headContext.headBranch, - remoteName: headContext.remoteName, - headRemoteUrlKey: headContext.headRemoteUrlKey, - }), - ), - ), - ), + Effect.andThen(fallback), + ); + }), ); }); const readRemoteStatus = Effect.fn("readRemoteStatus")(function* ( @@ -1615,7 +1620,94 @@ export const make = Effect.gen(function* () { } if (matching.includes("origin")) return "origin"; return matching[0] ?? null; - }).pipe(Effect.orElseSucceed(() => null)); + }); + }); + + // A no-upstream branch may have local commits after its last push. Prefer a + // unique exact ref, then a unique remote tip in its history; never break a + // fork tie by remote order. + const findPublishedBranchRemote = Effect.fn("findPublishedBranchRemote")(function* ( + cwd: string, + branch: string, + ) { + const localRef = `refs/heads/${branch}`; + const remoteNames = (yield* gitCore.execute({ + operation: "GitManager.findPublishedBranchRemote.remotes", + cwd, + args: ["remote"], + timeoutMs: 5_000, + })).stdout + .split("\n") + .map((name) => name.trim()) + .filter((name) => name.length > 0); + if (remoteNames.length === 0) return null; + + const refs = yield* gitCore.execute({ + operation: "GitManager.findPublishedBranchRemote", + cwd, + args: [ + "for-each-ref", + "--format=%(refname)%00%(objectname)", + localRef, + ...remoteNames.map((name) => `refs/remotes/${name}/${branch}`), + ], + timeoutMs: 5_000, + }); + const oidByRef = new Map( + refs.stdout + .split("\n") + .map((line) => line.trim().split("\u0000")) + .filter((entry): entry is [string, string] => entry.length === 2), + ); + const localOid = oidByRef.get(localRef); + if (!localOid) return null; + + const remoteRefs = remoteNames.flatMap((name) => { + const oid = oidByRef.get(`refs/remotes/${name}/${branch}`); + return oid ? [{ name, oid }] : []; + }); + const exactRemotes = remoteRefs.filter((remote) => remote.oid === localOid); + if (exactRemotes.length > 1) return null; + let remoteName = exactRemotes[0]?.name; + if (remoteName === undefined) { + const ancestorRemotes = yield* Effect.forEach( + remoteRefs, + (remote) => + gitCore + .execute({ + operation: "GitManager.findPublishedBranchRemote.ancestor", + cwd, + args: ["merge-base", "--is-ancestor", remote.oid, localOid], + allowNonZeroExit: true, + timeoutMs: 5_000, + }) + .pipe( + Effect.flatMap((result) => { + // `merge-base --is-ancestor` reports 0 for ancestor and 1 + // for not-ancestor; any other non-zero exit is a failed + // probe, not a negative result. A failed probe rejects the + // whole inference instead of letting a surviving probe win + // by default and attach the wrong fork's PR. + if (result.exitCode === 0) return Effect.succeed(remote.name); + if (result.exitCode === 1) return Effect.succeed(null); + return new GitCommandError({ + operation: "GitManager.findPublishedBranchRemote.ancestor", + command: "git merge-base --is-ancestor", + cwd, + exitCode: result.exitCode, + detail: "Ancestry probe failed; rejecting ancestry inference.", + }); + }), + ), + { concurrency: "unbounded" }, + ); + const matches = ancestorRemotes.filter((name) => name !== null); + if (matches.length !== 1) return null; + remoteName = matches[0]; + } + if (remoteName === undefined) return null; + const remoteUrl = yield* readConfigValueNullable(cwd, `remote.${remoteName}.url`); + return remoteUrl === null ? null : remoteName; }); // `git worktree add -b feature origin/main` makes the new local branch track @@ -1640,6 +1732,17 @@ export const make = Effect.gen(function* () { }, ) { const headContext = yield* resolveBranchHeadContext(cwd, details); + if (details.upstreamRef === null) { + const remoteName = yield* findPublishedBranchRemote(cwd, details.branch).pipe( + Effect.orElseSucceed(() => null), + ); + if (remoteName !== null && remoteName !== headContext.remoteName) { + return { + headContext: yield* resolveBranchHeadContext(cwd, { ...details, remoteName }), + lookup: true, + }; + } + } const upstreamHeadIsDefault = headContext.headBranch === details.defaultBranch || (details.defaultBranch === null && @@ -1651,7 +1754,11 @@ export const make = Effect.gen(function* () { ) { return { headContext, lookup: true }; } - const remoteName = yield* findRemoteTrackingRemote(cwd, details.branch, headContext.remoteName); + const remoteName = yield* findRemoteTrackingRemote( + cwd, + details.branch, + headContext.remoteName, + ).pipe(Effect.orElseSucceed(() => null)); if (remoteName === null) { return { headContext, lookup: false }; } @@ -1671,7 +1778,7 @@ export const make = Effect.gen(function* () { * terminal and agent pushes land), and configured upstream metadata survives * when a merged change request's remote branch is deleted. Together they * distinguish branches known to have reached a host from genuinely local - * branches. The ref glob spans every remote so a fork branch still counts. A + * branches. The remote ref check includes names containing slashes. A * repository that tracks no remotes at all cannot answer the question, * because then every branch looks unpublished; it, and any failed probe, * keeps the lookup. @@ -1705,11 +1812,11 @@ export const make = Effect.gen(function* () { return false; } - const [tracksAnyRemote, tracksThisBranch] = yield* Effect.all( - [matchesRef("refs/remotes"), matchesRef(`refs/remotes/*/${headContext.headBranch}`)], + const [tracksAnyRemote, trackedRemote] = yield* Effect.all( + [matchesRef("refs/remotes"), findRemoteTrackingRemote(cwd, headContext.headBranch, null)], { concurrency: "unbounded" }, ); - return tracksAnyRemote && !tracksThisBranch; + return tracksAnyRemote && trackedRemote === null; }).pipe(Effect.orElseSucceed(() => false)); }); diff --git a/docs/user/source-control.md b/docs/user/source-control.md index b906779021b1..4b15fe6e713e 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -140,6 +140,10 @@ make your first commit before pushing. Use a thread's Git actions to commit, push, and create a pull request. T3 Code can generate commit messages, review titles, and descriptions from your changes. +An existing fork PR can appear beside the branch even if you pushed without setting an upstream. +If its badge is missing because several remotes hold that branch, set the branch's upstream to +the intended remote with `git branch --set-upstream-to=/`. + Choose the writing style and model in **Settings → Source Control**. **Repository conventions** uses the project's instructions and recent commit subjects. From a908a7ac8780c5b4d6fb0bed8945d640f69d2d1d Mon Sep 17 00:00:00 2001 From: Tristan Knight Date: Sun, 11 Oct 2026 04:43:18 +0100 Subject: [PATCH 08/66] fix(search): find threads by their branch PR number (#14658) Co-authored-by: maria-rcks --- apps/web/src/components/CommandPalette.tsx | 18 +++- apps/web/src/components/Sidebar.logic.test.ts | 22 +++++ apps/web/src/components/Sidebar.logic.ts | 17 +++- apps/web/src/components/Sidebar.tsx | 40 ++++++++- .../src/components/ThreadStatusIndicators.tsx | 85 ++++++++++++++++++- .../shared/src/threadPullRequests.test.ts | 25 ++++++ packages/shared/src/threadPullRequests.ts | 25 ++++-- 7 files changed, 216 insertions(+), 16 deletions(-) diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 8462eb3822a4..42ead37a4d64 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -211,7 +211,11 @@ import { CommandPaletteMetaDot, ThreadCommandSubtitle, } from "./ThreadCommandSubtitle"; -import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators"; +import { + ThreadRowLeadingStatus, + ThreadRowTrailingStatus, + ThreadSearchPullRequestNumber, +} from "./ThreadStatusIndicators"; import { primaryServerKeybindingsAtom, primaryServerProvidersAtom } from "../state/server"; import { applyProviderInstanceSettings, @@ -1323,7 +1327,16 @@ function OpenCommandPaletteDialog(props: { sortOrder: clientSettings.sidebarThreadSortOrder, icon: , renderLeadingContent: (thread) => , - renderTrailingContent: (thread) => , + renderTrailingContent: (thread) => ( + <> + + + + ), renderDescription: (thread, { projectTitle }) => { const modelInstanceId = thread.runtime?.providerInstanceId ?? thread.modelSelection.instanceId; @@ -1383,6 +1396,7 @@ function OpenCommandPaletteDialog(props: { providerEntryByEnvironmentAndInstanceId, threadContentMatchByKey, threadSearch.query, + threadSearchQuery, threads, ], ); diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index bf2a0c0cbd6f..616ff8c4ec05 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -40,6 +40,7 @@ import { resolveThreadRowClassName, resolveThreadStatusPill, resolveWorkingStartedAt, + pullRequestMatchesSearchQuery, searchSidebarThreads, shouldClearThreadSelectionOnMouseDown, shouldShowSidebarV2Duration, @@ -994,6 +995,27 @@ describe("resolveSidebarThreadStatus", () => { }); }); +describe("pullRequestMatchesSearchQuery", () => { + const branchPullRequest = { + projectId: ProjectId.make("project"), + repository: "pingdotgg/t3code", + number: 123, + url: "https://github.com/pingdotgg/t3code/pull/123", + }; + + it("matches when every query token appears in the PR's terms", () => { + expect(pullRequestMatchesSearchQuery({ branchPullRequest }, "123")).toBe(true); + expect(pullRequestMatchesSearchQuery({ branchPullRequest }, "pingdotgg/t3code #123")).toBe( + true, + ); + }); + + it("rejects queries with a token the PR does not carry, and blank queries", () => { + expect(pullRequestMatchesSearchQuery({ branchPullRequest }, "rebase 123")).toBe(false); + expect(pullRequestMatchesSearchQuery({ branchPullRequest }, " ")).toBe(false); + }); +}); + describe("searchSidebarThreads", () => { const searchThread = (id: string, title: string, project: string) => ({ environmentId: localEnvironmentId, diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index a8743024832f..3fe1881332a5 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -23,7 +23,7 @@ import { type ThreadSortInput, } from "../lib/threadSort"; import type { SidebarThreadSummary, Thread } from "../types"; -import { cn } from "../lib/utils"; +import { cn, normalizeSearchText } from "../lib/utils"; import { isLatestRunSettled } from "../session-logic"; import { resolveServerBackedAppStageLabel } from "../branding.logic"; @@ -1110,6 +1110,21 @@ export function searchSidebarThreads< return [...titleMatches, ...contentMatches]; } +/** + * Whether a PR's search terms contain every whitespace-separated token of the + * query. Search results use this to pick the PR number to show, so it has to + * accept the multi-token queries the command palette matches on. + */ +export function pullRequestMatchesSearchQuery( + pullRequest: Parameters[0], + query: string, +): boolean { + const normalizedQuery = normalizeSearchText(query); + if (normalizedQuery.length === 0) return false; + const haystack = normalizeSearchText(threadPullRequestSearchTerms(pullRequest).join(" ")); + return normalizedQuery.split(" ").every((token) => haystack.includes(token)); +} + export function filterSidebarProjectScopeItems(input: { items: readonly TItem[]; query: string; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index d405d7210e03..16f80abe550d 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -248,6 +248,7 @@ import { synchronizeTerminalPulse, type TerminalStatusIndicator, useLinkedThreadPullRequest, + ThreadSearchPullRequestNumber, } from "./ThreadStatusIndicators"; import { resolveSnoozePresets, snoozeWakeLabel, type SnoozePreset } from "./Sidebar.snooze"; import { ProjectFavicon, type ProjectFaviconProject } from "./ProjectFavicon"; @@ -2233,6 +2234,7 @@ function latestRunDiff( } const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { + isSettled: boolean; thread: SidebarThreadSummary; project: EnvironmentProject | null; projectDisplayName: string | null; @@ -2332,11 +2334,12 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { tabIndex={-1} aria-selected={props.isHighlighted} aria-current={accessibility.current} + aria-describedby={`${props.resultId}-pr`} aria-label={accessibility.label} onMouseMove={props.onHighlight} onClick={props.onSelect} className={cn( - "flex min-h-9 w-full cursor-pointer items-center gap-2.5 rounded-md px-2.5 py-1 text-left text-sm outline-none", + "group/sidebar-row group/v2-row flex min-h-9 w-full cursor-pointer items-center gap-2.5 rounded-md px-2.5 py-1 text-left text-sm outline-none", props.isHighlighted || props.isRouteActive ? "bg-sidebar-row-active text-sidebar-foreground" : "text-sidebar-muted-foreground/75 hover:bg-sidebar-row-hover hover:text-sidebar-foreground", @@ -2347,12 +2350,40 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { } > {props.project ? ( - + ) : null} - {thread.title} - + + {thread.title} + + + + + {threadTimeLabel(thread)} @@ -5167,6 +5198,7 @@ export default function Sidebar() { ); return ( ); } + +/** Number presentation for compact thread search results. */ +function ThreadPullRequestNumber({ + number, + className, + ...props +}: ComponentProps<"span"> & { number: number }) { + return ( + + #{number} + + ); +} + +function settledPrHoverColorClass(state: NonNullable["state"], isDraft = false): string { + switch (state) { + case "open": + if (isDraft) { + return "group-hover/sidebar-row:text-zinc-500 dark:group-hover/sidebar-row:text-zinc-400/80"; + } + return "group-hover/sidebar-row:text-emerald-600 dark:group-hover/sidebar-row:text-emerald-300/90"; + case "merged": + return "group-hover/sidebar-row:text-violet-600 dark:group-hover/sidebar-row:text-violet-300/90"; + case "closed": + return "group-hover/sidebar-row:text-red-600 dark:group-hover/sidebar-row:text-red-300/90"; + } +} + +export function ThreadSearchPullRequestNumber({ + thread, + query, + enabled = true, + settled = false, +}: { + thread: SidebarThreadSummary; + query: string; + enabled?: boolean; + settled?: boolean; +}) { + const matchedLink = visibleThreadPullRequests(thread.pullRequests).find((pr) => + pullRequestMatchesSearchQuery({ pullRequests: [pr] }, query), + ); + const fallbackCandidates = + thread.pullRequests.length === 0 + ? [thread.linkedPullRequest, thread.branchPullRequest] + : [thread.branchPullRequest]; + const legacyReference = matchedLink + ? undefined + : fallbackCandidates.find( + (pr) => pr != null && pullRequestMatchesSearchQuery({ linkedPullRequest: pr }, query), + ); + const legacyStatus = useLinkedThreadPullRequest( + thread.environmentId, + legacyReference, + enabled, + undefined, + legacyReference, + ); + const reference = matchedLink ?? legacyReference; + const linked = matchedLink ? linkedPullRequestSnapshotStatus(matchedLink) : legacyStatus; + const status = prStatusIndicator(linked?.pr ?? null, linked?.sourceControlProvider); + return reference ? ( + + ) : null; +} diff --git a/packages/shared/src/threadPullRequests.test.ts b/packages/shared/src/threadPullRequests.test.ts index a7042a7c8828..2ac94fce121d 100644 --- a/packages/shared/src/threadPullRequests.test.ts +++ b/packages/shared/src/threadPullRequests.test.ts @@ -445,6 +445,31 @@ describe("chain selection and badge state", () => { }); describe("threadPullRequestSearchTerms", () => { + it.each([undefined, [], [link(34)]])( + "searches the branch PR alongside existing links (%j)", + (pullRequests) => { + const branchPullRequest = { + projectId: ProjectId.make("project"), + repository: "pingdotgg/t3code", + number: 11809, + url: "https://github.com/pingdotgg/t3code/pull/11809", + }; + const terms = threadPullRequestSearchTerms({ + pullRequests, + branchPullRequest, + linkedPullRequest: { + ...branchPullRequest, + number: 12, + url: "https://github.com/pingdotgg/t3code/pull/12", + }, + }); + for (const query of ["11809", "#11809", "pingdotgg/t3code#11809", branchPullRequest.url]) { + expect(terms.some((term) => term.includes(query))).toBe(true); + } + expect(terms).toContain(pullRequests?.length ? "#34" : "#12"); + }, + ); + it("includes completed and unsynced links but excludes dismissed links", () => { const terms = threadPullRequestSearchTerms({ pullRequests: [ diff --git a/packages/shared/src/threadPullRequests.ts b/packages/shared/src/threadPullRequests.ts index d74927237534..97b800d8682f 100644 --- a/packages/shared/src/threadPullRequests.ts +++ b/packages/shared/src/threadPullRequests.ts @@ -308,21 +308,30 @@ export function resolveThreadPullRequestBadge( return { kind: "pull-request", others: visible.length - 1, state }; } -/** Search terms for visible PR links, including the legacy single-link projection. */ +/** Search terms for visible PR links, the legacy projection, and the branch PR. */ export function threadPullRequestSearchTerms(thread: { readonly pullRequests?: ReadonlyArray | undefined; readonly linkedPullRequest?: ThreadLinkedPullRequest | null | undefined; + readonly branchPullRequest?: ThreadLinkedPullRequest | null | undefined; }): string[] { + const branch = thread.branchPullRequest; + const terms = branch + ? [`#${branch.number}`, `${branch.repository}#${branch.number}`, branch.url] + : []; if (thread.pullRequests !== undefined && thread.pullRequests.length > 0) { - return visibleThreadPullRequests(thread.pullRequests).flatMap((link) => [ - `#${link.number}`, - `${link.repository}#${link.number}`, - link.url, - link.snapshot?.title ?? "", - ]); + return terms.concat( + visibleThreadPullRequests(thread.pullRequests).flatMap((link) => [ + `#${link.number}`, + `${link.repository}#${link.number}`, + link.url, + link.snapshot?.title ?? "", + ]), + ); } const legacy = thread.linkedPullRequest; - return legacy ? [`#${legacy.number}`, `${legacy.repository}#${legacy.number}`, legacy.url] : []; + return legacy + ? terms.concat([`#${legacy.number}`, `${legacy.repository}#${legacy.number}`, legacy.url]) + : terms; } /** Older V2 event payloads stored one link; an explicit empty array means it was unlinked. */ From b9afba59f435e2e0d6368392b49f10d187125a09 Mon Sep 17 00:00:00 2001 From: Bilal Bakr <62337003+Bil0000@users.noreply.github.com> Date: Sun, 11 Oct 2026 07:34:02 +0300 Subject: [PATCH 09/66] feat(web): show Claude workflow phases and members in Lineage (#12598) Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: GPT-6.1-Sol (Codex) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- apps/server/src/auth/RpcAuthorization.test.ts | 7 + apps/server/src/auth/RpcAuthorization.ts | 1 + apps/server/src/mcp/OrchestratorMcpService.ts | 1 + apps/server/src/mcp/toolkits/core.test.ts | 153 +- .../src/mcp/toolkits/orchestrator/handlers.ts | 22 + .../src/mcp/toolkits/orchestrator/tools.ts | 15 + .../src/observability/RpcInstrumentation.ts | 1 + .../Adapters/ClaudeAdapterV2.test.ts | 2393 ++++++++++++++++- .../Adapters/ClaudeAdapterV2.testkit.ts | 7 +- .../Adapters/ClaudeAdapterV2.ts | 1131 ++++++-- .../ClaudeAgentSdkQueryRunner.test.ts | 80 + .../Adapters/CodexAdapterV2.ts | 80 +- .../Adapters/claudeWorkflowProgress.test.ts | 318 +++ .../Adapters/claudeWorkflowProgress.ts | 192 ++ .../BackgroundWorkStop.integration.test.ts | 86 +- ...laudeAutomaticDelivery.integration.test.ts | 1 + .../src/orchestration-v2/Orchestrator.ts | 19 +- .../orchestration-v2/ProjectionStore.test.ts | 164 +- .../ProviderEventIngestor.test.ts | 84 + .../orchestration-v2/ProviderEventIngestor.ts | 35 +- .../ProviderRuntimeRecoveryService.test.ts | 91 + .../RunExecutionService.test.ts | 223 +- .../orchestration-v2/RunExecutionService.ts | 47 +- .../SelectionRestart.integration.test.ts | 71 +- .../ThreadManagementService.test.ts | 235 ++ .../ThreadManagementService.ts | 116 +- .../src/orchestration-v2/runtimeLayer.ts | 8 +- .../testkit/ProviderReplayHarness.ts | 5 +- .../subagent_v2_approval/codex_output.ts | 14 + .../codex_output.ts | 14 + .../src/orchestration-v2/unknownField.ts | 10 + .../workflowAgentAnswers.test.ts | 121 + .../orchestration-v2/workflowAgentAnswers.ts | 97 + .../src/orchestration-v2/workflowFileRead.ts | 112 + .../workflowScriptQuery.test.ts | 22 +- .../orchestration-v2/workflowScriptQuery.ts | 137 +- .../src/relay/AgentAwarenessRelay.test.ts | 1 + apps/server/src/ws.ts | 1 + .../chat/ThreadLineageWorkflowRow.tsx | 266 ++ ...ThreadRelationshipsControl.agents.test.tsx | 523 +++- .../chat/ThreadRelationshipsControl.tsx | 230 +- .../ui/collapsible-section-header.tsx | 5 +- .../client-runtime/src/state/orchestration.ts | 4 + .../src/state/subagentRuntime.test.ts | 220 ++ .../src/state/subagentRuntime.ts | 204 +- .../client-runtime/src/t3ToolSummary.test.ts | 17 + packages/client-runtime/src/t3ToolSummary.ts | 7 + .../src/work-log/presentation.test.ts | 42 + .../src/work-log/presentation.ts | 1 + .../contracts/src/clientRpcPermissions.ts | 2 + .../contracts/src/orchestrationV2.test.ts | 64 + packages/contracts/src/orchestrationV2.ts | 155 ++ packages/contracts/src/orchestratorMcp.ts | 1 + packages/contracts/src/rpc.ts | 8 + .../src/server/ProviderAdapter.ts | 4 + packages/shared/src/t3McpToolPresentation.ts | 5 + 56 files changed, 7214 insertions(+), 659 deletions(-) create mode 100644 apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts create mode 100644 apps/server/src/orchestration-v2/Adapters/claudeWorkflowProgress.test.ts create mode 100644 apps/server/src/orchestration-v2/Adapters/claudeWorkflowProgress.ts create mode 100644 apps/server/src/orchestration-v2/unknownField.ts create mode 100644 apps/server/src/orchestration-v2/workflowAgentAnswers.test.ts create mode 100644 apps/server/src/orchestration-v2/workflowAgentAnswers.ts create mode 100644 apps/server/src/orchestration-v2/workflowFileRead.ts create mode 100644 apps/web/src/components/chat/ThreadLineageWorkflowRow.tsx create mode 100644 packages/client-runtime/src/state/subagentRuntime.test.ts diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index d46609edce77..ce90ad31fea8 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -8,6 +8,7 @@ import { ProviderInstanceId, ThreadId, AuthOrchestrationOperateScope, + ORCHESTRATION_V2_WS_METHODS, AuthOrchestrationReadScope, AuthSourceControlWriteScope, AuthPreviewOperateScope, @@ -35,6 +36,12 @@ describe("RPC authorization scopes", () => { expect(new Set(Object.keys(RPC_REQUIRED_SCOPES))).toEqual(new Set(WsRpcGroup.requests.keys())); }); + it("requires operate access to stop a workflow", () => { + expect(requiredScopeForRpcMethod(ORCHESTRATION_V2_WS_METHODS.stopWorkflow)).toBe( + AuthOrchestrationOperateScope, + ); + }); + it("authorizes background policy reporting and observation deliberately", () => { expect(requiredScopeForRpcMethod(WS_METHODS.serverReportClientActivity)).toBe( AuthOrchestrationReadScope, diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 405c9eee2eea..7d67d92f4f71 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -46,6 +46,7 @@ export const RPC_REQUIRED_SCOPES = { ...CLIENT_GUARDED_RPC_SCOPES, [ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: AuthOrchestrationOperateScope, [ORCHESTRATION_V2_WS_METHODS.getWorkflowScript]: AuthOrchestrationReadScope, + [ORCHESTRATION_V2_WS_METHODS.stopWorkflow]: AuthOrchestrationOperateScope, [ORCHESTRATION_V2_WS_METHODS.getTurnDiff]: AuthOrchestrationReadScope, [ORCHESTRATION_V2_WS_METHODS.getFullThreadDiff]: AuthOrchestrationReadScope, [ORCHESTRATION_V2_WS_METHODS.searchThreads]: AuthOrchestrationReadScope, diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index fb5153669d4a..bb4bedb74e01 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -879,6 +879,7 @@ function timelineItem(input: { visibility: input.row.visibility, sourceThreadId: input.row.sourceThreadId, itemId: input.row.sourceItemId, + ...(input.row.item.type === "subagent" ? { subagentId: input.row.item.subagentId } : {}), runId: input.row.item.runId, messageId, createdBy: message?.createdBy ?? null, diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index a938ba7fd999..e2b058da5068 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -8,9 +8,14 @@ import { ChatImageAttachment, CommandId, EnvironmentId, + NodeId, + OrchestratorMcpThreadReadResult, + ProviderDriverKind, ProviderInstanceId, RunId, ThreadId, + TurnItemId, + type OrchestrationV2ProjectedTurnItem, type OrchestrationV2ThreadShell, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; @@ -69,6 +74,7 @@ import { import { htmlRenderFromToolItem } from "@t3tools/shared/toolOutput"; const decodeMcpAttachmentInput = Schema.decodeUnknownEffect(McpAttachmentInput); +const decodeThreadReadResult = Schema.decodeUnknownEffect(OrchestratorMcpThreadReadResult); // Registration asks for every service the thread tools declare; these cases call none that use them. const layerThreadToolkit = McpHttpServer.layerThreadToolkit.pipe( @@ -663,16 +669,127 @@ function scheduledTask(id: string, runtimeMode: "auto" | "full-access"): never { } as never; } -it.effect("a caller cannot interrupt a thread that runs above its own modes", () => - Effect.gen(function* () { +it.effect.each(["t3_thread_interrupt", "t3_workflow_stop"])( + "a caller cannot use %s on a thread that runs above its own modes", + (toolName) => + Effect.gen(function* () { + const server = yield* McpServer.McpServer; + const result = yield* server + .callTool({ + name: toolName, + arguments: { + threadId: "full-access-thread", + ...(toolName === "t3_workflow_stop" ? { subagentId: "workflow-coordinator" } : {}), + }, + }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, clientScope("auto")), + Effect.provideService(McpSchema.McpServerClient, client), + ); + expect(declaredFailure(result)).toMatchObject({ code: "runtime_mode_escalation_denied" }); + }).pipe( + Effect.provide( + McpHttpServer.layerOrchestratorToolkit.pipe( + Layer.provideMerge(McpServer.McpServer.layer), + Layer.provide(NodeCrypto.layer), + Layer.provide( + Layer.mock(ThreadManagement.ThreadManagementService)({ + getThreadShell: () => + Effect.succeed({ + projectId: "project-a", + deletedAt: null, + runtimeMode: "full-access", + interactionMode: "default", + } as never), + getProjectThreadRecords: () => + Effect.succeed({ + thread: { + id: ThreadId.make("full-access-thread"), + projectId: "project-a", + runtimeMode: "full-access", + interactionMode: "default", + deletedAt: null, + }, + runs: [], + } as never), + stopWorkflow: () => Effect.die("workflow stop must not dispatch above the ceiling"), + interruptThread: () => Effect.die("interrupt must not dispatch above the ceiling"), + }), + ), + Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})), + Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})), + Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})), + Layer.provide(Layer.mock(ProjectService.ProjectService)({})), + Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})), + ), + ), + ), +); + +it.effect("discovers workflow stop targets and requires orchestration capability", () => { + const threadId = ThreadId.make("workflow-parent"); + const shell = McpToolAccessTestkit.liveThreadShell(threadId); + const subagentId = NodeId.make("workflow-coordinator"); + const itemId = TurnItemId.make("workflow-coordinator-activity"); + const row: OrchestrationV2ProjectedTurnItem = { + position: 0, + visibility: "local", + sourceThreadId: threadId, + sourceItemId: itemId, + item: { + id: itemId, + threadId, + runId: shell.activeRunId, + nodeId: subagentId, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 0, + status: "running", + title: "Workflow: checkout review", + startedAt: shell.createdAt, + completedAt: null, + updatedAt: shell.updatedAt, + type: "subagent", + subagentId, + origin: "provider_native", + driver: ProviderDriverKind.make("claudeAgent"), + providerInstanceId: ProviderInstanceId.make("claudeAgent"), + childThreadId: ThreadId.make("workflow-child"), + prompt: "Review checkout", + result: null, + }, + }; + const stopped: Array<{ readonly threadId: ThreadId; readonly subagentId: string }> = []; + return Effect.gen(function* () { const server = yield* McpServer.McpServer; - const result = yield* server - .callTool({ name: "t3_thread_interrupt", arguments: { threadId: "full-access-thread" } }) + const scope = clientScope("full-access"); + const read = yield* server + .callTool({ name: "t3_thread_read", arguments: { threadId, view: "activity" } }) .pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, clientScope("auto")), + Effect.provideService(McpInvocationContext.McpInvocationContext, scope), Effect.provideService(McpSchema.McpServerClient, client), ); - expect(declaredFailure(result)).toMatchObject({ code: "runtime_mode_escalation_denied" }); + expect(read.isError).toBe(false); + const result = yield* decodeThreadReadResult(read.structuredContent); + expect(result.items[0]?.subagentId).toBe(subagentId); + const call = (invocation: McpInvocationContext.McpInvocationScope) => + server + .callTool({ + name: "t3_workflow_stop", + arguments: { threadId, subagentId: result.items[0]?.subagentId }, + }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.provideService(McpSchema.McpServerClient, client), + ); + const denied = yield* call({ ...scope, capabilities: new Set(["worktree"]) }); + expect(declaredFailure(denied)).toMatchObject({ code: "capability_denied" }); + expect(stopped).toEqual([]); + const allowed = yield* call(scope); + expect(allowed.isError).toBe(false); + expect(stopped).toEqual([{ threadId: "workflow-parent", subagentId: "workflow-coordinator" }]); }).pipe( Effect.provide( McpHttpServer.layerOrchestratorToolkit.pipe( @@ -680,20 +797,14 @@ it.effect("a caller cannot interrupt a thread that runs above its own modes", () Layer.provide(NodeCrypto.layer), Layer.provide( Layer.mock(ThreadManagement.ThreadManagementService)({ - getThreadShell: () => - Effect.succeed({ projectId: "project-a", deletedAt: null } as never), + getThreadShell: (id) => Effect.succeed(McpToolAccessTestkit.liveThreadShell(id)), getProjectThreadRecords: () => - Effect.succeed({ - thread: { - id: ThreadId.make("full-access-thread"), - projectId: "project-a", - runtimeMode: "full-access", - interactionMode: "default", - deletedAt: null, - }, - runs: [], - } as never), - interruptThread: () => Effect.die("interrupt must not dispatch above the ceiling"), + Effect.succeed(McpToolAccessTestkit.idleThreadProjection(shell)), + getTimelinePage: () => Effect.succeed({ items: [row], totalItems: 1, hasMore: false }), + stopWorkflow: (input) => + Effect.sync(() => { + stopped.push(input); + }), }), ), Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})), @@ -703,8 +814,8 @@ it.effect("a caller cannot interrupt a thread that runs above its own modes", () Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})), ), ), - ), -); + ); +}); it.effect("only the caller that prepared a pending upload can discard it", () => Effect.gen(function* () { diff --git a/apps/server/src/mcp/toolkits/orchestrator/handlers.ts b/apps/server/src/mcp/toolkits/orchestrator/handlers.ts index e71b3d13888a..fa84ce59eebc 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/handlers.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/handlers.ts @@ -1,6 +1,8 @@ +import { OrchestratorMcpFailure } from "@t3tools/contracts"; import { OrchestratorToolkit } from "./tools.ts"; import * as Effect from "effect/Effect"; +import { readCaller } from "../../threadAccess.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; import * as McpToolAccess from "../../McpToolAccess.ts"; import * as OrchestratorMcpService from "../../OrchestratorMcpService.ts"; @@ -35,6 +37,26 @@ const handlers = { return yield* service.cancelTask(scope, input); }), ), + t3_workflow_stop: McpToolAccess.writesThreads( + (input) => [input.threadId], + (input) => + Effect.gen(function* () { + const { threads } = yield* readCaller(); + return yield* threads.stopWorkflow(input).pipe( + Effect.as({}), + Effect.mapError( + (cause) => + new OrchestratorMcpFailure({ + code: + cause.reason === "unavailable" || cause.reason === "stop-failed" + ? "orchestration_error" + : "invalid_request", + message: cause.message, + }), + ), + ); + }), + ), schedule_task: McpToolAccess.startsThreads( // A scheduled task runs with the caller's own modes. () => ({}), diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index 71ae2f892de5..3248618a2de0 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -1,5 +1,6 @@ import { OrchestratorMcpCapabilitiesResult, + OrchestrationV2StopWorkflowInput, OrchestratorMcpCreateThreadsInput, OrchestratorMcpCreateThreadsResult, OrchestratorMcpDelegateTaskInput, @@ -31,6 +32,7 @@ import { ThreadMetadataMcpUpdateResult, } from "@t3tools/contracts"; import { Tool, Toolkit } from "effect/ai"; +import * as Schema from "effect/Schema"; import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; @@ -100,6 +102,18 @@ const TaskCancelTool = Tool.make("task_cancel", { .annotate(Tool.Title, "Cancel delegated task") .annotate(Tool.Destructive, true); +const WorkflowStopTool = Tool.make("t3_workflow_stop", { + description: + "Stop one running Claude workflow in a T3 thread. Use the workflow coordinator's subagentId returned by t3_thread_read with view='activity'. Stops the whole workflow, including its phases and agents. Does not stop the parent thread or other workflows. Individual workflow phases and agents cannot be stopped separately.", + parameters: OrchestrationV2StopWorkflowInput, + success: Schema.Struct({}), + failure: OrchestratorMcpFailure, + failureMode: "return", + dependencies, +}) + .annotate(Tool.Title, "Stop a workflow") + .annotate(Tool.Destructive, true); + export const ScheduleTaskTool = Tool.make("schedule_task", { description: "Create persistent work in the app scheduler that runs even when no turn is active. Pass schedule as a STRUCTURED OBJECT, never JSON text. Timers: {type:'interval', everyMs:3600000} is hourly; {type:'fixed_time', timeOfDay:'09:00', weekdays:[1,2,3,4,5]} is weekday mornings; report the returned nextRunAt. Webhooks: {type:'webhook'} runs once per request to a generated URL. The run sees the request ONLY through prompt placeholders: {{body.path}} (e.g. {{body.action}}, {{body.release.tag_name}}), {{headers.name}}, {{query.name}}, {{body}}, or {{request}} (method, headers with credentials redacted, and body). For a sender that signs requests, first call request_secret so the user enters the secret privately (never ask for it in chat or invent one), then set signature with the returned secretRef, e.g. GitHub: {type:'webhook', signature:{header:'x-hub-signature-256', encoding:'hex', prefix:'sha256=', secretRef}}. The result's webhookUrl is the public URL to give the user; if it is absent, this environment has no T3 Connect managed tunnel, so tell the user to enable T3 Connect remote access rather than sharing a path. Omit projectId for this thread's project. In this thread's project, runs post into THIS thread by default (bindToCurrentThread=true), which suits an orchestrator that sees every trigger, delegates work, and can dedupe against what is in flight; use false only when the user wants a fresh top-level thread per run. Elsewhere each run launches a fresh thread. Provider, model, and runtime settings inherit from this thread, or from the project default when there is no calling thread.", @@ -261,6 +275,7 @@ export const OrchestratorToolkit = Toolkit.make( DelegateTaskTool, TaskStatusTool, TaskCancelTool, + WorkflowStopTool, ScheduleTaskTool, ListScheduledTasksTool, UpdateScheduledTaskTool, diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index db52eb31acf0..69b8b3d325ab 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -17,6 +17,7 @@ type WsRpcMethod = RpcGroup.Rpcs["_tag"]; const RPC_AGGREGATES = { [ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: "orchestrationV2", [ORCHESTRATION_V2_WS_METHODS.getWorkflowScript]: "orchestration", + [ORCHESTRATION_V2_WS_METHODS.stopWorkflow]: "orchestration", [ORCHESTRATION_V2_WS_METHODS.getTurnItem]: "orchestration", [ORCHESTRATION_V2_WS_METHODS.getTurnDiff]: "orchestration", [ORCHESTRATION_V2_WS_METHODS.getFullThreadDiff]: "orchestration", diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index d80e76072388..6a9a5c20edee 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -1,6 +1,8 @@ -// @effect-diagnostics nodeBuiltinImport:off - the fake Claude CLI hands the SDK Node streams. +// @effect-diagnostics nodeBuiltinImport:off -- workflow fixtures use files and the fake Claude CLI uses Node streams import * as NodeEvents from "node:events"; +import * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; import * as NodeStream from "node:stream"; import type { @@ -44,6 +46,7 @@ import { ThreadId, } from "@t3tools/contracts"; import { assert, describe, it } from "@effect/vitest"; +import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; @@ -1024,6 +1027,7 @@ describe("ClaudeAdapterV2 Auto-accept edits", () => { offer: () => Effect.void, setModel: () => Effect.void, setPermissionMode: () => Effect.void, + stopTask: () => Effect.void, interrupt: Effect.void, close: Effect.void, }; @@ -1183,6 +1187,7 @@ const captureSdkExecutablePaths = Effect.fn("captureSdkExecutablePaths")(functio offer: () => Effect.void, setModel: () => Effect.void, setPermissionMode: () => Effect.void, + stopTask: () => Effect.void, interrupt: Effect.void, close: Effect.void, }; @@ -1302,6 +1307,7 @@ describe("ClaudeAdapterV2 resume compaction", () => { offer: () => Effect.void, setModel: () => Effect.void, setPermissionMode: () => Effect.void, + stopTask: () => Effect.void, interrupt: Effect.void, close: Effect.void, }; @@ -1527,6 +1533,7 @@ describe("ClaudeAdapterV2 attachments", () => { }), setModel: () => Effect.void, setPermissionMode: () => Effect.void, + stopTask: () => Effect.void, interrupt: Effect.void, close: Effect.void, }), @@ -1671,6 +1678,7 @@ describe("ClaudeAdapterV2 attachments", () => { offer: () => Effect.void, setModel: () => Effect.void, setPermissionMode: () => Effect.void, + stopTask: () => Effect.void, interrupt: Effect.void, close: Effect.void, }; @@ -1765,6 +1773,7 @@ describe("ClaudeAdapterV2 native fork", () => { offer: () => Effect.void, setModel: () => Effect.void, setPermissionMode: () => Effect.void, + stopTask: () => Effect.void, interrupt: Effect.void, close: Effect.void, }; @@ -2056,6 +2065,7 @@ describe("ClaudeAdapterV2 native session identity", () => { offer: () => Effect.void, setModel: () => Effect.void, setPermissionMode: () => Effect.void, + stopTask: () => Effect.void, interrupt: Effect.void, close: Effect.void, }; @@ -2309,6 +2319,8 @@ describe("ClaudeAdapterV2 background wake turns", () => { readonly interrupt?: Effect.Effect; readonly stopTask?: (taskId: string) => Effect.Effect; readonly environment?: NodeJS.ProcessEnv; + readonly subagentLaunchToolUseId?: string; + readonly nativeSessionId?: string; readonly settings?: ClaudeSettings; // A CLI process opened after the first streams from its own queue, so the // first one can exit (Queue.shutdown) and a later turn can start another. @@ -2323,24 +2335,30 @@ describe("ClaudeAdapterV2 background wake turns", () => { const sdkMessages = yield* Queue.unbounded(); const processQueues: Array> = []; const processedMessages = new WeakMap>(); - const offerAndWait = Effect.fnUntraced(function* (message: SDKMessage) { + const offerAndWait = Effect.fnUntraced(function* ( + message: SDKMessage, + offered?: Deferred.Deferred, + ) { const processed = yield* Deferred.make(); processedMessages.set(message, processed); yield* Queue.offer(sdkMessages, message); + if (offered !== undefined) yield* Deferred.succeed(offered, undefined); yield* Deferred.await(processed); }); const offeredMessages: Array = []; const permissionModeChanges: Array = []; const continuationRequests: Array = []; - const subagentReceipts = - yield* Queue.unbounded< - Extract - >(); + const continuationReceipts = + yield* Queue.unbounded(); const terminalReceipts = yield* Queue.unbounded< Extract >(); + const subagentReceipts = + yield* Queue.unbounded< + Extract + >(); const systemNoticeReceipts = yield* Queue.unbounded< Extract @@ -2359,10 +2377,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { offer: (request) => Effect.sync(() => { continuationRequests.push(request); - }), + }).pipe(Effect.andThen(Queue.offer(continuationReceipts, request))), }, queryRunner: { - allocateSessionId: Effect.succeed(WAKE_NATIVE_SESSION), + allocateSessionId: Effect.succeed(options?.nativeSessionId ?? WAKE_NATIVE_SESSION), open: (input) => Effect.gen(function* () { openedOptions = input.options; @@ -2377,6 +2395,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { }), setModel: () => Effect.void, setPermissionMode: () => Effect.void, + stopTask: options?.stopTask ?? (() => Effect.void), interrupt: Effect.void, close: Queue.shutdown(processMessages), }; @@ -2409,13 +2428,13 @@ describe("ClaudeAdapterV2 background wake turns", () => { Effect.sync(() => { permissionModeChanges.push(mode); }), - ...(options?.stopTask === undefined ? {} : { stopTask: options.stopTask }), + stopTask: options?.stopTask ?? (() => Effect.void), interrupt: options?.interrupt ?? Effect.void, close: options?.close?.(sdkMessages) ?? Effect.void, }; }), forkSession: () => Effect.die("unused forkSession"), - subagentLaunchToolUseId: () => Effect.succeed(null), + subagentLaunchToolUseId: () => Effect.succeed(options?.subagentLaunchToolUseId ?? null), assertComplete: Effect.void, }, }); @@ -2436,7 +2455,9 @@ describe("ClaudeAdapterV2 background wake turns", () => { Stream.runForEach((event) => Effect.gen(function* () { events.push(event); - if (event.type === "subagent.updated") yield* Queue.offer(subagentReceipts, event); + if (event.type === "subagent.updated") { + yield* Queue.offer(subagentReceipts, event); + } if (event.type === "turn.terminal") { yield* Queue.offer(terminalReceipts, event); } @@ -2474,6 +2495,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { }, permissionModeChanges, continuationRequests, + continuationReceipts, events, terminalReceipts, subagentReceipts, @@ -4709,6 +4731,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { offer: () => Effect.void, setModel: () => Effect.void, setPermissionMode: () => Effect.void, + stopTask: () => Effect.void, interrupt: Effect.void, // The first CLI process keeps streaming until the test ends // it, so Stop stays parked waiting for it to exit. @@ -4968,6 +4991,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { offer: () => Effect.void, setModel: () => Effect.void, setPermissionMode: () => Effect.void, + stopTask: () => Effect.void, interrupt: Effect.void, close: Queue.shutdown(queue), }; @@ -6547,6 +6571,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { const harness = yield* makeWakeHarnessWithOptions({ close: (sdkMessages) => Deferred.await(closeGate).pipe(Effect.andThen(Queue.shutdown(sdkMessages))), + stopTask: () => Effect.void, interrupt: Deferred.succeed(interruptStarted, undefined), }); const idAllocator = yield* IdAllocator.IdAllocatorV2; @@ -6608,6 +6633,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { const harness = yield* makeWakeHarnessWithOptions({ close: (sdkMessages) => Deferred.await(closeGate).pipe(Effect.andThen(Queue.shutdown(sdkMessages))), + stopTask: () => Effect.void, interrupt: Deferred.succeed(interruptStarted, undefined), }); const idAllocator = yield* IdAllocator.IdAllocatorV2; @@ -9692,6 +9718,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { offer: () => Effect.void, setModel: () => Effect.void, setPermissionMode: () => Effect.void, + stopTask: () => Effect.void, interrupt: Effect.void, // End this process stream so openQuery can replace it. close: Queue.shutdown(sdkMessages), @@ -9881,6 +9908,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { offer: () => Effect.void, setModel: () => Effect.void, setPermissionMode: () => Effect.void, + stopTask: () => Effect.void, interrupt: Effect.void, close: Queue.shutdown(sdkMessages), }; @@ -10122,6 +10150,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { offer: () => Effect.void, setModel: () => Effect.void, setPermissionMode: () => Effect.void, + stopTask: () => Effect.void, interrupt: Effect.void, close: Queue.shutdown(sdkMessages), }; @@ -10320,6 +10349,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { offer: () => Effect.void, setModel: () => Effect.void, setPermissionMode: () => Effect.void, + stopTask: () => Effect.void, interrupt: Effect.void, close: Queue.shutdown(sdkMessages), }; @@ -10502,6 +10532,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { offer: () => Effect.void, setModel: () => Effect.void, setPermissionMode: () => Effect.void, + stopTask: () => Effect.void, interrupt: Effect.void, close: Queue.shutdown(sdkMessages), }; @@ -10640,6 +10671,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { offer: () => Effect.void, setModel: () => Effect.void, setPermissionMode: () => Effect.void, + stopTask: () => Effect.void, interrupt: Effect.void, close: Queue.shutdown(sdkMessages), }; @@ -10874,6 +10906,2345 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + const WORKFLOW_TASK_ID = "task-workflow-run"; + const WORKFLOW_TOOL_USE_ID = "toolu-workflow-run"; + const makeWorkflowHarnessWithOptions = ( + options?: Parameters[0], + ) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const configDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-claude-wf-" }); + const transcriptDir = NodePath.join(configDir, "projects", "workflow"); + yield* fileSystem.makeDirectory(transcriptDir, { recursive: true }); + yield* fileSystem.writeFileString( + NodePath.join(transcriptDir, "agent-a1.jsonl"), + '{"type":"assistant","message":{"role":"assistant","id":"msg_a1","content":[{"type":"text","text":"A1 from the transcript"}]}}\n', + ); + const harness = yield* makeWakeHarnessWithOptions({ + ...options, + environment: { ...options?.environment, CLAUDE_CONFIG_DIR: configDir }, + }); + return { ...harness, transcriptDir }; + }); + const makeWorkflowHarness = makeWorkflowHarnessWithOptions(); + + const workflowToolUse = claudeSdkFrame({ + type: "assistant", + message: { + model: "claude-sonnet-4-6", + content: [ + { + type: "tool_use", + id: WORKFLOW_TOOL_USE_ID, + name: "Workflow", + input: { script: "alpha.js" }, + }, + ], + }, + parent_tool_use_id: null, + uuid: "00000000-0000-4000-8000-000000001001", + session_id: WAKE_NATIVE_SESSION, + }); + const workflowTaskStarted = claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: WORKFLOW_TASK_ID, + tool_use_id: WORKFLOW_TOOL_USE_ID, + description: "Run the alpha workflow", + task_type: "local_workflow", + workflow_name: "probe-wf", + uuid: "00000000-0000-4000-8000-000000001002", + session_id: WAKE_NATIVE_SESSION, + }); + const workflowLaunchAck = (transcriptDir: string) => + claudeSdkFrame({ + type: "user", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: WORKFLOW_TOOL_USE_ID, + content: [{ type: "text", text: "Workflow launched." }], + }, + ], + }, + parent_tool_use_id: null, + uuid: "00000000-0000-4000-8000-000000001003", + session_id: WAKE_NATIVE_SESSION, + tool_use_result: { + taskType: "local_workflow", + runId: "wf_probe", + transcriptDir, + }, + }); + const workflowSnapshot = (input: { + readonly uuid: string; + readonly state: "queued" | "start" | "done"; + readonly secondState?: "queued" | "start" | "done"; + readonly startedAt?: number; + readonly label?: string; + readonly prompt?: string; + readonly model?: string; + readonly attempt?: number; + readonly omitResult?: boolean; + }) => + claudeSdkFrame({ + type: "system", + subtype: "task_progress", + task_id: WORKFLOW_TASK_ID, + tool_use_id: WORKFLOW_TOOL_USE_ID, + description: "Alpha: alpha:one", + workflow_progress: [ + { type: "workflow_phase", index: 1, title: "Alpha" }, + { + type: "workflow_agent", + index: 1, + label: input.label ?? "alpha:one", + agentId: "a1", + state: input.state, + phaseIndex: 1, + phaseTitle: "Alpha", + model: input.model ?? "claude-opus-5[1m]", + promptPreview: input.prompt ?? "Reply with exactly: A1", + ...(input.startedAt === undefined ? {} : { startedAt: input.startedAt }), + ...(input.attempt === undefined ? {} : { attempt: input.attempt }), + ...(input.state === "done" && input.omitResult !== true + ? { resultPreview: "A1 excerpt" } + : {}), + }, + { + type: "workflow_agent", + index: 2, + label: "alpha:two", + agentId: "a2", + state: input.secondState ?? input.state, + phaseIndex: 1, + phaseTitle: "Alpha", + promptPreview: "Reply with exactly: A2", + ...(input.state === "done" ? { resultPreview: "A2 excerpt" } : {}), + }, + ], + uuid: input.uuid, + session_id: WAKE_NATIVE_SESSION, + }); + const workflowNotification = (input: { + readonly uuid: string; + readonly status: "completed" | "failed" | "stopped"; + }) => + claudeSdkFrame({ + type: "system", + subtype: "task_notification", + task_id: WORKFLOW_TASK_ID, + tool_use_id: WORKFLOW_TOOL_USE_ID, + status: input.status, + summary: "Workflow finished.", + output_file: "/tmp/workflow.output", + uuid: input.uuid, + session_id: WAKE_NATIVE_SESSION, + }); + const workflowCoordinatorEvents = ( + events: ReadonlyArray, + ) => + events.filter( + ( + event, + ): event is Extract => + event.type === "subagent.updated" && + event.subagent.nativeTaskRef?.nativeId === WORKFLOW_TASK_ID, + ); + const workflowMemberEvents = ( + events: ReadonlyArray, + index: number, + ) => + events.filter( + ( + event, + ): event is Extract => + event.type === "subagent.updated" && + event.subagent.nativeTaskRef?.nativeId?.endsWith(`:agent:${index}`) === true, + ); + const threadMessages = ( + events: ReadonlyArray, + threadId: ThreadId | null | undefined, + ) => + events.filter( + ( + event, + ): event is Extract => + event.type === "message.updated" && event.message.threadId === threadId, + ); + + const launchWorkflow = Effect.fnUntraced(function* (input: { + readonly harness: Effect.Success; + readonly snapshotUuid: string; + readonly state?: "queued" | "start"; + }) { + yield* input.harness.offerAndWait(workflowToolUse); + yield* input.harness.offerAndWait(workflowTaskStarted); + yield* input.harness.offerAndWait(workflowLaunchAck(input.harness.transcriptDir)); + yield* input.harness.offerAndWait( + workflowSnapshot({ uuid: input.snapshotUuid, state: input.state ?? "start" }), + ); + yield* awaitUntil( + () => workflowMemberEvents(input.harness.events, 2).length === 1, + "workflow members projected", + ); + }); + + it.effect.each(["Agent", "Workflow"] as const)( + "keeps a workflow running after its %s launch reply without an async flag", + (toolName) => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWorkflowHarness; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make(`attempt-workflow-launch-${toolName}`), + text: "Run the workflow.", + attachments: [], + }), + ); + yield* harness.offerAndWait( + claudeSdkFrame({ + ...workflowToolUse, + message: { + model: "claude-sonnet-4-6", + content: [ + { type: "tool_use", id: WORKFLOW_TOOL_USE_ID, name: toolName, input: {} }, + ], + }, + }), + ); + yield* harness.offerAndWait(workflowTaskStarted); + yield* harness.offerAndWait(workflowLaunchAck(harness.transcriptDir)); + yield* harness.offerAndWait( + workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000001200", state: "start" }), + ); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000001201", result: "Launched." }), + ); + yield* Queue.take(harness.terminalReceipts); + assert.equal( + workflowCoordinatorEvents(harness.events).at(-1)?.subagent.status, + "running", + ); + assert.equal(workflowMemberEvents(harness.events, 1).at(-1)?.subagent.status, "running"); + assert.isTrue(yield* harness.hasPendingBackgroundWork); + const toolItems = harness.events.flatMap((event) => + event.type === "turn_item.updated" && + event.turnItem.type === "dynamic_tool" && + event.turnItem.nativeItemRef?.nativeId === WORKFLOW_TOOL_USE_ID + ? [event.turnItem.status] + : [], + ); + assert.deepEqual(toolItems, toolName === "Workflow" ? ["running", "completed"] : []); + yield* harness.offerAndWait( + workflowNotification({ + uuid: "00000000-0000-4000-8000-000000001202", + status: "completed", + }), + ); + yield* Queue.take(harness.continuationReceipts); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make(`attempt-workflow-complete-${toolName}`), + text: "Workflow finished.", + attachments: [], + providerTurnOrdinal: 2, + messageCreatedBy: "agent", + messageCreationSource: "provider", + }), + ); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000001205", result: "Finished." }), + ); + yield* Queue.take(harness.terminalReceipts); + assert.equal( + workflowCoordinatorEvents(harness.events).at(-1)?.subagent.status, + "completed", + ); + assert.equal( + workflowMemberEvents(harness.events, 1).at(-1)?.subagent.status, + "completed", + ); + assert.isFalse(yield* harness.hasPendingBackgroundWork); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + + it.effect("scopes workflow member artifacts to the provider thread", () => + Effect.scoped( + Effect.gen(function* () { + const memberArtifacts: Array> = []; + const memberThreads: Array = []; + for (const nativeSessionId of ["workflow-session-a", "workflow-session-b"]) { + const harness = yield* makeWorkflowHarnessWithOptions({ nativeSessionId }); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make(`attempt-${nativeSessionId}`), + text: "Run the workflow.", + attachments: [], + }), + ); + for (const frame of [ + workflowToolUse, + workflowTaskStarted, + workflowLaunchAck(harness.transcriptDir), + workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000001203", state: "done" }), + ]) { + yield* harness.offerAndWait(claudeSdkFrame({ ...frame, session_id: nativeSessionId })); + } + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + ...makeResultFrame({ uuid: "00000000-0000-4000-8000-000000001204", result: "Done." }), + session_id: nativeSessionId, + }), + ); + yield* Queue.take(harness.terminalReceipts); + const member = workflowMemberEvents(harness.events, 1).at(-1)?.subagent; + assert.isDefined(member); + memberThreads.push(member?.childThreadId); + const ids = harness.events.flatMap((event) => { + if (event.type === "subagent.updated" && event.subagent.id === member?.id) + return [event.subagent.id]; + if (event.type === "node.updated" && event.node.threadId === member?.childThreadId) + return [event.node.id]; + if ( + event.type === "message.updated" && + event.message.threadId === member?.childThreadId + ) + return [event.message.id]; + if ( + event.type === "turn_item.updated" && + event.turnItem.threadId === member?.childThreadId + ) + return [event.turnItem.id]; + return []; + }); + assert.isAtLeast(new Set(ids).size, 6); + memberArtifacts.push(ids); + } + assert.notEqual(memberThreads[0], memberThreads[1]); + assert.deepEqual( + memberArtifacts[0]?.filter((id) => memberArtifacts[1]?.includes(id)), + [], + ); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + + it.effect("stops only a live workflow task and waits for its stopped notification", () => + Effect.scoped( + Effect.gen(function* () { + const stoppedTasks: Array = []; + let interrupted = false; + let closed = false; + const harness = yield* makeWorkflowHarnessWithOptions({ + stopTask: (taskId) => + Effect.sync(() => { + stoppedTasks.push(taskId); + }), + interrupt: Effect.sync(() => { + interrupted = true; + }), + close: () => + Effect.sync(() => { + closed = true; + }), + }); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-workflow-stop"), + text: "Run the workflow.", + attachments: [], + }), + ); + yield* harness.offerAndWait(workflowToolUse); + yield* harness.offerAndWait(workflowTaskStarted); + yield* harness.offerAndWait(workflowLaunchAck(harness.transcriptDir)); + yield* harness.offerAndWait( + workflowSnapshot({ + uuid: "00000000-0000-4000-8000-000000001100", + state: "start", + }), + ); + let member = yield* Queue.take(harness.subagentReceipts); + while (!member.subagent.nativeTaskRef?.nativeId?.endsWith(":agent:2")) { + member = yield* Queue.take(harness.subagentReceipts); + } + const stopTask = harness.runtime.stopTask!; + for (const taskId of ["missing-task", `${WORKFLOW_TASK_ID}:agent:1`]) { + const exit = yield* stopTask({ providerThread: harness.providerThread, taskId }).pipe( + Effect.exit, + ); + assert.isTrue(Exit.isFailure(exit)); + } + const wrongThread = { + ...harness.providerThread, + nativeThreadRef: { + driver: harness.providerThread.driver, + nativeId: "wrong-session", + strength: "strong" as const, + }, + }; + assert.isTrue( + Exit.isFailure( + yield* stopTask({ + providerThread: wrongThread, + taskId: WORKFLOW_TASK_ID, + }).pipe(Effect.exit), + ), + ); + assert.deepEqual(stoppedTasks, []); + yield* stopTask({ providerThread: harness.providerThread, taskId: WORKFLOW_TASK_ID }); + assert.deepEqual(stoppedTasks, [WORKFLOW_TASK_ID]); + assert.isFalse(interrupted); + assert.isFalse(closed); + assert.equal(workflowCoordinatorEvents(harness.events).at(-1)?.subagent.status, "running"); + yield* harness.offerAndWait( + workflowNotification({ + uuid: "00000000-0000-4000-8000-000000001101", + status: "stopped", + }), + ); + member = yield* Queue.take(harness.subagentReceipts); + while (!member.subagent.nativeTaskRef?.nativeId?.endsWith(":agent:2")) { + member = yield* Queue.take(harness.subagentReceipts); + } + assert.equal( + workflowCoordinatorEvents(harness.events).at(-1)?.subagent.status, + "cancelled", + ); + for (const index of [1, 2]) { + assert.equal( + workflowMemberEvents(harness.events, index).at(-1)?.subagent.status, + "cancelled", + ); + } + assert.isTrue( + Exit.isFailure( + yield* stopTask({ + providerThread: harness.providerThread, + taskId: WORKFLOW_TASK_ID, + }).pipe(Effect.exit), + ), + ); + assert.deepEqual(stoppedTasks, [WORKFLOW_TASK_ID]); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + + it.effect("projects each workflow member as its own subagent thread", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWorkflowHarness; + const now = yield* DateTime.now; + const coordinatorTurnItems = () => + harness.events.filter( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "subagent" && + event.turnItem.nativeItemRef?.nativeId === WORKFLOW_TASK_ID, + ); + + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-workflow-members"), + text: "Run the alpha workflow.", + attachments: [], + }), + ); + yield* launchWorkflow({ + harness, + snapshotUuid: "00000000-0000-4000-8000-000000001004", + state: "queued", + }); + + const coordinator = workflowCoordinatorEvents(harness.events).at(-1)?.subagent; + const first = workflowMemberEvents(harness.events, 1).at(-1)?.subagent; + const second = workflowMemberEvents(harness.events, 2).at(-1)?.subagent; + assert.equal(first?.status, "pending"); + assert.isNull(first?.startedAt); + assert.equal(second?.status, "pending"); + assert.isNull(second?.startedAt); + const coordinatorThread = harness.events.find( + (event) => + event.type === "app_thread.created" && + event.appThread.id === coordinator?.childThreadId, + ); + assert.equal( + coordinatorThread?.type === "app_thread.created" + ? coordinatorThread.appThread.title + : null, + "Workflow · probe-wf", + ); + assert.equal(first?.title, "alpha:one"); + assert.equal(first?.prompt, "Reply with exactly: A1"); + assert.equal(first?.model, "claude-opus-5[1m]"); + // Members hang off the coordinator's own thread, not the launching run's. + assert.equal(first?.threadId, coordinator?.childThreadId); + assert.equal(second?.threadId, coordinator?.childThreadId); + assert.notEqual(first?.childThreadId, second?.childThreadId); + + assert.equal( + harness.events.find( + ( + event, + ): event is Extract< + ProviderAdapter.ProviderAdapterV2Event, + { type: "app_thread.created" } + > => event.type === "app_thread.created" && event.appThread.id === first?.childThreadId, + )?.appThread.title, + "alpha:one", + ); + const memberNodes = harness.events.filter( + ( + event, + ): event is Extract => + event.type === "node.updated" && + event.node.nativeItemRef?.nativeId === first?.nativeTaskRef?.nativeId, + ); + assert.deepEqual( + memberNodes.map((event) => event.node.kind), + ["subagent", "root_turn"], + ); + assert.deepEqual( + memberNodes.map((event) => [event.node.status, event.node.startedAt]), + [ + ["pending", null], + ["pending", null], + ], + ); + assert.deepEqual( + threadMessages(harness.events, first?.childThreadId).map((event) => event.message.text), + ["Reply with exactly: A1"], + ); + assert.deepEqual( + threadMessages(harness.events, second?.childThreadId).map((event) => event.message.text), + ["Reply with exactly: A2"], + ); + + yield* TestClock.adjust("60 seconds"); + const memberStartedAt = DateTime.add(now, { seconds: 30 }); + yield* harness.offerAndWait( + workflowSnapshot({ + uuid: "00000000-0000-4000-8000-000000001007", + state: "start", + secondState: "queued", + startedAt: DateTime.toEpochMillis(memberStartedAt), + }), + ); + let started = yield* Queue.take(harness.subagentReceipts); + while (started.subagent.id !== first?.id || started.subagent.status !== "running") { + started = yield* Queue.take(harness.subagentReceipts); + } + assert.deepEqual(started.subagent.startedAt, memberStartedAt); + const startedRoot = harness.events.findLast( + (event) => event.type === "node.updated" && event.node.threadId === first?.childThreadId, + ); + assert.equal( + startedRoot?.type === "node.updated" ? startedRoot.node.status : null, + "running", + ); + assert.deepEqual( + startedRoot?.type === "node.updated" ? startedRoot.node.startedAt : null, + memberStartedAt, + ); + + const turnItemsBeforeRepeat = coordinatorTurnItems().length; + yield* Queue.offer( + harness.sdkMessages, + workflowSnapshot({ + uuid: "00000000-0000-4000-8000-000000001005", + state: "start", + secondState: "queued", + }), + ); + // The coordinator's turn item is emitted after its members are + // projected, so a re-emitted member would already be in `events`. + yield* awaitUntil( + () => coordinatorTurnItems().length > turnItemsBeforeRepeat, + "repeated workflow snapshot", + ); + assert.lengthOf(workflowMemberEvents(harness.events, 1), 2); + assert.lengthOf(workflowMemberEvents(harness.events, 2), 1); + + const firstThreadId = first?.childThreadId; + const firstThreadEvents = () => + harness.events.filter( + ( + event, + ): event is Extract< + ProviderAdapter.ProviderAdapterV2Event, + { type: "app_thread.created" } + > => event.type === "app_thread.created" && event.appThread.id === firstThreadId, + ); + const turnItemsBeforeMetadata = coordinatorTurnItems().length; + yield* Queue.offer( + harness.sdkMessages, + workflowSnapshot({ + uuid: "00000000-0000-4000-8000-000000001017", + state: "start", + secondState: "queued", + label: "alpha:review", + prompt: "Review the change", + model: "claude-sonnet-4-6", + }), + ); + yield* awaitUntil( + () => coordinatorTurnItems().length > turnItemsBeforeMetadata, + "updated workflow member metadata", + ); + assert.equal( + workflowMemberEvents(harness.events, 1).at(-1)?.subagent.title, + "alpha:review", + ); + assert.equal( + workflowMemberEvents(harness.events, 1).at(-1)?.subagent.prompt, + "Review the change", + ); + assert.equal( + workflowMemberEvents(harness.events, 1).at(-1)?.subagent.model, + "claude-sonnet-4-6", + ); + assert.equal(firstThreadEvents().at(-1)?.appThread.title, "alpha:review"); + assert.equal( + firstThreadEvents().at(-1)?.appThread.modelSelection.model, + "claude-sonnet-4-6", + ); + assert.deepEqual( + firstThreadEvents().at(-1)?.appThread.createdAt, + firstThreadEvents()[0]?.appThread.createdAt, + ); + assert.equal( + threadMessages(harness.events, firstThreadId).at(-1)?.message.text, + "Review the change", + ); + + yield* TestClock.adjust("10 seconds"); + yield* harness.offerAndWait( + workflowSnapshot({ + uuid: "00000000-0000-4000-8000-000000001018", + state: "done", + secondState: "queued", + omitResult: true, + }), + ); + let finished = yield* Queue.take(harness.subagentReceipts); + while (finished.subagent.id !== first?.id || finished.subagent.status !== "completed") { + finished = yield* Queue.take(harness.subagentReceipts); + } + const completedAt = finished.subagent.completedAt; + assert.deepEqual(completedAt, DateTime.add(now, { seconds: 70 })); + yield* TestClock.adjust("60 seconds"); + yield* harness.offerAndWait( + workflowSnapshot({ + uuid: "00000000-0000-4000-8000-000000001019", + state: "done", + secondState: "queued", + }), + ); + let excerpt = yield* Queue.take(harness.subagentReceipts); + while (excerpt.subagent.id !== first?.id || excerpt.subagent.result !== "A1 excerpt") { + excerpt = yield* Queue.take(harness.subagentReceipts); + } + assert.deepEqual(excerpt.subagent.completedAt, completedAt); + const finishedRoot = harness.events.findLast( + (event) => event.type === "node.updated" && event.node.threadId === firstThreadId, + ); + assert.deepEqual( + finishedRoot?.type === "node.updated" ? finishedRoot.node.completedAt : undefined, + completedAt, + ); + + yield* harness.offerAndWait( + workflowNotification({ uuid: "00000000-0000-4000-8000-000000001008", status: "stopped" }), + ); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000001006", + result: "Launched the workflow.", + }), + ); + yield* Queue.take(harness.terminalReceipts); + const stopped = workflowMemberEvents(harness.events, 2).at(-1)?.subagent; + assert.equal(stopped?.status, "cancelled"); + assert.isNull(stopped?.startedAt); + const stoppedRoot = harness.events.findLast( + (event) => event.type === "node.updated" && event.node.threadId === second?.childThreadId, + ); + assert.equal( + stoppedRoot?.type === "node.updated" ? stoppedRoot.node.status : null, + "cancelled", + ); + assert.isNull( + stoppedRoot?.type === "node.updated" ? stoppedRoot.node.startedAt : undefined, + ); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + + it.effect("projects workflow member threads after resuming a restarted session", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWorkflowHarnessWithOptions({ + subagentLaunchToolUseId: WORKFLOW_TOOL_USE_ID, + }); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-claude-workflow-resume"), + text: "Continue the workflow.", + attachments: [], + }), + ); + yield* harness.offerAndWait( + claudeSdkFrame({ + ...workflowToolUse, + message: { + model: "claude-sonnet-4-6", + content: [ + { type: "tool_use", id: "toolu-resume-workflow", name: "SendMessage", input: {} }, + ], + }, + }), + ); + yield* harness.offerAndWait( + claudeSdkFrame({ + ...workflowTaskStarted, + tool_use_id: "toolu-resume-workflow", + prompt: "Continue the workflow.", + }), + ); + yield* harness.offerAndWait(workflowLaunchAck(harness.transcriptDir)); + yield* harness.offerAndWait( + workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000001040", state: "start" }), + ); + yield* harness.offerAndWait( + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000001041", result: "Resumed." }), + ); + yield* Queue.take(harness.terminalReceipts); + const member = workflowMemberEvents(harness.events, 1).at(-1)?.subagent; + const coordinator = workflowCoordinatorEvents(harness.events).at(-1)?.subagent; + assert.isDefined(member); + assert.equal(member?.status, "running"); + assert.isTrue( + harness.events.some( + (event) => + event.type === "app_thread.created" && event.appThread.id === member?.childThreadId, + ), + ); + assert.deepEqual( + threadMessages(harness.events, member?.childThreadId).map((event) => event.message.text), + ["Reply with exactly: A1"], + ); + assert.isFalse( + harness.events.some( + (event) => + event.type === "app_thread.created" && + event.appThread.id === coordinator?.childThreadId, + ), + ); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + + it.effect("does not republish a workflow roster on progress-only frames", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWorkflowHarness; + const now = yield* DateTime.now; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-workflow-progress-only"), + text: "Run the alpha workflow.", + attachments: [], + }), + ); + yield* launchWorkflow({ harness, snapshotUuid: "00000000-0000-4000-8000-000000001120" }); + const coordinatorCount = () => workflowCoordinatorEvents(harness.events).length; + const offerProgressOnly = (index: number) => + Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "system", + subtype: "task_progress", + task_id: WORKFLOW_TASK_ID, + tool_use_id: WORKFLOW_TOOL_USE_ID, + description: "Member is working", + uuid: `00000000-0000-4000-8000-${String(1200 + index).padStart(12, "0")}`, + session_id: WAKE_NATIVE_SESSION, + }), + ); + + const inTurnCount = coordinatorCount(); + for (let index = 0; index < 5; index++) yield* offerProgressOnly(index); + yield* Queue.offer( + harness.sdkMessages, + workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000001121", state: "start" }), + ); + yield* awaitUntil(() => coordinatorCount() > inTurnCount, "in-turn workflow snapshot"); + assert.equal(coordinatorCount(), inTurnCount + 1); + + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000001122", + result: "Launched the workflow.", + }), + ); + yield* Queue.take(harness.terminalReceipts); + + const betweenTurnCount = coordinatorCount(); + for (let index = 5; index < 10; index++) yield* offerProgressOnly(index); + yield* Queue.offer( + harness.sdkMessages, + workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000001123", state: "done" }), + ); + yield* awaitUntil( + () => coordinatorCount() > betweenTurnCount, + "between-turn workflow snapshot", + ); + assert.equal(coordinatorCount(), betweenTurnCount + 1); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + + it.effect("keeps resumed workflow tools open after stale interrupted cleanup", () => + Effect.scoped( + Effect.gen(function* () { + const clock = yield* Clock.Clock; + const cleanupPaused = yield* Deferred.make(); + const releaseCleanup = yield* Deferred.make(); + let readsUntilPause = 0; + yield* Effect.gen(function* () { + const harness = yield* makeWorkflowHarnessWithOptions({ + close: Queue.shutdown, + freshQueueOnReopen: true, + }); + const oldAttempt = RunAttemptId.make("attempt-workflow-stale-cleanup-old"); + const oldBash = "toolu-workflow-stale-cleanup-old-bash"; + const newBash = "toolu-workflow-stale-cleanup-new-bash"; + const resumeTool = "toolu-workflow-stale-cleanup-resume"; + const toolUpdates = (nativeId: string) => + harness.events.flatMap((event) => + event.type === "turn_item.updated" && + event.turnItem.type === "command_execution" && + event.turnItem.nativeItemRef?.nativeId === nativeId + ? [event.turnItem] + : [], + ); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: oldAttempt, + text: "Run the workflow.", + attachments: [], + }), + ); + yield* launchWorkflow({ harness, snapshotUuid: "00000000-0000-4000-8000-000000001150" }); + for (const frame of makeSubagentAssistantFrames({ + parentToolUseId: WORKFLOW_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000001151", + bashToolUseId: oldBash, + })) { + yield* harness.offerAndWait(frame); + } + yield* awaitUntil(() => toolUpdates(oldBash).length === 1, "old workflow tool running"); + readsUntilPause = 2; + const stop = yield* harness.runtime + .interruptTurn({ + providerThread: harness.providerThread, + providerTurnId: (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({ + driver: ClaudeAdapterV2.CLAUDE_PROVIDER, + nativeTurnId: `turn:${oldAttempt}`, + }), + }) + .pipe(Effect.forkScoped); + yield* Deferred.await(cleanupPaused); + yield* TestClock.adjust("10 seconds"); + yield* Fiber.join(stop); + yield* Queue.take(harness.terminalReceipts); + const oldToolCount = toolUpdates(oldBash).length; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-workflow-stale-cleanup-new"), + text: "Resume the workflow.", + attachments: [], + providerTurnOrdinal: 2, + }), + ); + const resumedQueue = harness.processQueues[1]!; + yield* Queue.offer( + resumedQueue, + claudeSdkFrame({ + ...workflowTaskStarted, + tool_use_id: resumeTool, + prompt: "Resume the workflow.", + uuid: "00000000-0000-4000-8000-000000001152", + }), + ); + for (const frame of makeSubagentAssistantFrames({ + parentToolUseId: resumeTool, + uuid: "00000000-0000-4000-8000-000000001153", + bashToolUseId: newBash, + })) { + yield* Queue.offer(resumedQueue, frame); + } + yield* awaitUntil( + () => toolUpdates(newBash).length === 1, + "resumed workflow tool running", + ); + yield* Deferred.succeed(releaseCleanup, undefined); + yield* Queue.take(harness.terminalReceipts); + assert.lengthOf(toolUpdates(oldBash), oldToolCount); + assert.deepEqual( + toolUpdates(newBash).map((item) => item.status), + ["running"], + ); + yield* Queue.offer( + resumedQueue, + makeSubagentToolResultFrame({ + parentToolUseId: resumeTool, + uuid: "00000000-0000-4000-8000-000000001154", + toolUseId: newBash, + }), + ); + yield* awaitUntil( + () => toolUpdates(newBash).at(-1)?.status === "completed", + "resumed workflow tool completed", + ); + assert.equal(toolUpdates(newBash).at(-1)?.output, "ok"); + }).pipe( + Effect.provideService(Clock.Clock, { + ...clock, + currentTimeMillis: Effect.gen(function* () { + if (readsUntilPause > 0 && --readsUntilPause === 0) { + yield* Deferred.succeed(cleanupPaused, undefined); + yield* Deferred.await(releaseCleanup); + } + return yield* clock.currentTimeMillis; + }), + }), + ); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + + it.effect.each(["root result", "query close"] as const)( + "keeps a waiting continuation inactive until replay can handle %s", + (arrival) => + Effect.scoped( + Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const digestStarted = yield* Deferred.make(); + const releaseDigest = yield* Deferred.make(); + const closeStarted = yield* Deferred.make(); + let pauseDigest = false; + yield* Effect.gen(function* () { + const harness = yield* makeWorkflowHarnessWithOptions({ + freshQueueOnReopen: true, + close: (messages) => + Deferred.succeed(closeStarted, undefined).pipe( + Effect.andThen(Queue.shutdown(messages)), + ), + }); + const start = (attemptId: string, ordinal: number, continuation: boolean) => + harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: DateTime.makeUnsafe(0), + attemptId: RunAttemptId.make(attemptId), + providerTurnOrdinal: ordinal, + text: "Continue working.", + attachments: [], + ...(continuation + ? { messageCreatedBy: "agent", messageCreationSource: "provider" } + : {}), + }), + ); + yield* start("attempt-workflow-waiting-launch", 1, false); + yield* launchWorkflow({ + harness, + snapshotUuid: "00000000-0000-4000-8000-000000003200", + }); + const fileSystem = yield* FileSystem.FileSystem; + const liveTranscriptDir = NodePath.join(harness.transcriptDir, "live"); + yield* fileSystem.makeDirectory(liveTranscriptDir); + yield* fileSystem.writeFileString( + NodePath.join(liveTranscriptDir, "agent-a1.jsonl"), + '{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Live member answer"}]}}\n', + ); + const liveTaskId = "task-workflow-live-while-waiting"; + const liveToolUseId = "toolu-workflow-live-while-waiting"; + yield* harness.offerAndWait( + claudeSdkFrame({ + type: "assistant", + message: { + model: "claude-sonnet-4-6", + content: [{ type: "tool_use", id: liveToolUseId, name: "Workflow", input: {} }], + }, + parent_tool_use_id: null, + session_id: WAKE_NATIVE_SESSION, + uuid: "00000000-0000-4000-8000-000000003201", + }), + ); + yield* harness.offerAndWait( + claudeSdkFrame({ + ...workflowTaskStarted, + task_id: liveTaskId, + tool_use_id: liveToolUseId, + uuid: "00000000-0000-4000-8000-000000003202", + }), + ); + yield* harness.offerAndWait( + claudeSdkFrame({ + type: "user", + message: { + role: "user", + content: [ + { type: "tool_result", tool_use_id: liveToolUseId, content: "Launched." }, + ], + }, + tool_use_result: { + taskType: "local_workflow", + runId: "wf_live_while_waiting", + transcriptDir: liveTranscriptDir, + }, + parent_tool_use_id: null, + session_id: WAKE_NATIVE_SESSION, + uuid: "00000000-0000-4000-8000-000000003203", + }), + ); + const liveProgress = (state: "start" | "done", uuid: string) => + claudeSdkFrame({ + ...workflowSnapshot({ state, uuid }), + task_id: liveTaskId, + tool_use_id: liveToolUseId, + }); + yield* harness.offerAndWait( + liveProgress("start", "00000000-0000-4000-8000-000000003204"), + ); + yield* harness.offerAndWait( + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000003205", + result: "Launched both workflows.", + }), + ); + yield* Queue.take(harness.terminalReceipts); + yield* harness.offerAndWait( + workflowNotification({ + uuid: "00000000-0000-4000-8000-000000003206", + status: "completed", + }), + ); + yield* Queue.take(harness.continuationReceipts); + yield* harness.offerAndWait(wakeTurnInit); + pauseDigest = true; + const progress = yield* harness + .offerAndWait(liveProgress("done", "00000000-0000-4000-8000-000000003207")) + .pipe(Effect.forkScoped); + yield* Deferred.await(digestStarted); + const replay = yield* start("attempt-workflow-waiting-replay", 2, true).pipe( + Effect.forkScoped({ startImmediately: true }), + ); + if (arrival === "query close") { + const stop = yield* harness.runtime + .interruptTurn({ + providerThread: harness.providerThread, + providerTurnId: (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({ + driver: ClaudeAdapterV2.CLAUDE_PROVIDER, + nativeTurnId: "turn:attempt-workflow-waiting-replay", + }), + requestRuntimeRestart: true, + }) + .pipe(Effect.forkScoped); + yield* Deferred.await(closeStarted); + yield* Deferred.succeed(releaseDigest, undefined); + yield* Fiber.join(progress); + yield* Fiber.join(stop); + assert.isTrue(Exit.isFailure(yield* Fiber.await(replay))); + } else { + const offered = yield* Deferred.make(); + const result = yield* harness + .offerAndWait(wakeResult, offered) + .pipe(Effect.forkScoped); + yield* Deferred.await(offered); + yield* Deferred.succeed(releaseDigest, undefined); + yield* Fiber.join(progress); + yield* Fiber.join(replay); + yield* Fiber.join(result); + yield* Queue.take(harness.terminalReceipts); + assert.lengthOf(harness.continuationRequests, 1); + } + yield* start("attempt-workflow-waiting-next-user", 3, false); + }).pipe( + Effect.provideService(Crypto.Crypto, { + ...crypto, + digest: (algorithm, data) => + Effect.gen(function* () { + if (pauseDigest) { + pauseDigest = false; + yield* Deferred.succeed(digestStarted, undefined); + yield* Deferred.await(releaseDigest); + } + return yield* crypto.digest(algorithm, data); + }), + }), + ); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + + it.effect.each(["registry", "transcript"] as const)( + "does not replay stale idle progress over a completed workflow after awaiting %s", + (boundary) => + Effect.scoped( + Effect.gen(function* () { + const clock = yield* Clock.Clock; + const crypto = yield* Crypto.Crypto; + const progressRead = yield* Deferred.make(); + const releaseProgress = yield* Deferred.make(); + let pauseNextRead = false; + yield* Effect.gen(function* () { + const harness = yield* makeWorkflowHarness; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-claude-idle-race-launch"), + text: "Run the workflow.", + attachments: [], + }), + ); + yield* launchWorkflow({ + harness, + snapshotUuid: "00000000-0000-4000-8000-000000001060", + }); + yield* harness.offerAndWait( + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000001061", + result: "Launched.", + }), + ); + yield* Queue.take(harness.terminalReceipts); + yield* harness.offerAndWait( + workflowNotification({ + uuid: "00000000-0000-4000-8000-000000001062", + status: "completed", + }), + ); + if (boundary === "registry") { + yield* harness.offerAndWait( + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000001063", result: "Done." }), + ); + } + const continuationInput = makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-claude-idle-race-continuation"), + text: "Continue.", + attachments: [], + providerTurnOrdinal: 2, + messageCreatedBy: "agent", + messageCreationSource: "provider", + }); + pauseNextRead = true; + const progress = yield* harness + .offerAndWait( + workflowSnapshot({ + uuid: "00000000-0000-4000-8000-000000001064", + state: boundary === "transcript" ? "done" : "start", + secondState: "start", + }), + ) + .pipe(Effect.forkScoped); + yield* Deferred.await(progressRead); + const continuation = yield* harness.runtime + .startTurn(continuationInput) + .pipe(Effect.forkScoped); + yield* Deferred.succeed(releaseProgress, undefined); + yield* Fiber.join(progress); + yield* Fiber.join(continuation); + if (boundary === "registry") { + yield* Queue.take(harness.terminalReceipts); + } else { + const lastMemberId = workflowMemberEvents(harness.events, 2)[0]?.subagent.id; + while (true) { + const event = yield* Queue.take(harness.subagentReceipts); + if (event.subagent.id === lastMemberId && event.subagent.status === "completed") + break; + } + } + if (boundary === "transcript") { + yield* harness.offerAndWait( + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000001065", result: "Done." }), + ); + yield* Queue.take(harness.terminalReceipts); + } + assert.isFalse(yield* harness.hasPendingBackgroundWork); + assert.equal( + workflowCoordinatorEvents(harness.events).at(-1)?.subagent.status, + "completed", + ); + const member = workflowMemberEvents(harness.events, 2).at(-1)?.subagent; + assert.equal(member?.status, "completed"); + const root = harness.events.findLast( + (event) => + event.type === "node.updated" && event.node.threadId === member?.childThreadId, + ); + assert.equal(root?.type === "node.updated" ? root.node.status : null, "completed"); + const item = harness.events.findLast( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "subagent" && + event.turnItem.nativeItemRef?.nativeId === WORKFLOW_TASK_ID, + ); + assert.equal( + item?.type === "turn_item.updated" ? item.turnItem.status : null, + "completed", + ); + }).pipe( + Effect.provideService(Clock.Clock, { + ...clock, + currentTimeMillis: Effect.gen(function* () { + if (boundary === "registry" && pauseNextRead) { + pauseNextRead = false; + yield* Deferred.succeed(progressRead, undefined); + yield* Deferred.await(releaseProgress); + } + return yield* clock.currentTimeMillis; + }), + }), + Effect.provideService(Crypto.Crypto, { + ...crypto, + digest: (algorithm, data) => + Effect.gen(function* () { + if (boundary === "transcript" && pauseNextRead) { + pauseNextRead = false; + yield* Deferred.succeed(progressRead, undefined); + yield* Deferred.await(releaseProgress); + } + return yield* crypto.digest(algorithm, data); + }), + }), + ); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + + it.effect.each([ + "root result", + "resume after root result", + "completion before buffered start", + ] as const)("orders %s arriving during workflow transcript replay", (arrival) => + Effect.scoped( + Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const digestStarted = yield* Deferred.make(); + const releaseDigest = yield* Deferred.make(); + const liveFrameOffered = yield* Deferred.make(); + let pauseDigest = false; + yield* Effect.gen(function* () { + const harness = yield* makeWorkflowHarnessWithOptions({ close: Queue.shutdown }); + const taskId = "task-resumed-during-workflow-replay"; + const taskStart = (toolUseId: string, prompt: string, uuid: string) => + claudeSdkFrame({ + ...workflowTaskStarted, + task_id: taskId, + task_type: "local_agent", + is_backgrounded: true, + subagent_type: "general-purpose", + spawn_depth: 1, + workflow_name: undefined, + tool_use_id: toolUseId, + prompt, + uuid, + }); + const taskFinished = (toolUseId: string, summary: string, uuid: string) => + claudeSdkFrame({ + ...workflowNotification({ uuid, status: "completed" }), + task_id: taskId, + tool_use_id: toolUseId, + summary, + }); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-workflow-replay-launch"), + text: "Run the workflow and a background subagent.", + attachments: [], + }), + ); + yield* harness.offerAndWait(workflowToolUse); + yield* harness.offerAndWait(workflowTaskStarted); + yield* harness.offerAndWait(workflowLaunchAck(harness.transcriptDir)); + yield* harness.offerAndWait( + workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000003001", state: "start" }), + ); + if (arrival !== "completion before buffered start") { + yield* harness.offerAndWait( + taskStart("toolu-initial", "Initial work.", "00000000-0000-4000-8000-000000003002"), + ); + } + yield* harness.offerAndWait( + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000003003", + result: "Launched both tasks.", + }), + ); + yield* Queue.take(harness.terminalReceipts); + yield* harness.offerAndWait( + workflowNotification({ + uuid: "00000000-0000-4000-8000-000000003004", + status: "completed", + }), + ); + yield* Queue.take(harness.continuationReceipts); + if (arrival === "completion before buffered start") { + yield* harness.offerAndWait( + taskStart("toolu-initial", "Initial work.", "00000000-0000-4000-8000-000000003005"), + ); + } else { + yield* harness.offerAndWait( + taskFinished( + "toolu-initial", + "Initial result.", + "00000000-0000-4000-8000-000000003005", + ), + ); + } + if (arrival === "root result") { + yield* harness.offerAndWait( + claudeSdkFrame({ + type: "system", + subtype: "init", + session_id: WAKE_NATIVE_SESSION, + uuid: "00000000-0000-4000-8000-000000003006", + }), + ); + } + pauseDigest = true; + const replay = yield* harness.runtime + .startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-workflow-replay-continuation"), + providerTurnOrdinal: 2, + text: "Continue.", + attachments: [], + messageCreatedBy: "agent", + messageCreationSource: "provider", + }), + ) + .pipe(Effect.forkScoped); + yield* Deferred.await(digestStarted); + const liveFrames = yield* Effect.gen(function* () { + if (arrival === "completion before buffered start") { + yield* harness.offerAndWait( + taskFinished( + "toolu-initial", + "Initial result.", + "00000000-0000-4000-8000-000000003007", + ), + liveFrameOffered, + ); + return; + } + yield* harness.offerAndWait( + claudeSdkFrame({ + ...makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000003008", + result: "Native wake turn finished.", + }), + origin: { kind: "task-notification" }, + num_turns: 1, + }), + liveFrameOffered, + ); + if (arrival === "resume after root result") { + yield* harness.offerAndWait( + claudeSdkFrame({ + type: "system", + subtype: "init", + session_id: WAKE_NATIVE_SESSION, + uuid: "00000000-0000-4000-8000-000000003009", + }), + ); + yield* harness.offerAndWait( + taskStart( + "toolu-latest", + "Latest live resume.", + "00000000-0000-4000-8000-000000003010", + ), + ); + } + }).pipe(Effect.forkScoped); + yield* Deferred.await(liveFrameOffered); + yield* Deferred.succeed(releaseDigest, undefined); + yield* Fiber.join(replay); + yield* Fiber.join(liveFrames); + if (arrival === "resume after root result") { + yield* Queue.take(harness.terminalReceipts); + yield* Queue.take(harness.continuationReceipts); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-workflow-replay-resume-continuation"), + providerTurnOrdinal: 3, + text: "Continue.", + attachments: [], + messageCreatedBy: "agent", + messageCreationSource: "provider", + }), + ); + } + if (arrival !== "root result") { + yield* harness.offerAndWait( + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000003011", + result: "Workflow answer delivered.", + }), + ); + } + yield* Queue.take(harness.terminalReceipts); + const latest = harness.events + .flatMap((event) => + event.type === "subagent.updated" && event.subagent.nativeTaskRef?.nativeId === taskId + ? [event.subagent] + : [], + ) + .at(-1); + if (arrival === "resume after root result") { + assert.equal(latest?.status, "running"); + assert.equal(latest?.prompt, "Latest live resume."); + assert.isNull(latest?.result); + assert.isTrue(yield* harness.hasPendingBackgroundWork); + } else { + assert.equal(latest?.status, "completed"); + assert.equal(latest?.result, "Initial result."); + assert.isFalse(yield* harness.hasPendingBackgroundWork); + assert.lengthOf(harness.continuationRequests, 1); + // The native wake turn ended once; no extra provider result will + // arrive to settle a mistakenly opened continuation. + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-workflow-replay-next-user"), + providerTurnOrdinal: 3, + text: "Next task.", + attachments: [], + }), + ); + } + }).pipe( + Effect.provideService(Crypto.Crypto, { + ...crypto, + digest: (algorithm, data) => + Effect.gen(function* () { + if (pauseDigest) { + pauseDigest = false; + yield* Deferred.succeed(digestStarted, undefined); + yield* Deferred.await(releaseDigest); + } + return yield* crypto.digest(algorithm, data); + }), + }), + ); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + + it.effect("preserves buffered workflow outcomes when interrupted during replay", () => + Effect.scoped( + Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const digestStarted = yield* Deferred.make(); + const releaseDigest = yield* Deferred.make(); + let pauseDigest = false; + yield* Effect.gen(function* () { + const harness = yield* makeWorkflowHarnessWithOptions({ close: Queue.shutdown }); + const secondTaskId = "task-workflow-buffered-completion"; + const secondToolUseId = "toolu-workflow-buffered-completion"; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-workflow-buffered-launch"), + text: "Run both workflows.", + attachments: [], + }), + ); + yield* launchWorkflow({ + harness, + snapshotUuid: "00000000-0000-4000-8000-000000003100", + }); + yield* harness.offerAndWait( + claudeSdkFrame({ + ...workflowTaskStarted, + task_id: secondTaskId, + tool_use_id: secondToolUseId, + uuid: "00000000-0000-4000-8000-000000003101", + }), + ); + yield* harness.offerAndWait( + claudeSdkFrame({ + ...workflowSnapshot({ + uuid: "00000000-0000-4000-8000-000000003102", + state: "start", + }), + task_id: secondTaskId, + tool_use_id: secondToolUseId, + }), + ); + yield* harness.offerAndWait( + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000003103", + result: "Launched both workflows.", + }), + ); + yield* Queue.take(harness.terminalReceipts); + yield* harness.offerAndWait( + workflowNotification({ + uuid: "00000000-0000-4000-8000-000000003104", + status: "completed", + }), + ); + yield* Queue.take(harness.continuationReceipts); + yield* harness.offerAndWait( + claudeSdkFrame({ + ...workflowNotification({ + uuid: "00000000-0000-4000-8000-000000003105", + status: "completed", + }), + task_id: secondTaskId, + tool_use_id: secondToolUseId, + }), + ); + const continuationInput = makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-workflow-buffered-replay"), + providerTurnOrdinal: 2, + text: "Continue.", + attachments: [], + messageCreatedBy: "agent", + messageCreationSource: "provider", + }); + pauseDigest = true; + const replay = yield* harness.runtime + .startTurn(continuationInput) + .pipe(Effect.forkScoped); + yield* Deferred.await(digestStarted); + yield* harness.runtime.interruptTurn({ + providerThread: harness.providerThread, + providerTurnId: (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({ + driver: ClaudeAdapterV2.CLAUDE_PROVIDER, + nativeTurnId: `turn:${continuationInput.attemptId}`, + }), + }); + yield* Deferred.succeed(releaseDigest, undefined); + yield* Fiber.join(replay); + const second = harness.events + .flatMap((event) => + event.type === "subagent.updated" && + event.subagent.nativeTaskRef?.nativeId === secondTaskId + ? [event.subagent] + : [], + ) + .at(-1); + assert.equal(second?.status, "completed"); + assert.equal(second?.result, "Workflow finished."); + }).pipe( + Effect.provideService(Crypto.Crypto, { + ...crypto, + digest: (algorithm, data) => + Effect.gen(function* () { + if (pauseDigest) { + pauseDigest = false; + yield* Deferred.succeed(digestStarted, undefined); + yield* Deferred.await(releaseDigest); + } + return yield* crypto.digest(algorithm, data); + }), + }), + ); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + + it.effect.each([ + ["api_error", false], + ["aborted_streaming", false], + ["api_error", true], + ] as const)( + "stops the run's live workflow after root result %s, continuation=%s", + ([terminalReason, continuation]) => + Effect.scoped( + Effect.gen(function* () { + const stoppedTasks: Array = []; + const harness = yield* makeWorkflowHarnessWithOptions({ + stopTask: (taskId) => Effect.sync(() => void stoppedTasks.push(taskId)), + }); + const now = yield* DateTime.now; + const turnInput = { + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-workflow-root-failure"), + text: "Run the workflow.", + attachments: [], + }; + yield* harness.runtime.startTurn(makeClaudeTestTurnInput(turnInput)); + yield* launchWorkflow({ harness, snapshotUuid: "00000000-0000-4000-8000-000000001050" }); + if (continuation) { + yield* harness.offerAndWait( + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000001051", + result: "Launched.", + }), + ); + yield* Queue.take(harness.terminalReceipts); + yield* harness.offerAndWait( + claudeSdkFrame({ + ...workflowTaskStarted, + uuid: "00000000-0000-4000-8000-000000001052", + }), + ); + } + yield* harness.offerAndWait( + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000001053", + result: "Root turn ended.", + terminalReason, + isError: terminalReason === "api_error", + }), + ); + if (continuation) { + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + ...turnInput, + attemptId: RunAttemptId.make("attempt-claude-workflow-root-failure-wake"), + providerTurnOrdinal: 2, + messageCreatedBy: "agent", + messageCreationSource: "provider", + }), + ); + } + const terminal = yield* Queue.take(harness.terminalReceipts); + assert.equal(terminal.status, terminalReason === "api_error" ? "failed" : "interrupted"); + assert.equal(terminal.threadDisposition, "reusable"); + // The run following the workflow (its launcher, or the run a resumed + // workflow was re-attributed to) stops following it, so it is stopped + // instead of left running out of sight. + const settled = terminalReason === "api_error" ? "failed" : "cancelled"; + assert.deepEqual(stoppedTasks, [WORKFLOW_TASK_ID]); + assert.isFalse(yield* harness.hasPendingBackgroundWork); + assert.equal(workflowCoordinatorEvents(harness.events).at(-1)?.subagent.status, settled); + for (const index of [1, 2]) { + assert.equal( + workflowMemberEvents(harness.events, index).at(-1)?.subagent.status, + settled, + ); + } + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + + it.effect.each([ + ["stopped", false, false, false], + ["failed", false, false, false], + ["completed", false, false, false], + ["stopped", true, false, false], + ["failed", true, false, false], + ["completed", true, false, false], + ["interrupted", false, false, false], + ["interrupted", true, false, false], + ["interrupt_timeout", true, false, false], + ["stop_buffered", true, false, false], + ["stop_buffered", true, false, "buffered"], + ["stop_buffered", true, false, "live"], + ["drain_buffered", true, false, "live"], + ["drain_buffered", true, false, "live_after_buffered"], + ["query_failed", false, false, false], + ["query_failed", true, false, false], + ["query_failed", true, false, "buffered"], + ["query_failed", true, true, false], + ] as const)( + "projects workflow members on %s, between turns=%s, after an unrelated failed turn=%s, resume=%s", + ([status, betweenTurns, afterFailedTurn, resumed]) => + Effect.scoped( + Effect.gen(function* () { + const interrupted = status === "interrupted" || status === "interrupt_timeout"; + const liveResume = resumed === "live" || resumed === "live_after_buffered"; + const expected = + status === "drain_buffered" + ? resumed === "live_after_buffered" + ? "completed" + : "running" + : status === "stopped" || interrupted || (status === "stop_buffered" && resumed) + ? "cancelled" + : status === "query_failed" + ? "failed" + : status === "stop_buffered" + ? "completed" + : status; + const harness = yield* makeWorkflowHarnessWithOptions({ + close: status === "interrupt_timeout" ? () => Effect.void : Queue.shutdown, + }); + const now = yield* DateTime.now; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-workflow-terminal"), + text: "Run the workflow.", + attachments: [], + }), + ); + yield* launchWorkflow({ harness, snapshotUuid: "00000000-0000-4000-8000-000000001010" }); + if (betweenTurns) { + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000001013", + result: "Launched.", + }), + ); + yield* Queue.take(harness.terminalReceipts); + if (afterFailedTurn || status === "interrupt_timeout") { + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-unrelated-failed"), + providerTurnOrdinal: 2, + text: "Unrelated follow-up.", + attachments: [], + }), + ); + if (afterFailedTurn) { + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000001015", + result: "Unrelated follow-up failed.", + terminalReason: "api_error", + isError: true, + }), + ); + yield* Queue.take(harness.terminalReceipts); + } + } + if (status === "query_failed") { + yield* harness.offerAndWait( + workflowSnapshot({ + uuid: "00000000-0000-4000-8000-000000001014", + state: "start", + label: "Updated between turns", + }), + ); + } + } + const bufferedCompletion = + status === "stop_buffered" || status === "drain_buffered" || resumed; + if ((!interrupted && status !== "query_failed") || resumed) + yield* Queue.offer( + harness.sdkMessages, + workflowNotification({ + uuid: "00000000-0000-4000-8000-000000001011", + status: bufferedCompletion ? "completed" : status, + }), + ); + if (betweenTurns && ((status !== "query_failed" && !interrupted) || resumed)) { + yield* Queue.take(harness.continuationReceipts); + } + if (resumed) { + if (resumed === "live_after_buffered") { + yield* harness.offerAndWait( + claudeSdkFrame({ + ...workflowTaskStarted, + tool_use_id: "toolu-workflow-earlier-resume", + prompt: "Earlier buffered resume.", + uuid: "00000000-0000-4000-8000-000000001019", + }), + ); + yield* harness.offerAndWait( + claudeSdkFrame({ + ...workflowNotification({ + uuid: "00000000-0000-4000-8000-000000001020", + status: "completed", + }), + tool_use_id: "toolu-workflow-earlier-resume", + }), + ); + } + if (liveResume) { + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-workflow-live-resume"), + providerTurnOrdinal: 2, + text: "Continue the workflow.", + attachments: [], + }), + ); + } + yield* harness.offerAndWait( + claudeSdkFrame({ + ...workflowTaskStarted, + tool_use_id: "toolu-workflow-resume", + prompt: "Continue the workflow.", + uuid: "00000000-0000-4000-8000-000000001016", + }), + ); + yield* harness.offerAndWait( + workflowSnapshot({ + uuid: "00000000-0000-4000-8000-000000001017", + state: "start", + attempt: 2, + label: "Updated between turns", + }), + ); + if (liveResume) { + if (resumed === "live_after_buffered") { + yield* harness.offerAndWait( + claudeSdkFrame({ + ...workflowNotification({ + uuid: "00000000-0000-4000-8000-000000001021", + status: "completed", + }), + tool_use_id: "toolu-workflow-resume", + summary: "Latest workflow result.", + }), + ); + } + yield* harness.offerAndWait( + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000001018", + result: "Resumed the workflow.", + }), + ); + yield* Queue.take(harness.terminalReceipts); + } + } + if (betweenTurns && status !== "query_failed" && !interrupted && !bufferedCompletion) { + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-workflow-terminal-wake"), + text: "Workflow finished.", + attachments: [], + providerTurnOrdinal: 2, + messageCreatedBy: "agent", + messageCreationSource: "provider", + }), + ); + } + if (interrupted || status === "stop_buffered") { + const interrupt = harness.runtime.interruptTurn({ + providerThread: harness.providerThread, + providerTurnId: (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({ + driver: ClaudeAdapterV2.CLAUDE_PROVIDER, + nativeTurnId: + status === "interrupt_timeout" + ? "turn:attempt-unrelated-failed" + : liveResume + ? "turn:attempt-workflow-live-resume" + : "turn:attempt-workflow-terminal", + }), + ...(betweenTurns ? { requestRuntimeRestart: true } : {}), + }); + if (status === "interrupt_timeout") { + const stop = yield* interrupt.pipe(Effect.forkScoped); + yield* TestClock.adjust("10 seconds"); + yield* Fiber.join(stop); + assert.equal((yield* Queue.take(harness.terminalReceipts)).status, "interrupted"); + assert.isFalse(yield* harness.hasPendingBackgroundWork); + } else { + yield* interrupt; + } + } else if (status === "query_failed") { + yield* Queue.shutdown(harness.sdkMessages); + } else if (status === "drain_buffered") { + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-workflow-terminal-wake"), + text: "Workflow finished.", + attachments: [], + providerTurnOrdinal: 3, + messageCreatedBy: "agent", + messageCreationSource: "provider", + }), + ); + assert.equal( + workflowCoordinatorEvents(harness.events).at(-1)?.subagent.status, + expected, + ); + } else { + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000001012", + result: "Finished.", + }), + ); + } + if ( + betweenTurns && + (status === "query_failed" || interrupted || status === "stop_buffered") + ) { + // The launching turn already ended. Await the last member's + // terminal event, which follows its root node update. + const lastMemberId = workflowMemberEvents(harness.events, 2)[0]?.subagent.id; + while (true) { + const update = yield* Queue.take(harness.subagentReceipts); + if (update.subagent.id === lastMemberId && update.subagent.status !== "running") + break; + } + assert.isFalse(yield* harness.hasPendingBackgroundWork); + if (status === "query_failed") { + assert.equal( + workflowMemberEvents(harness.events, 1).at(-1)?.subagent.title, + "Updated between turns", + ); + } + } else { + yield* Queue.take(harness.terminalReceipts); + } + assert.equal(workflowCoordinatorEvents(harness.events).at(-1)?.subagent.status, expected); + if (status === "drain_buffered") + assert.equal(yield* harness.hasPendingBackgroundWork, expected === "running"); + if (resumed === "live_after_buffered") { + assert.equal( + workflowCoordinatorEvents(harness.events).at(-1)?.subagent.result, + "Latest workflow result.", + ); + } + if (resumed) { + assert.notEqual( + workflowCoordinatorEvents(harness.events).at(-1)?.subagent.result, + "Workflow finished.", + ); + } + for (const index of [1, 2]) { + const updates = workflowMemberEvents(harness.events, index); + const member = updates.at(-1)?.subagent; + assert.equal(member?.status, expected); + assert.deepEqual(member?.startedAt, updates[0]?.subagent.startedAt); + const root = harness.events.findLast( + (event) => + event.type === "node.updated" && event.node.threadId === member?.childThreadId, + ); + assert.equal(root?.type === "node.updated" ? root.node.status : null, expected); + } + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + + it.effect("reads a transcript that appears after a terminal workflow notification", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWorkflowHarness; + const now = yield* DateTime.now; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-workflow-late-terminal-answer"), + text: "Run the alpha workflow.", + attachments: [], + }), + ); + yield* launchWorkflow({ harness, snapshotUuid: "00000000-0000-4000-8000-000000001030" }); + const secondThreadId = workflowMemberEvents(harness.events, 2)[0]?.subagent.childThreadId; + yield* Queue.offer( + harness.sdkMessages, + workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000001031", state: "done" }), + ); + yield* awaitUntil( + () => threadMessages(harness.events, secondThreadId).length === 2, + "workflow member excerpt", + ); + const notification = workflowNotification({ + uuid: "00000000-0000-4000-8000-000000001032", + status: "completed", + }); + yield* Queue.offer(harness.sdkMessages, notification); + yield* awaitUntil( + () => + harness.events.some( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "subagent" && + event.turnItem.nativeItemRef?.nativeId === WORKFLOW_TASK_ID && + event.turnItem.status === "completed", + ), + "terminal workflow notification", + ); + NodeFS.writeFileSync( + NodePath.join(harness.transcriptDir, "agent-a2.jsonl"), + '{"type":"assistant","message":{"role":"assistant","id":"msg_late","content":[{"type":"text","text":"A2 final answer"}]}}\n', + ); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ ...notification, uuid: "00000000-0000-4000-8000-000000001033" }), + ); + yield* awaitUntil( + () => + threadMessages(harness.events, secondThreadId).at(-1)?.message.text === + "A2 final answer", + "late terminal transcript answer", + ); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000001034", result: "Done." }), + ); + yield* Queue.take(harness.terminalReceipts); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + + it.effect("answers settled workflow members from a transcript, or the snapshot excerpt", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWorkflowHarness; + const now = yield* DateTime.now; + + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-workflow-settled"), + text: "Run the alpha workflow in the background.", + attachments: [], + }), + ); + yield* launchWorkflow({ harness, snapshotUuid: "00000000-0000-4000-8000-000000001007" }); + const firstThreadId = workflowMemberEvents(harness.events, 1)[0]?.subagent.childThreadId; + const secondThreadId = workflowMemberEvents(harness.events, 2)[0]?.subagent.childThreadId; + const currentAssistantAnswers = () => [ + ...new Map( + threadMessages(harness.events, firstThreadId) + .filter((event) => event.message.role === "assistant") + .map((event) => [event.message.id, event.message.text]), + ).values(), + ]; + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000001008", + result: "Launched the workflow.", + }), + ); + yield* Queue.take(harness.terminalReceipts); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-unrelated-failure"), + text: "Do another task.", + attachments: [], + providerTurnOrdinal: 2, + }), + ); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000001014", + result: "Another task failed.", + terminalReason: "api_error", + isError: true, + }), + ); + yield* Queue.take(harness.terminalReceipts); + assert.equal(workflowCoordinatorEvents(harness.events).at(-1)?.subagent.status, "running"); + assert.equal(workflowMemberEvents(harness.events, 1).at(-1)?.subagent.status, "running"); + + // The run outlives the turn that launched it, so this snapshot arrives + // with no turn context at all. + yield* Queue.offer( + harness.sdkMessages, + workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000001009", state: "done" }), + ); + yield* awaitUntil( + () => threadMessages(harness.events, secondThreadId).length === 2, + "settled workflow member answers", + ); + + const coordinator = workflowCoordinatorEvents(harness.events).at(-1)?.subagent; + assert.deepEqual( + coordinator?.workflow?.agents.map((member) => member.state), + ["completed", "completed"], + ); + assert.equal(workflowMemberEvents(harness.events, 1).at(-1)?.subagent.status, "completed"); + assert.equal(workflowMemberEvents(harness.events, 2).at(-1)?.subagent.status, "completed"); + assert.deepEqual( + threadMessages(harness.events, firstThreadId).map((event) => event.message.text), + ["Reply with exactly: A1", "A1 from the transcript"], + ); + assert.deepEqual( + threadMessages(harness.events, secondThreadId).map((event) => event.message.text), + ["Reply with exactly: A2", "Partial answer from workflow progress:\n\nA2 excerpt"], + ); + const coordinatorEventsBeforeRepeat = workflowCoordinatorEvents(harness.events).length; + const memberEventsBeforeRepeat = workflowMemberEvents(harness.events, 2).length; + NodeFS.appendFileSync( + NodePath.join(harness.transcriptDir, "agent-a1.jsonl"), + '{"type":"assistant","message":{"role":"assistant","id":"msg_a1","content":[{"type":"text","text":"A1 continued"}]}}\n', + ); + yield* Queue.offer( + harness.sdkMessages, + workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000001015", state: "done" }), + ); + yield* awaitUntil( + () => workflowCoordinatorEvents(harness.events).length > coordinatorEventsBeforeRepeat, + "repeated settled workflow snapshot", + ); + yield* awaitUntil( + () => + threadMessages(harness.events, firstThreadId).at(-1)?.message.text === + "A1 from the transcript\n\nA1 continued", + "continued workflow transcript answer", + ); + assert.lengthOf(workflowMemberEvents(harness.events, 2), memberEventsBeforeRepeat); + assert.lengthOf(threadMessages(harness.events, secondThreadId), 2); + assert.equal( + threadMessages(harness.events, firstThreadId).at(-1)?.message.text, + "A1 from the transcript\n\nA1 continued", + ); + NodeFS.writeFileSync( + NodePath.join(harness.transcriptDir, "agent-a2.jsonl"), + '{"type":"assistant","message":{"role":"assistant","id":"msg_a2","content":[{"type":"text","text":"A2 from the transcript"}]}}\n', + ); + yield* Queue.offer( + harness.sdkMessages, + workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000001016", state: "done" }), + ); + yield* awaitUntil( + () => threadMessages(harness.events, secondThreadId).length === 3, + "late workflow transcript answer", + ); + assert.lengthOf(workflowMemberEvents(harness.events, 2), memberEventsBeforeRepeat); + assert.equal( + threadMessages(harness.events, secondThreadId).at(-1)?.message.text, + "A2 from the transcript", + ); + const previousStartedAt = workflowMemberEvents(harness.events, 1).at(-1)?.subagent + .startedAt; + yield* TestClock.adjust(1_000); + NodeFS.writeFileSync( + NodePath.join(harness.transcriptDir, "agent-a1.jsonl"), + [ + '{"type":"assistant","message":{"role":"assistant","id":"msg_first","content":[{"type":"text","text":"First answer"}]}}', + '{"type":"assistant","message":{"role":"assistant","id":"msg_second","content":[{"type":"text","text":"Second answer"}]}}', + ].join("\n") + "\n", + ); + yield* harness.offerAndWait( + workflowSnapshot({ + uuid: "00000000-0000-4000-8000-000000001018", + state: "done", + attempt: 2, + }), + ); + yield* awaitUntil( + () => currentAssistantAnswers().join("\n\n").includes("Second answer"), + "two-turn workflow member answer", + ); + assert.equal(currentAssistantAnswers().join("\n\n"), "First answer\n\nSecond answer"); + assert.notDeepEqual( + workflowMemberEvents(harness.events, 1).at(-1)?.subagent.startedAt, + previousStartedAt, + ); + yield* Queue.offer( + harness.sdkMessages, + workflowSnapshot({ + uuid: "00000000-0000-4000-8000-000000001019", + state: "start", + attempt: 3, + }), + ); + yield* awaitUntil( + () => workflowMemberEvents(harness.events, 1).at(-1)?.subagent.status === "running", + "retried workflow member running", + ); + assert.notInclude(currentAssistantAnswers().join("\n\n"), "First answer"); + assert.notInclude(currentAssistantAnswers().join("\n\n"), "Second answer"); + const answerEventsDuringRetry = threadMessages(harness.events, firstThreadId).length; + yield* Queue.offer( + harness.sdkMessages, + workflowSnapshot({ + uuid: "00000000-0000-4000-8000-000000001020", + state: "start", + attempt: 3, + }), + ); + NodeFS.writeFileSync( + NodePath.join(harness.transcriptDir, "agent-a1.jsonl"), + '{"type":"assistant","message":{"role":"assistant","id":"msg_retry","content":[{"type":"text","text":"A1 retry answer"}]}}\n', + ); + yield* Queue.offer( + harness.sdkMessages, + workflowSnapshot({ + uuid: "00000000-0000-4000-8000-000000001021", + state: "done", + attempt: 3, + }), + ); + yield* awaitUntil( + () => + threadMessages(harness.events, firstThreadId).at(-1)?.message.text === + "A1 retry answer", + "retried workflow member answer", + ); + assert.deepEqual(currentAssistantAnswers(), ["A1 retry answer"]); + assert.lengthOf(threadMessages(harness.events, firstThreadId), answerEventsDuringRetry + 1); + NodeFS.rmSync(NodePath.join(harness.transcriptDir, "agent-a1.jsonl")); + yield* Queue.offer( + harness.sdkMessages, + workflowSnapshot({ + uuid: "00000000-0000-4000-8000-000000001022", + state: "start", + attempt: 4, + }), + ); + yield* awaitUntil( + () => currentAssistantAnswers().at(-1) === "Retry in progress.", + "retry progress answer", + ); + yield* Queue.offer( + harness.sdkMessages, + workflowSnapshot({ + uuid: "00000000-0000-4000-8000-000000001023", + state: "done", + attempt: 4, + omitResult: true, + }), + ); + yield* awaitUntil( + () => currentAssistantAnswers().at(-1) === "No answer from this workflow attempt.", + "empty retry answer", + ); + assert.deepEqual(currentAssistantAnswers(), ["No answer from this workflow attempt."]); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + describe("native goals", () => { const syntheticFrame = (uuid: string, text: string) => claudeSdkFrame({ diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts index 730ed8a575f2..de9254aeaf71 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts @@ -17,8 +17,8 @@ import { type ProviderReplayTranscript, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; -import * as Duration from "effect/Duration"; import * as Crypto from "effect/Crypto"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; @@ -242,6 +242,7 @@ interface ClaudeSubagentFoundFrame { } type ClaudeOutboundFrame = + | { readonly type: "query.stop_task"; readonly taskId: string } | ClaudeQueryOpenFrame | ClaudePromptOfferFrame | ClaudeQuerySetModelFrame @@ -825,6 +826,10 @@ function makeReplayQueryRunner( mode, }); }), + stopTask: (taskId) => + replayEffect(() => { + assertNextOutboundFrame({ type: "query.stop_task", taskId }); + }), interrupt: replayEffect(() => { assertNextOutboundFrame({ type: "query.interrupt" }); }), diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index da344c202ff0..b0f4a23a7884 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -4,6 +4,15 @@ import { formatReadToolLabel, formatSearchToolLabel, } from "@t3tools/shared/toolActivity"; +import { field, text } from "../unknownField.ts"; +import { readWorkflowAgentAnswers } from "../workflowAgentAnswers.ts"; +import { + CLAUDE_WORKFLOW_TASK_TYPE, + hasClaudeWorkflowSnapshot, + mergeClaudeWorkflowProgress, + parseClaudeWorkflowRunHandles, +} from "./claudeWorkflowProgress.ts"; + import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; import { normalizeClaudeTurnTokenUsage } from "../../provider/ClaudeTurnTokenUsage.ts"; import { @@ -58,6 +67,9 @@ import { type OrchestrationV2RuntimeRequest, type OrchestrationV2UserInputQuestion, type OrchestrationV2Subagent, + type OrchestrationV2AppThread, + type OrchestrationV2SubagentWorkflow, + type OrchestrationV2WorkflowRunHandles, type OrchestrationV2TurnItem, type OrchestrationV2WebSearchResult, type ProviderApprovalDecision, @@ -66,15 +78,18 @@ import { type ProviderRequestKind, type ProviderUserInputAnswers, type ProviderThreadId, + type RunId, type ThreadId, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; +import * as Hex from "effect/encoding/Hex"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Equal from "effect/Equal"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -83,6 +98,7 @@ import * as Path from "effect/Path"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; @@ -365,7 +381,7 @@ export interface ClaudeAgentSdkQuerySession { readonly setPermissionMode: ( mode: PermissionMode, ) => Effect.Effect; - readonly stopTask?: (taskId: string) => Effect.Effect; + readonly stopTask: (taskId: string) => Effect.Effect; readonly interrupt: Effect.Effect; readonly close: Effect.Effect; } @@ -513,6 +529,14 @@ export type ClaudeAgentSdkProtocolLogEvent = readonly mode: PermissionMode; }; } + | { + readonly direction: "outgoing"; + readonly stage: "decoded"; + readonly payload: { + readonly type: "query.stop_task"; + readonly taskId: string; + }; + } | { readonly direction: "outgoing"; readonly stage: "decoded"; @@ -809,7 +833,15 @@ export const layerQueryRunner: Layer.Layer< Effect.tryPromise({ try: () => queryRuntime.stopTask(taskId), catch: (cause) => queryRunnerError(cause, "stopTask"), - }), + }).pipe( + Effect.tap(() => + logProtocolEvent({ + direction: "outgoing", + stage: "decoded", + payload: { type: "query.stop_task", taskId }, + }), + ), + ), interrupt: Effect.tryPromise({ try: () => queryRuntime.interrupt(), catch: (cause) => queryRunnerError(cause, "interrupt"), @@ -1534,6 +1566,12 @@ const makeClaudeUserMessageWithAttachments = Effect.fnUntraced(function* (input: } satisfies SDKUserMessage; }); +const sha256Hex = Effect.fnUntraced(function* (crypto: Crypto.Crypto, value: string) { + return Hex.encode( + yield* crypto.digest("SHA-256", new TextEncoder().encode(value)).pipe(Effect.orDie), + ); +}); + export function isClaudePromptUuid(value: string): value is NonNullable { return /^[^-]+-[^-]+-[^-]+-[^-]+-[^-]+$/.test(value); } @@ -2908,6 +2946,12 @@ interface ActiveClaudeSubagent { // owning subagent's node for a subagent another subagent started. readonly rootNodeId: OrchestrationV2ExecutionNode["id"]; readonly childThreadId: ThreadId; + /** + * The child thread object itself, not just its id: workflow members are + * parented to it long after the launching turn has settled, and building a + * child thread needs the whole parent. + */ + childThread: OrchestrationV2AppThread | null; readonly childRootNodeId: OrchestrationV2ExecutionNode["id"]; readonly turnItemId: OrchestrationV2TurnItem["id"]; readonly turnItemOrdinal: number; @@ -2932,6 +2976,8 @@ interface ClaudeLiveQueryContext { readonly queryPolicyKey: string; readonly selectionKey: string; readonly closed: Deferred.Deferred; + // Retained for process-exit cleanup even when its last turn failed. + lastTurn: ActiveClaudeTurnContext | null; // Whether this CLI process echoes a prompt's uuid on the first frame of // the turn answering it ("early") or only on its result. Learned from the // first prompt turn. "acknowledged": the CLI confirmed it took a prompt's @@ -3334,6 +3380,29 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }); const events = yield* Queue.unbounded(); const activeTurn = yield* Ref.make(null); + const sdkMessageLock = yield* Semaphore.make(1); + // Survives turn settle: members mostly finish after the launching + // turn has ended, and each transition must be emitted exactly once. + const workflowMemberStates = yield* Ref.make( + new Map< + string, + { + readonly status: OrchestrationV2Subagent["status"]; + readonly attempt: number; + /** Digest of the last emitted transcript answer, not the text: it can run to the transcript cap. */ + readonly transcriptAnswerDigest: string | undefined; + readonly transcriptReadAttempts: number; + readonly finalTranscriptRead: boolean; + readonly result: string | undefined; + readonly label: string; + readonly prompt: string; + readonly model: string; + readonly createdAt: DateTime.Utc; + readonly startedAt: DateTime.Utc | null; + readonly completedAt: DateTime.Utc | null; + } + >(), + ); const interruptedTurns = yield* Ref.make(new Set()); const steeredTurns = yield* Ref.make(new Set()); const queryContext = yield* Ref.make(null); @@ -4335,6 +4404,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const ids = claudeSubagentIds(resume.context, resume.taskId); const now = yield* DateTime.now; const subagent: ActiveClaudeSubagent = { + childThread: null, task: { id: ids.nodeId, threadId: resume.context.input.threadId, @@ -4385,6 +4455,341 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ); }); + /** + * Stamps each member with the thread it owns. Derived from the + * coordinator's own child thread so the id inherits its provider-thread + * scoping, and done here so the snapshot the clients read carries the + * same id the projection emits. + */ + const withWorkflowMemberThreadIds = ( + workflow: OrchestrationV2SubagentWorkflow, + coordinatorChildThreadId: ThreadId, + ): OrchestrationV2SubagentWorkflow => ({ + ...workflow, + agents: workflow.agents.map((member) => ({ + ...member, + childThreadId: idAllocator.derive.threadFromProviderThread({ + driver: CLAUDE_PROVIDER, + nativeThreadId: `${coordinatorChildThreadId}:agent:${member.index}`, + }), + })), + }); + + /** Emits one conversation message into a subagent's own thread. */ + const emitSubagentConversationMessage = Effect.fnUntraced(function* (input: { + readonly nativeItemId: string; + readonly threadId: ThreadId; + readonly senderThreadId?: ThreadId; + readonly rootNodeId: OrchestrationV2ExecutionNode["id"]; + readonly role: "user" | "assistant"; + readonly text: string; + readonly ordinal: number; + readonly now: DateTime.Utc; + }) { + const artifacts = makeSubagentConversationArtifacts({ + messageId: idAllocator.derive.messageFromProviderItem({ + driver: CLAUDE_PROVIDER, + nativeItemId: input.nativeItemId, + }), + turnItemId: idAllocator.derive.turnItemFromProviderItem({ + driver: CLAUDE_PROVIDER, + nativeItemId: input.nativeItemId, + }), + threadId: input.threadId, + ...(input.senderThreadId === undefined ? {} : { senderThreadId: input.senderThreadId }), + rootNodeId: input.rootNodeId, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: { + driver: CLAUDE_PROVIDER, + nativeId: input.nativeItemId, + strength: "strong", + }, + role: input.role, + text: input.text, + ordinal: input.ordinal, + now: input.now, + }); + yield* emitProviderEvent({ + type: "message.updated", + driver: CLAUDE_PROVIDER, + message: artifacts.message, + }); + yield* emitProviderEvent({ + type: "turn_item.updated", + driver: CLAUDE_PROVIDER, + turnItem: artifacts.turnItem, + }); + }); + + /** + * Projects a workflow's members as ordinary subagents. + * + * A member is not a provider task — it exists only inside the + * coordinator's progress snapshot — but everything the user expects + * from a subagent (a thread to open, a prompt, an answer) is expressed + * by the same events, so this emits exactly the sequence + * updateClaudeSubagentNode emits rather than inventing a parallel + * shape. Members hang off the coordinator's thread, not the run's, so + * a fan-out of fifty does not bury the parent's lineage. + */ + const projectClaudeWorkflowMembers = Effect.fnUntraced(function* (input: { + readonly coordinator: ActiveClaudeSubagent; + readonly workflow: OrchestrationV2SubagentWorkflow; + }) { + const parentThread = input.coordinator.childThread; + const taskId = input.coordinator.task.nativeTaskRef?.nativeId; + if (parentThread === null || taskId === undefined || taskId === null) return; + const now = yield* DateTime.now; + const seen = yield* Ref.get(workflowMemberStates); + const transcriptDir = input.workflow.runHandles?.transcriptDir; + + for (const member of input.workflow.agents) { + if ((yield* Ref.get(sessionSubagentsByTaskId)).get(taskId) !== input.coordinator) + return; + const memberKey = `${input.coordinator.childThreadId}:agent:${member.index}`; + const previous = seen.get(memberKey); + const status = + (member.state === "queued" || member.state === "running") && + input.coordinator.task.status !== "running" + ? input.coordinator.task.status + : member.state === "queued" + ? "pending" + : member.state; + const settled = status !== "pending" && status !== "running"; + const prompt = member.prompt ?? member.label; + const model = member.model ?? parentThread.modelSelection.model; + const attempt = member.attempt ?? previous?.attempt ?? 1; + const restarted = previous !== undefined && attempt > previous.attempt; + const createdAt = previous?.createdAt ?? now; + const startedAt = + status === "pending" + ? null + : member.startedAt !== undefined + ? DateTime.makeUnsafe(member.startedAt) + : ((restarted ? null : previous?.startedAt) ?? + (status === "running" ? now : null)); + const completedAt = settled + ? ((restarted ? null : previous?.completedAt) ?? now) + : null; + const changed = + restarted || + previous?.status !== status || + !Equal.equals(previous?.startedAt, startedAt) || + previous?.result !== member.result || + previous?.label !== member.label || + previous?.prompt !== prompt || + previous?.model !== model; + const agentId = member.agentId; + const readTranscript = + settled && + transcriptDir !== undefined && + agentId !== undefined && + (input.coordinator.task.status === "running" + ? (restarted ? 0 : (previous?.transcriptReadAttempts ?? 0)) < 3 + : restarted || previous?.finalTranscriptRead !== true); + if (!changed && !readTranscript) continue; + + const nodeId = idAllocator.derive.nodeFromProviderItem({ + driver: CLAUDE_PROVIDER, + nativeItemId: memberKey, + }); + const childRootNodeId = idAllocator.derive.nodeFromProviderItem({ + driver: CLAUDE_PROVIDER, + nativeItemId: `${memberKey}:thread-root`, + }); + const childThreadId = member.childThreadId; + if (childThreadId === undefined) continue; + + if ( + previous === undefined || + previous.label !== member.label || + previous.model !== model + ) { + yield* emitProviderEvent({ + type: "app_thread.created", + driver: CLAUDE_PROVIDER, + appThread: makeSubagentChildThread({ + parentThread, + childThreadId, + parentNodeId: nodeId, + activeProviderThreadId: null, + providerInstanceId: parentThread.providerInstanceId, + modelSelection: { instanceId: parentThread.providerInstanceId, model }, + title: member.label, + now: createdAt, + createdBy: "agent", + creationSource: "provider", + }), + }); + } + if (previous === undefined || previous.prompt !== prompt) { + yield* emitSubagentConversationMessage({ + nativeItemId: `${memberKey}:prompt`, + threadId: childThreadId, + rootNodeId: childRootNodeId, + role: "user", + text: prompt, + ordinal: 100, + now, + }); + } + if (restarted && !settled) { + yield* emitSubagentConversationMessage({ + nativeItemId: `${memberKey}:answer:200`, + threadId: childThreadId, + rootNodeId: childRootNodeId, + role: "assistant", + text: "Retry in progress.", + ordinal: 200, + now, + }); + } + + const task = { + id: nodeId, + threadId: parentThread.id, + runId: null, + parentNodeId: input.coordinator.childRootNodeId, + origin: "provider_native" as const, + createdBy: "agent" as const, + driver: CLAUDE_PROVIDER, + providerInstanceId: parentThread.providerInstanceId, + providerThreadId: null, + childThreadId, + nativeTaskRef: { + driver: CLAUDE_PROVIDER, + nativeId: memberKey, + strength: "strong" as const, + }, + prompt, + title: member.label, + model, + status, + result: member.result ?? null, + startedAt, + completedAt, + updatedAt: now, + } satisfies OrchestrationV2Subagent; + + const nodeBase = { + runId: null, + status, + countsForRun: false, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: task.nativeTaskRef, + runtimeRequestId: null, + checkpointScopeId: null, + startedAt, + completedAt, + }; + if (changed) { + for (const node of [ + { + ...nodeBase, + id: nodeId, + threadId: parentThread.id, + parentNodeId: input.coordinator.childRootNodeId, + rootNodeId: input.coordinator.childRootNodeId, + kind: "subagent" as const, + }, + { + ...nodeBase, + id: childRootNodeId, + threadId: childThreadId, + parentNodeId: null, + rootNodeId: childRootNodeId, + kind: "root_turn" as const, + }, + ]) { + yield* emitProviderEvent({ type: "node.updated", driver: CLAUDE_PROVIDER, node }); + } + yield* emitProviderEvent({ + type: "subagent.updated", + driver: CLAUDE_PROVIDER, + subagent: task, + }); + } + + let transcriptAnswerDigest = restarted ? undefined : previous?.transcriptAnswerDigest; + let transcript: ReadonlyArray = []; + if (settled) { + // The member's own transcript, when the run left one: a missing or + // unreadable file is expected (a run predating run-handle capture, + // a member that never started) and falls back to the excerpt. + transcript = readTranscript + ? yield* readWorkflowAgentAnswers({ + transcriptDir, + agentId, + ...(adapterOptions.environment.CLAUDE_CONFIG_DIR === undefined + ? {} + : { + configDir: path.resolve( + session.cwd, + adapterOptions.environment.CLAUDE_CONFIG_DIR, + ), + }), + }).pipe(Effect.orElseSucceed((): ReadonlyArray => [])) + : []; + const transcriptAnswer = transcript.length > 0 ? transcript.join("\n\n") : undefined; + const answerDigest = + transcriptAnswer === undefined + ? undefined + : yield* sha256Hex(crypto, transcriptAnswer); + // A continuation may settle or resume this workflow during the read. + if ((yield* Ref.get(sessionSubagentsByTaskId)).get(taskId) !== input.coordinator) + return; + const previousDigest = transcriptAnswerDigest; + if (answerDigest !== undefined) transcriptAnswerDigest = answerDigest; + const answer = + transcriptAnswer ?? + (member.result !== undefined && changed && previousDigest === undefined + ? `Partial answer from workflow progress:\n\n${member.result}` + : attempt > 1 && changed && previousDigest === undefined + ? "No answer from this workflow attempt." + : undefined); + if ( + answer !== undefined && + (transcriptAnswer === undefined || restarted || answerDigest !== previousDigest) + ) { + yield* emitSubagentConversationMessage({ + nativeItemId: `${memberKey}:answer:200`, + threadId: childThreadId, + rootNodeId: childRootNodeId, + role: "assistant", + text: answer, + ordinal: 200, + now, + }); + } + } + + yield* Ref.update(workflowMemberStates, (current) => + new Map(current).set(memberKey, { + status, + attempt, + result: member.result, + label: member.label, + prompt, + model, + transcriptAnswerDigest, + transcriptReadAttempts: + (restarted ? 0 : (previous?.transcriptReadAttempts ?? 0)) + + (readTranscript ? 1 : 0), + finalTranscriptRead: + (!restarted && previous?.finalTranscriptRead === true) || + (readTranscript && + transcript.length > 0 && + input.coordinator.task.status !== "running"), + createdAt, + startedAt, + completedAt, + }), + ); + } + }); + const updateClaudeSubagentNode = Effect.fnUntraced(function* (input: { readonly context: ActiveClaudeTurnContext; readonly taskId: string; @@ -4397,24 +4802,39 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( readonly effort?: string; readonly owner?: ActiveClaudeSubagent; readonly progress?: string; + /** Raw SDK frame, folded into the coordinator's workflow snapshot. */ + readonly workflowFrame?: unknown; + readonly workflowName?: string; + readonly workflowRunHandles?: OrchestrationV2WorkflowRunHandles; readonly result?: string; readonly status: Extract< OrchestrationV2ExecutionNode["status"], "running" | "completed" | "failed" | "cancelled" >; readonly reopen?: boolean; + readonly onlyIfRunningInRun?: RunId | null; }) { // The session registry lets a wake-replay turn (fresh context maps) // hydrate a subagent that was created by an earlier, settled turn. + const registeredSubagent = (yield* Ref.get(sessionSubagentsByTaskId)).get(input.taskId); + // Terminal cleanup must include snapshots received after the turn settled. const existingSubagent = - input.context.subagentsByTaskId.get(input.taskId) ?? - (input.toolUseId === undefined - ? undefined - : input.context.subagentsByToolUseId.get(input.toolUseId)) ?? - (yield* Ref.get(sessionSubagentsByTaskId)).get(input.taskId); + input.onlyIfRunningInRun !== undefined + ? registeredSubagent + : (input.context.subagentsByTaskId.get(input.taskId) ?? + (input.toolUseId === undefined + ? undefined + : input.context.subagentsByToolUseId.get(input.toolUseId)) ?? + registeredSubagent); if (existingSubagent === undefined && input.status !== "running") { return; } + if ( + input.onlyIfRunningInRun !== undefined && + (existingSubagent?.task.status !== "running" || + existingSubagent.task.runId !== input.onlyIfRunningInRun) + ) + return; // Status is monotone with one exception: task_started for a known // task id is an authoritative CLI lifecycle event (SendMessage to a // completed subagent resumes it and re-emits task_started with the @@ -4433,23 +4853,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ) { return; } - // A call the subagent left open never returns once it ends. It ends - // first, while the run that owns the child thread still takes its - // events. - if (existingSubagent !== undefined && input.status !== "running") { - const openCalls = [...subagentToolCalls.values()].filter( - (toolCall) => toolCall.threadId === existingSubagent.childThreadId, - ); - yield* endToolCalls({ - context: input.context, - toolCalls: openCalls, - status: input.status === "cancelled" ? "interrupted" : "failed", - completedAt: yield* DateTime.now, - }); - for (const toolCall of openCalls) { - subagentToolCalls.delete(toolCall.nativeItemId); - } - } // A task_started under a tool call other than the current run's is // a resume: SendMessage re-emits task_started for the same task id // under its own tool_use_id, with the sent message as the prompt. @@ -4493,6 +4896,21 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( existingSubagent.task, ) : existingSubagent.task; + // Workflow telemetry is cumulative: fold this frame into whatever the + // coordinator already carried, so a usage-only frame cannot blank the + // member roster the user is watching. + const workflow = mergeClaudeWorkflowProgress({ + previous: priorTask?.workflow, + ...(input.workflowName === undefined ? {} : { name: input.workflowName }), + ...(input.workflowRunHandles === undefined + ? {} + : { runHandles: input.workflowRunHandles }), + message: input.workflowFrame, + }); + const workflowWithThreads = + workflow === undefined + ? undefined + : withWorkflowMemberThreadIds(workflow, childThreadId); const effort = existingSubagent === undefined ? (input.effort ?? @@ -4552,6 +4970,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( } : {}), ...(input.progress === undefined ? {} : { progress: input.progress }), + ...(workflowWithThreads === undefined ? {} : { workflow: workflowWithThreads }), ...(input.result === undefined ? {} : { result: input.result }), ...(isReopen ? { startedAt: now } : {}), completedAt: input.status === "running" ? null : (priorTask?.completedAt ?? now), @@ -4568,6 +4987,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( existingSubagent === undefined ? (input.toolUseId ?? null) : (resumeToolUseId ?? existingSubagent.runToolUseId), + childThread: existingSubagent?.childThread ?? null, effort, nextChildItemOrdinal: existingSubagent?.nextChildItemOrdinal ?? 100, resultItemOrdinal: existingSubagent?.resultItemOrdinal ?? null, @@ -4579,16 +4999,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( lastAssistantMessageId: input.reopen === true ? null : (existingSubagent?.lastAssistantMessageId ?? null), } satisfies ActiveClaudeSubagent; - input.context.subagentsByTaskId.set(input.taskId, subagent); - if (input.toolUseId !== undefined) { - input.context.subagentsByToolUseId.set(input.toolUseId, subagent); - const toolUseId = input.toolUseId; - yield* Ref.update(sessionSubagentTaskIdsByToolUseId, (current) => - current.get(toolUseId) === input.taskId - ? current - : new Map(current).set(toolUseId, input.taskId), - ); - } // The same terminal protection, applied atomically: a concurrent // fiber (live stream vs continuation drain) may have terminalized // the registry entry after this update's lookup read it. A resume @@ -4596,20 +5006,51 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( // is still the terminal generation the lookup resolved; if a // concurrent fiber installed a newer terminal entry meanwhile, the // re-open must not clobber its result. - yield* Ref.update(sessionSubagentsByTaskId, (current) => { + const accepted = yield* Ref.modify(sessionSubagentsByTaskId, (current) => { const registered = current.get(input.taskId); if ( - registered !== undefined && - registered.task.status !== "running" && - input.status === "running" && - !(isReopen && registered === existingSubagent) + (input.onlyIfRunningInRun !== undefined && + (registered?.task.status !== "running" || + registered.task.runId !== input.onlyIfRunningInRun)) || + (registered !== undefined && + registered.task.status !== "running" && + input.status === "running" && + !(isReopen && registered === existingSubagent)) ) { - return current; + return [false, current] as const; } - return new Map(current).set(input.taskId, subagent); + return [true, new Map(current).set(input.taskId, subagent)] as const; }); + if (!accepted) return; + // A call the subagent left open never returns once it ends. It ends + // first, while the run that owns the child thread still takes its + // events. + if (existingSubagent !== undefined && input.status !== "running") { + const openCalls = [...subagentToolCalls.values()].filter( + (toolCall) => toolCall.threadId === existingSubagent.childThreadId, + ); + yield* endToolCalls({ + context: input.context, + toolCalls: openCalls, + status: input.status === "cancelled" ? "interrupted" : "failed", + completedAt: yield* DateTime.now, + }); + for (const toolCall of openCalls) { + subagentToolCalls.delete(toolCall.nativeItemId); + } + } + input.context.subagentsByTaskId.set(input.taskId, subagent); + if (input.toolUseId !== undefined) { + input.context.subagentsByToolUseId.set(input.toolUseId, subagent); + const toolUseId = input.toolUseId; + yield* Ref.update(sessionSubagentTaskIdsByToolUseId, (current) => + current.get(toolUseId) === input.taskId + ? current + : new Map(current).set(toolUseId, input.taskId), + ); + } - if (existingSubagent === undefined) { + if (subagent.childThread === null) { const childThread = makeSubagentChildThread({ parentThread: input.context.input.appThread, childThreadId, @@ -4620,18 +5061,24 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( title: subagentThreadTitle({ parentTitle: input.context.input.appThread.title, prompt: task.prompt, - title: task.title, + title: + workflowWithThreads === undefined + ? task.title + : `Workflow · ${workflowWithThreads.name ?? task.title ?? "Untitled"}`, ordinal: input.context.subagentsByTaskId.size, }), now, createdBy: "agent", creationSource: "provider", }); - yield* emitProviderEvent({ - type: "app_thread.created", - driver: CLAUDE_PROVIDER, - appThread: childThread, - }); + if (existingSubagent === undefined) { + yield* emitProviderEvent({ + type: "app_thread.created", + driver: CLAUDE_PROVIDER, + appThread: childThread, + }); + } + subagent.childThread = childThread; } if (lifecycleChanged) { @@ -4695,46 +5142,29 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ? null : `${nativeItemId}:prompt:${resumeToolUseId}`; if (promptNativeItemId !== null) { - const promptArtifacts = makeSubagentConversationArtifacts({ + yield* emitSubagentConversationMessage({ + nativeItemId: promptNativeItemId, senderThreadId: input.context.input.threadId, - messageId: idAllocator.derive.messageFromProviderItem({ - driver: CLAUDE_PROVIDER, - nativeItemId: promptNativeItemId, - }), - turnItemId: idAllocator.derive.turnItemFromProviderItem({ - driver: CLAUDE_PROVIDER, - nativeItemId: promptNativeItemId, - }), threadId: childThreadId, rootNodeId: childRootNodeId, - providerThreadId: null, - providerTurnId: null, - nativeItemRef: { - driver: CLAUDE_PROVIDER, - nativeId: promptNativeItemId, - strength: "strong", - }, role: "user", text: task.prompt, ordinal: existingSubagent === undefined ? 100 : ++subagent.nextChildItemOrdinal, now, }); - yield* emitProviderEvent({ - type: "message.updated", - driver: CLAUDE_PROVIDER, - message: promptArtifacts.message, - }); - yield* emitProviderEvent({ - type: "turn_item.updated", - driver: CLAUDE_PROVIDER, - turnItem: promptArtifacts.turnItem, - }); } yield* emitProviderEvent({ type: "subagent.updated", driver: CLAUDE_PROVIDER, subagent: task, }); + if (workflowWithThreads !== undefined) { + yield* projectClaudeWorkflowMembers({ + coordinator: subagent, + workflow: workflowWithThreads, + }); + if ((yield* Ref.get(sessionSubagentsByTaskId)).get(input.taskId) !== subagent) return; + } yield* emitProviderEvent({ type: "turn_item.updated", driver: CLAUDE_PROVIDER, @@ -4782,39 +5212,15 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const resultNativeItemId = `${nativeItemId}:result`; const resultItemOrdinal = subagent.resultItemOrdinal ?? ++subagent.nextChildItemOrdinal; subagent.resultItemOrdinal = resultItemOrdinal; - const resultArtifacts = makeSubagentConversationArtifacts({ - messageId: idAllocator.derive.messageFromProviderItem({ - driver: CLAUDE_PROVIDER, - nativeItemId: resultNativeItemId, - }), - turnItemId: idAllocator.derive.turnItemFromProviderItem({ - driver: CLAUDE_PROVIDER, - nativeItemId: resultNativeItemId, - }), + yield* emitSubagentConversationMessage({ + nativeItemId: resultNativeItemId, threadId: childThreadId, rootNodeId: childRootNodeId, - providerThreadId: null, - providerTurnId: null, - nativeItemRef: { - driver: CLAUDE_PROVIDER, - nativeId: resultNativeItemId, - strength: "strong", - }, role: "assistant", text: input.result, ordinal: resultItemOrdinal, now, }); - yield* emitProviderEvent({ - type: "message.updated", - driver: CLAUDE_PROVIDER, - message: resultArtifacts.message, - }); - yield* emitProviderEvent({ - type: "turn_item.updated", - driver: CLAUDE_PROVIDER, - turnItem: resultArtifacts.turnItem, - }); } }); @@ -5281,6 +5687,33 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( readonly result?: SDKResultMessage; }) { yield* reasoningDeltas.flushTurn(input.context.nativeTurnId); + if (input.status !== "completed") { + // The run stops following its subagents once its root turn fails or is + // interrupted, so a workflow it follows is stopped rather than left + // running where Lineage can no longer see or stop it. + const liveQuery = yield* Ref.get(queryContext); + for (const [taskId, subagent] of yield* Ref.get(sessionSubagentsByTaskId)) { + if ( + subagent.task.workflow === undefined || + subagent.task.status !== "running" || + subagent.task.runId !== input.context.input.runId + ) + continue; + if (liveQuery !== null && !liveQuery.stopping) { + // Best effort and off the terminal path: an unanswered stop must + // not keep the turn from ending. + yield* liveQuery.query + .stopTask(taskId) + .pipe(Effect.timeout("15 seconds"), Effect.ignore, Effect.forkIn(sessionScope)); + } + yield* updateClaudeSubagentNode({ + context: input.context, + taskId, + status: input.status === "failed" ? "failed" : "cancelled", + onlyIfRunningInRun: input.context.input.runId, + }); + } + } // A subagent still running in the background keeps its open calls: // their results arrive after this turn. One left by an earlier CLI // process is gone and never reports them. @@ -5288,14 +5721,15 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const runningChildThreadIds = new Set( [...(yield* Ref.get(sessionSubagentsByTaskId)).values()].flatMap((subagent) => subagent.task.status === "running" && + (input.status === "completed" || + (liveQuery !== null && subagent.task.runId !== input.context.input.runId)) && liveQuery?.subagentsFromEarlierProcesses.has(subagent) !== true ? [subagent.childThreadId] : [], ), ); const endingSubagentCalls = [...subagentToolCalls.values()].filter( - (toolCall) => - input.status !== "completed" || !runningChildThreadIds.has(toolCall.threadId), + (toolCall) => !runningChildThreadIds.has(toolCall.threadId), ); yield* endToolCalls({ context: input.context, @@ -5570,27 +6004,72 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }); const finalizeActiveTurnAfterQueryExit = Effect.fnUntraced(function* ( + closedQuery: ClaudeLiveQueryContext, cause?: Cause.Cause, ) { - const context = yield* Ref.get(activeTurn); - if (context === null) { - return; - } - const completedAt = yield* DateTime.now; - const interrupted = (yield* Ref.get(interruptedTurns)).has(context.providerTurnId); - yield* finalizeActiveTurn({ - context, - status: interrupted ? "interrupted" : "failed", - completedAt, - ...(interrupted - ? {} - : { - failure: makeProviderFailure({ - cause: cause === undefined ? undefined : Cause.squash(cause), - class: "transport_error", + const current = yield* Ref.get(activeTurn); + const context = + current?.nativeThreadId === closedQuery.nativeThreadId ? current : closedQuery.lastTurn; + if (context === null) return; + const interrupted = + closedQuery.stopping || (yield* Ref.get(interruptedTurns)).has(context.providerTurnId); + const subagents = yield* Ref.get(sessionSubagentsByTaskId); + const buffered = (yield* Ref.get(wakeBuffers)).get(closedQuery.nativeThreadId)?.messages; + if (current === context) { + yield* finalizeActiveTurn({ + context, + status: interrupted ? "interrupted" : "failed", + completedAt: yield* DateTime.now, + ...(interrupted + ? {} + : { + failure: makeProviderFailure({ + cause: cause === undefined ? undefined : Cause.squash(cause), + class: "transport_error", + }), }), - }), - }); + }); + } else { + yield* resetBackgroundTaskStateForNativeThreadProcess(closedQuery.nativeThreadId); + } + // A workflow can outlive its launching turn, but not its CLI process. + // Apply buffered outcomes before Stop can discard their wake buffer. + // Capture ownership before finalization, which may yield to a new process. + for (const [taskId, subagent] of subagents) { + if ( + subagent.task.workflow === undefined || + subagent.task.status !== "running" || + closedQuery.subagentsFromEarlierProcesses.has(subagent) + ) + continue; + const latestLifecycle = buffered?.findLast( + ( + message, + ): message is Extract< + SDKMessage, + { readonly subtype: "task_started" | "task_notification" } + > => + message.type === "system" && + (message.subtype === "task_started" || message.subtype === "task_notification") && + message.task_id === taskId, + ); + // A later start resumes the same task and invalidates its older outcome. + const completion = + latestLifecycle?.subtype === "task_notification" ? latestLifecycle : undefined; + yield* updateClaudeSubagentNode({ + context, + taskId, + status: completion + ? claudeTaskOutcome(completion.status) + : interrupted + ? "cancelled" + : "failed", + ...(completion === undefined + ? {} + : { workflowFrame: completion, result: completion.summary }), + onlyIfRunningInRun: subagent.task.runId, + }); + } yield* Ref.update(interruptedTurns, (current) => { const next = new Set(current); next.delete(context.providerTurnId); @@ -5606,6 +6085,64 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( } }); + /** + * Refreshes a background workflow coordinator's entity between turns. + * Deliberately narrow: only a registered coordinator that this frame + * carries a snapshot for is touched, and only `subagent.updated` is + * emitted, because node and turn-item ids need a turn context this path + * does not have. Status is left alone — `task_notification` owns the + * lifecycle, and it has its own turn-aware path. + * These updates only reach the projection while the launching run is still + * ingesting, which holds because a running coordinator keeps that run open. + */ + const applyWorkflowProgressWithoutTurn = Effect.fnUntraced(function* (message: SDKMessage) { + if (message.type !== "system" || message.subtype !== "task_progress") return; + if (!hasClaudeWorkflowSnapshot(message)) return; + const taskId = message.task_id; + const registered = (yield* Ref.get(sessionSubagentsByTaskId)).get(taskId); + if ( + registered === undefined || + registered.task.workflow === undefined || + registered.task.status !== "running" + ) + return; + const workflow = mergeClaudeWorkflowProgress({ + previous: registered.task.workflow, + message, + }); + if (workflow === undefined) return; + const now = yield* DateTime.now; + const progress = text(message.description); + const workflowWithThreads = withWorkflowMemberThreadIds( + workflow, + registered.childThreadId, + ); + const task = { + ...registered.task, + ...(progress === undefined ? {} : { progress }), + workflow: workflowWithThreads, + updatedAt: now, + } satisfies OrchestrationV2Subagent; + const coordinator = { ...registered, task }; + const accepted = yield* Ref.modify(sessionSubagentsByTaskId, (current) => { + const latest = current.get(taskId); + if (latest !== registered || latest.task.status !== "running") { + return [false, current] as const; + } + return [true, new Map(current).set(taskId, coordinator)] as const; + }); + if (!accepted) return; + yield* emitProviderEvent({ + type: "subagent.updated", + driver: CLAUDE_PROVIDER, + subagent: task, + }); + yield* projectClaudeWorkflowMembers({ + coordinator, + workflow: workflowWithThreads, + }); + }); + const bufferWakeMessage = Effect.fnUntraced(function* (wakeInput: { readonly nativeThreadId: string; readonly message: SDKMessage; @@ -6131,6 +6668,13 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( (yield* Ref.get(activeTurn)) ?? (yield* settledTurnForSubagentFrame(liveQuery.nativeThreadId, message)); if (context === null) { + // A workflow is normally launched in the background, so most of its + // run happens after the turn that started it has settled. Nothing + // here can build turn items or nodes without a turn context, but the + // coordinator entity is what Lineage renders, so refresh it from the + // session registry and a live run keeps reporting its phases, members + // and usage instead of freezing at turn end. + yield* applyWorkflowProgressWithoutTurn(message); // task_notification must buffer wake evidence while still tracked // on the roster; clearing first would drop the wake pin. if (message.type === "system" && message.subtype === "task_notification") { @@ -6523,6 +7067,14 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( toolUseId: message.tool_use_id, title: message.description, }); + // A workflow coordinator is marked as one the moment it starts, so + // Lineage can expand it before the first member spawns rather than + // after the first progress snapshot. + const isWorkflow = + claudeTaskTypeFromSdkMessage(message) === CLAUDE_WORKFLOW_TASK_TYPE; + // `meta.name` from the workflow script; only workflow tasks carry it. + const workflowName = isWorkflow ? text(field(message, "workflow_name")) : undefined; + yield* updateClaudeSubagentNode({ context, taskId: message.task_id, @@ -6531,10 +7083,37 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ...(model === undefined ? {} : { model }), ...(launch?.effort === undefined ? {} : { effort: launch.effort }), ...(owner === undefined ? {} : { owner }), + ...(isWorkflow + ? { + workflowFrame: message, + ...(workflowName === undefined ? {} : { workflowName }), + } + : {}), + title: message.description, status: "running", reopen: true, }); + // A resume supersedes earlier buffered starts and outcomes for this task. + // Replaying a start preserves the completion buffered after it. + yield* Ref.update(wakeBuffers, (current) => { + const entry = current.get(liveQuery.nativeThreadId); + if (entry === undefined) return current; + const replayIndex = entry.messages.indexOf(message); + const messages = entry.messages.filter( + (buffered, index) => + !( + (replayIndex === -1 || index < replayIndex) && + buffered.type === "system" && + (buffered.subtype === "task_started" || + buffered.subtype === "task_notification") && + buffered.task_id === message.task_id + ), + ); + return messages.length === entry.messages.length + ? current + : new Map(current).set(liveQuery.nativeThreadId, { ...entry, messages }); + }); } } @@ -6544,8 +7123,14 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( liveQuery.nativeThreadId, message.task_id, ); + // A workflow frame is worth an update even with an empty description: + // it carries the phase plan, the member roster and the run's usage. + const carriesWorkflowTelemetry = hasClaudeWorkflowSnapshot(message); + const isWorkflowCoordinator = + (yield* Ref.get(sessionSubagentsByTaskId)).get(message.task_id)?.task.workflow !== + undefined; if ( - progress.length > 0 && + (carriesWorkflowTelemetry || (progress.length > 0 && !isWorkflowCoordinator)) && !context.ignoredTaskIds.has(message.task_id) && !isBackgroundTask ) { @@ -6553,7 +7138,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( context, taskId: message.task_id, ...(message.tool_use_id === undefined ? {} : { toolUseId: message.tool_use_id }), - progress, + ...(progress.length === 0 ? {} : { progress }), + workflowFrame: message, status: "running", }); } @@ -6607,6 +7193,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( context, taskId: message.task_id, ...(message.tool_use_id === undefined ? {} : { toolUseId: message.tool_use_id }), + // Terminal usage totals arrive here, after the last snapshot. + workflowFrame: message, result: message.summary, status: message.status === "completed" @@ -6673,10 +7261,25 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( } for (const { toolResult, output } of claudeToolResultEntriesFromMessage(message)) { - // Session scope: while the root is idle, this context is the turn - // that settled last, which never saw a subagent an earlier turn - // launched (a nested one whose Agent call returns now). const subagent = yield* resolveSubagentByToolUseId(context, toolResult.tool_use_id); + const runHandles = parseClaudeWorkflowRunHandles(claudeNativeToolOutputValue(output)); + // Independent of the branches below: unlike the Agent tool, a + // Workflow tool_use is an ordinary tool call and so lands in + // toolCalls, which skips the subagent branch entirely. Its launch + // acknowledgement is the only carrier of the run's filesystem + // handles, so harvest them wherever the result arrives. + if (subagent !== undefined && runHandles !== undefined) { + yield* updateClaudeSubagentNode({ + context, + taskId: subagent.task.nativeTaskRef?.nativeId ?? String(subagent.task.id), + toolUseId: toolResult.tool_use_id, + workflowRunHandles: runHandles, + // The acknowledgement arrives at launch, so the coordinator + // is still running; a late one is dropped by the same + // terminal protection that guards every other update. + status: "running", + }); + } // A resume task_started reuses the resuming tool call's // tool_use_id (e.g. SendMessage), whose tool_result only // acknowledges delivery. Only the Agent launch's tool_result may @@ -6689,7 +7292,10 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( // A background Agent launch resolves its tool_use immediately // with an async-launch ACK while the task keeps running; only // the eventual task_notification terminalizes the subagent. - if (isClaudeSubagentAsyncLaunchAck(output)) { + if ( + !isClaudeToolResultError(toolResult) && + (runHandles !== undefined || isClaudeSubagentAsyncLaunchAck(output)) + ) { continue; } const result = claudeSubagentResultText(output); @@ -6838,39 +7444,15 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ? `${subagent.lastAssistantText ?? ""}\n${assistantText.text}` : assistantText.text; subagent.lastAssistantMessageId = nativeMessageId; - const artifacts = makeSubagentConversationArtifacts({ - messageId: idAllocator.derive.messageFromProviderItem({ - driver: CLAUDE_PROVIDER, - nativeItemId: assistantText.nativeItemId, - }), - turnItemId: idAllocator.derive.turnItemFromProviderItem({ - driver: CLAUDE_PROVIDER, - nativeItemId: assistantText.nativeItemId, - }), + yield* emitSubagentConversationMessage({ + nativeItemId: assistantText.nativeItemId, threadId: subagent.childThreadId, rootNodeId: subagent.childRootNodeId, - providerThreadId: null, - providerTurnId: null, - nativeItemRef: { - driver: CLAUDE_PROVIDER, - nativeId: assistantText.nativeItemId, - strength: "strong", - }, role: "assistant", text: assistantText.text, ordinal: ++subagent.nextChildItemOrdinal, now, }); - yield* emitProviderEvent({ - type: "message.updated", - driver: CLAUDE_PROVIDER, - message: artifacts.message, - }); - yield* emitProviderEvent({ - type: "turn_item.updated", - driver: CLAUDE_PROVIDER, - turnItem: artifacts.turnItem, - }); return; } if (assistantText !== null && assistantText.text.length > 0) { @@ -7726,6 +8308,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( queryPolicyKey, selectionKey: compiledSelection.queryIdentity, closed, + lastTurn: null, promptEchoMode: "unknown", openedPermissionMode: queryOptions.permissionMode, permissionMode: queryOptions.permissionMode, @@ -7738,16 +8321,20 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }; yield* Ref.set(queryContext, context); yield* querySession.messages.pipe( - Stream.runForEach((message) => { - if ( - message.type === "system" && - (message.subtype === "init" || message.subtype === "status") && - message.permissionMode !== undefined - ) { - context.permissionMode = message.permissionMode; - } - return handleSdkMessage({ query: querySession, message }); - }), + Stream.runForEach((message) => + sdkMessageLock.withPermits(1)( + Effect.suspend(() => { + if ( + message.type === "system" && + (message.subtype === "init" || message.subtype === "status") && + message.permissionMode !== undefined + ) { + context.permissionMode = message.permissionMode; + } + return handleSdkMessage({ query: querySession, message }); + }), + ), + ), Effect.exit, Effect.flatMap( Effect.fnUntraced(function* (exit: ClaudeQueryStreamExit) { @@ -7767,6 +8354,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ); if (ownsLiveQuery) { yield* finalizeActiveTurnAfterQueryExit( + context, exit._tag === "Failure" ? exit.cause : undefined, ); yield* endSubagentCallsOfClosedQuery( @@ -7885,17 +8473,21 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( uuid: promptUuid, }); const querySession = yield* openQuery(turnInput, nativeThreadId); - yield* Ref.set(activeTurn, context); - yield* emitProviderEvent({ - type: "provider_turn.updated", - driver: CLAUDE_PROVIDER, - providerTurn: providerTurnPayload({ - context, - status: "running", - completedAt: null, - }), + const attachTurn = Effect.gen(function* () { + querySession.lastTurn = context; + yield* Ref.set(activeTurn, context); + yield* emitProviderEvent({ + type: "provider_turn.updated", + driver: CLAUDE_PROVIDER, + providerTurn: providerTurnPayload({ + context, + status: "running", + completedAt: null, + }), + }); }); if (userMessage !== null) { + yield* attachTurn; // A user turn that races a wake leaves the buffer alone: the // continuation run the worker queued behind this run drains it // afterwards with correct attribution. @@ -7904,64 +8496,95 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( yield* querySession.query.offer(userMessage); return; } - const drained = yield* Ref.modify(wakeBuffers, (current) => { - const entry = current.get(nativeThreadId); - if (entry === undefined) { - return [[] as ReadonlyArray, current] as const; - } - const updated = new Map(current); - updated.delete(nativeThreadId); - return [entry.messages, updated] as const; - }); - yield* Ref.update(requestedContinuations, (current) => { - const updated = new Set(current); - updated.delete(nativeThreadId); - return updated; - }); - if (drained.length === 0) { - // Spurious continuation (buffer already lost with a recycled - // session, or a duplicate request): settle immediately instead - // of leaving a run waiting on a prompt that was never sent. - const completedAt = yield* DateTime.now; - yield* finalizeActiveTurn({ context, status: "completed", completedAt }); - return; - } - // Replay any result message last: a result finalizes the turn, and - // replaying it before the rest would drop them back into the wake - // buffer and request another continuation. - const resultMessages = drained.filter((entry) => entry.type === "result"); - const opaqueReplayTombstones = taskIdSetForNativeThread( - yield* Ref.get(opaqueBackgroundTaskReplayTombstonesByNativeThread), - nativeThreadId, - ); - const hasOpaqueTaskNotification = drained.some( - (entry) => - entry.type === "system" && - entry.subtype === "task_notification" && - opaqueReplayTombstones.has(entry.task_id), - ); - for (const entry of drained) { - if (entry.type !== "result") { - yield* handleSdkMessage({ query: querySession.query, message: entry }); - } - } - const lastResult = resultMessages.at(-1); - if (lastResult !== undefined) { - yield* handleSdkMessage({ query: querySession.query, message: lastResult }); - return; - } - // A drained `init` means Claude began the wake turn, so its output - // may still be on the way: stay open for it. - const hasNativeWakeFrame = drained.some( - (entry) => - entry.type === "user" || - entry.type === "assistant" || - isClaudeTurnStartMessage(entry), + // Transcript reads suspend replay. Hold live SDK frames until the whole + // buffer has been applied so starts, completions and the root result stay ordered. + yield* sdkMessageLock.withPermits(1)( + Effect.gen(function* () { + if ( + (yield* Ref.get(queryContext))?.query !== querySession.query || + querySession.stopping + ) { + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver: CLAUDE_PROVIDER, + detail: "Claude provider query ended before the continuation could start.", + }); + } + // Live frames can finish while this continuation waits for the permit. + yield* attachTurn; + // Exit and interrupt cleanup can run during transcript reads. + // Keep pending outcomes visible until replay applies them. + const drained = (yield* Ref.get(wakeBuffers)).get(nativeThreadId)?.messages ?? []; + yield* Ref.update(requestedContinuations, (current) => { + const updated = new Set(current); + updated.delete(nativeThreadId); + return updated; + }); + if (drained.length === 0) { + // Spurious continuation (buffer already lost with a recycled + // session, or a duplicate request): settle immediately instead + // of leaving a run waiting on a prompt that was never sent. + const completedAt = yield* DateTime.now; + yield* finalizeActiveTurn({ context, status: "completed", completedAt }); + return; + } + // Replay any result message last: a result finalizes the turn, and + // replaying it before the rest would drop them back into the wake + // buffer and request another continuation. + const resultMessages = drained.filter((entry) => entry.type === "result"); + const opaqueReplayTombstones = taskIdSetForNativeThread( + yield* Ref.get(opaqueBackgroundTaskReplayTombstonesByNativeThread), + nativeThreadId, + ); + const hasOpaqueTaskNotification = drained.some( + (entry) => + entry.type === "system" && + entry.subtype === "task_notification" && + opaqueReplayTombstones.has(entry.task_id), + ); + for (const entry of drained) { + if ( + !(yield* Ref.get(wakeBuffers)).get(nativeThreadId)?.messages.includes(entry) + ) { + continue; + } + if (entry.type !== "result") { + yield* handleSdkMessage({ query: querySession.query, message: entry }); + } + yield* Ref.update(wakeBuffers, (current) => { + const buffered = current.get(nativeThreadId); + if (buffered === undefined) return current; + const index = buffered.messages.indexOf(entry); + if (index === -1) return current; + // An idle handler may requeue this same frame for the next continuation. + const messages = [ + ...buffered.messages.slice(0, index), + ...buffered.messages.slice(index + 1), + ]; + const next = new Map(current); + if (messages.length === 0) next.delete(nativeThreadId); + else next.set(nativeThreadId, { ...buffered, messages }); + return next; + }); + } + const lastResult = resultMessages.at(-1); + if (lastResult !== undefined) { + yield* handleSdkMessage({ query: querySession.query, message: lastResult }); + return; + } + // A drained `init` means Claude began the wake turn, so its output + // may still be on the way: stay open for it. + const hasNativeWakeFrame = drained.some( + (entry) => + entry.type === "user" || + entry.type === "assistant" || + isClaudeTurnStartMessage(entry), + ); + if (hasOpaqueTaskNotification && !hasNativeWakeFrame) { + const completedAt = yield* DateTime.now; + yield* finalizeActiveTurn({ context, status: "completed", completedAt }); + } + }), ); - if (hasOpaqueTaskNotification && !hasNativeWakeFrame) { - const completedAt = yield* DateTime.now; - yield* finalizeActiveTurn({ context, status: "completed", completedAt }); - } }, (effect, turnInput) => effect.pipe( @@ -8042,14 +8665,21 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( providerThreadId: turnInput.providerThread.id, providerTurnId: turnInput.providerTurnId, }); - yield* Ref.update(queryContext, (current) => - current?.query === existing.query ? null : current, + const ownedLiveQuery = yield* Ref.modify(queryContext, (current) => + current?.query === existing.query ? [true, null] : [false, current], ); - yield* finalizeActiveTurn({ - context: currentTurn, - status: "interrupted", - completedAt, - }); + if (ownedLiveQuery) { + existing.stopping = true; + yield* finalizeActiveTurnAfterQueryExit(existing); + yield* endSubagentCallsOfClosedQuery(existing.nativeThreadId, "interrupted"); + } else { + // The stream may already be finalizing while another process starts. + yield* finalizeActiveTurn({ + context: currentTurn, + status: "interrupted", + completedAt, + }); + } yield* Deferred.succeed(existing.closed, undefined); }, (effect, turnInput) => @@ -8155,6 +8785,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( current?.query === existing.query ? [true, null] : [false, current], ); if (ownedLiveQuery) { + yield* finalizeActiveTurnAfterQueryExit(existing); yield* endSubagentCallsOfClosedQuery(nativeThreadId, "interrupted"); } yield* Deferred.succeed(existing.closed, undefined); @@ -8266,6 +8897,36 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( message: { ...turnInput.message, text: "/compact" }, }), steerTurn, + stopTask: ({ providerThread, taskId }) => + Effect.gen(function* () { + const liveQuery = yield* Ref.get(queryContext); + const task = (yield* Ref.get(sessionSubagentsByTaskId)).get(taskId); + if ( + liveQuery === null || + liveQuery.stopping || + liveQuery.nativeThreadId !== providerThread.nativeThreadRef?.nativeId || + task === undefined || + task.task.threadId !== providerThread.appThreadId || + task.task.workflow === undefined || + task.task.status !== "running" || + liveQuery.subagentsFromEarlierProcesses.has(task) + ) { + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver: CLAUDE_PROVIDER, + detail: "No running workflow task belongs to this provider thread", + }); + } + yield* liveQuery.query.stopTask(taskId).pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterProtocolError({ + driver: CLAUDE_PROVIDER, + detail: "Workflow stop request failed", + cause, + }), + ), + ); + }), interruptTurn, stopSubagent: Effect.fn("ClaudeAdapterV2.stopSubagent")(function* (input) { const existing = yield* Ref.get(queryContext); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts new file mode 100644 index 000000000000..b782ad676423 --- /dev/null +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts @@ -0,0 +1,80 @@ +import * as ClaudeSdk from "@anthropic-ai/claude-agent-sdk"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { ProviderSessionId, ThreadId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import { vi } from "vite-plus/test"; + +import { ProviderEventLoggers } from "@t3tools/provider-core/server/ProviderEventLoggers"; +import { + CLAUDE_AGENT_SDK_QUERY_PROTOCOL, + ClaudeAgentSdkQueryRunner, + layerQueryRunner, +} from "./ClaudeAdapterV2.ts"; + +vi.mock("@anthropic-ai/claude-agent-sdk", { spy: true }); + +it.effect("logs successful task stops for replay and omits failed stops", () => + Effect.gen(function* () { + const writes: unknown[] = []; + const stopTask = vi.fn(async (_taskId: string) => {}); + const query = vi.spyOn(ClaudeSdk, "query").mockReturnValue({ + stopTask, + } as unknown as ClaudeSdk.Query); + yield* Effect.addFinalizer(() => Effect.sync(() => query.mockRestore())); + const threadId = ThreadId.make("thread-stop"); + const providerSessionId = ProviderSessionId.make("session-stop"); + yield* Effect.gen(function* () { + const runner = yield* ClaudeAgentSdkQueryRunner; + const session = yield* runner.open({ + threadId, + providerSessionId, + options: { + sessionId: "native-session-stop", + model: "claude-sonnet-4-6", + tools: [], + permissionMode: "default", + }, + }); + writes.length = 0; + yield* session.stopTask("workflow-task"); + assert.deepEqual(stopTask.mock.calls, [["workflow-task"]]); + assert.deepEqual(writes, [ + { + provider: "claudeAgent", + protocol: CLAUDE_AGENT_SDK_QUERY_PROTOCOL, + kind: "protocol", + providerSessionId, + event: { + direction: "outgoing", + stage: "decoded", + payload: { type: "query.stop_task", taskId: "workflow-task" }, + }, + }, + ]); + stopTask.mockRejectedValueOnce(new Error("Stop failed")); + assert.isTrue(Exit.isFailure(yield* Effect.exit(session.stopTask("failed-task")))); + assert.equal(writes.length, 1); + }).pipe( + Effect.provide( + layerQueryRunner.pipe( + Layer.provide( + Layer.succeed(ProviderEventLoggers, { + native: { + filePath: "/tmp/events.log", + write: (event) => + Effect.sync(() => { + writes.push(event); + }), + close: () => Effect.void, + }, + canonical: undefined, + }), + ), + ), + ), + ); + }).pipe(Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 398337b22e2c..38e64af02e12 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -4976,15 +4976,15 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( node: artifacts.node, }); yield* emitProviderEvent({ - type: "runtime_request.updated", + type: "turn_item.updated", driver: CODEX_PROVIDER, - threadId: artifacts.node.threadId, - runtimeRequest: artifacts.request, + turnItem: artifacts.turnItem, }); yield* emitProviderEvent({ - type: "turn_item.updated", + type: "runtime_request.updated", driver: CODEX_PROVIDER, - turnItem: artifacts.turnItem, + threadId: artifacts.node.threadId, + runtimeRequest: artifacts.request, }); yield* Ref.update(finalAnswerItemIdsByTurn, (current) => { const ids = current.get(payload.turnId); @@ -5083,15 +5083,15 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( node: artifacts.node, }); yield* emitProviderEvent({ - type: "runtime_request.updated", + type: "turn_item.updated", driver: CODEX_PROVIDER, - threadId: artifacts.node.threadId, - runtimeRequest: artifacts.request, + turnItem: artifacts.turnItem, }); yield* emitProviderEvent({ - type: "turn_item.updated", + type: "runtime_request.updated", driver: CODEX_PROVIDER, - turnItem: artifacts.turnItem, + threadId: artifacts.node.threadId, + runtimeRequest: artifacts.request, }); const resolved = yield* Deferred.await(decision).pipe( @@ -5143,15 +5143,15 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( node: artifacts.node, }); yield* emitProviderEvent({ - type: "runtime_request.updated", + type: "turn_item.updated", driver: CODEX_PROVIDER, - threadId: artifacts.node.threadId, - runtimeRequest: artifacts.request, + turnItem: artifacts.turnItem, }); yield* emitProviderEvent({ - type: "turn_item.updated", + type: "runtime_request.updated", driver: CODEX_PROVIDER, - turnItem: artifacts.turnItem, + threadId: artifacts.node.threadId, + runtimeRequest: artifacts.request, }); const resolved = yield* Deferred.await(decision).pipe( @@ -5204,15 +5204,15 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( node: artifacts.node, }); yield* emitProviderEvent({ - type: "runtime_request.updated", + type: "turn_item.updated", driver: CODEX_PROVIDER, - threadId: artifacts.node.threadId, - runtimeRequest: artifacts.request, + turnItem: artifacts.turnItem, }); yield* emitProviderEvent({ - type: "turn_item.updated", + type: "runtime_request.updated", driver: CODEX_PROVIDER, - turnItem: artifacts.turnItem, + threadId: artifacts.node.threadId, + runtimeRequest: artifacts.request, }); const resolved = yield* Deferred.await(decision).pipe( @@ -5289,15 +5289,15 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( node: artifacts.node, }); yield* emitProviderEvent({ - type: "runtime_request.updated", + type: "turn_item.updated", driver: CODEX_PROVIDER, - threadId: artifacts.node.threadId, - runtimeRequest: artifacts.request, + turnItem: artifacts.turnItem, }); yield* emitProviderEvent({ - type: "turn_item.updated", + type: "runtime_request.updated", driver: CODEX_PROVIDER, - turnItem: artifacts.turnItem, + threadId: artifacts.node.threadId, + runtimeRequest: artifacts.request, }); const resolved = yield* Deferred.await(decision).pipe( @@ -5348,15 +5348,15 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( node: artifacts.node, }); yield* emitProviderEvent({ - type: "runtime_request.updated", + type: "turn_item.updated", driver: CODEX_PROVIDER, - threadId: artifacts.node.threadId, - runtimeRequest: artifacts.request, + turnItem: artifacts.turnItem, }); yield* emitProviderEvent({ - type: "turn_item.updated", + type: "runtime_request.updated", driver: CODEX_PROVIDER, - turnItem: artifacts.turnItem, + threadId: artifacts.node.threadId, + runtimeRequest: artifacts.request, }); const resolved = yield* Deferred.await(decision).pipe( @@ -5408,15 +5408,15 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( node: artifacts.node, }); yield* emitProviderEvent({ - type: "runtime_request.updated", + type: "turn_item.updated", driver: CODEX_PROVIDER, - threadId: artifacts.node.threadId, - runtimeRequest: artifacts.request, + turnItem: artifacts.turnItem, }); yield* emitProviderEvent({ - type: "turn_item.updated", + type: "runtime_request.updated", driver: CODEX_PROVIDER, - turnItem: artifacts.turnItem, + threadId: artifacts.node.threadId, + runtimeRequest: artifacts.request, }); const resolved = yield* Deferred.await(decision).pipe( @@ -5466,15 +5466,15 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( node: artifacts.node, }); yield* emitProviderEvent({ - type: "runtime_request.updated", + type: "turn_item.updated", driver: CODEX_PROVIDER, - threadId: artifacts.node.threadId, - runtimeRequest: artifacts.request, + turnItem: artifacts.turnItem, }); yield* emitProviderEvent({ - type: "turn_item.updated", + type: "runtime_request.updated", driver: CODEX_PROVIDER, - turnItem: artifacts.turnItem, + threadId: artifacts.node.threadId, + runtimeRequest: artifacts.request, }); const resolved = yield* Deferred.await(answers).pipe( diff --git a/apps/server/src/orchestration-v2/Adapters/claudeWorkflowProgress.test.ts b/apps/server/src/orchestration-v2/Adapters/claudeWorkflowProgress.test.ts new file mode 100644 index 000000000000..2ece93ea3fb9 --- /dev/null +++ b/apps/server/src/orchestration-v2/Adapters/claudeWorkflowProgress.test.ts @@ -0,0 +1,318 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + mergeClaudeWorkflowProgress, + parseClaudeWorkflowRunHandles, +} from "./claudeWorkflowProgress.ts"; + +const startFrame = { + type: "system", + subtype: "task_started", + task_id: "w1", + task_type: "local_workflow", + workflow_name: "probe-wf", +}; + +const snapshotFrame = ( + entries: ReadonlyArray>, + usage?: Record, +) => ({ + type: "system", + subtype: "task_progress", + task_id: "w1", + description: "Alpha: alpha:one", + workflow_progress: entries, + ...(usage === undefined ? {} : { usage }), +}); + +const phase = (index: number, title: string) => ({ type: "workflow_phase", index, title }); + +const agent = (overrides: Record) => ({ + type: "workflow_agent", + index: 1, + label: "alpha:one", + state: "start", + phaseIndex: 1, + phaseTitle: "Alpha", + ...overrides, +}); + +describe("mergeClaudeWorkflowProgress", () => { + it("marks a coordinator as a workflow before any member exists", () => { + const workflow = mergeClaudeWorkflowProgress({ + previous: undefined, + name: "probe-wf", + message: startFrame, + }); + expect(workflow).toEqual({ name: "probe-wf", phases: [], agents: [] }); + }); + + it("leaves an ordinary subagent's frames alone", () => { + expect( + mergeClaudeWorkflowProgress({ + previous: undefined, + message: { + type: "system", + subtype: "task_progress", + task_id: "t1", + description: "reading files", + usage: { total_tokens: 500, tool_uses: 2, duration_ms: 90 }, + }, + }), + ).toBeUndefined(); + }); + + it("maps provider agent states and per-member metrics", () => { + const workflow = mergeClaudeWorkflowProgress({ + previous: undefined, + message: snapshotFrame([ + phase(1, "Alpha"), + agent({ + state: "done", + agentId: "a1", + model: "claude-opus-5[1m]", + attempt: 2, + tokens: 71_141, + toolCalls: 3, + durationMs: 1277, + startedAt: 1_700_000_000_000, + queuedAt: 1_699_999_999_000, + promptPreview: "Reply with exactly: A1", + resultPreview: "A1", + }), + agent({ index: 2, label: "alpha:two", state: "queued" }), + ]), + }); + expect(workflow?.phases).toEqual([{ index: 1, title: "Alpha" }]); + expect(workflow?.agents[0]).toEqual({ + index: 1, + label: "alpha:one", + agentId: "a1", + state: "completed", + phaseIndex: 1, + phaseTitle: "Alpha", + model: "claude-opus-5[1m]", + attempt: 2, + totalTokens: 71_141, + toolCalls: 3, + durationMs: 1277, + queuedAt: 1_699_999_999_000, + startedAt: 1_700_000_000_000, + prompt: "Reply with exactly: A1", + result: "A1", + }); + expect(workflow?.agents[1]?.state).toBe("queued"); + }); + + it.each(["something-new", "toString", "constructor", "__proto__"])( + "treats unrecognised state %s as still running rather than settled", + (state) => { + const workflow = mergeClaudeWorkflowProgress({ + previous: undefined, + message: snapshotFrame([agent({ state })]), + }); + expect(workflow?.agents[0]?.state).toBe("running"); + }, + ); + + it("keeps the roster through a usage-only frame", () => { + const first = mergeClaudeWorkflowProgress({ + previous: undefined, + name: "probe-wf", + message: snapshotFrame([phase(1, "Alpha"), agent({})], { + total_tokens: 100, + tool_uses: 1, + duration_ms: 40, + }), + }); + const second = mergeClaudeWorkflowProgress({ + previous: first, + message: snapshotFrame([], { total_tokens: 250, tool_uses: 2, duration_ms: 900 }), + }); + expect(second?.agents).toHaveLength(1); + expect(second?.phases).toHaveLength(1); + expect(second?.name).toBe("probe-wf"); + expect(second?.totalTokens).toBe(250); + }); + + it("harvests run handles from the launch acknowledgement", () => { + const workflow = mergeClaudeWorkflowProgress({ + previous: undefined, + runHandles: parseClaudeWorkflowRunHandles({ + status: "async_launched", + taskType: "local_workflow", + runId: "wf_abc", + transcriptDir: "/root/.claude/projects/p/s/subagents/workflows/wf_abc", + scriptPath: "/root/.claude/projects/p/s/workflows/scripts/x.js", + }), + message: startFrame, + }); + expect(workflow?.runHandles?.transcriptDir).toBe( + "/root/.claude/projects/p/s/subagents/workflows/wf_abc", + ); + expect(workflow?.runHandles?.runId).toBe("wf_abc"); + }); + + it("ignores a non-workflow tool acknowledgement", () => { + expect( + parseClaudeWorkflowRunHandles({ status: "async_launched", taskType: "local_agent" }), + ).toBeUndefined(); + }); + + it("keeps a phase the plan reached at runtime even if a later frame omits it", () => { + const first = mergeClaudeWorkflowProgress({ + previous: undefined, + message: snapshotFrame([phase(1, "Alpha"), phase(2, "Beta"), phase(3, "Gamma")]), + }); + const second = mergeClaudeWorkflowProgress({ + previous: first, + message: snapshotFrame([phase(1, "Alpha")]), + }); + expect(second?.phases.map((entry) => entry.title)).toEqual(["Alpha", "Beta", "Gamma"]); + }); + + it("keeps other members and phase links when a sparse frame starts a new attempt", () => { + const first = mergeClaudeWorkflowProgress({ + previous: undefined, + message: snapshotFrame([ + phase(1, "Alpha"), + agent({ + state: "error", + attempt: 1, + promptPreview: "First prompt", + resultPreview: "First result", + tokens: 100, + toolCalls: 2, + durationMs: 50, + queuedAt: 1000, + startedAt: 1010, + }), + agent({ index: 2, state: "done" }), + ]), + }); + const second = mergeClaudeWorkflowProgress({ + previous: first, + message: snapshotFrame([ + { type: "workflow_agent", index: 1, label: "alpha:one", state: "start", attempt: 2 }, + ]), + }); + expect(second?.agents).toEqual([ + { + index: 1, + label: "alpha:one", + state: "running", + attempt: 2, + phaseIndex: 1, + phaseTitle: "Alpha", + }, + first?.agents[1], + ]); + expect(second?.phases).toEqual(first?.phases); + }); + + it("ignores an older attempt after a retry completes", () => { + const first = mergeClaudeWorkflowProgress({ + previous: undefined, + message: snapshotFrame([agent({ state: "done", attempt: 2, resultPreview: "New result" })]), + }); + const second = mergeClaudeWorkflowProgress({ + previous: first, + message: snapshotFrame([agent({ state: "error", attempt: 1, resultPreview: "Old result" })]), + }); + expect(second?.agents).toEqual(first?.agents); + }); + + it.each([ + ["done", "start"], + ["done", "queued"], + ["error", "start"], + ["error", "queued"], + ["start", "queued"], + ])("keeps member state when the same attempt regresses from %s to %s", (state, staleState) => { + const first = mergeClaudeWorkflowProgress({ + previous: undefined, + message: snapshotFrame([agent({ state, attempt: 2, resultPreview: "Current result" })]), + }); + const second = mergeClaudeWorkflowProgress({ + previous: first, + message: snapshotFrame([ + agent({ state: staleState, attempt: 2, resultPreview: "Old result" }), + ]), + }); + expect(second?.agents).toEqual(first?.agents); + }); + + it("retains member fields omitted by a later frame in the same attempt", () => { + const first = mergeClaudeWorkflowProgress({ + previous: undefined, + message: snapshotFrame([agent({ attempt: 2, promptPreview: "Current prompt", tokens: 100 })]), + }); + const second = mergeClaudeWorkflowProgress({ + previous: first, + message: snapshotFrame([agent({ state: "done", resultPreview: "Current result" })]), + }); + expect(second?.agents[0]).toMatchObject({ + state: "completed", + attempt: 2, + prompt: "Current prompt", + result: "Current result", + totalTokens: 100, + }); + }); + + it("keeps existing run handles when a later acknowledgement supplies only a script path", () => { + const first = mergeClaudeWorkflowProgress({ + previous: undefined, + runHandles: { runId: "wf_abc", transcriptDir: "/workflows/wf_abc" }, + message: startFrame, + }); + const second = mergeClaudeWorkflowProgress({ + previous: first, + runHandles: { scriptPath: "/scripts/workflow.js" }, + message: startFrame, + }); + expect(second?.runHandles).toEqual({ + runId: "wf_abc", + transcriptDir: "/workflows/wf_abc", + scriptPath: "/scripts/workflow.js", + }); + }); + + it("preserves prompt and result whitespace", () => { + const workflow = mergeClaudeWorkflowProgress({ + previous: undefined, + message: snapshotFrame([ + agent({ promptPreview: " indented\n", resultPreview: "\n answer\n" }), + ]), + }); + expect(workflow?.agents[0]?.prompt).toBe(" indented\n"); + expect(workflow?.agents[0]?.result).toBe("\n answer\n"); + }); + + it("drops timestamps outside the DateTime range", () => { + const workflow = mergeClaudeWorkflowProgress({ + previous: undefined, + message: snapshotFrame([ + agent({ startedAt: 8_640_000_000_000_001, queuedAt: Number.MAX_SAFE_INTEGER }), + agent({ index: 2, startedAt: 8_640_000_000_000_000, queuedAt: 0 }), + ]), + }); + expect(workflow?.agents[0]).not.toHaveProperty("startedAt"); + expect(workflow?.agents[0]).not.toHaveProperty("queuedAt"); + expect(workflow?.agents[1]?.startedAt).toBe(8_640_000_000_000_000); + expect(workflow?.agents[1]?.queuedAt).toBe(0); + }); + + it("drops malformed entries instead of failing the frame", () => { + const workflow = mergeClaudeWorkflowProgress({ + previous: undefined, + message: snapshotFrame([ + { type: "workflow_phase", title: "No index" }, + { type: "workflow_agent", index: 4 }, + { type: "something_else", index: 1, title: "Ignored" }, + phase(1, "Alpha"), + ]), + }); + expect(workflow).toEqual({ phases: [{ index: 1, title: "Alpha" }], agents: [] }); + }); +}); diff --git a/apps/server/src/orchestration-v2/Adapters/claudeWorkflowProgress.ts b/apps/server/src/orchestration-v2/Adapters/claudeWorkflowProgress.ts new file mode 100644 index 000000000000..dd28a5d9b848 --- /dev/null +++ b/apps/server/src/orchestration-v2/Adapters/claudeWorkflowProgress.ts @@ -0,0 +1,192 @@ +import type { + OrchestrationV2SubagentWorkflow, + OrchestrationV2WorkflowAgent, + OrchestrationV2WorkflowPhase, + OrchestrationV2WorkflowRunHandles, +} from "@t3tools/contracts"; + +import { field, text } from "../unknownField.ts"; + +export const CLAUDE_WORKFLOW_TASK_TYPE = "local_workflow"; + +export function hasClaudeWorkflowSnapshot( + message: unknown, +): message is { readonly workflow_progress: ReadonlyArray } { + const entries = field(message, "workflow_progress"); + return Array.isArray(entries) && entries.length > 0; +} + +const AGENT_STATES: Record = { + queued: "queued", + start: "running", + done: "completed", + error: "failed", +}; + +function count(value: unknown): number | undefined { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : undefined; +} + +function timestamp(value: unknown): number | undefined { + const milliseconds = count(value); + return milliseconds !== undefined && milliseconds <= 8_640_000_000_000_000 + ? milliseconds + : undefined; +} + +function preview(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function optional(key: string, value: T | undefined): Record { + return value === undefined ? {} : ({ [key]: value } as Record); +} + +function parsePhase(entry: unknown): OrchestrationV2WorkflowPhase | null { + const index = count(field(entry, "index")); + const title = text(field(entry, "title")); + return index === undefined || title === undefined ? null : { index, title }; +} + +function parseAgent(entry: unknown): OrchestrationV2WorkflowAgent | null { + const index = count(field(entry, "index")); + const label = text(field(entry, "label")); + if (index === undefined || label === undefined) return null; + // An unknown state is in-flight, not settled: guessing "completed" would + // freeze a live row, while guessing "running" self-corrects on the next frame. + const reportedState = text(field(entry, "state")) ?? ""; + const state = Object.hasOwn(AGENT_STATES, reportedState) + ? (AGENT_STATES[reportedState] ?? "running") + : "running"; + return { + index, + label, + state, + ...optional("agentId", text(field(entry, "agentId"))), + ...optional("phaseIndex", count(field(entry, "phaseIndex"))), + ...optional("phaseTitle", text(field(entry, "phaseTitle"))), + ...optional("model", text(field(entry, "model"))), + ...optional("attempt", count(field(entry, "attempt"))), + ...optional("totalTokens", count(field(entry, "tokens"))), + ...optional("toolCalls", count(field(entry, "toolCalls"))), + ...optional("durationMs", count(field(entry, "durationMs"))), + ...optional("queuedAt", timestamp(field(entry, "queuedAt"))), + ...optional("startedAt", timestamp(field(entry, "startedAt"))), + ...optional("prompt", preview(field(entry, "promptPreview"))), + ...optional("result", preview(field(entry, "resultPreview"))), + }; +} + +/** + * Reads the Workflow tool's structured result. A background launch answers the + * model with an acknowledgement rather than the run's output, and that + * acknowledgement is the only place the run's filesystem handles appear — + * nothing later on the event stream repeats them. + */ +export function parseClaudeWorkflowRunHandles( + toolUseResult: unknown, +): OrchestrationV2WorkflowRunHandles | undefined { + if (text(field(toolUseResult, "taskType")) !== CLAUDE_WORKFLOW_TASK_TYPE) return undefined; + const handles = { + ...optional("runId", text(field(toolUseResult, "runId"))), + ...optional("transcriptDir", text(field(toolUseResult, "transcriptDir"))), + ...optional("scriptPath", text(field(toolUseResult, "scriptPath"))), + }; + return Object.keys(handles).length === 0 ? undefined : handles; +} + +function parseClaudeWorkflowProgress(message: unknown): { + readonly phases: ReadonlyArray; + readonly agents: ReadonlyArray; +} | null { + if (!hasClaudeWorkflowSnapshot(message)) return null; + const phases: OrchestrationV2WorkflowPhase[] = []; + const agents: OrchestrationV2WorkflowAgent[] = []; + for (const entry of message.workflow_progress) { + const type = text(field(entry, "type")); + if (type === "workflow_phase") { + const phase = parsePhase(entry); + if (phase !== null) phases.push(phase); + } else if (type === "workflow_agent") { + const agent = parseAgent(entry); + if (agent !== null) agents.push(agent); + } + } + return phases.length === 0 && agents.length === 0 ? null : { phases, agents }; +} + +/** + * Folds one frame into the coordinator's workflow state. A frame without a + * snapshot still carries usage, so totals keep climbing between snapshots. + * A phase only ever joins the plan — the provider re-sends the full plan on + * every snapshot, but a late frame that omits an already-seen phase must not + * shorten the arc the user is looking at. + * + * Returns undefined when nothing identifies the task as a workflow. Progress + * frames do not repeat `task_type`, so this is the single place that decides + * whether a task is a coordinator; without it every ordinary subagent's usage + * frame would mint an empty workflow and render as a coordinator. + */ +export function mergeClaudeWorkflowProgress(input: { + readonly previous: OrchestrationV2SubagentWorkflow | undefined; + readonly name?: string | undefined; + readonly runHandles?: OrchestrationV2WorkflowRunHandles | undefined; + readonly message: unknown; +}): OrchestrationV2SubagentWorkflow | undefined { + const { previous } = input; + const snapshot = parseClaudeWorkflowProgress(input.message); + if ( + previous === undefined && + input.name === undefined && + input.runHandles === undefined && + snapshot === null + ) { + return undefined; + } + const reported = field(input.message, "usage"); + const phasesByIndex = new Map( + (previous?.phases ?? []).map((phase) => [phase.index, phase] as const), + ); + for (const phase of snapshot?.phases ?? []) phasesByIndex.set(phase.index, phase); + const agentsByIndex = new Map( + (previous?.agents ?? []).map((agent) => [agent.index, agent] as const), + ); + for (const agent of snapshot?.agents ?? []) { + const prior = agentsByIndex.get(agent.index); + const attempt = agent.attempt ?? prior?.attempt ?? 1; + if (prior !== undefined && attempt < (prior.attempt ?? 1)) continue; + const restarted = prior !== undefined && attempt > (prior.attempt ?? 1); + const regressed = + !restarted && + (((prior?.state === "completed" || prior?.state === "failed") && + agent.state !== prior.state) || + (prior?.state === "running" && agent.state === "queued")); + agentsByIndex.set( + agent.index, + regressed + ? { ...agent, ...prior } + : { + ...(restarted + ? { + ...optional("phaseIndex", prior.phaseIndex), + ...optional("phaseTitle", prior.phaseTitle), + } + : prior), + ...agent, + }, + ); + } + const runHandles = + input.runHandles === undefined + ? previous?.runHandles + : { ...previous?.runHandles, ...input.runHandles }; + return { + ...optional("name", input.name ?? previous?.name), + ...optional("runHandles", runHandles), + phases: Array.from(phasesByIndex.values()).sort((left, right) => left.index - right.index), + agents: Array.from(agentsByIndex.values()).sort((left, right) => left.index - right.index), + ...optional("totalTokens", count(field(reported, "total_tokens")) ?? previous?.totalTokens), + ...optional("toolCalls", count(field(reported, "tool_uses")) ?? previous?.toolCalls), + ...optional("durationMs", count(field(reported, "duration_ms")) ?? previous?.durationMs), + }; +} diff --git a/apps/server/src/orchestration-v2/BackgroundWorkStop.integration.test.ts b/apps/server/src/orchestration-v2/BackgroundWorkStop.integration.test.ts index d1e62aad91bb..ed560fad6fd4 100644 --- a/apps/server/src/orchestration-v2/BackgroundWorkStop.integration.test.ts +++ b/apps/server/src/orchestration-v2/BackgroundWorkStop.integration.test.ts @@ -42,10 +42,12 @@ const stopEarlierBackgroundWork = ({ stopWithQueue, olderStart = false, stalledRun, + workflowMember = false, }: { readonly failedStart?: boolean; readonly stopWithQueue?: "thread.stop" | "run.interrupt"; readonly olderStart?: boolean; + readonly workflowMember?: boolean; readonly stalledRun?: | "missing-session" | "missing-session-terminal" @@ -488,23 +490,27 @@ const stopEarlierBackgroundWork = ({ events: [ // A subagent the reviewer started, which the provider records on the // reviewer's own thread. - { - id: EventId.make("nested-subagent-item"), - type: "turn-item.updated", - threadId: reviewerThreadId, - occurredAt: now, - payload: { - ...nested, - id: nestedItemId, - nodeId: nestedSubagentId, - providerTurnId: null, - nativeItemRef: null, - parentItemId: null, - ordinal: 1, - type: "subagent", - subagentId: nestedSubagentId, - }, - }, + ...(workflowMember + ? [] + : [ + { + id: EventId.make("nested-subagent-item"), + type: "turn-item.updated", + threadId: reviewerThreadId, + occurredAt: now, + payload: { + ...nested, + id: nestedItemId, + nodeId: nestedSubagentId, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 1, + type: "subagent", + subagentId: nestedSubagentId, + }, + } as const, + ]), { id: EventId.make("nested-subagent"), type: "subagent.updated", @@ -515,12 +521,36 @@ const stopEarlierBackgroundWork = ({ id: nestedSubagentId, parentNodeId: reviewerRootId, createdBy: "agent", - nativeTaskRef: null, + nativeTaskRef: workflowMember + ? { driver, nativeId: "workflow:member", strength: "strong" } + : null, model: null, }, }, ], }); + if (workflowMember) { + // Workflow members have execution nodes and child roots, but no subagent turn item. + const memberRoot = (yield* orchestrator.getThreadProjection(tester.threadId)).nodes[0]!; + yield* sink.write({ + events: [ + { + id: EventId.make("workflow-member-node"), + type: "node.updated", + threadId: reviewerThreadId, + occurredAt: now, + payload: { + ...memberRoot, + id: nestedSubagentId, + threadId: reviewerThreadId, + parentNodeId: reviewerRootId, + rootNodeId: reviewerRootId, + kind: "subagent", + }, + }, + ], + }); + } // A settled run with its root node, attempt, provider turn and, // optionally, a command or native subagent it left running. const settledRun = (input: { @@ -883,10 +913,18 @@ const stopEarlierBackgroundWork = ({ const reviewerThread = yield* orchestrator.getThreadProjection(reviewerThreadId); assert.equal(reviewerThread.nodes[0]?.status, "interrupted"); assert.equal(reviewerThread.subagents[0]?.status, "interrupted"); - assert.equal( - reviewerThread.turnItems.find((item) => item.id === nestedItemId)?.status, - "interrupted", - ); + if (workflowMember) { + assert.isUndefined(reviewerThread.turnItems.find((item) => item.id === nestedItemId)); + assert.equal( + reviewerThread.nodes.find((node) => node.id === nestedSubagentId)?.status, + "interrupted", + ); + } else { + assert.equal( + reviewerThread.turnItems.find((item) => item.id === nestedItemId)?.status, + "interrupted", + ); + } const testerThread = yield* orchestrator.getThreadProjection(tester.threadId); assert.equal(testerThread.nodes[0]?.status, "interrupted"); }).pipe( @@ -905,6 +943,10 @@ it.effect("Stop reaches background work an earlier provider thread still runs", stopEarlierBackgroundWork({}), ); +it.effect("Stop settles workflow members without subagent turn items", () => + stopEarlierBackgroundWork({ workflowMember: true }), +); + it.effect( "Stop reaches earlier background work after the newest run fails before provider start", () => stopEarlierBackgroundWork({ failedStart: true }), diff --git a/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts b/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts index 15e175aa7c74..f7e2e70ab3c5 100644 --- a/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts +++ b/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts @@ -158,6 +158,7 @@ it.effect.each(["child completion", "scheduled message", "user steering"] as con }), setModel: () => Effect.void, setPermissionMode: () => Effect.void, + stopTask: () => Effect.void, interrupt: Effect.die("automatic delivery must never interrupt"), close: Effect.void, }), diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index e3f5f72665b3..de38ae792388 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -8421,7 +8421,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio OrchestrationV2ThreadProjection, "nodes" | "subagents" | "turnItems" >; - readonly subagent: Extract; + readonly subagent: Pick< + Extract, + "subagentId" | "origin" | "childThreadId" | "providerInstanceId" + >; }> = [{ projection: input.projection, subagent: item }]; for (const { projection, subagent } of dying) { const ended = yield* endOrphanedNativeSubagent({ @@ -8466,6 +8469,20 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio for (const nested of child.turnItems) { if (nested.type === "subagent") dying.push({ projection: child, subagent: nested }); } + // Workflow members have records and child roots without subagent turn items. + for (const nested of child.subagents) { + if (nested.origin === "provider_native" && nested.runId === null) { + dying.push({ + projection: child, + subagent: { + subagentId: nested.id, + origin: nested.origin, + childThreadId: nested.childThreadId, + providerInstanceId: nested.providerInstanceId, + }, + }); + } + } } } for (const providerThread of input.projection.providerThreads) { diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index c939749e8cd3..bda7b896e187 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -349,6 +349,54 @@ it.effect("memory recovery selection includes unfinished items from missing runs }).pipe(Effect.provide(ProjectionStore.layerMemory)), ); +const runlessNativeSubagentNeedsRecovery = Effect.gen(function* () { + const store = yield* ProjectionStore.ProjectionStoreV2; + const threadId = yield* addRolledBackRecoveryCandidate("runless-native-recovery"); + const now = yield* DateTime.now; + const id = NodeId.make("node:runless-native-recovery"); + assert.notInclude(yield* store.getRecoveryThreadIds("runtime"), threadId); + for (const status of ["pending", "running", "waiting", "completed"] as const) { + yield* store.apply({ + id: EventId.make(`event:runless-native-recovery:${status}`), + type: "subagent.updated", + threadId, + nodeId: id, + driver, + providerInstanceId, + occurredAt: now, + payload: { + id, + threadId, + runId: null, + parentNodeId: NodeId.make("node:runless-native-parent"), + origin: "provider_native", + createdBy: "agent", + driver, + providerInstanceId, + providerThreadId: null, + childThreadId: null, + nativeTaskRef: { driver, nativeId: "runless-native", strength: "strong" }, + prompt: "Do the work", + title: "Workflow member", + model: null, + status, + result: null, + startedAt: now, + completedAt: status === "completed" ? now : null, + updatedAt: now, + }, + }); + assert.equal( + (yield* store.getRecoveryThreadIds("runtime")).includes(threadId), + status !== "completed", + ); + } +}); + +it.effect("memory recovery selects unfinished runless native subagents", () => + runlessNativeSubagentNeedsRecovery.pipe(Effect.provide(ProjectionStore.layerMemory)), +); + // Project A holds an active thread, an archived thread and a fork of a run in // an archived project B thread, so a project-scoped snapshot still has to load // a fork source that is outside the project and archived. @@ -501,6 +549,11 @@ it.effect("memory shell snapshots scope to one project", () => ); it.layer(layerTest)("ProjectionStoreV2", (it) => { + it.effect( + "SQL recovery selects unfinished runless native subagents", + () => runlessNativeSubagentNeedsRecovery, + ); + it.effect( "keeps restart-cancelled work through a stale run.updated", () => restartCancelledWorkSurvivesStaleRunUpdate, @@ -2520,6 +2573,8 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { const now = yield* DateTime.now; const settledThreadId = ThreadId.make("thread:recovery-candidates:settled"); const runningThreadId = ThreadId.make("thread:recovery-candidates:running"); + const memberThreadId = ThreadId.make("thread:recovery-candidates:member"); + const childThreadId = ThreadId.make("thread:recovery-candidates:child"); const rolledBackThreadId = yield* addRolledBackRecoveryCandidate("recovery-candidates"); const orphanedThreadId = yield* addOrphanedRecoveryCandidate("recovery-candidates"); const projectId = ProjectId.make("project:recovery-candidates"); @@ -2551,7 +2606,7 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { deletedAt: null, }); - for (const threadId of [settledThreadId, runningThreadId]) { + for (const threadId of [settledThreadId, runningThreadId, memberThreadId, childThreadId]) { yield* projectionStore.apply({ id: EventId.make(`event:recovery-candidates:${threadId}:created`), type: "thread.created", @@ -2591,11 +2646,118 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { }, }); + const memberId = NodeId.make("node:recovery-candidates:member"); + const childRootNodeId = NodeId.make("node:recovery-candidates:child-root"); + const nativeTaskRef = { driver, nativeId: "workflow:agent:1", strength: "strong" as const }; + const memberNode = { + id: memberId, + threadId: memberThreadId, + runId: null, + parentNodeId: NodeId.make("node:recovery-candidates:coordinator-root"), + rootNodeId: NodeId.make("node:recovery-candidates:coordinator-root"), + kind: "subagent" as const, + status: "running" as const, + countsForRun: false, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: nativeTaskRef, + runtimeRequestId: null, + checkpointScopeId: null, + startedAt: now, + completedAt: null, + }; + yield* projectionStore.apply({ + id: EventId.make("event:recovery-candidates:member"), + type: "subagent.updated", + threadId: memberThreadId, + nodeId: memberId, + driver, + providerInstanceId, + occurredAt: now, + payload: { + id: memberId, + threadId: memberThreadId, + runId: null, + parentNodeId: memberNode.parentNodeId, + origin: "provider_native", + createdBy: "agent", + driver, + providerInstanceId, + providerThreadId: null, + childThreadId, + nativeTaskRef, + prompt: "Do the work", + title: "Member", + model: null, + status: "running", + result: null, + startedAt: now, + completedAt: null, + updatedAt: now, + }, + }); + for (const [threadId, node] of [ + [memberThreadId, memberNode], + [ + childThreadId, + { + ...memberNode, + id: childRootNodeId, + threadId: childThreadId, + parentNodeId: null, + rootNodeId: childRootNodeId, + kind: "root_turn" as const, + }, + ], + ] as const) { + yield* projectionStore.apply({ + id: EventId.make(`event:recovery-candidates:node:${node.id}`), + type: "node.updated", + threadId, + nodeId: node.id, + driver, + occurredAt: now, + payload: node, + }); + } + const recoveryThreadIds = yield* projectionStore.getRecoveryThreadIds("runtime"); assert.include(recoveryThreadIds, runningThreadId); assert.include(recoveryThreadIds, orphanedThreadId); + assert.include(recoveryThreadIds, memberThreadId); + assert.include(recoveryThreadIds, childThreadId); assert.notInclude(recoveryThreadIds, settledThreadId); assert.notInclude(recoveryThreadIds, rolledBackThreadId); + const memberRecovery = yield* projectionStore.getRuntimeRecoveryProjection(memberThreadId); + const childRecovery = yield* projectionStore.getRuntimeRecoveryProjection(childThreadId); + assert.deepEqual( + memberRecovery.subagents.map((subagent) => subagent.id), + [memberId], + ); + assert.deepEqual( + memberRecovery.nodes.map((node) => node.id), + [memberId], + ); + assert.deepEqual( + childRecovery.nodes.map((node) => node.id), + [childRootNodeId], + ); + yield* projectionStore.apply({ + id: EventId.make("event:recovery-candidates:member-cancelled"), + type: "subagent.updated", + threadId: memberThreadId, + nodeId: memberId, + driver, + providerInstanceId, + occurredAt: now, + payload: { + ...memberRecovery.subagents[0]!, + status: "cancelled", + completedAt: now, + updatedAt: now, + }, + }); + assert.include(yield* projectionStore.getRecoveryThreadIds("runtime"), childThreadId); }), ); diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts index a6b7cc23d087..19232ddeeac4 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts @@ -1589,6 +1589,90 @@ layer("ProviderEventIngestorV2", (it) => { }), ); + it.effect("updates provider child metadata without recreating its saved thread", () => + Effect.gen(function* () { + const now = yield* DateTime.now; + const eventSink = yield* EventSink.EventSinkV2; + const projectionStore = yield* ProjectionStore.ProjectionStoreV2; + const ingestor = yield* ProviderEventIngestor.ProviderEventIngestorV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const rootEvent = yield* threadCreatedEvent(now); + if (rootEvent.type !== "thread.created") throw new Error("Expected thread fixture"); + const childThreadId = idAllocator.derive.threadFromProviderThread({ + driver: CODEX_DRIVER, + nativeThreadId: "workflow-member-metadata", + }); + const providerSessionId = yield* idAllocator.allocate.providerSession({ + providerInstanceId: modelSelection.instanceId, + threadId: rootEvent.threadId, + }); + const offered = { + ...rootEvent.payload, + id: childThreadId, + title: "Inspect checkout", + activeProviderThreadId: null, + lineage: { + parentThreadId: rootEvent.threadId, + relationshipToParent: "subagent" as const, + rootThreadId: rootEvent.threadId, + }, + }; + const ingest = (appThread: OrchestrationV2AppThread) => + ingestor.ingestNormalized({ + providerSessionId, + providerInstanceId: modelSelection.instanceId, + threadId: rootEvent.threadId, + event: { type: "app_thread.created", driver: CODEX_DRIVER, appThread }, + }); + yield* eventSink.write({ events: [rootEvent] }); + const created = yield* Effect.all([ingest(offered), ingest(offered)], { + concurrency: "unbounded", + }); + assert.deepEqual( + created.flat().map((stored) => stored.event.type), + ["thread.created"], + ); + const saved = { + ...offered, + archivedAt: now, + deletedAt: now, + activeProviderThreadId: rootEvent.payload.activeProviderThreadId, + branch: "user/checkout", + }; + yield* eventSink.write({ + events: [ + { + ...rootEvent, + id: yield* idAllocator.allocate.event({ threadId: childThreadId }), + threadId: childThreadId, + type: "thread.deleted", + payload: saved, + }, + ], + }); + for (const metadata of [ + { title: "Review checkout", modelSelection }, + { title: "Review checkout", modelSelection: { ...modelSelection, model: "gpt-6.1-sol" } }, + ]) { + const updated = yield* ingest({ ...offered, ...metadata }); + assert.deepEqual( + updated.map((stored) => stored.event.type), + ["thread.metadata-updated"], + ); + const thread = yield* projectionStore.getThread(childThreadId); + assert.deepEqual(thread, { ...saved, ...metadata, updatedAt: thread.updatedAt }); + } + assert.deepEqual( + yield* ingest({ + ...offered, + title: "Review checkout", + modelSelection: { ...modelSelection, model: "gpt-6.1-sol" }, + }), + [], + ); + }), + ); + it.effect("moves a native subagent's thread to the model its provider reports later", () => Effect.gen(function* () { const now = yield* DateTime.now; diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts index 308b8b210ceb..d02e78ce55bc 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts @@ -535,14 +535,37 @@ export const layer: Layer.Layer< const normalize: ProviderEventIngestorV2Shape["normalize"] = (input) => Effect.gen(function* () { switch (input.event.type) { - case "app_thread.created": + case "app_thread.created": { + const offered = input.event.appThread; + const existing = yield* projections.getThread(offered.id).pipe( + Effect.catchTags({ + ProjectionStoreThreadNotFoundError: () => Effect.succeed(null), + }), + ); + if ( + existing !== null && + existing.title === offered.title && + modelSelectionsEqual(existing.modelSelection, offered.modelSelection) + ) + return []; + const now = yield* DateTime.now; return [ yield* makeDomainEvent(input, { - type: "thread.created", - threadId: input.event.appThread.id, - payload: input.event.appThread, + type: existing === null ? "thread.created" : "thread.metadata-updated", + threadId: offered.id, + payload: + existing === null + ? offered + : { + ...existing, + title: offered.title, + modelSelection: offered.modelSelection, + updatedAt: now, + }, + occurredAt: now, }), ]; + } case "provider_session.updated": return [ yield* makeDomainEvent(input, { @@ -795,6 +818,10 @@ export const layer: Layer.Layer< ); }), ), + (effect) => + input.event.type === "app_thread.created" + ? threadCommands.withLock(input.event.appThread.id, effect) + : effect, ), }); }), diff --git a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.test.ts b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.test.ts index c8802a30f4d4..05c2b41c0116 100644 --- a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.test.ts @@ -854,6 +854,97 @@ it.effect( }, ); +it.effect("cancels workflow members and child roots without run ids after restart", () => { + const parentThreadId = ThreadId.make("thread_recovery_workflow_members"); + const childThreadId = ThreadId.make("thread_recovery_workflow_member_child"); + const memberId = NodeId.make("node_recovery_workflow_member"); + const childRootId = NodeId.make("node_recovery_workflow_member_root"); + const providerInstanceId = ProviderInstanceId.make("claudeAgent"); + const driver = ProviderDriverKind.make("claudeAgent"); + const member = { + id: memberId, + threadId: parentThreadId, + runId: null, + origin: "provider_native", + status: "running", + driver, + providerInstanceId, + }; + const parentProjection = { + thread: { id: parentThreadId, providerInstanceId }, + runs: [], + attempts: [], + nodes: [ + { id: memberId, threadId: parentThreadId, runId: null, kind: "subagent", status: "running" }, + ], + subagents: [member], + runtimeRequests: [], + providerSessions: [], + providerThreads: [], + providerTurns: [], + messages: [], + turnItems: [], + } as unknown as OrchestrationV2ThreadProjection; + const childProjection = { + ...parentProjection, + thread: { id: childThreadId, providerInstanceId }, + nodes: [ + { + id: childRootId, + threadId: childThreadId, + runId: null, + kind: "root_turn", + status: "running", + }, + ], + subagents: [], + } as unknown as OrchestrationV2ThreadProjection; + const committed: Array[0]> = []; + const layer = ProviderRuntimeRecovery.layer.pipe( + Layer.provide(ServerSettings.layerTest()), + Layer.provide( + Layer.mergeAll( + Layer.mock(ProjectionStore.ProjectionStoreV2)({ + getRecoveryThreadIds: () => Effect.succeed([parentThreadId, childThreadId]), + getRuntimeRecoveryProjection: (threadId) => + Effect.succeed(threadId === parentThreadId ? parentProjection : childProjection), + }), + Layer.mock(EventSink.EventSinkV2)({ + commitCommand: (input) => { + committed.push(input); + return Effect.succeed({ committed: true, cancelledEffectCount: 0 } as never); + }, + }), + IdAllocator.layer, + Layer.mock(EffectWorker.OrchestrationEffectWorkerV2)({ + runRecoveryOnce: Effect.succeed(false), + }), + Layer.mock(EffectOutbox.EffectOutboxV2)({ + reconcileAfterProcessLoss: Effect.succeed({ requeued: 0, cancelled: 0 }), + }), + ), + ), + ); + + return Effect.gen(function* () { + yield* (yield* ProviderRuntimeRecovery.ProviderRuntimeRecoveryService).reconcile("startup"); + assert.deepEqual( + committed.flatMap((command) => + command.events.map((event) => [ + command.threadId, + event.type, + "status" in event.payload ? event.payload.status : null, + ]), + ), + [ + [parentThreadId, "subagent.updated", "cancelled"], + [parentThreadId, "node.updated", "cancelled"], + [childThreadId, "node.updated", "cancelled"], + ], + ); + }).pipe(Effect.provide(layer)); +}); + it.effect( "clears persisted pendingBackgroundTasks and terminalizes stale background items on settled runs", () => { diff --git a/apps/server/src/orchestration-v2/RunExecutionService.test.ts b/apps/server/src/orchestration-v2/RunExecutionService.test.ts index c42c402003d4..d877922a644a 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.test.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.test.ts @@ -165,7 +165,7 @@ it.effect("routes shared-runtime events only to their owning root run", () => }), ); -it("leaves a child thread created after the root turn ended to the run that is live then", () => { +it("routes only enrolled workflow members after the root turn ends", () => { const threadId = ThreadId.make("thread:late-child"); const rootProviderTurnId = ProviderTurnId.make("provider-turn:late-child"); const identity: RunExecutionService.ProviderEventRouteIdentity = { @@ -174,14 +174,17 @@ it("leaves a child thread created after the root turn ended to the run that is l attemptId: RunAttemptId.make("attempt:late-child"), providerThreadId: ProviderThreadId.make("provider-thread:late-child"), }; - const childCreated = (childThreadId: ThreadId): ProviderAdapter.ProviderAdapterV2Event => + const childCreated = ( + childThreadId: ThreadId, + parentThreadId = threadId, + ): ProviderAdapter.ProviderAdapterV2Event => ({ type: "app_thread.created", driver, appThread: { id: childThreadId, lineage: { - parentThreadId: threadId, + parentThreadId, relationshipToParent: "subagent", rootThreadId: threadId, }, @@ -200,6 +203,13 @@ it("leaves a child thread created after the root turn ended to the run that is l initial, ); assert.isTrue(earlyAccepted); + const existingDescendant = ThreadId.make("thread:late-child:existing-descendant"); + const [descendantAccepted, withDescendant] = RunExecutionService.routeProviderEvent( + childCreated(existingDescendant, earlyChild), + identity, + live, + ); + assert.isTrue(descendantAccepted); const [terminalAccepted, ended] = RunExecutionService.routeProviderEvent( { type: "turn.terminal", @@ -212,7 +222,7 @@ it("leaves a child thread created after the root turn ended to the run that is l threadDisposition: "reusable", }, identity, - live, + withDescendant, ); assert.isTrue(terminalAccepted); // A child the root launched before it ended stays with this run. @@ -224,6 +234,92 @@ it("leaves a child thread created after the root turn ended to the run that is l ); assert.isFalse(lateAccepted); assert.isFalse(afterLate.ownedThreadIds.has(lateChild)); + + const memberId = ThreadId.make("thread:late-child:workflow-member"); + assert.isFalse( + RunExecutionService.routeProviderEvent(childCreated(memberId, earlyChild), identity, ended)[0], + ); + const coordinator: OrchestrationV2Subagent = { + id: NodeId.make("node:late-child:workflow"), + threadId, + runId: identity.runId, + parentNodeId: NodeId.make("node:late-child"), + origin: "provider_native", + createdBy: "agent", + driver, + providerInstanceId: ProviderInstanceId.make("codex"), + providerThreadId: null, + childThreadId: earlyChild, + nativeTaskRef: null, + prompt: "Review the code", + title: "Review", + model: null, + status: "running", + result: null, + startedAt: null, + completedAt: null, + updatedAt: DateTime.makeUnsafe(0), + workflow: { + phases: [], + agents: [{ index: 0, label: "Reviewer", state: "running", childThreadId: memberId }], + }, + }; + const [foreignAccepted, afterForeign] = RunExecutionService.routeProviderEvent( + { + type: "subagent.updated", + driver, + subagent: { + ...coordinator, + threadId: earlyChild, + childThreadId: existingDescendant, + runId: RunId.make("run:late-child:new"), + }, + }, + identity, + ended, + ); + assert.isTrue(foreignAccepted); + assert.isFalse( + RunExecutionService.routeProviderEvent( + childCreated(memberId, existingDescendant), + identity, + afterForeign, + )[0], + ); + const [workflowAccepted, afterWorkflow] = RunExecutionService.routeProviderEvent( + { type: "subagent.updated", driver, subagent: coordinator }, + identity, + ended, + ); + assert.isTrue(workflowAccepted); + assert.isFalse( + RunExecutionService.routeProviderEvent(childCreated(memberId), identity, afterWorkflow)[0], + ); + const [memberAccepted, afterMember] = RunExecutionService.routeProviderEvent( + childCreated(memberId, earlyChild), + identity, + afterWorkflow, + ); + assert.isTrue(memberAccepted); + assert.isTrue(afterMember.ownedThreadIds.has(memberId)); + const [unrelatedAccepted] = RunExecutionService.routeProviderEvent( + childCreated(ThreadId.make("thread:late-child:unrelated-member"), lateChild), + identity, + ended, + ); + assert.isFalse(unrelatedAccepted); + const [unrelatedDescendantAccepted, afterUnrelatedDescendant] = + RunExecutionService.routeProviderEvent( + childCreated(ThreadId.make("thread:late-child:unrelated-descendant"), earlyChild), + identity, + afterWorkflow, + ); + assert.isFalse(unrelatedDescendantAccepted); + assert.isFalse( + afterUnrelatedDescendant.ownedThreadIds.has( + ThreadId.make("thread:late-child:unrelated-descendant"), + ), + ); }); it("does not route a superseded attempt through a reused provider thread", () => { @@ -1225,10 +1321,11 @@ it.effect.each(["failure", "interruption", "stale-attempt", "start-guard"] as co }), ); -it.effect("keeps ingesting owned child events after the root turn terminalizes", () => +it.effect("ingests new member threads of an owned child after the root turn terminalizes", () => Effect.gen(function* () { const threadId = ThreadId.make("thread:run-execution-late-child"); const childThreadId = ThreadId.make("thread:run-execution-late-child:child"); + const memberThreadId = ThreadId.make("thread:run-execution-late-child:member"); const runId = RunId.make("run:run-execution-late-child"); const attemptId = RunAttemptId.make("attempt:run-execution-late-child"); const providerInstanceId = ProviderInstanceId.make("codex"); @@ -1268,9 +1365,15 @@ it.effect("keeps ingesting owned child events after the root turn terminalizes", Layer.mock(ProviderEventIngestor.ProviderEventIngestorV2)({ ingestNormalized: (input) => Effect.gen(function* () { + if ( + input.event.type === "app_thread.created" && + input.event.appThread.id === memberThreadId + ) { + yield* Ref.update(order, (current) => [...current, "member-thread"]); + } if ( input.event.type === "message.updated" && - input.event.message.threadId === childThreadId + input.event.message.threadId === memberThreadId ) { yield* Ref.update(order, (current) => [...current, "child-message"]); yield* Deferred.succeed(childMessageIngested, undefined).pipe(Effect.ignore); @@ -1307,12 +1410,21 @@ it.effect("keeps ingesting owned child events after the root turn terminalizes", type: "subagent.updated", driver, subagent: { - id: subagentNodeId, - threadId, - runId, - status: "running", + ...makeRunOwnedSubagentFixture({ + ids: backgroundScenarioIds("run-execution-late-child"), + providerInstanceId, + childThreadId, + driver, + status: "running", + }), + workflow: { + phases: [], + agents: [ + { index: 0, label: "Reviewer", state: "running", childThreadId: memberThreadId }, + ], + }, }, - } as ProviderAdapter.ProviderAdapterV2Event, + }, { type: "provider_turn.updated", driver, @@ -1335,12 +1447,24 @@ it.effect("keeps ingesting owned child events after the root turn terminalizes", failure: null, threadDisposition: "reusable", }, + { + type: "app_thread.created", + driver, + appThread: { + id: memberThreadId, + lineage: { + parentThreadId: childThreadId, + relationshipToParent: "subagent", + rootThreadId: threadId, + }, + }, + } as ProviderAdapter.ProviderAdapterV2Event, { type: "message.updated", driver, message: { id: MessageId.make("message:run-execution-late-child:child"), - threadId: childThreadId, + threadId: memberThreadId, runId: null, nodeId: childNodeId, role: "assistant", @@ -1428,7 +1552,7 @@ it.effect("keeps ingesting owned child events after the root turn terminalizes", Effect.timeoutOption("2 seconds"), ); assert.isTrue(Option.isSome(observed), "child message was not ingested after root terminal"); - assert.deepEqual(yield* Ref.get(order), ["root-finalized", "child-message"]); + assert.deepEqual(yield* Ref.get(order), ["root-finalized", "member-thread", "child-message"]); }), ); @@ -2614,11 +2738,42 @@ it.effect( ids, status: "running", }); + const memberThreadId = ThreadId.make("thread:subagent-interrupt-cascade:member"); + const runningMember = { + ...runningSubagent, + id: NodeId.make("node:subagent-interrupt-cascade:member"), + threadId: childThreadId, + runId: null, + parentNodeId: runningChildNode.id, + childThreadId: memberThreadId, + }; + const foreignSubagent = { + ...runningMember, + id: NodeId.make("node:subagent-interrupt-cascade:foreign-subagent"), + runId: RunId.make("run:subagent-interrupt-cascade:foreign"), + childThreadId: unrelatedChildThreadId, + }; + const runningMemberRoot = { + ...runningChildNode, + id: NodeId.make("node:subagent-interrupt-cascade:member-root"), + threadId: memberThreadId, + rootNodeId: NodeId.make("node:subagent-interrupt-cascade:member-root"), + }; + const foreignChildNode = { + ...runningChildNode, + id: NodeId.make("node:subagent-interrupt-cascade:foreign-child"), + runId: foreignSubagent.runId, + }; const runningChildTurnItem = makeLinkedChildTurnItemFixture({ ids, driver, type: "command_execution", }); + const foreignChildTurnItem = { + ...runningChildTurnItem, + id: TurnItemId.make("turn-item:subagent-interrupt-cascade:foreign-child"), + runId: foreignSubagent.runId, + }; const suppressedChildAssistantTurnItem = makeLinkedChildTurnItemFixture({ ids: { ...ids, @@ -2692,6 +2847,11 @@ it.effect( driver, turnItem: unrelatedChildTurnItem, }, + { type: "subagent.updated", driver, subagent: runningMember }, + { type: "subagent.updated", driver, subagent: foreignSubagent }, + { type: "node.updated", driver, node: runningMemberRoot }, + { type: "node.updated", driver, node: foreignChildNode }, + { type: "turn_item.updated", driver, turnItem: foreignChildTurnItem }, rootTerminalEvent(ids, "interrupted"), // Late provider completion after interrupt must not be ingested. { @@ -2758,7 +2918,7 @@ it.effect( providerTurnId: ids.rootProviderTurnId, } as OrchestrationV2RunAttempt, attemptId: ids.attemptId, - relatedThreadIds: [unrelatedChildThreadId], + relatedThreadIds: [unrelatedChildThreadId, memberThreadId], providerTurnOrdinal: 1, message: { messageId: MessageId.make("message:subagent-interrupt-cascade:user"), @@ -2810,8 +2970,39 @@ it.effect( const runUpdatedIndex = events.findIndex((event) => event.type === "run.updated"); assert.isAtLeast(runUpdatedIndex, 0, "root run.updated must be written"); - assert.lengthOf(subagentEvents, 1); - const terminalSubagent = subagentEvents[0]; + assert.lengthOf(subagentEvents, 2); + const terminalSubagent = subagentEvents.find( + (event) => event.payload.id === runningSubagent.id, + ); + const terminalMember = subagentEvents.find((event) => event.payload.id === runningMember.id); + assert.isDefined(terminalMember); + assert.equal(terminalMember.payload.status, "interrupted"); + assert.equal(terminalMember.payload.runId, null); + assert.equal(terminalMember.payload.childThreadId, memberThreadId); + assert.isTrue( + events.some( + (event) => + event.type === "node.updated" && + event.payload.id === runningMemberRoot.id && + event.payload.status === "interrupted", + ), + ); + assert.isFalse( + events.some( + (event) => event.type === "subagent.updated" && event.payload.id === foreignSubagent.id, + ), + ); + assert.isFalse( + events.some( + (event) => event.type === "node.updated" && event.payload.id === foreignChildNode.id, + ), + ); + assert.isFalse( + events.some( + (event) => + event.type === "turn-item.updated" && event.payload.id === foreignChildTurnItem.id, + ), + ); assert.isDefined(terminalSubagent); assert.equal(terminalSubagent.payload.status, "interrupted"); assert.equal(terminalSubagent.payload.childThreadId, childThreadId); diff --git a/apps/server/src/orchestration-v2/RunExecutionService.ts b/apps/server/src/orchestration-v2/RunExecutionService.ts index fa6d0fbd7f12..b2fe77f6cd85 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.ts @@ -53,8 +53,12 @@ import * as RunFinalizationService from "./RunFinalizationService.ts"; export interface ProviderEventRoutingState { readonly ownedThreadIds: ReadonlySet; // Set once this run's root turn ended. A child thread created after that - // belongs to the run that is live then, so this one no longer adopts it. + // belongs to the run that is live then, so this one no longer adopts it, + // except a workflow member enrolled below: its coordinator keeps this run + // open, so the member is this run's to follow. readonly rootTurnEnded: boolean; + /** Member thread id to the coordinator child thread that spawned it. */ + readonly workflowMemberParents: ReadonlyMap; readonly ownedProviderThreadIds: ReadonlySet; readonly ownedProviderTurnIds: ReadonlySet; readonly inheritedBackgroundTurnItems: ReadonlyMap; @@ -334,6 +338,7 @@ export function makeProviderEventRoutingState(input: { return { ownedThreadIds: new Set([input.identity.threadId, ...(input.relatedThreadIds ?? [])]), rootTurnEnded: false, + workflowMemberParents: new Map(), ownedProviderThreadIds: new Set([ input.identity.providerThreadId, ...(input.relatedProviderThreadIds ?? []), @@ -379,10 +384,12 @@ export function routeProviderEvent( return [true, state]; } const isOwnedSubagent = - !state.rootTurnEnded && event.appThread.lineage.relationshipToParent === "subagent" && event.appThread.lineage.parentThreadId !== null && - ownsThread(event.appThread.lineage.parentThreadId); + ownsThread(event.appThread.lineage.parentThreadId) && + (!state.rootTurnEnded || + state.workflowMemberParents.get(event.appThread.id) === + event.appThread.lineage.parentThreadId); if (!isOwnedSubagent) { return [false, state]; } @@ -417,8 +424,24 @@ export function routeProviderEvent( } return [true, addProviderThread(event.node.providerThreadId)]; } - case "subagent.updated": - return [ownsRun(event.subagent.runId) || ownsChildThread(event.subagent.threadId), state]; + case "subagent.updated": { + const belongs = ownsRun(event.subagent.runId) || ownsChildThread(event.subagent.threadId); + const coordinator = event.subagent; + if ( + !ownsRun(coordinator.runId) || + coordinator.workflow === undefined || + coordinator.childThreadId === null + ) { + return [belongs, state]; + } + const workflowMemberParents = new Map(state.workflowMemberParents); + for (const member of coordinator.workflow.agents) { + if (member.childThreadId !== undefined) { + workflowMemberParents.set(member.childThreadId, coordinator.childThreadId); + } + } + return [true, { ...state, workflowMemberParents }]; + } case "message.updated": return [ownsRun(event.message.runId) || ownsChildThread(event.message.threadId), state]; case "turn_item.updated": { @@ -1070,7 +1093,13 @@ export const layer: Layer.Layer< const belongsToOwnedChildThread = event.node.threadId !== input.run.threadId && routing.ownedThreadIds.has(event.node.threadId); - if (!belongsToRootSubagent && !belongsToOwnedChildThread) { + if ( + !belongsToRootSubagent && + !( + belongsToOwnedChildThread && + (event.node.runId === null || event.node.runId === input.run.id) + ) + ) { return; } yield* Ref.update(openRunOwnedSubagents, (current) => { @@ -1108,7 +1137,11 @@ export const layer: Layer.Layer< return next; }); } - if (belongsToOwnedChildThread && deliverable) { + if ( + belongsToOwnedChildThread && + (event.turnItem.runId === null || belongsToRootRun) && + deliverable + ) { yield* Ref.update(openRunOwnedSubagents, (current) => { const childTurnItems = new Map(current.childTurnItems); if (isSettledTurnItemStatus(event.turnItem.status)) { diff --git a/apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts b/apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts index 958fb89cc8e4..03fbfa59331e 100644 --- a/apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts +++ b/apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts @@ -388,7 +388,7 @@ function makeCompletingHandoffAdapter( }; } -it.live("restarts selection as a new attempt and retries after old-session cleanup", () => +it.live("retries a failed selection restart session open before completing", () => Effect.scoped( Effect.gen(function* () { const cwd = yield* checkpointWorkspace("selection-restart-lifecycle"); @@ -404,6 +404,9 @@ it.live("restarts selection as a new attempt and retries after old-session clean const result = yield* Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const eventSink = yield* EventSink.EventSinkV2; + yield* orchestrator.dispatch({ type: "thread.create", createdBy: "user", @@ -418,6 +421,21 @@ it.live("restarts selection as a new attempt and retries after old-session clean branch: null, worktreePath: cwd, }); + const running = yield* eventSink + .stream({ + threadId, + afterSequence: yield* eventSink.latestSequence({ threadId }), + }) + .pipe( + Stream.map((stored) => stored.event), + Stream.filter( + (event) => + event.type === "provider-turn.updated" && event.payload.status === "running", + ), + Stream.take(1), + Stream.runDrain, + Effect.forkScoped, + ); yield* orchestrator.dispatch({ type: "message.dispatch", createdBy: "user", @@ -430,11 +448,8 @@ it.live("restarts selection as a new attempt and retries after old-session clean modelSelection: initialSelection, dispatchMode: { type: "start_immediately" }, }); - for (let index = 0; index < 1_000; index += 1) { - const current = yield* orchestrator.getThreadProjection(threadId); - if (current.providerTurns.some((turn) => turn.status === "running")) break; - yield* Effect.sleep("5 millis"); - } + yield* Fiber.join(running); + yield* worker.drain(); const activeProjection = yield* orchestrator.getThreadProjection(threadId); assert.isTrue(activeProjection.providerTurns.some((turn) => turn.status === "running")); const activeRunId = activeProjection.runs[0]?.id; @@ -442,6 +457,20 @@ it.live("restarts selection as a new attempt and retries after old-session clean return yield* Effect.die("active restart test run is missing"); } + const completed = yield* eventSink + .stream({ + threadId, + afterSequence: yield* eventSink.latestSequence({ threadId }), + }) + .pipe( + Stream.map((stored) => stored.event), + Stream.filter( + (event) => event.type === "run.updated" && event.payload.status === "completed", + ), + Stream.take(1), + Stream.runDrain, + Effect.forkScoped, + ); yield* orchestrator.dispatch({ type: "message.dispatch", createdBy: "user", @@ -454,27 +483,12 @@ it.live("restarts selection as a new attempt and retries after old-session clean modelSelection: replacementSelection, dispatchMode: { type: "restart_active", targetRunId: activeRunId }, }); - for (let index = 0; index < 1_000; index += 1) { - const current = yield* orchestrator.getThreadProjection(threadId); - if (current.attempts.length === 2 && current.attempts[1]?.status === "completed") { - const captured = yield* Ref.get(state); - return { projection: current, captured }; - } - yield* Effect.sleep("5 millis"); - } - const current = yield* orchestrator.getThreadProjection(threadId); - const adapterState = yield* Ref.get(state); - yield* Effect.logError("selection restart did not complete", { - runs: current.runs.map((run) => [run.status, run.activeAttemptId]), - attempts: current.attempts.map((attempt) => [attempt.id, attempt.status]), - providerTurns: current.providerTurns.map((turn) => [turn.id, turn.status]), - providerThreads: current.providerThreads.map((thread) => [ - thread.providerSessionId, - thread.status, - ]), - adapterState, - }); - return yield* Effect.die("selection restart did not complete"); + yield* Fiber.join(completed); + yield* worker.drain(); + return { + projection: yield* orchestrator.getThreadProjection(threadId), + captured: yield* Ref.get(state), + }; }).pipe( Effect.provide( ProviderReplayHarness.layerWithRegistry( @@ -501,7 +515,8 @@ it.live("restarts selection as a new attempt and retries after old-session clean ), "selection restart supersede must not project hard-Stop interrupt items", ); - assert.equal(projection.runs[0]?.modelSelection.model, replacementSelection.model); + assert.equal(projection.runs.at(-1)?.modelSelection.model, replacementSelection.model); + assert.equal(projection.runs.at(-1)?.status, "completed"); assert.isTrue(captured.failedReplacementOpen); // The old pooled process remains available to its other threads; this // thread moved to a freshly allocated replacement session. diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts index 94e17bdc567e..b4694a19863c 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts @@ -5,25 +5,38 @@ import { NodeId, type OrchestrationV2Command, type OrchestrationV2Run, + type OrchestrationV2Subagent, + type OrchestrationV2ProviderThread, + ProviderThreadId, + ProviderDriverKind, + ProviderSessionId, type OrchestrationV2StoredEvent, type OrchestrationV2ThreadProjection, + type OrchestrationV2ThreadShell, ProjectId, ProviderInstanceId, RunId, ThreadId, } from "@t3tools/contracts"; import * as Deferred from "effect/Deferred"; +import * as DateTime from "effect/DateTime"; +import * as Option from "effect/Option"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import * as LegacyV1ThreadImporter from "./legacy/LegacyV1ThreadImporter.ts"; import * as Orchestrator from "./Orchestrator.ts"; +import * as ProviderSessionManager from "./ProviderSessionManager.ts"; +import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import * as ThreadManagementService from "./ThreadManagementService.ts"; +import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; +import { DispatchModeLimit, type DispatchModeRefusal } from "./DispatchModeLimit.ts"; it("stamps authoritative provenance on commands that create threads or messages", () => { const command: OrchestrationV2Command = { @@ -269,6 +282,7 @@ it.effect("classifies projection infrastructure failures separately from a missi cause: infrastructureCause, }); const layerTest = ThreadManagementService.layer.pipe( + Layer.provide(Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({})), Layer.provide( Layer.mock(Orchestrator.OrchestratorV2)({ getThreadProjection: () => Effect.fail(projectionError), @@ -302,6 +316,7 @@ it.effect("uses thread-not-found only after a projection loads outside the proje }, } as OrchestrationV2ThreadProjection; const layerTest = ThreadManagementService.layer.pipe( + Layer.provide(Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({})), Layer.provide( Layer.mock(Orchestrator.OrchestratorV2)({ getThreadProjection: () => Effect.succeed(projection), @@ -327,6 +342,7 @@ it.effect("preserves failed legacy materialization when reading checkpoint conte cause: new Error("checkpoint import failed"), }); const layerTest = ThreadManagementService.layerWithLegacyImporter.pipe( + Layer.provide(Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({})), Layer.provide( Layer.mergeAll( Layer.mock(Orchestrator.OrchestratorV2)({ @@ -368,6 +384,7 @@ it.effect.each([ runs: status === "missing" ? [] : [{ id: runId, status }], }) as unknown as OrchestrationV2ThreadProjection; const layerTest = ThreadManagementService.layer.pipe( + Layer.provide(Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({})), Layer.provide( Layer.mock(Orchestrator.OrchestratorV2)({ getThreadEventSequence: () => Effect.succeed(0), @@ -434,6 +451,7 @@ it.effect("waitForThread reads the run again only when the run updates", () => const stored = (sequence: number, event: object) => ({ sequence, event: { threadId, ...event } }) as unknown as OrchestrationV2StoredEvent; const layerTest = ThreadManagementService.layer.pipe( + Layer.provide(Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({})), Layer.provide( Layer.mock(Orchestrator.OrchestratorV2)({ getThreadEventSequence: () => Effect.succeed(0), @@ -474,6 +492,222 @@ it.effect("waitForThread reads the run again only when the run updates", () => }), ); +it.effect.each([ + "running", + "missing", + "completed", + "unsupported", + "missing-native-id", + "wrong-thread", + "no-session", + "unsupported-runtime", + "provider-failed", + "provider-timeout", + "within-limit", + "runtime-raised", + "interaction-raised", +] as const)("stops a persisted workflow with state %s", (state) => + Effect.gen(function* () { + const threadId = ThreadId.make("thread:workflow-stop"); + const subagentId = NodeId.make("node:workflow-stop"); + const runId = RunId.make("run:workflow-stop"); + const providerThreadId = ProviderThreadId.make("provider-thread:workflow-stop"); + const providerSessionId = ProviderSessionId.make("provider-session:workflow-stop"); + const driver = ProviderDriverKind.make("claudeAgent"); + const instanceId = ProviderInstanceId.make("claudeAgent"); + const now = yield* DateTime.now; + let targetModes: Pick = { + runtimeMode: "approval-required", + interactionMode: "plan", + }; + const task: OrchestrationV2Subagent = { + id: subagentId, + threadId, + runId, + parentNodeId: NodeId.make("node:workflow-parent"), + origin: "provider_native", + createdBy: "agent", + driver, + providerInstanceId: instanceId, + providerThreadId: null, + childThreadId: null, + nativeTaskRef: + state === "missing-native-id" + ? null + : { + driver, + nativeId: "native-workflow-id", + strength: "strong", + }, + workflow: { phases: [], agents: [] }, + prompt: "Run the workflow", + title: null, + model: null, + status: state === "completed" ? "completed" : "running", + result: null, + startedAt: now, + completedAt: null, + updatedAt: now, + ...(state === "unsupported" ? { driver: ProviderDriverKind.make("codex") } : {}), + }; + const providerThread: OrchestrationV2ProviderThread = { + id: providerThreadId, + driver, + providerInstanceId: instanceId, + providerSessionId, + appThreadId: state === "wrong-thread" ? ThreadId.make("other-thread") : threadId, + ownerNodeId: null, + nativeThreadRef: { driver, nativeId: "native-thread", strength: "strong" }, + nativeConversationHeadRef: null, + status: "idle", + firstRunOrdinal: 1, + lastRunOrdinal: 1, + handoffIds: [], + forkedFrom: null, + pendingBackgroundTasks: [], + contextUsage: null, + nativeMetadata: null, + createdAt: now, + updatedAt: now, + }; + const calls: Array<{ taskId: string; providerThread: OrchestrationV2ProviderThread }> = []; + const stopStarted = yield* Deferred.make(); + const runtime = { + driver, + providerSessionId, + instanceId, + ...(state === "unsupported-runtime" + ? {} + : { + stopTask: (input: (typeof calls)[number]) => + state === "provider-failed" + ? Effect.fail( + new ProviderAdapter.ProviderAdapterProtocolError({ + driver, + detail: "private provider detail", + }), + ) + : state === "provider-timeout" + ? Deferred.succeed(stopStarted, undefined).pipe(Effect.andThen(Effect.never)) + : Effect.sync(() => { + calls.push(input); + }), + }), + } as unknown as ProviderAdapter.ProviderAdapterV2SessionRuntime; + const layerTest = ThreadManagementService.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.mock(Orchestrator.OrchestratorV2)({ + getThreadRecords: () => + Effect.sync( + () => + ({ + thread: { id: threadId, deletedAt: null, ...targetModes }, + subagents: state === "missing" ? [] : [task], + runs: [{ id: runId, providerThreadId }], + providerThreads: [providerThread], + }) as unknown as OrchestrationV2ThreadProjection, + ), + }), + Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({ + get: (id) => { + expect(id).toBe(providerSessionId); + return Effect.succeed(state === "no-session" ? Option.none() : Option.some(runtime)); + }, + }), + ), + ), + ); + yield* Effect.gen(function* () { + const service = yield* ThreadManagementService.ThreadManagementService; + const refused = yield* Ref.make(undefined); + const stop = service + .stopWorkflow({ threadId, subagentId }) + .pipe( + Effect.provideService( + DispatchModeLimit, + state === "within-limit" || state === "runtime-raised" || state === "interaction-raised" + ? { runtimeMode: "approval-required", interactionMode: "plan", refused } + : undefined, + ), + Effect.result, + ); + const executor = yield* ThreadCommandExecutor.ThreadCommandExecutor; + const result = + state === "runtime-raised" || state === "interaction-raised" + ? yield* Effect.gen(function* () { + const started = yield* Deferred.make(); + const pending = yield* executor.withLock( + threadId, + Effect.gen(function* () { + const fiber = yield* Deferred.succeed(started, undefined).pipe( + Effect.andThen(stop), + Effect.forkChild, + ); + yield* Deferred.await(started); + targetModes = + state === "runtime-raised" + ? { ...targetModes, runtimeMode: "full-access" } + : { ...targetModes, interactionMode: "default" }; + return fiber; + }), + ); + return yield* Fiber.join(pending); + }) + : state === "provider-timeout" + ? yield* Effect.gen(function* () { + const finished = yield* Ref.make(false); + const pending = yield* stop.pipe( + Effect.tap(() => Ref.set(finished, true)), + Effect.forkChild, + ); + yield* Deferred.await(stopStarted); + yield* TestClock.adjust("15 seconds"); + expect(yield* Ref.get(finished)).toBe(true); + const result = yield* Fiber.join(pending); + expect(yield* executor.withLock(threadId, Effect.succeed("lock-released"))).toBe( + "lock-released", + ); + return result; + }) + : yield* stop; + if (state === "running" || state === "within-limit") { + expect(result).toMatchObject({ _tag: "Success" }); + expect(calls).toEqual([{ taskId: "native-workflow-id", providerThread }]); + expect(task.status).toBe("running"); + } else { + expect(result).toMatchObject({ + _tag: "Failure", + failure: { + _tag: "OrchestrationV2StopWorkflowError", + threadId, + subagentId, + reason: + state === "missing" || state === "completed" + ? "not-running" + : state === "unsupported" || state === "unsupported-runtime" + ? "unsupported" + : state === "provider-failed" || state === "provider-timeout" + ? "stop-failed" + : "unavailable", + }, + }); + expect(calls).toEqual([]); + if (state === "provider-timeout") { + expect(result).toMatchObject({ failure: { cause: { _tag: "TimeoutError" } } }); + } + if (state === "runtime-raised" || state === "interaction-raised") { + expect(yield* Ref.get(refused)).toEqual({ + threadId, + ...targetModes, + mode: state === "runtime-raised" ? "runtime" : "interaction", + }); + } + } + }).pipe(Effect.provide(Layer.merge(layerTest, ThreadCommandExecutor.layer))); + }), +); + it.effect.each([ { status: "completed" as const, settles: true }, { status: "failed" as const, settles: false }, @@ -485,6 +719,7 @@ it.effect.each([ const runId = RunId.make("run:thread-management:settle-after-run"); const dispatched: Array = []; const layerTest = ThreadManagementService.layer.pipe( + Layer.provide(Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({})), Layer.provide( Layer.mock(Orchestrator.OrchestratorV2)({ getThreadEventSequence: () => Effect.succeed(0), diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index 3da3ef12438d..c1a542bf7761 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -11,6 +11,8 @@ import { type ChatAttachment, CommandId, MessageId, + OrchestrationV2StopWorkflowError, + type OrchestrationV2StopWorkflowInput, type ModelSelection, type OrchestrationV2Actor, type OrchestrationV2Command, @@ -35,10 +37,14 @@ import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as Orchestrator from "./Orchestrator.ts"; +import * as ProviderSessionManager from "./ProviderSessionManager.ts"; +import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; +import { DispatchModeLimit, exceededDispatchModeLimit } from "./DispatchModeLimit.ts"; import { projectTurnItemForDetail } from "./WireProjection.ts"; import * as LegacyV1ThreadImporter from "./legacy/LegacyV1ThreadImporter.ts"; @@ -364,6 +370,13 @@ export interface ThreadManagementServiceShape { readonly interruptThread: ( input: ThreadManagementInterruptInput, ) => Effect.Effect; + /** + * Asks the provider to stop a running workflow coordinator. The provider's + * own terminal notification then settles the coordinator and its members. + */ + readonly stopWorkflow: ( + input: OrchestrationV2StopWorkflowInput, + ) => Effect.Effect; /** * Sends `thread.stop` to every delegated task under a thread, depth first. Their command IDs * derive from `commandId`, so a retry repeats nothing that already stopped. A task that @@ -440,7 +453,9 @@ function latestSteerableRun( const SETTLE_AFTER_RUN_WAIT_MS = 24 * 60 * 60 * 1_000; const make = Effect.gen(function* () { + const threadDispatch = yield* ThreadCommandExecutor.ThreadCommandExecutor; const orchestrator = yield* Orchestrator.OrchestratorV2; + const sessions = yield* ProviderSessionManager.ProviderSessionManagerV2; const layerScope = yield* Effect.scope; const legacyImporter = yield* LegacyV1ThreadImporter.LegacyV1ThreadImporter; @@ -841,6 +856,90 @@ const make = Effect.gen(function* () { return { type: "interrupt_requested", run: interruptibleRun, dispatch } as const; }); + const stopWorkflow: ThreadManagementServiceShape["stopWorkflow"] = (input) => + Effect.gen(function* () { + const records = yield* orchestrator + .getThreadRecords(input.threadId, ["subagents", "runs", "providerThreads"]) + .pipe( + Effect.mapError( + (cause) => + new OrchestrationV2StopWorkflowError({ ...input, reason: "unavailable", cause }), + ), + ); + if (records.thread.deletedAt !== null) { + return yield* new OrchestrationV2StopWorkflowError({ ...input, reason: "unavailable" }); + } + // A limited caller (an agent over MCP) may not stop work on a thread + // running above its own modes. Checked under the thread lock, where the + // thread's user cannot raise the modes between this read and the stop. + const limit = yield* DispatchModeLimit; + const exceeded = + limit === undefined ? undefined : exceededDispatchModeLimit(limit, records.thread); + if (limit !== undefined && exceeded !== undefined) { + if (limit.refused !== undefined) { + yield* Ref.set(limit.refused, { + threadId: input.threadId, + runtimeMode: records.thread.runtimeMode, + interactionMode: records.thread.interactionMode, + mode: exceeded, + }); + } + return yield* new OrchestrationV2StopWorkflowError({ ...input, reason: "unavailable" }); + } + const task = records.subagents.find((candidate) => candidate.id === input.subagentId); + if ( + task === undefined || + task.threadId !== input.threadId || + task.workflow === undefined || + task.status !== "running" + ) { + return yield* new OrchestrationV2StopWorkflowError({ ...input, reason: "not-running" }); + } + if (task.origin !== "provider_native" || task.driver !== "claudeAgent") { + return yield* new OrchestrationV2StopWorkflowError({ ...input, reason: "unsupported" }); + } + const providerThreadId = + task.providerThreadId ?? + records.runs.find((run) => run.id === task.runId)?.providerThreadId; + const providerThread = records.providerThreads.find( + (candidate) => + candidate.id === providerThreadId && + candidate.appThreadId === input.threadId && + candidate.providerInstanceId === task.providerInstanceId && + candidate.driver === task.driver, + ); + const taskId = task.nativeTaskRef?.nativeId; + if (providerThread?.providerSessionId == null || taskId == null || taskId.length === 0) { + return yield* new OrchestrationV2StopWorkflowError({ ...input, reason: "unavailable" }); + } + const runtime = yield* sessions + .get(providerThread.providerSessionId) + .pipe( + Effect.mapError( + (cause) => + new OrchestrationV2StopWorkflowError({ ...input, reason: "unavailable", cause }), + ), + ); + if (Option.isNone(runtime)) { + return yield* new OrchestrationV2StopWorkflowError({ ...input, reason: "unavailable" }); + } + if (runtime.value.stopTask === undefined) { + return yield* new OrchestrationV2StopWorkflowError({ ...input, reason: "unsupported" }); + } + // Bounded so a provider that never answers cannot hold the thread lock. + yield* runtime.value.stopTask({ providerThread, taskId }).pipe( + Effect.timeout("15 seconds"), + Effect.mapError( + (cause) => + new OrchestrationV2StopWorkflowError({ + ...input, + reason: "stop-failed", + cause, + }), + ), + ); + }).pipe((effect) => threadDispatch.withLock(input.threadId, effect)); + const stopDelegatedTasks: ThreadManagementServiceShape["stopDelegatedTasks"] = (input) => Effect.gen(function* () { const { subagents } = yield* orchestrator.getThreadRecords(input.threadId, ["subagents"]); @@ -919,6 +1018,7 @@ const make = Effect.gen(function* () { settleThread, interruptThread, stopDelegatedTasks, + stopWorkflow, getThreadEventSequence: orchestrator.getThreadEventSequence, recoverDelegatedTask: orchestrator.recoverDelegatedTask, delegatedTaskResultPending: orchestrator.delegatedTaskResultPending, @@ -938,11 +1038,19 @@ const layerLegacyV1ThreadImporterNoop = Layer.succeed( }), ); -export const layer: Layer.Layer = - Layer.effect(ThreadManagementService, make).pipe(Layer.provide(layerLegacyV1ThreadImporterNoop)); +export const layer: Layer.Layer< + ThreadManagementService, + never, + Orchestrator.OrchestratorV2 | ProviderSessionManager.ProviderSessionManagerV2 +> = Layer.effect(ThreadManagementService, make).pipe( + Layer.provide(layerLegacyV1ThreadImporterNoop), + Layer.provide(ThreadCommandExecutor.layer), +); export const layerWithLegacyImporter: Layer.Layer< ThreadManagementService, never, - LegacyV1ThreadImporter.LegacyV1ThreadImporter | Orchestrator.OrchestratorV2 -> = Layer.effect(ThreadManagementService, make); + | LegacyV1ThreadImporter.LegacyV1ThreadImporter + | Orchestrator.OrchestratorV2 + | ProviderSessionManager.ProviderSessionManagerV2 +> = Layer.effect(ThreadManagementService, make).pipe(Layer.provide(ThreadCommandExecutor.layer)); diff --git a/apps/server/src/orchestration-v2/runtimeLayer.ts b/apps/server/src/orchestration-v2/runtimeLayer.ts index 2442a4fa010c..623b1e084118 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.ts @@ -240,7 +240,13 @@ const layerAgentSessionImporterProvided = AgentSessionImporter.layer.pipe( ); const layerThreadManagementProvided = ThreadManagementService.layerWithLegacyImporter.pipe( - Layer.provide(Layer.merge(layerOrchestratorProvided, layerLegacyV1ThreadImporterProvided)), + Layer.provide( + Layer.mergeAll( + layerOrchestratorProvided, + layerLegacyV1ThreadImporterProvided, + layerProviderSessionManagerProvided, + ), + ), ); export const layerProjectSetupScriptRunner = ProjectSetupScriptRunner.layer.pipe( Layer.provide(layerProjectService), diff --git a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts index 27c1cc28ae5a..577a78aae5ac 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts @@ -242,7 +242,10 @@ export function layerProviderReplay { const layerRegistry = harness.makeProviderAdapterRegistryLayer( diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2_approval/codex_output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2_approval/codex_output.ts index bc4a61641a07..e22e961aaa37 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2_approval/codex_output.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2_approval/codex_output.ts @@ -59,6 +59,20 @@ export function assertSubagentV2ApprovalOutput( const approval = approvalItems[0]!; assert.equal(approval.runId, run.id); assert.equal(approval.status, "completed"); + const pendingRequestIndex = result.domainEvents.findIndex( + (event) => + event.type === "runtime-request.updated" && + event.payload.id === request.id && + event.payload.status === "pending", + ); + const waitingItemIndex = result.domainEvents.findIndex( + (event) => + event.type === "turn-item.updated" && + event.payload.id === approval.id && + event.payload.status === "waiting", + ); + assert.isAtLeast(waitingItemIndex, 0); + assert.isAbove(pendingRequestIndex, waitingItemIndex); if (approval.type !== "approval_request") throw new Error("expected an approval item"); assert.equal(approval.requestId, request.id); assert.include(approval.prompt ?? "", "subagent-approval.txt"); diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2_nested_approval/codex_output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2_nested_approval/codex_output.ts index f7765b03be63..28b2d173833c 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2_nested_approval/codex_output.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2_nested_approval/codex_output.ts @@ -72,6 +72,20 @@ export function assertSubagentV2NestedApprovalOutput( const approval = approvalItems[0]!; assert.equal(approval.runId, run.id); assert.equal(approval.status, "completed"); + const pendingRequestIndex = result.domainEvents.findIndex( + (event) => + event.type === "runtime-request.updated" && + event.payload.id === request.id && + event.payload.status === "pending", + ); + const waitingItemIndex = result.domainEvents.findIndex( + (event) => + event.type === "turn-item.updated" && + event.payload.id === approval.id && + event.payload.status === "waiting", + ); + assert.isAtLeast(waitingItemIndex, 0); + assert.isAbove(pendingRequestIndex, waitingItemIndex); if (approval.type !== "approval_request") throw new Error("expected an approval item"); assert.equal(approval.requestId, request.id); assert.include(approval.prompt ?? "", "nested-approval.txt"); diff --git a/apps/server/src/orchestration-v2/unknownField.ts b/apps/server/src/orchestration-v2/unknownField.ts new file mode 100644 index 000000000000..94c7e9fbcb15 --- /dev/null +++ b/apps/server/src/orchestration-v2/unknownField.ts @@ -0,0 +1,10 @@ +/** Readers for decoding undeclared provider payloads without trusting shapes. */ +export function field(source: unknown, key: string): unknown { + return typeof source === "object" && source !== null ? Reflect.get(source, key) : undefined; +} + +export function text(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return trimmed.length === 0 ? undefined : trimmed; +} diff --git a/apps/server/src/orchestration-v2/workflowAgentAnswers.test.ts b/apps/server/src/orchestration-v2/workflowAgentAnswers.test.ts new file mode 100644 index 000000000000..fdeb043c40d6 --- /dev/null +++ b/apps/server/src/orchestration-v2/workflowAgentAnswers.test.ts @@ -0,0 +1,121 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import { it as effectIt } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { afterAll, beforeAll, describe, expect, it } from "vite-plus/test"; + +import { parseWorkflowAgentAnswers, readWorkflowAgentAnswers } from "./workflowAgentAnswers.ts"; + +const line = (value: unknown) => `${JSON.stringify(value)}\n`; + +const assistant = (id: string, content: ReadonlyArray) => + line({ type: "assistant", message: { role: "assistant", id, content } }); + +describe("parseWorkflowAgentAnswers", () => { + it("returns one entry per assistant turn", () => { + expect( + parseWorkflowAgentAnswers( + assistant("a", [{ type: "text", text: "first" }]) + + assistant("b", [{ type: "text", text: "second" }]), + ), + ).toEqual(["first", "second"]); + }); + + it("joins the lines of one split assistant message into a single turn", () => { + expect( + parseWorkflowAgentAnswers( + assistant("a", [{ type: "thinking", thinking: "weighing it up" }]) + + assistant("a", [{ type: "text", text: "the answer" }]) + + assistant("a", [{ type: "text", text: "and more" }]), + ), + ).toEqual(["the answer\n\nand more"]); + }); + + it("ignores harness bookkeeping and the member's own prompt", () => { + expect( + parseWorkflowAgentAnswers( + line({ type: "attachment", attachment: { type: "skill_listing", content: "- adhd" } }) + + line({ type: "user", message: { role: "user", content: "the prompt" } }) + + line({ + type: "assistant", + isMeta: true, + message: { role: "assistant", id: "m", content: [{ type: "text", text: "injected" }] }, + }) + + assistant("a", [{ type: "text", text: "real answer" }]), + ), + ).toEqual(["real answer"]); + }); + + it("skips a turn that produced no text", () => { + expect( + parseWorkflowAgentAnswers( + assistant("a", [{ type: "tool_use", id: "t", name: "Bash", input: {} }]), + ), + ).toEqual([]); + }); + + it("preserves answer indentation and ignores blank text", () => { + expect( + parseWorkflowAgentAnswers( + assistant("a", [{ type: "text", text: " indented code" }]) + + assistant("b", [{ type: "text", text: " " }]), + ), + ).toEqual([" indented code"]); + }); + + it("survives a trailing line the byte cap cut in half", () => { + expect( + parseWorkflowAgentAnswers( + assistant("a", [{ type: "text", text: "complete" }]) + '{"type":"assistant","mess', + ), + ).toEqual(["complete"]); + }); +}); + +// Transcripts must sit under /projects: readContainedWorkflowFile +// rejects any path outside it. A temporary configDir keeps the real +// ~/.claude untouched. +let configDir: string; +let transcriptDir: string; +beforeAll(() => { + configDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "wf-config-")); + transcriptDir = NodePath.join(configDir, "projects", "test"); + NodeFS.mkdirSync(transcriptDir, { recursive: true }); +}); + +afterAll(() => { + NodeFS.rmSync(configDir, { recursive: true, force: true }); +}); + +describe("readWorkflowAgentAnswers", () => { + // The under-cap case is the control: without it a read stuck on [] would pass. + effectIt.effect("reads final answers from both small and capped transcripts", () => + Effect.gen(function* () { + const turn = assistant("a", [{ type: "text", text: "the answer" }]); + const filler = line({ type: "user", message: { role: "user", content: "x".repeat(4096) } }); + const write = (agentId: string, contents: string) => + NodeFS.writeFileSync(NodePath.join(transcriptDir, `agent-${agentId}.jsonl`), contents); + const read = (agentId: string) => + readWorkflowAgentAnswers({ transcriptDir, agentId, configDir }); + write("small", turn); + const edgeAnswer = "x".repeat( + 512 * 1024 + 1 - Buffer.byteLength(assistant("edge", [{ type: "text", text: "" }])), + ); + write("edge", assistant("edge", [{ type: "text", text: edgeAnswer }])); + write( + "boundary", + filler.repeat(160) + turn + " ".repeat(512 * 1024 - Buffer.byteLength(turn)), + ); + write( + "huge", + turn + filler.repeat(160) + assistant("final", [{ type: "text", text: "final answer" }]), + ); + expect(yield* read("small")).toEqual(["the answer"]); + expect(yield* read("edge")).toEqual([edgeAnswer]); + expect(yield* read("huge")).toEqual(["final answer"]); + expect(yield* read("boundary")).toEqual(["the answer"]); + }), + ); +}); diff --git a/apps/server/src/orchestration-v2/workflowAgentAnswers.ts b/apps/server/src/orchestration-v2/workflowAgentAnswers.ts new file mode 100644 index 000000000000..0e8fd9edf5cf --- /dev/null +++ b/apps/server/src/orchestration-v2/workflowAgentAnswers.ts @@ -0,0 +1,97 @@ +// Path joining only; the filesystem access lives in readContainedWorkflowFile. +// @effect-diagnostics nodeBuiltinImport:off +/** + * Reads what a workflow member actually answered. + * + * A member agent never becomes a provider task and emits nothing on the event + * stream, so the transcript the harness writes is the only full record of its + * turn — the progress snapshot carries a capped excerpt. The path is derived + * here from the run's transcript directory and the member's provider id rather + * than accepted from the caller, and the read itself is contained by + * readContainedWorkflowFile. + */ +import * as NodePath from "node:path"; + +import { OrchestrationWorkflowFileError } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; + +import { field, text } from "./unknownField.ts"; +import { readContainedWorkflowFile } from "./workflowFileRead.ts"; + +const TRANSCRIPT_BYTE_CAP = 512 * 1024; + +/** A member id names a file, so anything path-shaped is rejected outright. */ +const AGENT_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/; + +/** + * Collects the assistant text of each turn in the harness's JSONL. + * + * Two shapes have to be handled: `attachment` lines and `isMeta` user lines are + * harness bookkeeping (hook output, skill listings, injected context) and are + * never conversation, and one assistant API message is split across several + * lines sharing a `message.id`, so those are joined into a single turn rather + * than read as separate ones. + */ +export function parseWorkflowAgentAnswers(contents: string): ReadonlyArray { + const turns: string[] = []; + const turnIndexById = new Map(); + + for (const line of contents.split("\n")) { + if (line.trim().length === 0) continue; + let entry: unknown; + try { + entry = JSON.parse(line); + } catch { + // A capped read can cut the final line mid-object; drop it and keep the rest. + continue; + } + if (text(field(entry, "type")) !== "assistant") continue; + if (field(entry, "isMeta") === true) continue; + + const message = field(entry, "message"); + const content = field(message, "content"); + if (!Array.isArray(content)) continue; + const body = content + .filter((block) => text(field(block, "type")) === "text") + .map((block) => field(block, "text")) + .filter((value): value is string => typeof value === "string" && value.trim().length > 0) + .join("\n\n"); + if (body.length === 0) continue; + + const messageId = text(field(message, "id")); + const existing = messageId === undefined ? undefined : turnIndexById.get(messageId); + if (existing !== undefined) { + turns[existing] = `${turns[existing]}\n\n${body}`; + continue; + } + if (messageId !== undefined) turnIndexById.set(messageId, turns.length); + turns.push(body); + } + + return turns; +} + +export const readWorkflowAgentAnswers = Effect.fn("orchestration.readWorkflowAgentAnswers")( + function* (input: { + readonly transcriptDir: string; + readonly agentId: string; + readonly configDir?: string; + }) { + if (!AGENT_ID_PATTERN.test(input.agentId)) { + return yield* new OrchestrationWorkflowFileError({ + reason: "invalid-path", + path: input.agentId, + }); + } + const file = yield* readContainedWorkflowFile({ + path: NodePath.join(input.transcriptDir, `agent-${input.agentId}.jsonl`), + extension: ".jsonl", + byteCap: TRANSCRIPT_BYTE_CAP, + tail: true, + ...(input.configDir === undefined ? {} : { configDir: input.configDir }), + }); + return parseWorkflowAgentAnswers( + file.truncated ? file.contents.slice(file.contents.indexOf("\n") + 1) : file.contents, + ); + }, +); diff --git a/apps/server/src/orchestration-v2/workflowFileRead.ts b/apps/server/src/orchestration-v2/workflowFileRead.ts new file mode 100644 index 000000000000..86c949639402 --- /dev/null +++ b/apps/server/src/orchestration-v2/workflowFileRead.ts @@ -0,0 +1,112 @@ +// @effect-diagnostics nodeBuiltinImport:off +/** + * Contained reads of files the Claude harness persisted for a workflow run. + * + * Containment rules (lifted from the reviewed #3650 inspection service): + * - the resolved realpath must live under the configured Claude projects + * directory (where the harness persists workflow scripts and transcripts) — + * realpath re-containment defeats symlink escapes, including a symlinked + * leaf file; + * - only the caller's expected extension is served; + * - reads are size-capped rather than failed, with a truncation marker. + * + * The client-supplied path is a hint from the workflow's runHandles; it is + * never trusted beyond these checks. + */ +import * as NodeFS from "node:fs"; +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; + +import { OrchestrationWorkflowFileError } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as HostProcess from "@t3tools/shared/HostProcess"; + +function workflowFilesRoot(home: string, configDir?: string): string { + return NodePath.join(configDir?.trim() || NodePath.join(home, ".claude"), "projects"); +} + +export const readContainedWorkflowFile = Effect.fn("orchestration.readContainedWorkflowFile")( + function* (input: { + readonly path: string; + readonly extension: string; + readonly byteCap: number; + readonly tail?: boolean; + readonly configDir?: string; + }) { + if (!NodePath.isAbsolute(input.path) || NodePath.extname(input.path) !== input.extension) { + return yield* new OrchestrationWorkflowFileError({ + reason: "invalid-path", + path: input.path, + }); + } + + const home = yield* HostProcess.HomeDirectory; + const root = yield* Effect.tryPromise({ + try: () => NodeFSP.realpath(workflowFilesRoot(home, input.configDir)), + catch: (cause) => + new OrchestrationWorkflowFileError({ + reason: "root-unavailable", + path: input.path, + cause, + }), + }); + + // Realpath the FILE itself (not just its directory): a symlink named + // like a transcript inside a contained directory must not escape. + const resolved = yield* Effect.tryPromise({ + try: () => NodeFSP.realpath(input.path), + catch: (cause) => + new OrchestrationWorkflowFileError({ reason: "not-found", path: input.path, cause }), + }); + + if (resolved !== root && !resolved.startsWith(`${root}${NodePath.sep}`)) { + return yield* new OrchestrationWorkflowFileError({ reason: "outside-root", path: resolved }); + } + if (NodePath.extname(resolved) !== input.extension) { + return yield* new OrchestrationWorkflowFileError({ + reason: "wrong-extension", + path: resolved, + }); + } + + // fd inode vs path inode catches a swapped leaf, not a swapped intermediate + // dir; root is 0700 ~/.claude, so that already needs the uid we run as. + // Both containment checks fail with their own tagged reason; "read-failed" + // is reserved for genuine platform failures with the real cause attached. + const read = yield* Effect.tryPromise({ + try: async () => { + const handle = await NodeFSP.open( + resolved, + NodeFS.constants.O_RDONLY | (NodeFS.constants.O_NONBLOCK ?? 0), + ); + try { + const stat = await handle.stat(); + if (!stat.isFile()) { + return { failure: "not-regular-file" as const }; + } + const pathStat = await NodeFSP.lstat(resolved); + if (stat.ino !== pathStat.ino || stat.dev !== pathStat.dev) { + return { failure: "changed-during-read" as const }; + } + const buffer = Buffer.alloc(Math.min(stat.size, input.byteCap + (input.tail ? 1 : 0))); + const truncated = stat.size > buffer.length; + const offset = input.tail ? stat.size - buffer.length : 0; + const { bytesRead } = await handle.read(buffer, 0, buffer.length, offset); + return { + contents: buffer.subarray(0, bytesRead).toString("utf8"), + truncated, + }; + } finally { + await handle.close(); + } + }, + catch: (cause) => + new OrchestrationWorkflowFileError({ reason: "read-failed", path: resolved, cause }), + }); + if ("failure" in read) { + return yield* new OrchestrationWorkflowFileError({ reason: read.failure, path: resolved }); + } + + return { path: resolved, contents: read.contents, truncated: read.truncated }; + }, +); diff --git a/apps/server/src/orchestration-v2/workflowScriptQuery.test.ts b/apps/server/src/orchestration-v2/workflowScriptQuery.test.ts index 311b89427697..39f14c53b85c 100644 --- a/apps/server/src/orchestration-v2/workflowScriptQuery.test.ts +++ b/apps/server/src/orchestration-v2/workflowScriptQuery.test.ts @@ -2,6 +2,9 @@ import * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; +import * as NodeChildProcess from "node:child_process"; +import { OrchestrationWorkflowFileError } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { it as effectIt } from "@effect/vitest"; import * as Effect from "effect/Effect"; import { afterAll, assert, describe } from "vite-plus/test"; @@ -30,7 +33,18 @@ afterAll(() => { NodeFS.rmSync(outside, { force: true }); }); -describe("readWorkflowScript containment", () => { +describe("workflow script containment", () => { + effectIt.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( + "rejects a FIFO without waiting for a writer", + () => + Effect.gen(function* () { + const fifo = NodePath.join(root, "pipe.js"); + NodeChildProcess.execFileSync("mkfifo", [fifo]); + const error = yield* Effect.flip(readWorkflowScript({ scriptPath: fifo })); + assert.equal(error.reason, "not-regular-file"); + }), + ); + effectIt.effect("serves a real script under the projects root", () => Effect.gen(function* () { const result = yield* readWorkflowScript({ scriptPath }); @@ -47,6 +61,12 @@ describe("readWorkflowScript containment", () => { readWorkflowScript({ scriptPath: scriptPath.replace(".js", ".ts") }), ); assert.equal(nonJs._tag, "Failure"); + const error = yield* Effect.flip(readWorkflowScript({ scriptPath: "run.js" })); + assert.equal(error._tag, "OrchestrationGetWorkflowScriptError"); + assert.equal(error.scriptPath, "run.js"); + assert.equal(error.reason, "invalid-path"); + assert.equal(error.message, "Workflow scripts must be absolute .js paths."); + assert.instanceOf(error.cause, OrchestrationWorkflowFileError); }), ); diff --git a/apps/server/src/orchestration-v2/workflowScriptQuery.ts b/apps/server/src/orchestration-v2/workflowScriptQuery.ts index ef2ea88c574a..1775202a79a3 100644 --- a/apps/server/src/orchestration-v2/workflowScriptQuery.ts +++ b/apps/server/src/orchestration-v2/workflowScriptQuery.ts @@ -1,128 +1,23 @@ -// @effect-diagnostics nodeBuiltinImport:off -/** - * Read-only access to persisted workflow scripts for the Agents surface's - * "{} script" affordance. - * - * Containment rules (lifted from the reviewed #3650 inspection service): - * - the resolved realpath must live under ~/.claude/projects (where the - * Claude harness persists workflow scripts) — realpath re-containment - * defeats symlink escapes, including a symlinked leaf file; - * - only .js leaf files are served; - * - reads are size-capped rather than failed, with a truncation marker. - * - * The client-supplied path is a hint from the workflow's runHandles; it is - * never trusted beyond these checks. - */ -import * as NodeFSP from "node:fs/promises"; -import * as NodePath from "node:path"; - import { OrchestrationGetWorkflowScriptError } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; -import * as HostProcess from "@t3tools/shared/HostProcess"; - -const SCRIPT_BYTE_CAP = 256 * 1024; - -function scriptsRoot(home: string): string { - return NodePath.join(home, ".claude", "projects"); -} +import { readContainedWorkflowFile } from "./workflowFileRead.ts"; export const readWorkflowScript = Effect.fn("orchestration.readWorkflowScript")(function* (input: { readonly scriptPath: string; }) { - const requested = input.scriptPath; - - if (!NodePath.isAbsolute(requested) || NodePath.extname(requested) !== ".js") { - return yield* new OrchestrationGetWorkflowScriptError({ - reason: "invalid-path", - scriptPath: requested, - }); - } - - const home = yield* HostProcess.HomeDirectory; - const root = yield* Effect.tryPromise({ - try: () => NodeFSP.realpath(scriptsRoot(home)), - catch: (cause) => - new OrchestrationGetWorkflowScriptError({ - reason: "root-unavailable", - scriptPath: requested, - cause, - }), - }); - - // Realpath the FILE itself (not just its directory): a symlink named - // like a script inside a contained directory must not escape. - const resolved = yield* Effect.tryPromise({ - try: () => NodeFSP.realpath(requested), - catch: (cause) => - new OrchestrationGetWorkflowScriptError({ - reason: "not-found", - scriptPath: requested, - cause, - }), - }); - - if (resolved !== root && !resolved.startsWith(`${root}${NodePath.sep}`)) { - return yield* new OrchestrationGetWorkflowScriptError({ - reason: "outside-root", - scriptPath: resolved, - }); - } - if (NodePath.extname(resolved) !== ".js") { - return yield* new OrchestrationGetWorkflowScriptError({ - reason: "not-js", - scriptPath: resolved, - }); - } - - // TOCTOU-safe read (review finding): open FIRST, then verify what was - // actually opened via the file descriptor. Re-checking the path after - // open would race against a swap; fstat on the handle cannot. The two - // containment checks fail with their own tagged reasons (not manufactured - // Errors folded into read-failed); "read-failed" is reserved for genuine - // platform failures with the real cause attached. - const read = yield* Effect.tryPromise({ - try: async () => { - const handle = await NodeFSP.open(resolved, "r"); - try { - const stat = await handle.stat(); - if (!stat.isFile()) { - return { failure: "not-regular-file" as const }; - } - // The opened inode must be the same one realpath resolved to: a - // process swapping the path between realpath and open changes the - // inode, which this comparison catches. - const pathStat = await NodeFSP.lstat(resolved); - if (stat.ino !== pathStat.ino || stat.dev !== pathStat.dev) { - return { failure: "changed-during-read" as const }; - } - const truncated = stat.size > SCRIPT_BYTE_CAP; - const buffer = Buffer.alloc(Math.min(stat.size, SCRIPT_BYTE_CAP)); - const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0); - return { - contents: buffer.subarray(0, bytesRead).toString("utf8"), - truncated, - }; - } finally { - await handle.close(); - } - }, - catch: (cause) => - new OrchestrationGetWorkflowScriptError({ - reason: "read-failed", - scriptPath: resolved, - cause, - }), - }); - if ("failure" in read) { - return yield* new OrchestrationGetWorkflowScriptError({ - reason: read.failure, - scriptPath: resolved, - }); - } - - return { - scriptPath: resolved, - contents: read.contents, - truncated: read.truncated, - }; + const file = yield* readContainedWorkflowFile({ + path: input.scriptPath, + extension: ".js", + byteCap: 256 * 1024, + }).pipe( + Effect.mapError( + (error) => + new OrchestrationGetWorkflowScriptError({ + reason: error.reason === "wrong-extension" ? "not-js" : error.reason, + scriptPath: error.path, + cause: error, + }), + ), + ); + return { scriptPath: file.path, contents: file.contents, truncated: file.truncated }; }); diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index f10af26391c6..bc26ef7f021a 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -212,6 +212,7 @@ const makeTestRelay = Effect.fnUntraced(function* ( settleAfterRun: unused, settleThread: unused, interruptThread: unused, + stopWorkflow: () => Effect.die("unused stopWorkflow"), stopDelegatedTasks: unused, getThreadEventSequence: unused, recoverDelegatedTask: unused, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index f3606bdb83c1..61bad3faf318 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1864,6 +1864,7 @@ const layerWsRpc = ( ), ), ), + [ORCHESTRATION_V2_WS_METHODS.stopWorkflow]: (input) => threadManagement.stopWorkflow(input), [ORCHESTRATION_V2_WS_METHODS.getWorkflowScript]: (input) => readWorkflowScript({ scriptPath: input.scriptPath }), [ORCHESTRATION_V2_WS_METHODS.getTurnItem]: (input) => diff --git a/apps/web/src/components/chat/ThreadLineageWorkflowRow.tsx b/apps/web/src/components/chat/ThreadLineageWorkflowRow.tsx new file mode 100644 index 000000000000..1d85211db9bb --- /dev/null +++ b/apps/web/src/components/chat/ThreadLineageWorkflowRow.tsx @@ -0,0 +1,266 @@ +import type { + SubagentWorkflowGroup, + RuntimeSubagent, +} from "@t3tools/client-runtime/state/subagentRuntime"; +import type { ProviderDriverKind, ProviderInstanceId, ServerProvider } from "@t3tools/contracts"; +import { ChevronDownIcon, LoaderCircleIcon, SquareIcon } from "lucide-react"; +import { useState, type ReactNode } from "react"; + +import { AgentElapsed } from "./AgentElapsed"; +import { SubagentTooltipContent } from "./SubagentTooltipContent"; +import { ThreadHoverCardPopup } from "../ThreadHoverCard"; +import { ThreadRelationshipIcon } from "./ThreadRelationshipIcon"; +import { cn } from "../../lib/utils"; +import { ThreadDetailsControl } from "./ThreadDetailsControl"; +import { CollapsibleSectionHeader, SectionHeaderStatus } from "../ui/collapsible-section-header"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { + THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS, + THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS, +} from "./threadDetailsPanelStyles"; + +/** + * Wall clock for the phase: its members run in parallel, so the span from the + * first start to the last finish is the time the phase actually took. Shaped + * for AgentElapsed, which ticks it while the phase is still running. + */ +function phaseElapsed(phase: SubagentWorkflowGroup["phases"][number]) { + const instants = (key: "startedAt" | "completedAt") => + phase.members + .map((member) => member[key]) + .filter((value) => value !== null) + .sort(); + const running = phase.state === "running"; + const startedAt = instants("startedAt")[0] ?? null; + const completedAt = running ? null : (instants("completedAt").at(-1) ?? null); + return { + status: running ? ("running" as const) : ("completed" as const), + // A settled phase whose members never reported an end has no span to show. + startedAt: running || completedAt !== null ? startedAt : null, + completedAt, + }; +} + +/** Phase state for the header's label; the tone and member badges show it visually. */ +function phaseStatusLabel(phase: SubagentWorkflowGroup["phases"][number]) { + if (phase.state === "running") return "running"; + if (phase.members.some((member) => member.status === "failed")) return "failed"; + if ( + phase.members.some((member) => member.status === "cancelled" || member.status === "interrupted") + ) { + return "stopped"; + } + return phase.state === "done" ? "done" : "not started"; +} + +/** Members run under the coordinator's provider, so they share its glyph. */ +function WorkflowMemberRow({ + member, + providerInstanceId, + provider, + providers, + driver, + onOpen, + isThreadAvailable, +}: { + member: RuntimeSubagent; + providerInstanceId: ProviderInstanceId; + provider: ServerProvider | undefined; + providers: ReadonlyArray | undefined; + driver: ProviderDriverKind | undefined; + onOpen: (threadId: string) => void; + isThreadAvailable: (threadId: string) => boolean; +}) { + const threadId = member.childThreadId; + const canOpen = threadId !== null && isThreadAvailable(threadId); + return ( +
  • + + canOpen && onOpen(threadId)} + /> + } + > + + {member.title} + {member.status} + {member.startedAt ? ( + + + + ) : null} + + + } + status={member.status} + result={member.result ?? member.error} + progress={member.progress} + /> + + +
  • + ); +} + +export function ThreadLineageWorkflowRow({ + group, + header, + providerInstanceId, + provider, + providers, + driver, + onOpenThread, + isThreadAvailable, + onStop, + stopping, + stopDisabled, +}: { + readonly group: SubagentWorkflowGroup; + readonly header: ReactNode; + readonly providerInstanceId: ProviderInstanceId; + readonly provider: ServerProvider | undefined; + readonly providers: ReadonlyArray | undefined; + readonly driver: ProviderDriverKind | undefined; + readonly onOpenThread: (threadId: string) => void; + readonly isThreadAvailable: (threadId: string) => boolean; + readonly onStop?: (() => void) | undefined; + readonly stopping: boolean; + readonly stopDisabled: boolean; +}) { + const [expanded, setExpanded] = useState(false); + // The running phase opens itself until the user picks a side for it. + const [phaseOpen, setPhaseOpen] = useState>(() => new Map()); + const label = group.workflow.workflowName ?? group.workflow.title; + const phases = group.phases.filter((phase) => phase.members.length > 0); + return ( + // The flag lets the lineage list trade its compact height for the open tree. +
  • +
    + {header} + {onStop ? ( +
    + + + } + > + {stopping ? ( + + ) : ( + + )} + + Stop entire workflow + +
    + ) : null} +
    + {expanded ? ( +
      + {phases.map((phase) => { + const open = phaseOpen.get(phase.index) ?? phase.state === "running"; + const status = phaseStatusLabel(phase); + return ( +
    • + setPhaseOpen((phases) => new Map(phases).set(phase.index, !open))} + accessory={ + <> + {status === "failed" ? ( + Failed + ) : null} + + {phase.settledCount}/{phase.members.length}{" "} + {phase.members.length === 1 ? "agent" : "agents"} + + + + + + } + > + {phase.title} + + {open ? ( +
        + {phase.members.map((member) => ( + + ))} +
      + ) : null} +
    • + ); + })} + {group.unphasedMembers.map((member) => ( + + ))} + {phases.length === 0 && group.unphasedMembers.length === 0 ? ( +
    • No agents yet
    • + ) : null} +
    + ) : null} +
  • + ); +} diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.agents.test.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.agents.test.tsx index 16021e9ce71b..d7793287516b 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.agents.test.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.agents.test.tsx @@ -18,6 +18,8 @@ const state = vi.hoisted(() => ({ configs: new Map(), showTooltips: false, command: vi.fn().mockResolvedValue({ _tag: "Success" }), + workflowCommand: vi.fn().mockResolvedValue({ _tag: "Success" }), + toast: vi.fn(), })); vi.mock("@tanstack/react-router", () => ({ useNavigate: () => state.navigate })); @@ -30,7 +32,25 @@ vi.mock("../../state/entities", () => ({ vi.mock("../../lib/archivedThreadsState", () => ({ useArchivedThreadSnapshots: () => ({ snapshots: [] }), })); -vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => state.command })); +vi.mock("../../state/use-atom-command", () => ({ + useAtomCommand: (command: { label: string }) => + command.label === "environment-data:orchestration:stop-workflow" + ? state.workflowCommand + : state.command, +})); +vi.mock("../ui/toast", () => ({ toastManager: { add: state.toast } })); +vi.mock("../../state/orchestration", async () => { + const { Atom } = await import("effect/reactivity"); + const granted = Atom.make(true); + return { + orchestrationEnvironment: { + stopWorkflow: { + label: "environment-data:orchestration:stop-workflow", + permissionAtom: () => granted, + }, + }, + }; +}); vi.mock("../ui/tooltip", () => ({ Tooltip: ({ children }: { children: ReactNode }) => children, TooltipTrigger: ({ render, children }: { render: ReactElement; children: ReactNode }) => @@ -49,17 +69,423 @@ afterEach(async () => { state.projects = []; state.configs.clear(); state.showTooltips = false; + state.navigate.mockClear(); state.command.mockClear(); + state.workflowCommand.mockReset().mockResolvedValue({ _tag: "Success" }); + state.toast.mockClear(); state.projection = null; }); +it("stops the entire native workflow through its parent without changing state before acknowledgement", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + const coordinator = { + id: "workflow", + origin: "provider_native", + driver: "claudeAgent", + providerInstanceId: "claude-work", + childThreadId: "workflow-chat", + title: "Checkout review", + status: "running", + startedAt: DateTime.makeUnsafe("2026-09-21T12:00:00Z"), + completedAt: null, + updatedAt: DateTime.makeUnsafe("2026-09-21T12:00:00Z"), + workflow: { + name: "Checkout review", + phases: [{ index: 0, title: "Inspect" }], + agents: [{ index: 0, label: "Checker", state: "running", phaseIndex: 0 }], + }, + }; + state.projection = { + thread: { id: "parent", lineage: { relationshipToParent: null } }, + runs: [], + providerThreads: [], + providerSessions: [], + contextTransfers: [], + subagents: [ + coordinator, + { + ...coordinator, + id: "worker", + childThreadId: "worker-chat", + origin: "app_owned", + title: "Worker", + workflow: undefined, + }, + ], + }; + state.shells = [ + { + environmentId: "remote", + source: { + id: "workflow-chat", + title: "Coordinator chat", + lineage: { parentThreadId: "parent", relationshipToParent: "subagent" }, + activityRunStatus: "waiting", + }, + }, + ]; + await act(async () => { + renderer = create( + , + ); + }); + await act(async () => + renderer.root.findByProps({ "aria-label": "Expand Checkout review" }).props.onClick(), + ); + const stopButton = () => + renderer.root.findByProps({ "aria-label": "Stop workflow Checkout review" }); + expect( + renderer.root.findAll( + (node) => node.type === "button" && String(node.props["aria-label"]).startsWith("Stop "), + ), + ).toHaveLength(2); + let acknowledge!: (result: { _tag: string }) => void; + state.workflowCommand.mockImplementationOnce( + () => + new Promise((resolve) => { + acknowledge = resolve; + }), + ); + await act(async () => { + stopButton().props.onClick(); + }); + expect(state.workflowCommand).toHaveBeenCalledExactlyOnceWith({ + environmentId: "remote", + input: { threadId: "parent", subagentId: "workflow" }, + }); + expect(state.command).not.toHaveBeenCalled(); + expect(state.navigate).not.toHaveBeenCalled(); + expect(stopButton().props.disabled).toBe(true); + expect(renderer.root.findByProps({ "aria-label": "Stop subagent Worker" }).props.disabled).toBe( + true, + ); + expect(renderer.root.findByProps({ "aria-label": "Open Checker chat" })).toBeDefined(); + expect(renderer.root.findByType("h3").children).toEqual(["Lineage · 2 running"]); + await act(async () => { + acknowledge({ _tag: "Success" }); + }); + expect(stopButton().props.disabled).toBe(false); + expect(renderer.root.findByType("h3").children).toEqual(["Lineage · 2 running"]); + state.workflowCommand.mockResolvedValueOnce({ _tag: "Failure" }); + await act(async () => { + stopButton().props.onClick(); + }); + expect(state.toast).toHaveBeenCalledExactlyOnceWith({ + type: "error", + title: "Could not stop workflow", + }); + expect(stopButton().props.disabled).toBe(false); + expect(state.command).not.toHaveBeenCalled(); + expect(state.navigate).not.toHaveBeenCalled(); +}); + +it.each([ + ["claudeAgent", "provider_native", "completed"], + ["claudeAgent", "provider_native", "failed"], + ["claudeAgent", "provider_native", "interrupted"], + ["claudeAgent", "provider_native", "waiting"], + ["codex", "provider_native", "running"], + ["claudeAgent", "app_owned", "running"], +])("does not offer workflow stop for %s %s %s", async (driver, origin, status) => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + state.projection = { + thread: { id: "parent", lineage: { relationshipToParent: null } }, + runs: [], + providerThreads: [], + providerSessions: [], + contextTransfers: [], + subagents: [ + { + id: "workflow", + driver, + origin, + status, + providerInstanceId: "claude-work", + childThreadId: "workflow-chat", + title: "Checkout review", + startedAt: DateTime.makeUnsafe("2026-09-21T12:00:00Z"), + completedAt: null, + updatedAt: DateTime.makeUnsafe("2026-09-21T12:00:00Z"), + workflow: { name: "Checkout review", phases: [], agents: [] }, + }, + ], + }; + await act(async () => { + renderer = create( + , + ); + }); + const previousAgents = renderer.root.findAll( + (node) => + node.type === "button" && node.props["aria-expanded"] === false && !node.props["aria-label"], + ); + if (previousAgents.length > 0) await act(async () => previousAgents[0]!.props.onClick()); + expect(renderer.root.findByProps({ "aria-label": "Expand Checkout review" })).toBeDefined(); + expect( + renderer.root.findAllByProps({ "aria-label": "Stop workflow Checkout review" }), + ).toHaveLength(0); + expect(state.workflowCommand).not.toHaveBeenCalled(); +}); + +it("opens the correct chat for every workflow phase and unphased member", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + state.showTooltips = true; + state.configs.set("remote", { + providers: [ + { + instanceId: "claude-personal", + driver: "claudeAgent", + displayName: "Personal account", + models: [], + }, + { + instanceId: "claude-work", + driver: "claudeAgent", + displayName: "Work account", + models: [{ slug: "claude-sonnet-4-6", name: "Sonnet" }], + }, + ], + }); + const phases = ["Inspect", "Improve", "Verify"].map((title, index) => ({ index, title })); + const agents = Array.from({ length: 7 }, (_, index) => ({ + index, + label: `Member ${index}`, + state: + index === 2 ? "cancelled" : index === 4 ? "failed" : index === 5 ? "running" : "completed", + ...(index < 6 ? { phaseIndex: Math.floor(index / 2) } : {}), + childThreadId: `member-chat-${index}`, + model: "claude-sonnet-4-6", + result: `Result ${index}`, + totalTokens: 1200, + // Staggered starts, so a phase spans longer than either member's own run. + startedAt: 1_700_000_000_000 + index * 1000, + durationMs: 5000, + })); + const project = (members: typeof agents) => ({ + thread: { id: "parent", lineage: { relationshipToParent: null } }, + runs: [], + providerThreads: [], + providerSessions: [], + contextTransfers: [], + subagents: [ + { + id: "workflow", + driver: "claudeAgent", + providerInstanceId: "claude-work", + childThreadId: "workflow-chat", + title: "Checkout review", + status: "running", + startedAt: null, + completedAt: null, + updatedAt: DateTime.makeUnsafe("2026-09-21T12:00:00Z"), + workflow: { name: "Checkout review", phases, agents: members }, + }, + ], + }); + state.projection = project(agents); + // Matching IDs from another environment cannot make these chats available. + const memberShells = agents.map((agent) => ({ + id: agent.childThreadId, + title: agent.label, + lineage: { parentThreadId: "workflow-chat", relationshipToParent: "subagent" }, + status: "completed", + activityRunStatus: null, + })); + state.shells = memberShells.map((source) => ({ environmentId: "other", source })); + const panel = ( + + ); + await act(async () => { + renderer = create(panel); + }); + await act(async () => + renderer.root.findByProps({ "aria-label": "Expand Checkout review" }).props.onClick(), + ); + const memberButtons = () => + renderer.root.findAll( + (node) => + node.type === "button" && String(node.props["aria-label"]).startsWith("Open Member"), + ); + const phaseButtons = () => + renderer.root.findAll( + (node) => + node.type === "button" && + typeof node.props["aria-expanded"] === "boolean" && + String(node.props["aria-label"]).includes(" phase, "), + ); + // Only the running phase starts open, beside the unphased member. + expect(memberButtons()).toHaveLength(3); + expect(phaseButtons()).toHaveLength(3); + expect(renderer.root.findByProps({ "aria-label": "Expand Inspect phase, done" })).toBeDefined(); + expect( + renderer.root.findByProps({ "aria-label": "Expand Improve phase, stopped" }), + ).toBeDefined(); + expect( + renderer.root.findByProps({ "aria-label": "Collapse Verify phase, running" }), + ).toBeDefined(); + for (const phase of phaseButtons().slice(0, 2)) await act(async () => phase.props.onClick()); + expect(memberButtons()).toHaveLength(7); + for (const button of memberButtons()) { + expect(button.props.disabled).toBe(true); + await act(async () => button.props.onClick()); + } + expect(state.navigate).not.toHaveBeenCalled(); + // The coordinator roster arrives before the member thread shells. + state.shells = memberShells.map((source) => ({ environmentId: "remote", source })); + await act(async () => renderer.update(cloneElement(panel))); + for (const button of memberButtons()) expect(button.props.disabled).toBe(false); + for (const agent of agents) { + await act(async () => + renderer.root.findByProps({ "aria-label": `Open ${agent.label} chat` }).props.onClick(), + ); + expect(state.navigate).toHaveBeenLastCalledWith({ + to: "/$environmentId/$threadId", + params: { environmentId: "remote", threadId: agent.childThreadId }, + }); + } + const rendered = JSON.stringify(renderer.toJSON()); + expect(rendered.match(/Sonnet · Work account/g)).toHaveLength(7); + expect(rendered).not.toContain("Personal account"); + for (const phase of phases) expect(rendered).toContain(phase.title); + // The closed row leaves phase progress to the tree. + expect(rendered).not.toContain(" phases"); + expect(rendered).toContain('"1","/","2"," ","agents"'); + expect(rendered).toContain("Running"); + // The phase holding the running member reports itself as the active one. + expect(rendered).toContain("running"); + expect(rendered).toContain("done"); + expect(rendered).toContain("stopped"); + // Members 0 and 1 start a second apart and run 5s each, so Inspect took 6s. + expect(rendered).toContain('"6s"'); + for (const phase of phaseButtons()) { + const label = String(phase.props["aria-label"]).slice("Collapse ".length); + await act(async () => phase.props.onClick()); + expect(memberButtons()).toHaveLength(5); + await act(async () => + renderer.root.findByProps({ "aria-label": `Expand ${label}` }).props.onClick(), + ); + expect(memberButtons()).toHaveLength(7); + } + // A phase the user closed stays closed once it starts running, and stays + // closed after it settles. + await act(async () => phaseButtons()[0]!.props.onClick()); + expect(memberButtons()).toHaveLength(5); + const restarted = agents.map((agent) => + agent.index === 0 ? { ...agent, state: "running" } : agent, + ); + state.projection = project(restarted); + await act(async () => renderer.update(cloneElement(panel))); + expect(memberButtons()).toHaveLength(5); + state.projection = project(agents); + await act(async () => renderer.update(cloneElement(panel))); + expect(memberButtons()).toHaveLength(5); + await act(async () => + renderer.root.findByProps({ "aria-label": "Collapse Checkout review" }).props.onClick(), + ); + expect(memberButtons()).toHaveLength(0); + expect(state.navigate).toHaveBeenCalledTimes(7); +}); + +it("groups and counts a workflow by its run status instead of its coordinator chat", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + state.showTooltips = true; + const coordinator = { + id: "workflow", + driver: "claudeAgent", + providerInstanceId: "claudeAgent", + childThreadId: "workflow-chat", + title: "Checkout review", + status: "running", + startedAt: DateTime.makeUnsafe("2026-09-21T12:00:00Z"), + progress: "Inspecting the checkout workflow", + result: "Workflow checks complete", + completedAt: null, + updatedAt: DateTime.makeUnsafe("2026-09-21T12:00:00Z"), + workflow: { + name: "Checkout review", + phases: [{ index: 0, title: "Inspect" }], + agents: [{ index: 0, label: "Checker", state: "running", phaseIndex: 0 }], + }, + }; + const projection = { + thread: { id: "parent", lineage: { relationshipToParent: null } }, + runs: [], + providerThreads: [], + providerSessions: [], + contextTransfers: [], + subagents: [coordinator], + }; + const child = { + id: "workflow-chat", + title: "Coordinator chat", + lineage: { parentThreadId: "parent", relationshipToParent: "subagent" }, + status: "completed", + activityRunStatus: null, + }; + state.projection = projection; + state.shells = [{ environmentId: "test", source: child }]; + const panel = ( + + ); + await act(async () => { + renderer = create(panel); + }); + const text = () => + renderer.root + .findAll((node) => typeof node.type === "string") + .flatMap((node) => node.children.filter((child) => typeof child === "string")) + .join(""); + expect(renderer.root.findByType("h3").children).toEqual(["Lineage · 1 running"]); + await act(async () => + renderer.root.findByProps({ "aria-label": "Expand Checkout review" }).props.onClick(), + ); + expect(renderer.root.findByProps({ "aria-label": "Open Checker chat" })).toBeDefined(); + state.shells = [{ environmentId: "test", source: { ...child, activityRunStatus: "waiting" } }]; + await act(async () => renderer.update(cloneElement(panel))); + expect(renderer.root.findByType("h3").children).toEqual(["Lineage · 1 running"]); + expect(text()).toContain("Inspecting the checkout workflow"); + state.projection = { + ...projection, + subagents: [ + { + ...coordinator, + status: "completed", + completedAt: DateTime.makeUnsafe("2026-09-21T12:10:00Z"), + }, + ], + }; + await act(async () => renderer.update(cloneElement(panel))); + expect(renderer.root.findByType("h3").children).toEqual(["Lineage"]); + expect(text()).toContain("Previous agents"); + expect(renderer.root.findAllByProps({ "aria-label": "Open Checker chat" })).toHaveLength(0); + await act(async () => + renderer.root.findByProps({ type: "button", "aria-expanded": false }).props.onClick(), + ); + expect(renderer.root.findByProps({ "aria-label": "Expand Checkout review" })).toBeDefined(); + expect(text()).toContain("Workflow checks complete"); + expect(text()).toContain("10m"); +}); + it.each([ - { driver: "codex", origin: "app_owned" }, - { driver: "claudeAgent", origin: "app_owned" }, - { driver: "claudeAgent", origin: "provider_native" }, + { driver: "codex", origin: "app_owned", runId: null, canStop: true }, + { driver: "claudeAgent", origin: "app_owned", runId: null, canStop: true }, + { driver: "claudeAgent", origin: "provider_native", runId: "run-1", canStop: true }, + { driver: "claudeAgent", origin: "provider_native", runId: null, canStop: false }, ])( - "stops active $origin $driver subagents without opening their thread", - async ({ driver, origin }) => { + "offers individual Stop for supported $origin $driver subagents with run $runId", + async ({ driver, origin, runId, canStop }) => { vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); const parent = { id: "parent", @@ -76,6 +502,7 @@ it.each([ childThreadId: "child", origin, threadId: "parent", + runId, nativeTaskRef: { driver, nativeId: "claude-task", strength: "strong" }, driver, providerInstanceId: "codex", @@ -108,6 +535,12 @@ it.each([ await act(async () => { renderer = create(panel); }); + if (!canStop) { + expect(renderer.root.findAllByProps({ "aria-label": "Stop subagent Worker" })).toHaveLength( + 0, + ); + return; + } const stopButton = () => renderer.root.findByProps({ "aria-label": "Stop subagent Worker" }); await act(async () => stopButton().props.onClick()); expect(state.command).toHaveBeenCalledWith({ @@ -397,11 +830,16 @@ it("shows readable models and only differing workspace details in agent tooltips }; return read(renderer.root); }; - expect(text()).toContain("My GPT · high"); - state.projection = { + const rerender = async (source = child) => { + state.shells = [{ environmentId: "test", source: { ...source } }]; + await act(async () => renderer.update(cloneElement(panel))); + }; + const nativeProjection = { ...projection, subagents: [{ ...projection.subagents[0], origin: "provider_native" }], }; + expect(text()).toContain("My GPT · high"); + state.projection = nativeProjection; await act(async () => renderer.update(cloneElement(panel))); expect(text()).toContain("My GPT"); expect(text()).not.toContain("My GPT · high"); @@ -467,8 +905,7 @@ it("shows readable models and only differing workspace details in agent tooltips [undefined, ""], ] as const) { child.modelSelection.options = options; - state.shells = [{ environmentId: "test", source: { ...child } }]; - await act(async () => renderer.update(cloneElement(panel))); + await rerender(); expect(text()).toContain(`My GPT${expected}`); if (!expected) expect(text()).not.toContain("My GPT ·"); } @@ -532,8 +969,7 @@ it("shows readable models and only differing workspace details in agent tooltips { id: "reasoningEffort", value: "high" }, ...(value === undefined ? [] : [{ id: descriptor.id, value }]), ]; - state.shells = [{ environmentId: "test", source: { ...child } }]; - await act(async () => renderer.update(cloneElement(panel))); + await rerender(); expect(text(true)).toContain("My GPT · high"); expect(text(true)).not.toMatch(/Fast|Ultrafast|Normal|Standard|Flex/); expect(text()).toContain(`My GPT · ${iconLabel}high`); @@ -541,27 +977,11 @@ it("shows readable models and only differing workspace details in agent tooltips child.modelSelection.options = child.modelSelection.options.filter( ({ id }) => id !== "reasoningEffort", ); - state.shells = [{ environmentId: "test", source: { ...child } }]; - await act(async () => renderer.update(cloneElement(panel))); + await rerender(); expect(text()).not.toContain(" · high"); expect(text(true)).not.toMatch(/Fast|Ultrafast|Normal|Standard|Flex/); if (iconLabel) expect(text()).toContain(iconLabel); else expect(text()).not.toContain("mode on"); - state.projection = { - ...projection, - subagents: [{ ...projection.subagents[0], origin: "provider_native" }], - }; - await act(async () => renderer.update(cloneElement(panel))); - expect(text()).not.toMatch(/Fast|Ultrafast|Normal|Standard|Flex| · high/); - state.projection = projection; - for (const modelSelection of [ - { ...child.modelSelection, instanceId: "other" }, - { ...child.modelSelection, model: "gpt-5.5" }, - ]) { - state.shells = [{ environmentId: "test", source: { ...child, modelSelection } }]; - await act(async () => renderer.update(cloneElement(panel))); - expect(text()).not.toMatch(/Fast|Ultrafast|Normal|Standard|Flex| · high/); - } } state.configs.set("test", { providers: [ @@ -589,11 +1009,21 @@ it("shows readable models and only differing workspace details in agent tooltips { id: "reasoningEffort", value: "high" }, { id: "serviceTier", value: "priority" }, ]; - state.shells = [{ environmentId: "test", source: { ...child } }]; - await act(async () => renderer.update(cloneElement(panel))); + await rerender(); expect(text(true)).toContain("My GPT · Work account · high"); expect(text()).toContain("My GPT · Work account · Fast mode onhigh"); expect(text()).not.toContain("Personal account"); + state.projection = nativeProjection; + await act(async () => renderer.update(cloneElement(panel))); + expect(text()).not.toMatch(/Fast|Ultrafast|Normal|Standard|Flex| · high/); + state.projection = projection; + for (const modelSelection of [ + { ...child.modelSelection, instanceId: "other" }, + { ...child.modelSelection, model: "gpt-5.5" }, + ]) { + await rerender({ ...child, modelSelection }); + expect(text()).not.toMatch(/Fast|Ultrafast|Normal|Standard|Flex| · high/); + } state.shells = []; for (const status of ["running", "completed", "failed"] as const) { state.projection = { @@ -629,21 +1059,14 @@ it("shows readable models and only differing workspace details in agent tooltips state.projection = projection; state.configs.set("test", speedConfig); child.modelSelection.options = [{ id: "reasoningEffort", value: "high" }]; - state.shells = [ - { - environmentId: "test", - source: { ...child, modelSelection: { ...child.modelSelection, instanceId: "other" } }, - }, - ]; - await act(async () => renderer.update(cloneElement(panel))); + await rerender({ ...child, modelSelection: { ...child.modelSelection, instanceId: "other" } }); expect(text()).not.toContain("My GPT ·"); const config = state.configs.get("test"); state.configs.clear(); await act(async () => renderer.update(cloneElement(panel))); expect(text()).toContain("GPT-5.4"); expect(text()).not.toContain("GPT-5.4 ·"); - state.shells = [{ environmentId: "test", source: { ...child } }]; - await act(async () => renderer.update(cloneElement(panel))); + await rerender(); expect(text()).toContain("GPT-5.4 · high"); state.configs.set("test", config); for (const model of ["custom/model-v1", "custom/model-v2"]) { @@ -651,13 +1074,10 @@ it("shows readable models and only differing workspace details in agent tooltips ...projection, subagents: [{ ...projection.subagents[0], model }], }; - state.shells = [ - { - environmentId: "test", - source: { ...child, modelSelection: { ...child.modelSelection, model: "custom/model-v1" } }, - }, - ]; - await act(async () => renderer.update(cloneElement(panel))); + await rerender({ + ...child, + modelSelection: { ...child.modelSelection, model: "custom/model-v1" }, + }); expect(text().includes(`${model} · high`)).toBe(model === "custom/model-v1"); } @@ -692,16 +1112,14 @@ it("shows readable models and only differing workspace details in agent tooltips state.projection = projection; child.worktreePath = "/main/worktrees/checker"; - state.shells = [{ environmentId: "test", source: { ...child } }]; - await act(async () => renderer.update(cloneElement(panel))); + await rerender(); expect(text()).toContain("Worktree"); expect(text()).toContain("checker"); expect(text()).not.toContain("/main/worktrees"); expect(text()).not.toContain("Project"); child.branch = "fix/checker"; - state.shells = [{ environmentId: "test", source: { ...child } }]; - await act(async () => renderer.update(cloneElement(panel))); + await rerender(); expect(text()).toContain("Branch"); expect(text()).toContain("fix/checker"); expect(text()).not.toContain("Worktree"); @@ -710,8 +1128,7 @@ it("shows readable models and only differing workspace details in agent tooltips child.projectId = "other"; child.worktreePath = null; child.branch = null; - state.shells = [{ environmentId: "test", source: { ...child } }]; - await act(async () => renderer.update(cloneElement(panel))); + await rerender(); expect(text()).toContain("Other project"); expect(text()).toContain("Workspace"); expect(text()).toContain("other"); diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx index d7f6471eea3c..283abd67c3ec 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx @@ -1,3 +1,4 @@ +import { useAtomValue } from "@effect/atom-react"; import { ThreadDetailsControl } from "./ThreadDetailsControl"; import { ThreadHoverCardPopup } from "../ThreadHoverCard"; import { ThreadDetailsSection } from "./ThreadDetailsSection"; @@ -6,6 +7,7 @@ import { SubagentTooltipContent } from "./SubagentTooltipContent"; import { PullRequestGlyph } from "../pullRequest/pullRequestIcons"; import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; import { + deriveWorkflowGroups, projectedSubagentsToRuntime, type RuntimeSubagent, } from "@t3tools/client-runtime/state/subagentRuntime"; @@ -24,11 +26,12 @@ import { canDetachThreadProviderSession, resolveLatestMergeBackRun, } from "@t3tools/client-runtime/state/thread-workflows"; -import type { - EnvironmentId, - OrchestrationV2Subagent, - OrchestrationV2ThreadShell, - ThreadId, +import { + NodeId, + type EnvironmentId, + type OrchestrationV2Subagent, + type OrchestrationV2ThreadShell, + type ThreadId, } from "@t3tools/contracts"; import { deriveSubagentElapsedMs } from "@t3tools/shared/orchestrationTiming"; import { groupBy } from "effect/Array"; @@ -56,9 +59,11 @@ import { useThreadShells, } from "../../state/entities"; import { threadEnvironment } from "../../state/threads"; +import { orchestrationEnvironment } from "../../state/orchestration"; import { useAtomCommand } from "../../state/use-atom-command"; import { AgentElapsed } from "./AgentElapsed"; import { ThreadRelationshipIcon, threadRelationshipStatusLabel } from "./ThreadRelationshipIcon"; +import { ThreadLineageWorkflowRow } from "./ThreadLineageWorkflowRow"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; @@ -97,11 +102,13 @@ export function ThreadLineageRowList(props: { component: this sits inside an already scrolling panel, where a max-height-only virtual viewport measures badly. Every row is a focusable button, so keyboard users reach and scroll the region through the rows - themselves and the container needs no extra tab stop of its own. + themselves and the container needs no extra tab stop of its own. An + expanded workflow asks for a taller window, so it raises the bound while + its tree is open. */}
      {props.children}
    @@ -136,6 +143,7 @@ function ThreadLineageGroup(props: {
    {props.label ? ( setExpanded(!expanded)} accessory={ @@ -217,28 +225,39 @@ export function ThreadRelationshipsPanel(props: { const ref = scopeThreadRef(props.environmentId, props.threadId); const projection = useThreadProjection(ref)?.projection ?? null; const providers = useServerConfigs().get(props.environmentId)?.providers; - const subagentsByThreadId = useMemo( - () => - new Map( - (projection?.subagents ?? []) - .filter((subagent) => subagent.childThreadId !== null) - .map((subagent) => [ - subagent.childThreadId, - { - ...projectedSubagentsToRuntime([subagent])[0]!, - id: subagent.id, - threadId: subagent.threadId, - nativeTaskRef: subagent.nativeTaskRef, - nativeStatus: subagent.status, - driver: subagent.driver, - providerInstanceId: subagent.providerInstanceId, - origin: subagent.origin, - modelSelection: subagent.modelSelection, - }, - ]), - ), + const runtimeSubagents = useMemo( + () => projectedSubagentsToRuntime(projection?.subagents ?? []), [projection?.subagents], ); + const subagentsByThreadId = useMemo(() => { + const byId = new Map(runtimeSubagents.map((agent) => [agent.id, agent])); + return new Map( + (projection?.subagents ?? []) + .filter((subagent) => subagent.childThreadId !== null) + .map((subagent) => [ + subagent.childThreadId, + { + ...byId.get(subagent.id)!, + id: subagent.id, + threadId: subagent.threadId, + runId: subagent.runId, + nativeTaskRef: subagent.nativeTaskRef, + nativeStatus: subagent.status, + driver: subagent.driver, + providerInstanceId: subagent.providerInstanceId, + origin: subagent.origin, + modelSelection: subagent.modelSelection, + }, + ]), + ); + }, [projection?.subagents, runtimeSubagents]); + // A workflow coordinator is already a subagent row here; the grouped model + // is what lets that row unfold into the phases and members it ran. + const workflowGroupsById = useMemo( + () => + new Map(deriveWorkflowGroups(runtimeSubagents).map((group) => [group.workflow.id, group])), + [runtimeSubagents], + ); const threadShells = useThreadShells(); const projects = useProjects().filter((project) => project.environmentId === props.environmentId); const archived = useArchivedThreadSnapshots([props.environmentId]); @@ -260,8 +279,12 @@ export function ThreadRelationshipsPanel(props: { const mergeBack = useAtomCommand(threadEnvironment.mergeBack); const stopSession = useAtomCommand(threadEnvironment.stopSession); const interruptTurn = useAtomCommand(threadEnvironment.interruptTurn); + const stopWorkflowCommand = useAtomCommand(orchestrationEnvironment.stopWorkflow); + const canOperateWorkflow = useAtomValue( + orchestrationEnvironment.stopWorkflow.permissionAtom(props.environmentId), + ); const [busyAction, setBusyAction] = useState<"merge" | "detach" | null>(null); - const [stoppingThreadId, setStoppingThreadId] = useState(null); + const [stoppingId, setStoppingId] = useState(null); const latestMergeBackRun = projection === null ? null : resolveLatestMergeBackRun(projection); const mergeTargetThreadId = resolveMergeBackTargetThreadId(projection); const relationshipRows = useMemo( @@ -271,8 +294,17 @@ export function ThreadRelationshipsPanel(props: { rows: immediateThreadRelationships(graph, props.threadId), currentThreadId: props.threadId, mergeTargetThreadId, + }).map((row) => { + const agent = + row.edge.kind === "subagent" && !isParentThreadRelationship(row.edge, props.threadId) + ? subagentsByThreadId.get(row.threadId) + : undefined; + const workflowGroup = agent === undefined ? undefined : workflowGroupsById.get(agent.id); + return workflowGroup === undefined + ? row + : { ...row, edge: { ...row.edge, status: workflowGroup.workflow.status } }; }), - [graph, mergeTargetThreadId, props.threadId], + [graph, mergeTargetThreadId, props.threadId, subagentsByThreadId, workflowGroupsById], ); const canMerge = mergeTargetThreadId !== null && latestMergeBackRun !== null; const canDetach = projection ? canDetachThreadProviderSession(projection) : false; @@ -341,8 +373,8 @@ export function ThreadRelationshipsPanel(props: { childThreadId: ThreadId, agent: Pick, ) => { - if (stoppingThreadId !== null) return; - setStoppingThreadId(childThreadId); + if (stoppingId !== null) return; + setStoppingId(childThreadId); const result = await interruptTurn({ environmentId: props.environmentId, input: @@ -350,12 +382,25 @@ export function ThreadRelationshipsPanel(props: { ? { threadId: agent.threadId, subagentId: agent.id } : { threadId: childThreadId }, }); - setStoppingThreadId(null); + setStoppingId(null); if (result._tag === "Failure") { toastManager.add({ type: "error", title: "Could not stop subagent" }); } }; + const stopWorkflow = async (subagentId: string) => { + if (stoppingId !== null) return; + setStoppingId(subagentId); + const result = await stopWorkflowCommand({ + environmentId: props.environmentId, + input: { threadId: props.threadId, subagentId: NodeId.make(subagentId) }, + }); + setStoppingId(null); + if (result._tag === "Failure") { + toastManager.add({ type: "error", title: "Could not stop workflow" }); + } + }; + const parentTitle = mergeTargetThreadId === null ? null @@ -400,33 +445,41 @@ export function ThreadRelationshipsPanel(props: { const isSubagent = edge.kind === "subagent"; const isMergeTarget = threadId === mergeTargetThreadId; const isParent = isParentThreadRelationship(edge, props.threadId); - const status = threadRelationshipRowStatus(graph, { threadId, edge }); + const rowStatus = threadRelationshipRowStatus(graph, { threadId, edge }); const RelationshipIcon = isParent ? CornerLeftUpIcon : isSubagent ? BotIcon : GitForkIcon; const relationship = relationshipLabel(edge, props.threadId); - const agent = currentSubagent( - isSubagent && !isParent ? subagentsByThreadId.get(threadId) : undefined, - node?.thread, - ); - const failed = status === "failed" || status === "error"; + const projectedAgent = + isSubagent && !isParent ? subagentsByThreadId.get(threadId) : undefined; + const workflowGroup = + projectedAgent === undefined + ? undefined + : workflowGroupsById.get(projectedAgent.id); + const agent = workflowGroup + ? projectedAgent + : currentSubagent(projectedAgent, node?.thread); + const failed = rowStatus === "failed" || rowStatus === "error"; const canStop = agent && (agent.origin === "app_owned" || (agent.origin === "provider_native" && agent.driver === "claudeAgent" && + agent.runId != null && agent.nativeTaskRef?.strength === "strong" && agent.nativeTaskRef.nativeId !== null && ["pending", "running", "waiting"].includes(agent.nativeStatus))) && agent.startedAt && ["pending", "running", "waiting"].includes(agent.status); - const trailingVisibilityClass = canStop - ? "group-hover:opacity-0 group-focus-within:opacity-0 pointer-coarse:opacity-0 [@media(hover:none)]:opacity-0" - : ""; const threadTitle = relationshipThreadTitle({ - title: node?.thread?.title ?? agent?.title ?? threadId, + // A workflow's name is shorter than its child thread's title. + title: + workflowGroup?.workflow.workflowName ?? + node?.thread?.title ?? + agent?.title ?? + threadId, isSubagent, }); const provider = providers?.find( @@ -435,6 +488,14 @@ export function ThreadRelationshipsPanel(props: { (agent?.providerInstanceId ?? node?.thread?.providerInstanceId), ); const providerDriver = agent?.driver ?? provider?.driver; + const canStopWorkflow = + canOperateWorkflow && + workflowGroup?.workflow.status === "running" && + agent?.origin === "provider_native" && + providerDriver === "claudeAgent"; + const trailingVisibilityClass = (workflowGroup ? canStopWorkflow : canStop) + ? "group-hover:opacity-0 group-focus-within:opacity-0 pointer-coarse:opacity-0 [@media(hover:none)]:opacity-0" + : ""; const project = projects.find((project) => project.id === node?.thread?.projectId); const relationshipHint = node?.missing ? "This related thread is unavailable" @@ -468,7 +529,7 @@ export function ThreadRelationshipsPanel(props: { driver={isSubagent && !isParent ? providerDriver : undefined} provider={provider} fallbackIcon={RelationshipIcon} - status={status} + status={rowStatus} /> @@ -478,23 +539,68 @@ export function ThreadRelationshipsPanel(props: { {agent ? null : ( )} - {/* One trailing item keeps room for the title: the icon dot already - carries status, so an agent with a known time shows only that. */} - {agent && !failed && deriveSubagentElapsedMs(agent, 0) !== null ? ( + {agent && + !failed && + (deriveSubagentElapsedMs(agent, 0) !== null || canStopWorkflow) ? ( ) : !isMergeTarget ? ( - {threadRelationshipStatusLabel(status)} + {threadRelationshipStatusLabel(rowStatus)} ) : null} ); + // A workflow pairs this row with a disclosure, so it renders as the + // leading half of a split row; everything else renders it directly. + const relationshipLink = ( + + openThread(threadId)} + /> + } + > + {relationshipContent} + + {relationshipTooltip} + + ); + if (workflowGroup && agent) { + return ( + openThread(memberThreadId as ThreadId)} + isThreadAvailable={(memberThreadId) => + graph.nodes.get(memberThreadId as ThreadId)?.missing === false + } + onStop={canStopWorkflow ? () => void stopWorkflow(agent.id) : undefined} + stopping={stoppingId === agent.id} + stopDisabled={stoppingId !== null} + header={relationshipLink} + /> + ); + } return (
  • {isMergeTarget ? ( @@ -507,7 +613,7 @@ export function ThreadRelationshipsPanel(props: { size="sm" variant="ghost" part="primary" - aria-label={`${threadTitle} ${threadRelationshipStatusLabel(status)}`} + aria-label={`${threadTitle} ${threadRelationshipStatusLabel(rowStatus)}`} disabled={node?.missing === true} onClick={() => openThread(threadId)} /> @@ -553,27 +659,11 @@ export function ThreadRelationshipsPanel(props: { - {threadRelationshipStatusLabel(status)} + {threadRelationshipStatusLabel(rowStatus)}
  • ) : ( - - openThread(threadId)} - part="row" - /> - } - > - {relationshipContent} - - {relationshipTooltip} - + relationshipLink )} {canStop && agent ? (
    @@ -586,12 +676,12 @@ export function ThreadRelationshipsPanel(props: { part="icon" tone="destructive" aria-label={`Stop subagent ${threadTitle}`} - disabled={stoppingThreadId !== null} + disabled={stoppingId !== null} onClick={() => void stopSubagent(threadId, agent)} /> } > - {stoppingThreadId === threadId ? ( + {stoppingId === threadId ? ( ) : ( diff --git a/apps/web/src/components/ui/collapsible-section-header.tsx b/apps/web/src/components/ui/collapsible-section-header.tsx index 6a685fe03acd..b87d7a324973 100644 --- a/apps/web/src/components/ui/collapsible-section-header.tsx +++ b/apps/web/src/components/ui/collapsible-section-header.tsx @@ -14,11 +14,13 @@ export function CollapsibleSectionHeader({ children, expanded, tone = "muted", + variant = "default", accessory, ...buttonProps }: Omit, "className" | "style" | "aria-expanded"> & { expanded: boolean; tone?: keyof typeof tones; + variant?: "default" | "panel"; accessory?: ReactNode; }) { return ( @@ -27,7 +29,8 @@ export function CollapsibleSectionHeader({ type="button" aria-expanded={expanded} className={cn( - "flex h-8 w-full cursor-pointer items-center gap-2 rounded-md px-2 text-left text-xs font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70", + "flex h-8 w-full cursor-pointer items-center gap-2 rounded-md text-left text-xs font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70", + variant === "panel" ? "ps-2 pe-2.5" : "px-2", tones[tone].label, )} > diff --git a/packages/client-runtime/src/state/orchestration.ts b/packages/client-runtime/src/state/orchestration.ts index 04341a643733..e0837d570ad7 100644 --- a/packages/client-runtime/src/state/orchestration.ts +++ b/packages/client-runtime/src/state/orchestration.ts @@ -44,6 +44,10 @@ export function createOrchestrationEnvironmentAtoms( staleTimeMs: 300_000, idleTtlMs: 300_000, }), + stopWorkflow: createEnvironmentRpcCommand(runtime, { + label: "environment-data:orchestration:stop-workflow", + tag: ORCHESTRATION_V2_WS_METHODS.stopWorkflow, + }), // Keyed by the item revision, so a live row refetches as its output grows. turnItem: createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:orchestration:turn-item", diff --git a/packages/client-runtime/src/state/subagentRuntime.test.ts b/packages/client-runtime/src/state/subagentRuntime.test.ts new file mode 100644 index 000000000000..1c4756b081f2 --- /dev/null +++ b/packages/client-runtime/src/state/subagentRuntime.test.ts @@ -0,0 +1,220 @@ +import { describe, expect, it } from "vite-plus/test"; +import type { OrchestrationV2Subagent, OrchestrationV2SubagentWorkflow } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; + +import { deriveWorkflowGroups, projectedSubagentsToRuntime } from "./subagentRuntime.ts"; + +const at = (iso: string) => DateTime.makeUnsafe(iso); + +type ProjectedSubagent = Parameters[0][number]; + +function subagent(overrides: Partial = {}): ProjectedSubagent { + return { + id: "coordinator", + title: "probe", + prompt: "export const meta = …", + model: "claude-opus-5[1m]", + status: "running" satisfies OrchestrationV2Subagent["status"], + result: null, + startedAt: at("2026-08-01T10:00:00.000Z"), + completedAt: null, + updatedAt: at("2026-08-01T10:00:05.000Z"), + ...overrides, + }; +} + +const member = ( + overrides: Partial = {}, +): OrchestrationV2SubagentWorkflow["agents"][number] => ({ + index: 1, + label: "alpha:one", + state: "running", + phaseIndex: 1, + phaseTitle: "Alpha", + ...overrides, +}); + +const groupsOf = (subagents: ReadonlyArray) => + deriveWorkflowGroups(projectedSubagentsToRuntime(subagents)); + +describe("projectedSubagentsToRuntime workflow expansion", () => { + it("leaves a plain subagent flat", () => { + const [agent, ...rest] = projectedSubagentsToRuntime([subagent()]); + expect(rest).toEqual([]); + expect(agent?.kind).toBe("subagent"); + expect(agent?.phases).toEqual([]); + }); + + it("projects a coordinator plus one row per member", () => { + const rows = projectedSubagentsToRuntime([ + subagent({ + workflow: { + name: "probe-wf", + phases: [ + { index: 1, title: "Alpha" }, + { index: 2, title: "Beta" }, + ], + agents: [member(), member({ index: 2, label: "alpha:two", state: "queued" })], + totalTokens: 1000, + }, + }), + ]); + expect(rows.map((row) => row.kind)).toEqual(["workflow", "workflow_agent", "workflow_agent"]); + expect(rows[0]?.workflowName).toBe("probe-wf"); + expect(rows[0]?.usage?.totalTokens).toBe(1000); + expect(rows[1]).toMatchObject({ + id: "coordinator:agent:1", + title: "alpha:one", + parentAgentId: "coordinator", + phaseIndex: 1, + phaseTitle: "Alpha", + status: "running", + }); + // A queued member is not started work: it must not report an elapsed clock. + expect(rows[2]?.status).toBe("pending"); + expect(rows[2]?.startedAt).toBeNull(); + }); + + it("derives a settled member's end from its reported duration", () => { + const [, settled] = projectedSubagentsToRuntime([ + subagent({ + workflow: { + phases: [], + agents: [ + member({ + state: "completed", + startedAt: Date.UTC(2026, 7, 1, 10, 0, 0), + durationMs: 1500, + totalTokens: 71_141, + toolCalls: 3, + prompt: "Reply with exactly: A1", + result: "A1", + }), + ], + }, + }), + ]); + expect(settled?.status).toBe("completed"); + expect(settled?.completedAt).toBe("2026-08-01T10:00:01.500Z"); + expect(settled?.result).toBe("A1"); + // The prompt stays on progress so the detail view can show both halves. + expect(settled?.progress).toBe("Reply with exactly: A1"); + expect(settled?.usage).toEqual({ totalTokens: 71_141, toolUses: 3, durationMs: 1500 }); + }); + + it.each(["completed", "cancelled", "failed"] as const)( + "settles unfinished members when the coordinator is %s", + (status) => { + const groups = groupsOf([ + subagent({ + status, + completedAt: at("2026-08-01T10:00:09.000Z"), + workflow: { + phases: [], + agents: [ + member(), + member({ index: 2, state: "queued" }), + member({ index: 3, state: "completed" }), + ], + }, + }), + ]); + // The settled run keeps its members reachable under the coordinator. + expect(groups[0]?.workflow.status).toBe(status); + const members = groups[0]?.phases.flatMap((phase) => phase.members); + expect(members?.map((agent) => agent.status)).toEqual([status, status, "completed"]); + expect(members?.[0]?.completedAt).toBe("2026-08-01T10:00:09.000Z"); + }, + ); + + it("surfaces a retried member through the row's activation count", () => { + const [, retried] = projectedSubagentsToRuntime([ + subagent({ workflow: { phases: [], agents: [member({ attempt: 3 })] } }), + ]); + expect(retried?.activationCount).toBe(3); + expect(retried?.attempt).toBe(3); + }); +}); + +describe("deriveWorkflowGroups over a dynamic workflow", () => { + it("groups members under their phases and counts the run", () => { + const groups = groupsOf([ + subagent({ + workflow: { + name: "probe-wf", + phases: [ + { index: 1, title: "Alpha" }, + { index: 2, title: "Beta" }, + ], + agents: [ + member({ state: "completed" }), + member({ index: 2, label: "alpha:two", state: "failed" }), + member({ index: 3, label: "beta:one", phaseIndex: 2, phaseTitle: "Beta" }), + member({ + index: 4, + label: "beta:two", + phaseIndex: 2, + phaseTitle: "Beta", + state: "queued", + }), + ], + }, + }), + ]); + const group = groups[0]; + expect(group?.phases.map((phase) => [phase.title, phase.state])).toEqual([ + ["Alpha", "done"], + ["Beta", "running"], + ]); + expect(group?.phases.map((phase) => phase.settledCount)).toEqual([2, 0]); + expect(group?.phases.flatMap((phase) => phase.members)).toHaveLength(4); + expect(group?.unphasedMembers).toEqual([]); + }); + + it("shows a phase the script only reached at runtime", () => { + const groups = groupsOf([ + subagent({ + workflow: { + // The declared plan has not caught up with the member's phase yet. + phases: [{ index: 1, title: "Alpha" }], + agents: [ + member({ state: "completed" }), + member({ index: 2, label: "dynamic:late", phaseIndex: 3, phaseTitle: "Gamma" }), + ], + }, + }), + ]); + const group = groups[0]; + expect(group?.phases.map((phase) => phase.title)).toEqual(["Alpha", "Gamma"]); + expect(group?.unphasedMembers).toEqual([]); + }); + + it.each([false, true])("keeps a reported phase title, missing title first=%s", (missingFirst) => { + const agents = [member({ phaseTitle: "Review" }), member({ index: 2, phaseTitle: undefined })]; + const groups = groupsOf([ + subagent({ workflow: { phases: [], agents: missingFirst ? agents.toReversed() : agents } }), + ]); + expect(groups[0]?.phases[0]?.title).toBe("Review"); + }); + + it("keeps unphased members with their workflow and excludes unrelated agents", () => { + const runtime = projectedSubagentsToRuntime([ + subagent({ id: "ordinary" }), + subagent({ + id: "__proto__", + workflow: { + phases: [], + agents: [member({ phaseIndex: undefined, phaseTitle: undefined })], + }, + }), + subagent({ id: "constructor", workflow: { phases: [], agents: [] } }), + subagent({ id: "orphan", workflow: { phases: [], agents: [member()] } }), + ]); + const groups = deriveWorkflowGroups(runtime.filter((agent) => agent.id !== "orphan")); + expect(groups.map((group) => group.workflow.id)).toEqual(["__proto__", "constructor"]); + expect(groups[0]?.phases).toEqual([]); + expect(groups[0]?.unphasedMembers.map((agent) => agent.title)).toEqual(["alpha:one"]); + expect(groups[1]?.phases).toEqual([]); + expect(groups[1]?.unphasedMembers).toEqual([]); + }); +}); diff --git a/packages/client-runtime/src/state/subagentRuntime.ts b/packages/client-runtime/src/state/subagentRuntime.ts index 2b1fc96e54ac..fe9eaea9258f 100644 --- a/packages/client-runtime/src/state/subagentRuntime.ts +++ b/packages/client-runtime/src/state/subagentRuntime.ts @@ -2,8 +2,13 @@ * Subagent status helpers shared by web and mobile, and the runtime shape the * web agent rows render. */ +import { groupBy } from "effect/Array"; import * as DateTime from "effect/DateTime"; -import type { OrchestrationV2Subagent, OrchestrationV2TurnItem } from "@t3tools/contracts"; +import type { + OrchestrationV2Subagent, + OrchestrationV2SubagentWorkflow, + OrchestrationV2TurnItem, +} from "@t3tools/contracts"; import { isOrchestrationV2WorkActive } from "@t3tools/contracts"; export type RuntimeSubagentStatus = @@ -36,11 +41,12 @@ export interface SubagentWorkflowPhase { readonly title: string; } +// Optional-and-undefined, matching the contract's own optionals. export interface SubagentRunHandles { - readonly runId?: string; - readonly scriptPath?: string; - readonly transcriptDir?: string; - readonly sessionUrl?: string; + readonly runId?: string | undefined; + readonly scriptPath?: string | undefined; + readonly transcriptDir?: string | undefined; + readonly sessionUrl?: string | undefined; } export interface RuntimeSubagent { @@ -66,6 +72,8 @@ export interface RuntimeSubagent { readonly workflowName: string | null; readonly phases: ReadonlyArray; readonly runHandles: SubagentRunHandles | null; + /** The thread this agent owns, when it has one. */ + readonly childThreadId: string | null; readonly recentActivity: ReadonlyArray; /** First retained observation, used as the roster's stable display order. */ readonly firstSeenAt: string; @@ -91,18 +99,105 @@ export function isActiveSubagentStatus(status: RuntimeSubagentStatus): boolean { return isOrchestrationV2WorkActive(status); } -/** - * A subagent card whose child is still working. The card outlives its - * launching turn: once that turn settles, the waiting footer counts the child, - * so the card stays out of the turn's fold until the child finishes. - */ export function isLiveSubagentTurnItem(item: OrchestrationV2TurnItem): boolean { return item.type === "subagent" && isOrchestrationV2WorkActive(item.status); } +/** A workflow coordinator with its members grouped by phase, as Lineage renders it. */ +export interface SubagentWorkflowGroup { + readonly workflow: RuntimeSubagent; + readonly phases: ReadonlyArray<{ + readonly index: number; + readonly title: string; + readonly members: ReadonlyArray; + /** done = every member settled (success or error); running = any active. */ + readonly state: "pending" | "running" | "done"; + readonly settledCount: number; + }>; + /** Members with no resolvable phase (orphans render under the workflow). */ + readonly unphasedMembers: ReadonlyArray; +} + +function workflowUsage(source: { + readonly totalTokens?: number | undefined; + readonly toolCalls?: number | undefined; + readonly durationMs?: number | undefined; +}): SubagentUsage | null { + if (source.totalTokens === undefined) return null; + return { + totalTokens: source.totalTokens, + ...(source.toolCalls === undefined ? {} : { toolUses: source.toolCalls }), + ...(source.durationMs === undefined ? {} : { durationMs: source.durationMs }), + }; +} + +function isoFromEpochMillis(value: number | undefined): string | null { + return value === undefined ? null : DateTime.formatIso(DateTime.makeUnsafe(value)); +} + +/** + * Expands a coordinator's workflow roster into member rows. Members are + * synthesized from the coordinator's snapshot rather than read from their own + * subagent entities, so a parent thread's Lineage can show them without + * loading the coordinator's child thread; their identity is the coordinator id + * plus the spawn ordinal. + */ +function workflowMembersToRuntime( + coordinator: RuntimeSubagent, + workflow: OrchestrationV2SubagentWorkflow, +): ReadonlyArray { + return workflow.agents.map((agent) => { + // Every member state but queued is already a runtime status. + const status = + (agent.state === "queued" || agent.state === "running") && + isTerminalSubagentStatus(coordinator.status) + ? coordinator.status + : agent.state === "queued" + ? "pending" + : agent.state; + const failed = status === "failed"; + const startedAt = isoFromEpochMillis(agent.startedAt); + // The provider reports a settled member's duration, not its end instant. + const completedAt = isTerminalSubagentStatus(status) + ? agent.startedAt !== undefined && agent.durationMs !== undefined + ? isoFromEpochMillis(agent.startedAt + agent.durationMs) + : coordinator.completedAt + : null; + return { + // Members share the run's handles: the transcript directory is what + // makes their conversation readable, and only the run knows it. + ...coordinator, + id: `${coordinator.id}:agent:${agent.index}`, + kind: "workflow_agent" as const, + title: agent.label, + model: agent.model ?? null, + status, + // A member the workflow retried counts each attempt as a run. + activationCount: agent.attempt ?? 1, + usage: workflowUsage(agent), + // A member keeps its prompt on `progress` even once settled: that is the + // question half of its conversation, and the detail view shows both halves. + progress: agent.prompt ?? null, + result: failed ? null : (agent.result ?? null), + error: failed ? (agent.result ?? null) : null, + parentAgentId: coordinator.id, + agentIndex: agent.index, + phaseIndex: agent.phaseIndex ?? null, + phaseTitle: agent.phaseTitle ?? null, + attempt: agent.attempt ?? null, + phases: [], + childThreadId: agent.childThreadId ?? null, + recentActivity: [], + firstSeenAt: isoFromEpochMillis(agent.queuedAt) ?? startedAt ?? coordinator.firstSeenAt, + startedAt, + completedAt, + updatedAt: completedAt ?? startedAt ?? coordinator.firstSeenAt, + } satisfies RuntimeSubagent; + }); +} + /** - * Projects orchestration-v2 subagent entities into the runtime shape the web - * agent rows render. + * Projects subagents and workflow members into the runtime roster. */ export function projectedSubagentsToRuntime( subagents: ReadonlyArray<{ @@ -112,18 +207,23 @@ export function projectedSubagentsToRuntime( readonly model: string | null; readonly status: OrchestrationV2Subagent["status"]; readonly progress?: string | undefined; + readonly childThreadId?: string | null | undefined; + readonly workflow?: OrchestrationV2SubagentWorkflow | undefined; readonly result: string | null; readonly startedAt: DateTime.Utc | null; readonly completedAt: DateTime.Utc | null; readonly updatedAt: DateTime.Utc; }>, ): ReadonlyArray { - return subagents.map((subagent) => { + return subagents.flatMap((subagent) => { const updatedAt = DateTime.formatIso(subagent.updatedAt); const startedAt = subagent.startedAt === null ? null : DateTime.formatIso(subagent.startedAt); - return { + const firstSeenAt = startedAt ?? updatedAt; + const { workflow } = subagent; + const runHandles = workflow?.runHandles ?? null; + const coordinator = { id: subagent.id, - kind: "subagent" as const, + kind: workflow === undefined ? "subagent" : "workflow", title: subagent.title ?? (subagent.prompt.length > 80 ? `${subagent.prompt.slice(0, 77)}...` : subagent.prompt), @@ -132,7 +232,7 @@ export function projectedSubagentsToRuntime( effort: null, status: subagent.status, activationCount: 1, - usage: null, + usage: workflow === undefined ? null : workflowUsage(workflow), progress: subagent.progress ?? null, lastToolName: null, result: subagent.result, @@ -143,14 +243,78 @@ export function projectedSubagentsToRuntime( phaseIndex: null, phaseTitle: null, attempt: null, - workflowName: null, - phases: [], - runHandles: null, + workflowName: workflow?.name ?? null, + phases: workflow?.phases ?? [], + runHandles, + childThreadId: subagent.childThreadId ?? null, recentActivity: [], - firstSeenAt: startedAt ?? updatedAt, + firstSeenAt, startedAt, completedAt: subagent.completedAt === null ? null : DateTime.formatIso(subagent.completedAt), updatedAt, } satisfies RuntimeSubagent; + return workflow === undefined + ? [coordinator] + : [coordinator, ...workflowMembersToRuntime(coordinator, workflow)]; + }); +} + +export function deriveWorkflowGroups( + agents: ReadonlyArray, +): ReadonlyArray { + const workflows = agents + .filter((agent) => agent.kind === "workflow") + .sort((a, b) => a.firstSeenAt.localeCompare(b.firstSeenAt) || a.id.localeCompare(b.id)); + const members = groupBy( + agents.filter((agent) => agent.kind !== "workflow" && agent.parentAgentId !== null), + (agent) => agent.parentAgentId!, + ); + + return workflows.map((workflow) => { + const workflowMembers = Object.hasOwn(members, workflow.id) ? members[workflow.id]! : []; + // Union, not either/or: the declared plan lags the members, because the + // provider only admits a phase once something in it starts. Members seed + // the map; the declared titles then overwrite whatever they guessed. + const phaseTitles = new Map(); + for (const member of workflowMembers) { + if (member.phaseIndex === null) continue; + phaseTitles.set( + member.phaseIndex, + member.phaseTitle ?? phaseTitles.get(member.phaseIndex) ?? `Phase ${member.phaseIndex + 1}`, + ); + } + for (const phase of workflow.phases) phaseTitles.set(phase.index, phase.title); + const knownPhases = Array.from(phaseTitles.entries()) + .map(([index, title]) => ({ index, title })) + .sort((a, b) => a.index - b.index); + + const phases = knownPhases.map((phase) => { + const phaseMembers = workflowMembers + .filter((member) => member.phaseIndex === phase.index) + .sort((a, b) => (a.agentIndex ?? 0) - (b.agentIndex ?? 0)); + const hasActiveMember = phaseMembers.some( + // Idle members count as active for phase-liveness: a resumable Codex + // member has not finished the phase. + (member) => isActiveSubagentStatus(member.status) || member.status === "idle", + ); + const settledCount = phaseMembers.filter((member) => + isTerminalSubagentStatus(member.status), + ).length; + const state: "pending" | "running" | "done" = + phaseMembers.length === 0 ? "pending" : hasActiveMember ? "running" : "done"; + return { + index: phase.index, + title: phase.title, + members: phaseMembers, + state, + settledCount, + }; + }); + + const unphasedMembers = workflowMembers + .filter((member) => member.phaseIndex === null) + .sort((a, b) => (a.agentIndex ?? 0) - (b.agentIndex ?? 0)); + + return { workflow, phases, unphasedMembers }; }); } diff --git a/packages/client-runtime/src/t3ToolSummary.test.ts b/packages/client-runtime/src/t3ToolSummary.test.ts index 8ae001ea7769..45868cc70fdc 100644 --- a/packages/client-runtime/src/t3ToolSummary.test.ts +++ b/packages/client-runtime/src/t3ToolSummary.test.ts @@ -248,4 +248,21 @@ describe("summarizeT3ToolCalls", () => { ]).label, ).toBe("Requested deletion of 1 scheduled task"); }); + + it("counts workflow stop requests by coordinator and excludes failed requests", () => { + const first = completed({ threadId: "parent", subagentId: "workflow-1" }, {}); + const failed = completed({ threadId: "parent", subagentId: "workflow-3" }, { isError: true }); + expect( + summarizeT3ToolCalls("workflow-stop", [ + first, + first, + completed({ threadId: "parent", subagentId: "workflow-2" }, {}), + failed, + ]), + ).toEqual({ label: "Requested stops for 2 workflows", failedCount: 1 }); + expect(summarizeT3ToolCalls("workflow-stop", [failed])).toEqual({ + label: "Tried to stop 1 workflow", + failedCount: 1, + }); + }); }); diff --git a/packages/client-runtime/src/t3ToolSummary.ts b/packages/client-runtime/src/t3ToolSummary.ts index b9a46fa65287..101950dd6a5d 100644 --- a/packages/client-runtime/src/t3ToolSummary.ts +++ b/packages/client-runtime/src/t3ToolSummary.ts @@ -168,6 +168,13 @@ export function summarizeT3ToolCalls( quantity(countEntities(threadIds), "thread"), ); break; + case "workflow-stop": + label = phrase( + "Requested stops for", + "stop", + quantity(countEntities(entityIds("subagentId")), "workflow"), + ); + break; case "task-status": label = phrase("Checked", "check", `task status ${times}`); break; diff --git a/packages/client-runtime/src/work-log/presentation.test.ts b/packages/client-runtime/src/work-log/presentation.test.ts index 6e41a53478a1..241215310aad 100644 --- a/packages/client-runtime/src/work-log/presentation.test.ts +++ b/packages/client-runtime/src/work-log/presentation.test.ts @@ -308,6 +308,7 @@ describe("resolveWorkEntryToolPresentation", () => { ["t3_thread_fork", "Forking this thread", "Requested a fork of this thread"], ["t3_thread_send_attachments", "Sending attachments", "Sent attachments"], ["run_scheduled_task_now", "Running a scheduled task", "Requested a run of a scheduled task"], + ["t3_workflow_stop", "Stopping a workflow", "Requested a stop for a workflow"], ])("labels %s through its lifecycle", (tool, running, completed) => { expect(resolveWorkEntryToolPresentation({ label: `T3-code.${tool}` })?.displayName).toBe( running, @@ -320,6 +321,47 @@ describe("resolveWorkEntryToolPresentation", () => { ).toBe(completed); }); + it("summarizes workflow stop targets separately from their parent thread", () => { + const entry = { + id: "workflow-stop", + createdAt: "2026-10-07T00:00:00.000Z", + tone: "tool", + label: "Custom title", + itemType: "dynamic_tool", + toolLifecycleStatus: "completed", + toolData: { + server: "t3-code", + tool: "t3_workflow_stop", + arguments: { threadId: "parent", subagentId: "workflow-1" }, + result: {}, + }, + } satisfies WorkLogPresentationEntry; + expect( + summarizeToolGroup([ + entry, + entry, + { + ...entry, + toolData: { + ...entry.toolData, + arguments: { threadId: "parent", subagentId: "workflow-2" }, + }, + }, + { + ...entry, + toolData: { + ...entry.toolData, + tool: "t3_thread_interrupt", + arguments: { threadId: "parent" }, + }, + }, + ]), + ).toEqual({ + summary: "Requested stops for 2 workflows and requested interrupts for 1 thread", + hasFailure: false, + }); + }); + it("summarizes project tools from MCP arguments and results without claiming failed effects", () => { const entry: WorkLogPresentationEntry = { id: "clone", diff --git a/packages/client-runtime/src/work-log/presentation.ts b/packages/client-runtime/src/work-log/presentation.ts index 8ef6f58be595..90c33b789a64 100644 --- a/packages/client-runtime/src/work-log/presentation.ts +++ b/packages/client-runtime/src/work-log/presentation.ts @@ -609,6 +609,7 @@ function summaryActionPriority(action: ToolGroupAction | T3McpToolSummaryAction) case "thread-create": case "thread-send": case "thread-interrupt": + case "workflow-stop": case "schedule-create": case "schedule-update": case "schedule-delete": diff --git a/packages/contracts/src/clientRpcPermissions.ts b/packages/contracts/src/clientRpcPermissions.ts index 533f7b75e888..90b683648cb7 100644 --- a/packages/contracts/src/clientRpcPermissions.ts +++ b/packages/contracts/src/clientRpcPermissions.ts @@ -6,6 +6,7 @@ import { AuthSourceControlWriteScope, type AuthEnvironmentScope, } from "./auth.ts"; +import { ORCHESTRATION_V2_WS_METHODS } from "./orchestrationV2.ts"; import { WS_METHODS } from "./rpc.ts"; /** Incremental client enforcement; the server still authorizes every request. */ @@ -43,6 +44,7 @@ export const CLIENT_GUARDED_RPC_SCOPES = { [WS_METHODS.scheduledTasksDelete]: AuthOrchestrationOperateScope, [WS_METHODS.scheduledTasksRunNow]: AuthOrchestrationOperateScope, [WS_METHODS.scheduledTasksRotateWebhookToken]: AuthOrchestrationOperateScope, + [ORCHESTRATION_V2_WS_METHODS.stopWorkflow]: AuthOrchestrationOperateScope, } as const; export type ClientGuardedRpcTag = keyof typeof CLIENT_GUARDED_RPC_SCOPES; diff --git a/packages/contracts/src/orchestrationV2.test.ts b/packages/contracts/src/orchestrationV2.test.ts index cd185f908d5c..bca9ff656ebe 100644 --- a/packages/contracts/src/orchestrationV2.test.ts +++ b/packages/contracts/src/orchestrationV2.test.ts @@ -36,6 +36,7 @@ import { OrchestrationV2ShellSnapshot, OrchestrationV2SubscribeThreadInput, OrchestrationV2Subagent, + OrchestrationV2SubagentJson, OrchestrationV2ThreadHistoryPage, OrchestrationV2ThreadProjection, OrchestrationV2ThreadStreamItem, @@ -112,6 +113,7 @@ const decodeOrchestrationV2Checkpoint = Schema.decodeUnknownSync(OrchestrationV2 const decodeOrchestrationV2DomainEvent = Schema.decodeUnknownSync(OrchestrationV2DomainEvent); const decodeProviderReplayTranscript = Schema.decodeUnknownSync(ProviderReplayTranscript); const decodeOrchestrationV2Subagent = Schema.decodeUnknownSync(OrchestrationV2Subagent); +const encodeOrchestrationV2SubagentJson = Schema.encodeSync(OrchestrationV2SubagentJson); const decodeOrchestrationV2ThreadProjection = Schema.decodeUnknownSync( OrchestrationV2ThreadProjection, ); @@ -929,6 +931,68 @@ describe("orchestration V2 contracts", () => { expect(turnItem.progress).toBe("Inspecting package metadata"); }); + it.each([" indented prompt\n", "\n \t\n"])( + "preserves workflow prompt and result %j in stored and wire JSON", + (preview) => { + const subagent = { + ...decodeOrchestrationV2Subagent({ + id: "workflow-coordinator", + threadId: "thread-1", + runId: "run-1", + parentNodeId: "node-root-1", + origin: "provider_native", + createdBy: "agent", + driver: "claudeAgent", + providerInstanceId: "claudeAgent", + providerThreadId: null, + childThreadId: null, + nativeTaskRef: null, + prompt: "Run workflow", + title: null, + model: null, + status: "running", + result: null, + startedAt: now, + completedAt: null, + updatedAt: now, + }), + workflow: { + phases: [], + agents: [ + { + index: 1, + label: "Member", + state: "completed" as const, + prompt: preview, + result: preview, + }, + ], + }, + }; + const { workflow, ...coordinator } = subagent; + const incoming = { ...encodeOrchestrationV2SubagentJson(coordinator), workflow }; + for (const codec of [ + OrchestrationV2SubagentJson, + Schema.toCodecJson(OrchestrationV2Subagent), + ]) { + const encode = Schema.encodeSync(codec); + const decode = Schema.decodeUnknownSync(codec); + const json: unknown = JSON.parse(JSON.stringify(encode(subagent))); + expect(json).toMatchObject({ + workflow: { agents: [{ prompt: preview, result: preview }] }, + }); + expect(decode(json).workflow?.agents[0]).toMatchObject({ + prompt: preview, + result: preview, + }); + expect(decode(incoming).workflow?.agents[0]).toMatchObject({ + prompt: preview, + result: preview, + }); + } + }, + ); + it("decodes app-owned subagent parent-wake policies", () => { const appOwnedSubagent = { id: "node-subagent-2", diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 842f0576f290..4822493765a8 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -664,6 +664,86 @@ export const OrchestrationV2ExecutionNode = Schema.Struct({ }); export type OrchestrationV2ExecutionNode = typeof OrchestrationV2ExecutionNode.Type; +/** + * One entry of a dynamic workflow's phase plan. The plan grows while the run + * executes: a phase a script only reaches at runtime joins the list when it + * starts, so a client must treat the list as append-only rather than as a + * fixed arc declared up front. + */ +export const OrchestrationV2WorkflowPhase = Schema.Struct({ + index: NonNegativeInt, + title: TrimmedNonEmptyString, +}); +export type OrchestrationV2WorkflowPhase = typeof OrchestrationV2WorkflowPhase.Type; + +/** + * One agent inside a workflow run. Timestamps are epoch milliseconds rather + * than `DateTimeUtc` because only the top-level entity fields are remapped for + * the JSON wire shape (see `OrchestrationV2SubagentJson`), so a nested + * `DateTimeUtc` would never be encoded. + */ +export const OrchestrationV2WorkflowAgent = Schema.Struct({ + /** Spawn ordinal within the run. Stable identity: an agent keeps it across updates. */ + index: NonNegativeInt, + label: TrimmedNonEmptyString, + /** + * Provider-assigned id, absent until the agent actually starts. Names the + * member's transcript file inside the run's transcript directory. + */ + agentId: Schema.optional(TrimmedNonEmptyString), + /** The member's own thread, so the UI can open it like any subagent's. */ + childThreadId: Schema.optional(ThreadId), + state: Schema.Literals(["queued", "running", "completed", "failed"]), + phaseIndex: Schema.optional(NonNegativeInt), + phaseTitle: Schema.optional(TrimmedNonEmptyString), + model: Schema.optional(TrimmedNonEmptyString), + /** Retry counter; > 1 means the agent was restarted after a provider error. */ + attempt: Schema.optional(NonNegativeInt), + totalTokens: Schema.optional(NonNegativeInt), + toolCalls: Schema.optional(NonNegativeInt), + durationMs: Schema.optional(NonNegativeInt), + queuedAt: Schema.optional(NonNegativeInt), + startedAt: Schema.optional(NonNegativeInt), + /** + * Prompt and result excerpts. A workflow member's whole conversation is one + * prompt and one answer, so together these are its transcript as far as the + * provider exposes it — both are capped by the CLI at a few hundred + * characters. + */ + prompt: Schema.optional(Schema.NonEmptyString), + result: Schema.optional(Schema.NonEmptyString), +}); +export type OrchestrationV2WorkflowAgent = typeof OrchestrationV2WorkflowAgent.Type; + +/** + * Filesystem handles the provider reports when a run launches. The transcript + * directory is what makes a member's full answer readable: the progress + * snapshot only carries a capped excerpt, so the server reads the member's + * transcript from here to fill its thread. + */ +export const OrchestrationV2WorkflowRunHandles = Schema.Struct({ + runId: Schema.optional(TrimmedNonEmptyString), + transcriptDir: Schema.optional(TrimmedNonEmptyString), + scriptPath: Schema.optional(TrimmedNonEmptyString), +}); +export type OrchestrationV2WorkflowRunHandles = typeof OrchestrationV2WorkflowRunHandles.Type; + +/** + * Workflow-run shape carried by the coordinator subagent. The snapshot is the + * provider's own replace-in-full view of the run; members are additionally + * projected as subagents in their own right. + */ +export const OrchestrationV2SubagentWorkflow = Schema.Struct({ + name: Schema.optional(TrimmedNonEmptyString), + runHandles: Schema.optional(OrchestrationV2WorkflowRunHandles), + phases: Schema.Array(OrchestrationV2WorkflowPhase), + agents: Schema.Array(OrchestrationV2WorkflowAgent), + totalTokens: Schema.optional(NonNegativeInt), + toolCalls: Schema.optional(NonNegativeInt), + durationMs: Schema.optional(NonNegativeInt), +}); +export type OrchestrationV2SubagentWorkflow = typeof OrchestrationV2SubagentWorkflow.Type; + export const OrchestrationV2Subagent = Schema.Struct({ id: NodeId, threadId: ThreadId, @@ -698,6 +778,8 @@ export const OrchestrationV2Subagent = Schema.Struct({ "interrupted", ]), progress: Schema.optional(Schema.String), + /** Present only on a workflow coordinator task; absent on ordinary subagents. */ + workflow: Schema.optional(OrchestrationV2SubagentWorkflow), result: Schema.NullOr(Schema.String), startedAt: Schema.NullOr(Schema.DateTimeUtc), completedAt: Schema.NullOr(Schema.DateTimeUtc), @@ -3163,6 +3245,7 @@ export const ORCHESTRATION_V2_WS_METHODS = { getArchivedShellSnapshot: "orchestration.getArchivedShellSnapshot", getThreadProjection: "orchestration.getThreadProjection", getWorkflowScript: "orchestration.getWorkflowScript", + stopWorkflow: "orchestration.stopWorkflow", getTurnItem: "orchestration.getTurnItem", launchThread: "orchestration.launchThread", subscribeArchivedShell: "orchestration.subscribeArchivedShell", @@ -3448,11 +3531,41 @@ export class OrchestrationV2ThreadLaunchError extends Schema.TaggedError()( + "OrchestrationV2StopWorkflowError", + { + threadId: ThreadId, + subagentId: NodeId, + reason: Schema.Literals(["not-running", "unsupported", "unavailable", "stop-failed"]), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + switch (this.reason) { + case "not-running": + return "This workflow is no longer running."; + case "unsupported": + return "This provider cannot stop a workflow."; + case "unavailable": + return "This workflow has no live provider task."; + case "stop-failed": + return "Failed to stop the workflow."; + } + } +} + export const OrchestrationV2RpcError = Schema.Union([ OrchestrationV2DispatchCommandError, OrchestrationV2GetThreadProjectionError, OrchestrationV2GetShellSnapshotError, OrchestrationV2ThreadLaunchError, + OrchestrationV2StopWorkflowError, ]); export type OrchestrationV2RpcError = typeof OrchestrationV2RpcError.Type; @@ -3473,6 +3586,44 @@ export const OrchestrationV2GetWorkflowScriptResult = Schema.Struct({ export type OrchestrationV2GetWorkflowScriptResult = typeof OrchestrationV2GetWorkflowScriptResult.Type; +/** + * Shared by every read of a file the Claude harness persisted for a workflow + * run — the script and each member's transcript. One vocabulary because both + * reads apply identical containment rules. + */ +const WORKFLOW_FILE_ERROR_MESSAGES = { + "invalid-path": "Workflow files must be absolute paths with the expected extension.", + "root-unavailable": "Workflow file root unavailable.", + "not-found": "Workflow file not found.", + "outside-root": "Path is outside the workflow files root.", + "wrong-extension": "Resolved file has the wrong extension.", + "not-regular-file": "Workflow file is not a regular file.", + "changed-during-read": "Workflow file changed between resolution and open.", + "read-failed": "Workflow file read failed.", +} as const; + +export class OrchestrationWorkflowFileError extends Schema.TaggedError()( + "OrchestrationWorkflowFileError", + { + reason: Schema.Literals([ + "invalid-path", + "root-unavailable", + "not-found", + "outside-root", + "wrong-extension", + "not-regular-file", + "changed-during-read", + "read-failed", + ]), + path: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return WORKFLOW_FILE_ERROR_MESSAGES[this.reason]; + } +} + export const OrchestrationV2GetTurnItemInput = Schema.Struct({ threadId: ThreadId, itemId: TurnItemId, @@ -3601,6 +3752,10 @@ export const OrchestrationV2RpcSchemas = { input: OrchestrationV2GetThreadProjectionInput, output: OrchestrationV2ThreadProjection, }, + stopWorkflow: { + input: OrchestrationV2StopWorkflowInput, + output: Schema.Void, + }, getWorkflowScript: { input: OrchestrationV2GetWorkflowScriptInput, output: OrchestrationV2GetWorkflowScriptResult, diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index 61ee976e9e7d..0738426a4449 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -399,6 +399,7 @@ export const OrchestratorMcpThreadTimelineItem = Schema.Struct({ visibility: Schema.Literals(["local", "inherited", "synthetic"]), sourceThreadId: ThreadId, itemId: TurnItemId, + subagentId: Schema.optional(NodeId), runId: Schema.NullOr(RunId), messageId: Schema.NullOr(MessageId), createdBy: Schema.NullOr(OrchestrationV2Actor), diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 420930a62376..225a38176e15 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -208,6 +208,7 @@ import { import { ORCHESTRATION_V2_WS_METHODS, OrchestrationGetWorkflowScriptError, + OrchestrationV2StopWorkflowError, OrchestrationV2DispatchCommandError, OrchestrationV2GetShellSnapshotError, OrchestrationV2GetThreadProjectionError, @@ -1662,6 +1663,12 @@ const WsOrchestrationV2GetThreadProjectionRpc = Rpc.make( }, ); +const WsOrchestrationV2StopWorkflowRpc = Rpc.make(ORCHESTRATION_V2_WS_METHODS.stopWorkflow, { + payload: OrchestrationV2RpcSchemas.stopWorkflow.input, + success: OrchestrationV2RpcSchemas.stopWorkflow.output, + error: Schema.Union([OrchestrationV2StopWorkflowError, EnvironmentAuthorizationError]), +}); + const WsOrchestrationV2GetWorkflowScriptRpc = Rpc.make( ORCHESTRATION_V2_WS_METHODS.getWorkflowScript, { @@ -2032,6 +2039,7 @@ export const WsRpcGroup = RpcGroup.make( WsSubscribeResourceTelemetryRpc, WsOrchestrationV2DispatchCommandRpc, WsOrchestrationV2GetWorkflowScriptRpc, + WsOrchestrationV2StopWorkflowRpc, WsOrchestrationV2GetTurnItemRpc, WsOrchestrationV2GetTurnDiffRpc, WsOrchestrationV2GetFullThreadDiffRpc, diff --git a/packages/provider-core/src/server/ProviderAdapter.ts b/packages/provider-core/src/server/ProviderAdapter.ts index 9094e8989fa4..809dbfac0110 100644 --- a/packages/provider-core/src/server/ProviderAdapter.ts +++ b/packages/provider-core/src/server/ProviderAdapter.ts @@ -595,6 +595,10 @@ export interface ProviderAdapterV2SessionRuntime { readonly steerTurn: ( input: ProviderAdapterV2SteerInput, ) => Effect.Effect; + readonly stopTask?: (input: { + readonly providerThread: OrchestrationV2ProviderThread; + readonly taskId: string; + }) => Effect.Effect; readonly interruptTurn: ( input: ProviderAdapterV2InterruptInput, ) => Effect.Effect; diff --git a/packages/shared/src/t3McpToolPresentation.ts b/packages/shared/src/t3McpToolPresentation.ts index 11abf77f704b..5557d13bd9dc 100644 --- a/packages/shared/src/t3McpToolPresentation.ts +++ b/packages/shared/src/t3McpToolPresentation.ts @@ -21,6 +21,7 @@ export type T3McpToolSummaryAction = | "thread-send" | "thread-wait" | "thread-interrupt" + | "workflow-stop" | "thread-configuration" | "thread-configure" | "thread-fork" @@ -138,6 +139,10 @@ const T3_MCP_TOOLS: Readonly> = { t3_thread_read: tool(["Read", "Reading", "Read", "a T3 thread"], "thread-read"), t3_thread_send: tool(["Send", "Sending", "Sent", "to a T3 thread"], "thread-send"), t3_thread_wait: tool(["Wait", "Waiting", "Waited", "for a T3 thread"], "thread-wait"), + t3_workflow_stop: tool( + ["Stop", "Stopping", "Requested a stop for", "a workflow"], + "workflow-stop", + ), t3_thread_interrupt: tool( ["Interrupt", "Interrupting", "Requested an interrupt of", "a T3 thread"], "thread-interrupt", From 2e194f8071643fe74d3f9a1466d8c98975adac49 Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 10 Oct 2026 21:50:16 -0700 Subject: [PATCH 10/66] fix(mobile): round Android Agents sheet corners (#14925) --- apps/mobile/src/features/threads/ThreadAgentsSheet.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadAgentsSheet.tsx b/apps/mobile/src/features/threads/ThreadAgentsSheet.tsx index 1d082515172d..72437e9baac6 100644 --- a/apps/mobile/src/features/threads/ThreadAgentsSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadAgentsSheet.tsx @@ -19,8 +19,10 @@ import { AndroidSheetHeader } from "../../components/AndroidScreenHeader"; import { AppText as Text } from "../../components/AppText"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { environmentThreadDetails } from "../../state/threads"; -import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader"; +import { NativeStackScreenOptions, nativeHeaderScrollEdgeEffects } from "../../native/StackHeader"; import { SubagentRow } from "./SubagentRow"; +import { MaterialScreenContent } from "../../components/MaterialScreenContent"; +import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); @@ -31,6 +33,7 @@ export function useThreadTurnSubagents(target: AgentsTarget): ThreadTurnSubagent } export function ThreadAgentsSheet({ route }: StaticScreenProps) { + const { layout } = useAdaptiveWorkspaceLayout(); const target = route.params; const navigation = useNavigation(); const insets = useSafeAreaInsets(); @@ -112,8 +115,11 @@ export function ThreadAgentsSheet({ route }: StaticScreenProps) { return ( + navigation.goBack()} /> - {content} + + {content} + ); } From b4542c5418e727db5c5b578e3dc2b39e915fd0bc Mon Sep 17 00:00:00 2001 From: AdEx-Partners-DE Date: Sun, 11 Oct 2026 06:52:16 +0200 Subject: [PATCH 11/66] fix(web): the Usage page sends a signed-out browser to pairing (#17938) Co-authored-by: Claude Opus 5.5 --- apps/web/src/routes/usage.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/web/src/routes/usage.tsx b/apps/web/src/routes/usage.tsx index c617e434b2e1..9f765b5e6827 100644 --- a/apps/web/src/routes/usage.tsx +++ b/apps/web/src/routes/usage.tsx @@ -1,7 +1,15 @@ -import { createFileRoute } from "@tanstack/react-router"; +import { createFileRoute, redirect } from "@tanstack/react-router"; import { UsagePage } from "../components/usage/UsagePage"; export const Route = createFileRoute("/usage")({ + beforeLoad: async ({ context }) => { + if ( + context.authGateState.status !== "authenticated" && + context.authGateState.status !== "hosted-static" + ) { + throw redirect({ to: "/pair", replace: true }); + } + }, component: UsagePage, }); From cb7d0739389a602d81000bfb5aeb6ebea9e853bb Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 10 Oct 2026 22:35:03 -0700 Subject: [PATCH 12/66] fix(server): OpenCode 2 continuations end after a reconnect took a Stop's end (#14744) Co-authored-by: Claude Opus 5.5 (1M context) --- .../Adapters/OpenCode2AdapterV2.test.ts | 356 +++++++++++++++++- .../Adapters/OpenCode2AdapterV2.testkit.ts | 6 +- .../src/server/v2/adapter.ts | 63 +++- 3 files changed, 409 insertions(+), 16 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts index 5c7161decd44..9cb06619c08e 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts @@ -48,6 +48,10 @@ import { t3McpServerName, } from "@t3tools/provider-opencode/testing"; import { openCode2ReplayRuntime } from "./OpenCode2AdapterV2.testkit.ts"; +import { + makeProviderReplayGate, + type ProviderReplayGate, +} from "@t3tools/provider-testing/replayGate"; const SESSION = "ses_f148ca2deffeJcwCnRQtb0YFNX"; const WORK = "/work/opencode2"; @@ -64,12 +68,18 @@ const reply = (operation: string, data: unknown): ProviderReplayEntry => ({ frame: { type: "sdk.response", operation, data }, }); const replyData = (operation: string, data: unknown) => reply(operation, { data }); -const event = (type: string, data: Record): ProviderReplayEntry => ({ +const event = ( + type: string, + data: Record, + /** Holds the event until a test's replay gate releases this label. */ + label?: string, +): ProviderReplayEntry => ({ type: "emit_inbound", frame: { type: "sdk.event", event: { id: `evt_${type.replaceAll(".", "")}0000`, created: 1, type, data, ...durable }, }, + ...(label === undefined ? {} : { label }), }); const durable = { durable: { aggregateID: SESSION, seq: 1, version: 1 } }; @@ -316,7 +326,11 @@ const colorForm = { */ const resumed = ( entries: ReadonlyArray, - options?: { readonly external?: boolean; readonly supervised?: boolean }, + options?: { + readonly external?: boolean; + readonly supervised?: boolean; + readonly replayGate?: ProviderReplayGate; + }, ) => Effect.gen(function* () { const runtime = yield* openCode2ReplayRuntime( @@ -335,7 +349,10 @@ const resumed = ( : []), ...entries, ]), - options?.external === undefined ? undefined : { external: options.external }, + { + ...(options?.external === undefined ? {} : { external: options.external }), + ...(options?.replayGate === undefined ? {} : { replayGate: options.replayGate }), + }, ); const thread = yield* runtime.resumeThread({ providerThread: providerThread(yield* DateTime.now), @@ -1724,6 +1741,88 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), ); + it.effect("still checks the server when a failed prompt's execution starts late", () => + Effect.gen(function* () { + const offered = yield* Deferred.make(); + const { runtime, thread } = yield* resumed([ + out("session.prompt", { sessionID: SESSION, text: "" }), + // The request failed, but the server took the prompt: its execution + // starts only after the turn already failed. + reply("session.prompt", { + status: 502, + body: { _tag: "UnknownError", message: "bad gateway" }, + }), + event("session.execution.started", { sessionID: SESSION }), + // That run is still going, so the next turn stops it and fails. + out("session.active"), + reply("session.active", { data: { [SESSION]: { type: "running" } } }), + out("session.interrupt", { sessionID: SESSION }), + reply("session.interrupt", { interrupted: true }), + ]).pipe( + Effect.provideService(ProviderContinuationRequests.ProviderContinuationRequests, { + offer: () => Deferred.succeed(offered, undefined).pipe(Effect.asVoid), + take: Effect.never, + }), + ); + const ended = yield* terminals(runtime, 2); + yield* runtime.startTurn(turnInput(thread)).pipe(Effect.ignore); + // The late start has been read. + yield* Deferred.await(offered); + yield* runtime.startTurn(secondTurn(thread)); + const [first, second] = yield* Fiber.join(ended); + assert.equal(first?.status, "failed"); + assert.equal(second?.failure?.message, OPENCODE_2_STILL_STOPPING); + }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), + ); + + it.effect("still checks the server when a failed prompt starts after a reconnect", () => + Effect.gen(function* () { + const offered = yield* Deferred.make(); + const gate = makeProviderReplayGate(["drop"]); + const { runtime, thread } = yield* resumed( + [ + out("session.prompt", { sessionID: SESSION, text: "" }), + reply("session.prompt", { + status: 502, + body: { _tag: "UnknownError", message: "bad gateway" }, + }), + // The stream drops before the prompt's execution starts, and the + // reconnect finds the session active: the prompt's run is still to come. + event("session.usage.updated", { sessionID: SESSION }, "drop"), + { type: "runtime_exit", status: "success" } as const, + out("event.subscribe"), + out("session.active"), + replyData("session.active", { [SESSION]: { type: "running" } }), + out("permission.list", { sessionID: SESSION }), + replyData("permission.list", []), + out("session.form.list", { sessionID: SESSION }), + replyData("session.form.list", []), + // Its own start: that run is still going, so the next turn stops it and fails. + event("session.execution.started", { sessionID: SESSION }), + out("session.active"), + reply("session.active", { data: { [SESSION]: { type: "running" } } }), + out("session.interrupt", { sessionID: SESSION }), + reply("session.interrupt", { interrupted: true }), + ], + { replayGate: gate }, + ).pipe( + Effect.provideService(ProviderContinuationRequests.ProviderContinuationRequests, { + offer: () => Deferred.succeed(offered, undefined).pipe(Effect.asVoid), + take: Effect.never, + }), + ); + const ended = yield* terminals(runtime, 2); + yield* runtime.startTurn(turnInput(thread)).pipe(Effect.ignore); + gate.release("drop"); + // The late start has been read. + yield* Deferred.await(offered); + yield* runtime.startTurn(secondTurn(thread)); + const [first, second] = yield* Fiber.join(ended); + assert.equal(first?.status, "failed"); + assert.equal(second?.failure?.message, OPENCODE_2_STILL_STOPPING); + }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), + ); + it.effect("prompts again without a check after the server refused a prompt", () => Effect.gen(function* () { const { runtime, thread } = yield* resumed([ @@ -1764,6 +1863,257 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), ); + it.effect("ends a background reply's continuation after a lost stream took a Stop's end", () => + Effect.gen(function* () { + const offered = yield* Deferred.make(); + const gate = makeProviderReplayGate(["drop", "reply"]); + const { runtime, thread } = yield* resumed( + [ + ...backgroundLaunch(CHILD), + // Restarting the turn stops only the parent, and the server never answers. + out("session.interrupt", { sessionID: SESSION }), + reply("session.interrupt", ""), + // The restarted turn finds the parent still stopping and fails. + out("session.active"), + replyData("session.active", { [SESSION]: { type: "running" } }), + out("session.interrupt", { sessionID: SESSION }), + reply("session.interrupt", { interrupted: true }), + // The stream drops before the parent's end; the reconnect finds it idle. + event("session.usage.updated", { sessionID: SESSION }, "drop"), + { type: "runtime_exit", status: "success" } as const, + out("event.subscribe"), + out("session.active"), + replyData("session.active", { [CHILD]: { type: "running" } }), + out("permission.list", { sessionID: CHILD }), + replyData("permission.list", []), + out("session.form.list", { sessionID: CHILD }), + replyData("session.form.list", []), + // The subagent ends and OpenCode answers its report on its own. + event("session.execution.succeeded", { sessionID: CHILD }), + event("session.inbox.enqueued", { + inboxID: "msg_report", + sessionID: SESSION, + item: { + type: "synthetic", + payload: { + text: `\nCHILD_OK\n`, + description: "Sleep", + metadata: { + source: "subagent", + childID: CHILD, + agent: "General", + state: "completed", + }, + }, + delivery: "steer", + }, + }), + event("session.execution.started", { sessionID: SESSION }), + event( + "session.text.ended", + { + sessionID: SESSION, + assistantMessageID: "msg_followup", + ordinal: 0, + text: "CHILD_OK", + }, + "reply", + ), + event("session.execution.succeeded", { sessionID: SESSION }), + // The subagent's session runs again: its turn marks that the reply's end was handled. + event("session.execution.started", { sessionID: CHILD }), + ], + { replayGate: gate }, + ).pipe( + Effect.provideService(ProviderContinuationRequests.ProviderContinuationRequests, { + offer: () => Deferred.succeed(offered, undefined).pipe(Effect.asVoid), + take: Effect.never, + }), + ); + // The single reader of the runtime's events. + const attached = yield* Deferred.make(); + const collected = yield* runtime.events.pipe( + Stream.tap((event) => + event.type === "subagent.updated" && event.subagent.childThreadId !== null + ? Deferred.succeed(attached, undefined) + : Effect.void, + ), + Stream.takeUntil( + (event) => + event.type === "provider_turn.updated" && + event.providerTurn.nativeTurnRef?.nativeId?.startsWith(`${CHILD}:turn:`) === true, + ), + Stream.runCollect, + Effect.forkScoped, + ); + yield* runtime.startTurn(withLineage(thread)); + yield* Deferred.await(attached); + const interrupt = yield* runtime + .interruptTurn({ providerThread: thread, providerTurnId: yield* providerTurnId }) + .pipe(Effect.forkScoped); + yield* TestClock.adjust("11 seconds"); + yield* Fiber.join(interrupt); + yield* runtime.startTurn({ ...secondTurn(thread), appThread: withLineage(thread).appThread }); + gate.release("drop"); + yield* Deferred.await(offered); + yield* runtime.startTurn({ + ...withLineage(thread), + runId: RunId.make("run:opencode2-adapter:wake"), + runOrdinal: 3, + providerTurnOrdinal: 3, + attemptId: RunAttemptId.make("attempt:opencode2-adapter:wake"), + message: { + ...turnInput(thread).message, + messageId: MessageId.make("message:opencode2-adapter:wake"), + createdBy: "agent" as const, + creationSource: "provider" as const, + }, + }); + gate.release("reply"); + // The reply's own end finishes its continuation: the lost Stop's end does not take it. + assert.deepEqual( + [...(yield* Fiber.join(collected))].flatMap((event) => + event.type === "turn.terminal" ? [event.status] : [], + ), + ["interrupted", "failed", "completed"], + ); + }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), + ); + + /** + * A prompt fails without a clear rejection, OpenCode never took it, and a + * background subagent's report then wakes the parent into a reply. With + * `endedFirst` that reply ends before its continuation takes it. + */ + const replyAfterUnclearFailure = (endedFirst: boolean) => + Effect.gen(function* () { + const offered = yield* Deferred.make(); + const gate = makeProviderReplayGate(endedFirst ? ["wake"] : ["wake", "reply"]); + const { runtime, thread } = yield* resumed( + [ + ...backgroundLaunch(CHILD), + event("session.execution.succeeded", { sessionID: SESSION }), + out("session.update", { sessionID: CHILD, permissions: t3Rules }), + reply("session.update", null), + out("session.prompt", { sessionID: SESSION, text: "" }), + reply("session.prompt", { + status: 502, + body: { _tag: "UnknownError", message: "bad gateway" }, + }), + // The subagent ends and OpenCode answers its report on its own. + event("session.execution.succeeded", { sessionID: CHILD }, "wake"), + event("session.inbox.enqueued", { + inboxID: "msg_report", + sessionID: SESSION, + item: { + type: "synthetic", + payload: { + text: `\nCHILD_OK\n`, + description: "Sleep", + metadata: { + source: "subagent", + childID: CHILD, + agent: "General", + state: "completed", + }, + }, + delivery: "steer", + }, + }), + event("session.execution.started", { sessionID: SESSION }), + event( + "session.text.ended", + { + sessionID: SESSION, + assistantMessageID: "msg_followup", + ordinal: 0, + text: "CHILD_OK", + }, + endedFirst ? undefined : "reply", + ), + event("session.execution.succeeded", { sessionID: SESSION }), + // The subagent's session runs again: a turn of its own, after the reply's end. + event("session.execution.started", { sessionID: CHILD }), + ], + { replayGate: gate }, + ).pipe( + Effect.provideService(ProviderContinuationRequests.ProviderContinuationRequests, { + offer: () => Deferred.succeed(offered, undefined).pipe(Effect.asVoid), + take: Effect.never, + }), + ); + // The single reader of the runtime's events. + const launched = yield* Deferred.make(); + const childRan = yield* Deferred.make(); + const ended: Array = []; + const allEnded = yield* Deferred.make(); + yield* runtime.events.pipe( + Stream.tap((event) => + Effect.gen(function* () { + if ( + event.type === "provider_turn.updated" && + event.providerTurn.nativeTurnRef?.nativeId?.startsWith(`${CHILD}:turn:`) === true + ) { + yield* Deferred.succeed(childRan, undefined); + } + if (event.type !== "turn_item.updated" && event.type !== "turn.terminal") return; + ended.push(event); + if (event.type === "turn.terminal") yield* Deferred.succeed(launched, undefined); + if (ended.filter((entry) => entry.type === "turn.terminal").length === 3) { + yield* Deferred.succeed(allEnded, undefined); + } + }), + ), + Stream.runDrain, + Effect.forkScoped, + ); + yield* runtime.startTurn(withLineage(thread)); + yield* Deferred.await(launched); + yield* runtime + .startTurn({ ...secondTurn(thread), appThread: withLineage(thread).appThread }) + .pipe(Effect.ignore); + gate.release("wake"); + yield* Deferred.await(offered); + // Everything up to the subagent's next run has been read: the reply ended. + if (endedFirst) yield* Deferred.await(childRan); + yield* runtime.startTurn({ + ...withLineage(thread), + runId: RunId.make("run:opencode2-adapter:wake"), + runOrdinal: 3, + providerTurnOrdinal: 3, + attemptId: RunAttemptId.make("attempt:opencode2-adapter:wake"), + message: { + ...turnInput(thread).message, + messageId: MessageId.make("message:opencode2-adapter:wake"), + createdBy: "agent" as const, + creationSource: "provider" as const, + }, + }); + if (!endedFirst) gate.release("reply"); + // The reply's own end finishes its continuation: the failed prompt's mark does not take it. + yield* Deferred.await(allEnded); + assert.deepEqual( + ended.flatMap((event) => (event.type === "turn.terminal" ? [event.status] : [])), + ["completed", "failed", "completed"], + ); + assert.include( + ended.flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.type === "assistant_message" + ? [event.turnItem.text] + : [], + ), + "CHILD_OK", + ); + }).pipe(Effect.scoped, Effect.provide(TestClock.layer())); + + it.effect("ends a background reply's continuation after a prompt failed unclearly", () => + replyAfterUnclearFailure(false), + ); + + it.effect("ends a finished background reply's continuation after a prompt failed unclearly", () => + replyAfterUnclearFailure(true), + ); + it.effect("does not report a Stop the server says did nothing", () => Effect.gen(function* () { const { runtime, thread } = yield* resumed([ diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts index 7eb295997483..db251e005d79 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts @@ -286,7 +286,11 @@ function layerRegistry( */ export const openCode2ReplayRuntime = ( entries: ReadonlyArray, - options?: { readonly external?: boolean; readonly borrowers?: { current: number } }, + options?: { + readonly external?: boolean; + readonly borrowers?: { current: number }; + readonly replayGate?: ProviderReplayGate; + }, ) => Effect.gen(function* () { const adapter = yield* makeReplayAdapter( diff --git a/packages/provider-opencode/src/server/v2/adapter.ts b/packages/provider-opencode/src/server/v2/adapter.ts index b23762fcc6e7..bf4cdceebae3 100644 --- a/packages/provider-opencode/src/server/v2/adapter.ts +++ b/packages/provider-opencode/src/server/v2/adapter.ts @@ -340,6 +340,12 @@ interface ThreadState { * so the next execution end belongs to that run; it clears this and ends no turn. */ unsettled: boolean; + /** + * Whether the run `unsettled` waits on had started when it was marked. A + * prompt whose request failed may start its run late; that start is the + * run's own, and only a start after it means its end was lost. + */ + unsettledStarted: boolean; /** The session's location, where its agents' path rules are read. */ directory: string; /** @@ -923,6 +929,11 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr const emit = (event: ProviderAdapter.ProviderAdapterV2Event) => Queue.offer(events, event).pipe(Effect.asVoid); const ownerOf = (sessionId: string) => childOwners.get(sessionId) ?? threads.get(sessionId); + /** Marks a run T3 ended while OpenCode may still run it (see `unsettled`). */ + const markUnsettled = (state: ThreadState) => { + state.unsettled = true; + state.unsettledStarted = busy.has(state.sessionId); + }; const newThreadState = ( sessionId: string, @@ -936,6 +947,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr active: undefined, model: undefined, unsettled: false, + unsettledStarted: false, directory, agent: "build", title: undefined, @@ -1838,7 +1850,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr reason, }); if (state.active === undefined) return; - state.unsettled = true; + markUnsettled(state); yield* finishTurn(state, { status: "failed", failure: makeProviderFailure({ @@ -1935,7 +1947,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr const [permissions, forms] = listed.value; if (permissions.length === 0 && forms.length === 0) return; // The stopped run's end is not the next turn's. - state.unsettled = true; + markUnsettled(state); for (const request of permissions) { yield* stopStaleRequest(state.sessionId, { type: "permission", id: request.id }); } @@ -2120,7 +2132,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr // goes on. A subagent's session has no Stop end to skip. const asker = threads.get(form.sessionID) ?? state; if (!cancelled) return yield* abandonRequest(asker, "form cancel failed"); - if (asker.subagent === undefined) asker.unsettled = true; + if (asker.subagent === undefined) markUnsettled(asker); yield* finishTurn(asker, { status: "failed", failure: makeProviderFailure({ @@ -2358,7 +2370,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr delivered.every((entry) => state.stoppedChildren.has(entry.childId)); for (const entry of delivered) state.stoppedChildren.delete(entry.childId); if (stopped) { - state.unsettled = true; + markUnsettled(state); yield* client.session .interrupt({ sessionID: Session.ID.make(state.sessionId) }) .pipe(Effect.timeout(INTERRUPT_TIMEOUT), Effect.ignore({ log: true })); @@ -2484,6 +2496,16 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr event.type === "session.execution.started" || event.type === "unreadable.execution.started" ) { + // A session runs one execution at a time, each opening with `started` + // on this ordered stream. Once the run `unsettled` waits on has + // started, a later start means that run is over: an end not seen by + // now was lost with a dropped stream, and the next end is this + // execution's own. A run not yet started when it was marked starts here. + const state = threads.get(sessionId); + if (state?.unsettled === true) { + if (state.unsettledStarted) state.unsettled = false; + else state.unsettledStarted = true; + } busy.add(sessionId); } else if (event.type === "unreadable.execution.ended" || executionEnd(event.type)) { busy.delete(sessionId); @@ -2515,14 +2537,20 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr return yield* route(event, sessionId); }); - /** Handles one event for its session, as the turn running there sees it. */ + /** + * Handles one event for its session, as the turn running there sees it. + * `held` is set for a held execution's events as its turn replays them. + */ const route = Effect.fnUntraced(function* ( event: OpenCode2Client.OpenCode2StreamEvent, sessionId: string | undefined, + held = false, ) { // The end of the run a timed-out Stop left behind; no turn is its own. + // A held execution's end is its own: any such run was over, or was this + // execution, before it started. const ended = - event.type === "unreadable.execution.ended" || executionEnd(event.type) + !held && (event.type === "unreadable.execution.ended" || executionEnd(event.type)) ? threads.get(sessionId ?? "") : undefined; if (ended?.unsettled === true) { @@ -2777,10 +2805,18 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr const background = [...threads.values()].filter( (state) => state.subagent === undefined && hasBackground(state), ); - if (running.length === 0 && background.length === 0 && busy.size === 0) return; + const marked = [...threads.values()].some((state) => state.unsettled); + if (running.length === 0 && background.length === 0 && busy.size === 0 && !marked) return; const active = yield* client.session.active(); // An execution that ended while the stream was down never said so. for (const sessionId of busy) if (!(sessionId in active)) busy.delete(sessionId); + // A run `unsettled` waits on is over once its session is idle: any start + // from here is a new execution's, even if the run's own start was lost in + // the gap. A session still active may not have started that run yet, so + // its next start may still be the run's own; its end clears the mark. + for (const state of threads.values()) { + if (state.unsettled && !(state.sessionId in active)) state.unsettledStarted = true; + } for (const [sessionId, state] of running) { const turn = state.active; if (turn === undefined) continue; @@ -3403,7 +3439,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr /** Feeds a held execution's events to the turn now running on its session. */ const replay = Effect.fnUntraced(function* (wake: Wake) { wake.dropped = true; - for (const event of wake.events) yield* route(event, sessionOfEvent(event)); + for (const event of wake.events) yield* route(event, sessionOfEvent(event), true); }); /** @@ -3438,6 +3474,9 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr if (turn !== undefined && wake.first !== undefined) { turn.providerTurn = { ...turn.providerTurn, nativeTurnRef: ref(wake.first, "weak") }; } + // The execution still runs, so the run a mark waits on is this one or + // was over before it started: its end, still to come, is this turn's. + if (wake.running) state.unsettled = false; yield* replay(wake); }), ); @@ -3520,7 +3559,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr const running = state.wakes.some((wake) => wake.running); for (const wake of state.wakes.splice(0)) wake.dropped = true; if (running) { - state.unsettled = true; + markUnsettled(state); yield* client.session .interrupt({ sessionID: Session.ID.make(state.sessionId) }) .pipe(Effect.timeout(INTERRUPT_TIMEOUT), Effect.ignore({ log: true })); @@ -3896,7 +3935,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr ? Effect.gen(function* () { // Without a clear rejection the server may have taken the // prompt, so the next turn checks before it prompts again. - if (!CLEAR_PROMPT_REJECTIONS.has(cause._tag)) state.unsettled = true; + if (!CLEAR_PROMPT_REJECTIONS.has(cause._tag)) markUnsettled(state); yield* finishTurn(state, { status: "failed", failure: makeProviderFailure({ cause, class: "provider_error" }), @@ -4004,13 +4043,13 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr ), ); if (reply._tag === "None") { - state.unsettled = true; + markUnsettled(state); return yield* lock.withPermit(finishTurn(state, { status: "interrupted" })); } // Between the executions a late steer spans nothing runs yet. The // steers are taken back, and a run they already started is skipped. if (!reply.value.interrupted && state.active === turn && turn.heldEnd !== undefined) { - state.unsettled = true; + markUnsettled(state); yield* lock.withPermit(finishTurn(state, { status: "interrupted" })); return yield* cancelStrandedSteers(state); } From ae5fd184288aa600f5afbaaf53eeabc5610f9036 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 10 Oct 2026 22:46:25 -0700 Subject: [PATCH 13/66] fix(server): OpenCode 2 continuations replay only their own background reply (#14752) Co-authored-by: Claude Opus 5.5 (1M context) --- .../Adapters/OpenCode2AdapterV2.test.ts | 752 ++++++++++++++++-- .../ProviderContinuationService.test.ts | 19 + .../ProviderContinuationService.ts | 10 +- .../server/ProviderContinuationRequests.ts | 6 + .../src/server/v2/adapter.ts | 138 +++- 5 files changed, 861 insertions(+), 64 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts index 9cb06619c08e..548ff5cb6643 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts @@ -715,6 +715,28 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, } as OrchestrationV2AppThread, }); + /** + * The continuation turn the orchestrator starts for an offered wake: its + * message is the one the offer named, as `ProviderContinuationService` dispatches it. + */ + const continuationTurn = ( + thread: OrchestrationV2ProviderThread, + offer: ProviderContinuationRequests.ProviderContinuationRequest, + suffix = "wake", + ) => ({ + ...withLineage(thread), + runId: RunId.make(`run:opencode2-adapter:${suffix}`), + runOrdinal: 2, + providerTurnOrdinal: 2, + attemptId: RunAttemptId.make(`attempt:opencode2-adapter:${suffix}`), + message: { + ...turnInput(thread).message, + messageId: offer.messageId!, + text: offer.detail ?? "", + createdBy: "agent" as const, + creationSource: "provider" as const, + }, + }); const CHILD = "ses_f1485c529ffea4URrYruwEg0Ja"; /** * The single reader of the runtime's events: resolves `attached` once the @@ -1865,7 +1887,8 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { it.effect("ends a background reply's continuation after a lost stream took a Stop's end", () => Effect.gen(function* () { - const offered = yield* Deferred.make(); + const offered = + yield* Deferred.make(); const gate = makeProviderReplayGate(["drop", "reply"]); const { runtime, thread } = yield* resumed( [ @@ -1926,7 +1949,7 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { { replayGate: gate }, ).pipe( Effect.provideService(ProviderContinuationRequests.ProviderContinuationRequests, { - offer: () => Deferred.succeed(offered, undefined).pipe(Effect.asVoid), + offer: (request) => Deferred.succeed(offered, request).pipe(Effect.asVoid), take: Effect.never, }), ); @@ -1955,26 +1978,133 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { yield* Fiber.join(interrupt); yield* runtime.startTurn({ ...secondTurn(thread), appThread: withLineage(thread).appThread }); gate.release("drop"); - yield* Deferred.await(offered); yield* runtime.startTurn({ - ...withLineage(thread), - runId: RunId.make("run:opencode2-adapter:wake"), + ...continuationTurn(thread, yield* Deferred.await(offered)), runOrdinal: 3, providerTurnOrdinal: 3, - attemptId: RunAttemptId.make("attempt:opencode2-adapter:wake"), - message: { - ...turnInput(thread).message, - messageId: MessageId.make("message:opencode2-adapter:wake"), - createdBy: "agent" as const, - creationSource: "provider" as const, - }, }); gate.release("reply"); // The reply's own end finishes its continuation: the lost Stop's end does not take it. + const events = [...(yield* Fiber.join(collected))]; assert.deepEqual( - [...(yield* Fiber.join(collected))].flatMap((event) => - event.type === "turn.terminal" ? [event.status] : [], + events.flatMap((event) => (event.type === "turn.terminal" ? [event.status] : [])), + ["interrupted", "failed", "completed"], + ); + // The continuation replayed the reply. + assert.include( + events.flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.type === "assistant_message" + ? [event.turnItem.text] + : [], + ), + "CHILD_OK", + ); + }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), + ); + + it.effect("lets a prompt join a background reply after a lost stream took a Stop's end", () => + Effect.gen(function* () { + const offered = yield* Deferred.make(); + const gate = makeProviderReplayGate(["drop"]); + const { runtime, thread } = yield* resumed( + [ + ...backgroundLaunch(CHILD), + // Restarting the turn stops only the parent, and the server never answers. + out("session.interrupt", { sessionID: SESSION }), + reply("session.interrupt", ""), + // The restarted turn finds the parent still stopping and fails. + out("session.active"), + replyData("session.active", { [SESSION]: { type: "running" } }), + out("session.interrupt", { sessionID: SESSION }), + reply("session.interrupt", { interrupted: true }), + // The stream drops before the parent's end; the reconnect finds it idle. + event("session.usage.updated", { sessionID: SESSION }, "drop"), + { type: "runtime_exit", status: "success" } as const, + out("event.subscribe"), + out("session.active"), + replyData("session.active", { [CHILD]: { type: "running" } }), + out("permission.list", { sessionID: CHILD }), + replyData("permission.list", []), + out("session.form.list", { sessionID: CHILD }), + replyData("session.form.list", []), + // The subagent ends and OpenCode answers its report on its own. + event("session.execution.succeeded", { sessionID: CHILD }), + event("session.inbox.enqueued", { + inboxID: "msg_report", + sessionID: SESSION, + item: { + type: "synthetic", + payload: { + text: `\nCHILD_OK\n`, + description: "Sleep", + metadata: { + source: "subagent", + childID: CHILD, + agent: "General", + state: "completed", + }, + }, + delivery: "steer", + }, + }), + event("session.execution.started", { sessionID: SESSION }), + // A prompt sent while that reply runs joins it, with no check first. + out("session.prompt", { sessionID: SESSION, text: "" }), + promptAccepted, + event("session.text.ended", { + sessionID: SESSION, + assistantMessageID: "msg_followup", + ordinal: 0, + text: "CHILD_OK", + }), + event("session.execution.succeeded", { sessionID: SESSION }), + ], + { replayGate: gate }, + ).pipe( + Effect.provideService(ProviderContinuationRequests.ProviderContinuationRequests, { + offer: () => Deferred.succeed(offered, undefined).pipe(Effect.asVoid), + take: Effect.never, + }), + ); + // The single reader of the runtime's events. + const attached = yield* Deferred.make(); + const ended = yield* runtime.events.pipe( + Stream.tap((event) => + event.type === "subagent.updated" && event.subagent.childThreadId !== null + ? Deferred.succeed(attached, undefined) + : Effect.void, ), + Stream.filter( + ( + event, + ): event is Extract => + event.type === "turn.terminal", + ), + Stream.take(3), + Stream.runCollect, + Effect.forkScoped, + ); + yield* runtime.startTurn(withLineage(thread)); + yield* Deferred.await(attached); + const interrupt = yield* runtime + .interruptTurn({ providerThread: thread, providerTurnId: yield* providerTurnId }) + .pipe(Effect.forkScoped); + yield* TestClock.adjust("11 seconds"); + yield* Fiber.join(interrupt); + yield* runtime.startTurn({ ...secondTurn(thread), appThread: withLineage(thread).appThread }); + gate.release("drop"); + // The reply has started. + yield* Deferred.await(offered); + yield* runtime.startTurn({ + ...withLineage(thread), + runId: RunId.make("run:opencode2-adapter:3"), + runOrdinal: 3, + providerTurnOrdinal: 3, + attemptId: RunAttemptId.make("attempt:opencode2-adapter:3"), + }); + // The reply's start cleared the Stop's mark, so its end finishes the joined turn. + assert.deepEqual( + [...(yield* Fiber.join(ended))].map((terminal) => terminal.status), ["interrupted", "failed", "completed"], ); }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), @@ -1987,7 +2117,8 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { */ const replyAfterUnclearFailure = (endedFirst: boolean) => Effect.gen(function* () { - const offered = yield* Deferred.make(); + const offered = + yield* Deferred.make(); const gate = makeProviderReplayGate(endedFirst ? ["wake"] : ["wake", "reply"]); const { runtime, thread } = yield* resumed( [ @@ -2038,7 +2169,7 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { { replayGate: gate }, ).pipe( Effect.provideService(ProviderContinuationRequests.ProviderContinuationRequests, { - offer: () => Deferred.succeed(offered, undefined).pipe(Effect.asVoid), + offer: (request) => Deferred.succeed(offered, request).pipe(Effect.asVoid), take: Effect.never, }), ); @@ -2073,21 +2204,12 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { .startTurn({ ...secondTurn(thread), appThread: withLineage(thread).appThread }) .pipe(Effect.ignore); gate.release("wake"); - yield* Deferred.await(offered); // Everything up to the subagent's next run has been read: the reply ended. if (endedFirst) yield* Deferred.await(childRan); yield* runtime.startTurn({ - ...withLineage(thread), - runId: RunId.make("run:opencode2-adapter:wake"), + ...continuationTurn(thread, yield* Deferred.await(offered)), runOrdinal: 3, providerTurnOrdinal: 3, - attemptId: RunAttemptId.make("attempt:opencode2-adapter:wake"), - message: { - ...turnInput(thread).message, - messageId: MessageId.make("message:opencode2-adapter:wake"), - createdBy: "agent" as const, - creationSource: "provider" as const, - }, }); if (!endedFirst) gate.release("reply"); // The reply's own end finishes its continuation: the failed prompt's mark does not take it. @@ -4378,19 +4500,7 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { yield* Effect.gen(function* () { while (offers.length === 0) yield* Effect.yieldNow; }).pipe(Effect.timeout("2 seconds"), Effect.orDie); - yield* runtime.startTurn({ - ...withLineage(thread), - runId: RunId.make("run:opencode2-adapter:wake"), - runOrdinal: 2, - providerTurnOrdinal: 2, - attemptId: RunAttemptId.make("attempt:opencode2-adapter:wake"), - message: { - ...turnInput(thread).message, - messageId: MessageId.make("message:opencode2-adapter:wake"), - createdBy: "agent" as const, - creationSource: "provider" as const, - }, - }); + yield* runtime.startTurn(continuationTurn(thread, offers[0]!)); yield* Deferred.await(bothEnded); const [first, second] = turns; return { runtime, thread, first: first!, second: second! }; @@ -4457,6 +4567,556 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { }).pipe(Effect.scoped), ); + it.effect("keeps a subagent a stopped background reply started off the next turn", () => + Effect.gen(function* () { + const CHILD_B = "ses_f1485c529ffeBBBBBBBBBBBBBB"; + const SPAWNED = "ses_f1485c529ffeDDDDDDDDDDDDDD"; + const NESTED = "ses_f1485c529ffeNNNNNNNNNNNNNN"; + const toolB = { sessionID: SESSION, assistantMessageID: "msg_assistant", id: "call-b" }; + const spawn = { sessionID: SESSION, assistantMessageID: "msg_reply", id: "call-spawn" }; + const offers: Array = []; + const gate = makeProviderReplayGate(["settled"]); + const { runtime, thread } = yield* resumed( + [ + ...backgroundLaunch(CHILD), + // The same turn starts a second background subagent, which runs on. + event("session.tool.input.started", { ...toolB, name: "subagent" }), + event("session.tool.called", { + ...toolB, + name: "subagent", + input: { description: "Sleep", prompt: "sleep", background: true }, + executed: false, + }), + event("session.created", { ...childCreated(CHILD_B), sessionID: CHILD_B }), + event("session.tool.progress", { + ...toolB, + metadata: { sessionID: CHILD_B, status: "running" }, + }), + event("session.execution.succeeded", { sessionID: SESSION }), + event("session.execution.started", { sessionID: CHILD_B }), + // The first subagent's report wakes the parent into a reply T3 holds + // for its continuation. + event("session.execution.succeeded", { sessionID: CHILD }), + event("session.inbox.enqueued", { + inboxID: "msg_report", + sessionID: SESSION, + item: { + type: "synthetic", + payload: { + text: `\nCHILD_OK\n`, + description: "Sleep", + metadata: { + source: "subagent", + childID: CHILD, + agent: "General", + state: "completed", + }, + }, + delivery: "steer", + }, + }), + event("session.execution.started", { sessionID: SESSION }), + // The reply starts a background subagent of its own, named only by + // the reply's held events. + event("session.tool.input.started", { ...spawn, name: "subagent" }), + event("session.tool.called", { + ...spawn, + name: "subagent", + input: { description: "Sleep", prompt: "sleep", background: true }, + executed: false, + }), + event("session.created", { ...childCreated(SPAWNED), sessionID: SPAWNED }), + event("session.tool.progress", { + ...spawn, + metadata: { sessionID: SPAWNED, status: "running" }, + }), + event("session.execution.succeeded", { sessionID: SESSION }), + // The second subagent's text, shown live, marks that the reply has ended. + event("session.text.ended", { + sessionID: CHILD_B, + assistantMessageID: "msg_child_b", + ordinal: 0, + text: "B_WORKING", + }), + // The user's Stop stops the second subagent and drops the held reply. + out("session.interrupt", { sessionID: CHILD_B }), + reply("session.interrupt", { interrupted: true }), + event("session.execution.interrupted", { sessionID: CHILD_B }), + // The subagent only the dropped reply named is stopped too; that Stop + // fails, so it runs on. + out("session.interrupt", { sessionID: SPAWNED }), + reply("session.interrupt", { + status: 500, + body: { _tag: "UnknownError", message: "interrupt failed" }, + }), + // An unrelated prompt runs while that subagent, and a subagent it + // starts, ask for permissions, which no turn shows: they are refused. + out("session.prompt", { sessionID: SESSION, text: "" }), + promptAccepted, + event("session.execution.started", { sessionID: SESSION }), + event("permission.asked", { ...shellAsk.data, id: "per_spawned", sessionID: SPAWNED }), + out("permission.reply", { + sessionID: SPAWNED, + requestID: "per_spawned", + decision: "reject", + }), + reply("permission.reply", null), + event("session.created", { + ...childCreated(NESTED), + sessionID: NESTED, + parentID: SPAWNED, + }), + event("permission.asked", { ...shellAsk.data, id: "per_nested", sessionID: NESTED }), + out("permission.reply", { + sessionID: NESTED, + requestID: "per_nested", + decision: "reject", + }), + reply("permission.reply", null), + event("session.text.ended", { + sessionID: SESSION, + assistantMessageID: "msg_unrelated", + ordinal: 0, + text: "UNRELATED", + }), + event("session.execution.succeeded", { sessionID: SESSION }), + // The next Stop tries the subagent again, and the one it started, and reaches both. + out("session.interrupt", { sessionID: SPAWNED }), + reply("session.interrupt", { interrupted: true }), + out("session.interrupt", { sessionID: NESTED }), + reply("session.interrupt", { interrupted: true }), + // OpenCode reports the stopped subagent to the thread's session and + // wakes it; that execution answers no turn, so it is stopped and no + // continuation is offered for it. + event("session.execution.interrupted", { sessionID: SPAWNED }), + event("session.inbox.enqueued", { + inboxID: "msg_report_spawned", + sessionID: SESSION, + item: { + type: "synthetic", + payload: { + text: `\n`, + description: "Sleep", + metadata: { + source: "subagent", + childID: SPAWNED, + agent: "General", + state: "cancelled", + }, + }, + delivery: "steer", + }, + }), + event("session.execution.started", { sessionID: SESSION }), + out("session.interrupt", { sessionID: SESSION }), + reply("session.interrupt", { interrupted: true }), + event("session.execution.interrupted", { sessionID: SESSION }, "settled"), + ], + { replayGate: gate }, + ).pipe( + Effect.provideService(ProviderContinuationRequests.ProviderContinuationRequests, { + offer: (request) => Effect.sync(() => void offers.push(request)), + take: Effect.never, + }), + ); + const collected: Array = []; + const replyEnded = yield* Deferred.make(); + const bothEnded = yield* Deferred.make(); + yield* runtime.events.pipe( + Stream.tap((event) => + Effect.gen(function* () { + collected.push(event); + if (event.type === "turn_item.updated" && event.turnItem.type === "assistant_message") { + if (event.turnItem.text === "B_WORKING") + yield* Deferred.succeed(replyEnded, undefined); + } + if (collected.filter((entry) => entry.type === "turn.terminal").length === 2) { + yield* Deferred.succeed(bothEnded, undefined); + } + }), + ), + Stream.runDrain, + Effect.forkScoped, + ); + yield* runtime.startTurn(withLineage(thread)); + yield* Deferred.await(replyEnded); + assert.lengthOf(offers, 1); + // The user stops the thread before the reply's continuation runs. + yield* runtime.interruptTurn({ + providerThread: thread, + providerTurnId: yield* providerTurnId, + requestRuntimeRestart: true, + }); + yield* runtime.startTurn({ ...secondTurn(thread), appThread: withLineage(thread).appThread }); + yield* Deferred.await(bothEnded); + // A later Stop reaches both subagents; once stopped they are not tried + // again (the replay fails on any further request). + for (let stops = 0; stops < 2; stops++) { + yield* runtime.interruptTurn({ + providerThread: thread, + providerTurnId: yield* providerTurnId, + requestRuntimeRestart: true, + }); + } + // The stopped subagent's report wakes nothing T3 runs. + yield* Effect.promise(() => gate.waitForReached("settled")); + gate.release("settled"); + assert.lengthOf(offers, 1); + // The unrelated turn shows no request it never made. + assert.deepEqual( + collected.flatMap((event) => + event.type === "runtime_request.updated" + ? [`${event.runtimeRequest.nativeRequestRef?.nativeId}:${event.runtimeRequest.status}`] + : [], + ), + [], + ); + }).pipe(Effect.scoped), + ); + + it.effect( + "keeps every subagent a stopped background reply's subagent started off the next turn", + () => + Effect.gen(function* () { + const CHILD_B = "ses_f1485c529ffeBBBBBBBBBBBBBB"; + const SPAWNED = "ses_f1485c529ffeDDDDDDDDDDDDDD"; + const NESTED = "ses_f1485c529ffeNNNNNNNNNNNNNN"; + const DEEP = "ses_f1485c529ffeEEEEEEEEEEEEEE"; + const toolB = { sessionID: SESSION, assistantMessageID: "msg_assistant", id: "call-b" }; + const spawn = { sessionID: SESSION, assistantMessageID: "msg_reply", id: "call-spawn" }; + const { runtime, thread } = yield* resumed([ + ...backgroundLaunch(CHILD), + // The same turn starts a second background subagent, which runs on. + event("session.tool.input.started", { ...toolB, name: "subagent" }), + event("session.tool.called", { + ...toolB, + name: "subagent", + input: { description: "Sleep", prompt: "sleep", background: true }, + executed: false, + }), + event("session.created", { ...childCreated(CHILD_B), sessionID: CHILD_B }), + event("session.tool.progress", { + ...toolB, + metadata: { sessionID: CHILD_B, status: "running" }, + }), + event("session.execution.succeeded", { sessionID: SESSION }), + event("session.execution.started", { sessionID: CHILD_B }), + // The first subagent's report wakes the parent into a reply T3 holds. + event("session.execution.succeeded", { sessionID: CHILD }), + event("session.inbox.enqueued", { + inboxID: "msg_report", + sessionID: SESSION, + item: { + type: "synthetic", + payload: { + text: `\nCHILD_OK\n`, + description: "Sleep", + metadata: { + source: "subagent", + childID: CHILD, + agent: "General", + state: "completed", + }, + }, + delivery: "steer", + }, + }), + event("session.execution.started", { sessionID: SESSION }), + // The held reply starts a subagent, which starts one, which starts another. + event("session.tool.input.started", { ...spawn, name: "subagent" }), + event("session.tool.called", { + ...spawn, + name: "subagent", + input: { description: "Sleep", prompt: "sleep", background: true }, + executed: false, + }), + event("session.created", { ...childCreated(SPAWNED), sessionID: SPAWNED }), + event("session.tool.progress", { + ...spawn, + metadata: { sessionID: SPAWNED, status: "running" }, + }), + event("session.created", { + ...childCreated(NESTED), + sessionID: NESTED, + parentID: SPAWNED, + }), + event("session.created", { ...childCreated(DEEP), sessionID: DEEP, parentID: NESTED }), + event("session.execution.succeeded", { sessionID: SESSION }), + // The second subagent's text, shown live, marks that the reply has ended. + event("session.text.ended", { + sessionID: CHILD_B, + assistantMessageID: "msg_child_b", + ordinal: 0, + text: "B_WORKING", + }), + // The user's Stop stops the second subagent and drops the held reply, + // then each subagent under the reply, the deepest of which runs on. + out("session.interrupt", { sessionID: CHILD_B }), + reply("session.interrupt", { interrupted: true }), + event("session.execution.interrupted", { sessionID: CHILD_B }), + out("session.interrupt", { sessionID: SPAWNED }), + reply("session.interrupt", { interrupted: true }), + out("session.interrupt", { sessionID: NESTED }), + reply("session.interrupt", { interrupted: true }), + out("session.interrupt", { sessionID: DEEP }), + reply("session.interrupt", { + status: 500, + body: { _tag: "UnknownError", message: "interrupt failed" }, + }), + // An unrelated prompt runs while the deepest asks for a permission, + // which no turn shows: it is refused. + out("session.prompt", { sessionID: SESSION, text: "" }), + promptAccepted, + event("session.execution.started", { sessionID: SESSION }), + event("permission.asked", { ...shellAsk.data, id: "per_deep", sessionID: DEEP }), + out("permission.reply", { sessionID: DEEP, requestID: "per_deep", decision: "reject" }), + reply("permission.reply", null), + event("session.execution.succeeded", { sessionID: SESSION }), + ]); + const collected: Array = []; + const replyEnded = yield* Deferred.make(); + const bothEnded = yield* Deferred.make(); + yield* runtime.events.pipe( + Stream.tap((event) => + Effect.gen(function* () { + collected.push(event); + if ( + event.type === "turn_item.updated" && + event.turnItem.type === "assistant_message" && + event.turnItem.text === "B_WORKING" + ) { + yield* Deferred.succeed(replyEnded, undefined); + } + if (collected.filter((entry) => entry.type === "turn.terminal").length === 2) { + yield* Deferred.succeed(bothEnded, undefined); + } + }), + ), + Stream.runDrain, + Effect.forkScoped, + ); + yield* runtime.startTurn(withLineage(thread)); + yield* Deferred.await(replyEnded); + yield* runtime.interruptTurn({ + providerThread: thread, + providerTurnId: yield* providerTurnId, + requestRuntimeRestart: true, + }); + yield* runtime.startTurn({ + ...secondTurn(thread), + appThread: withLineage(thread).appThread, + }); + yield* Deferred.await(bothEnded); + // The unrelated turn shows no request it never made. + assert.deepEqual( + collected.flatMap((event) => + event.type === "runtime_request.updated" + ? [ + `${event.runtimeRequest.nativeRequestRef?.nativeId}:${event.runtimeRequest.status}`, + ] + : [], + ), + [], + ); + }).pipe(Effect.scoped), + ); + + it.effect("ends a continuation while a skipped reply's subagent does not answer its Stop", () => + Effect.gen(function* () { + const CHILD_B = "ses_f1485c529ffeBBBBBBBBBBBBBB"; + const SPAWNED = "ses_f1485c529ffeDDDDDDDDDDDDDD"; + const toolB = { sessionID: SESSION, assistantMessageID: "msg_assistant", id: "call-b" }; + const spawn = { sessionID: SESSION, assistantMessageID: "msg_reply_a", id: "call-spawn" }; + const report = (inboxID: string, child: string) => + event("session.inbox.enqueued", { + inboxID, + sessionID: SESSION, + item: { + type: "synthetic", + payload: { + text: `\nOK\n`, + description: "Sleep", + metadata: { + source: "subagent", + childID: child, + agent: "General", + state: "completed", + }, + }, + delivery: "steer", + }, + }); + const offers: Array = []; + const bothOffered = yield* Deferred.make(); + const { runtime, thread } = yield* resumed([ + ...backgroundLaunch(CHILD), + event("session.tool.input.started", { ...toolB, name: "subagent" }), + event("session.tool.called", { + ...toolB, + name: "subagent", + input: { description: "Sleep", prompt: "sleep", background: true }, + executed: false, + }), + event("session.created", { ...childCreated(CHILD_B), sessionID: CHILD_B }), + event("session.tool.progress", { + ...toolB, + metadata: { sessionID: CHILD_B, status: "running" }, + }), + event("session.execution.succeeded", { sessionID: SESSION }), + // Reply A starts a subagent of its own, named only by its held events. + event("session.execution.succeeded", { sessionID: CHILD }), + report("msg_report_a", CHILD), + event("session.execution.started", { sessionID: SESSION }), + event("session.tool.input.started", { ...spawn, name: "subagent" }), + event("session.tool.called", { + ...spawn, + name: "subagent", + input: { description: "Sleep", prompt: "sleep", background: true }, + executed: false, + }), + event("session.created", { ...childCreated(SPAWNED), sessionID: SPAWNED }), + event("session.execution.succeeded", { sessionID: SESSION }), + event("session.execution.succeeded", { sessionID: CHILD_B }), + report("msg_report_b", CHILD_B), + event("session.execution.started", { sessionID: SESSION }), + event("session.text.ended", { + sessionID: SESSION, + assistantMessageID: "msg_reply_b", + ordinal: 0, + text: "REPLY_B", + }), + event("session.execution.succeeded", { sessionID: SESSION }), + // B's continuation skips reply A and stops its subagent, which never answers. + out("session.interrupt", { sessionID: SPAWNED }), + reply("session.interrupt", ""), + ]).pipe( + Effect.provideService(ProviderContinuationRequests.ProviderContinuationRequests, { + offer: (request) => + Effect.suspend(() => + offers.push(request) === 2 + ? Deferred.succeed(bothOffered, undefined).pipe(Effect.asVoid) + : Effect.void, + ), + take: Effect.never, + }), + ); + const ended = yield* terminals(runtime, 2); + yield* runtime.startTurn(withLineage(thread)); + yield* Deferred.await(bothOffered); + yield* runtime.startTurn(continuationTurn(thread, offers[1]!, "wake-b")); + // B's reply replays and ends its turn while that Stop still waits. + assert.deepEqual( + [...(yield* Fiber.join(ended))].map((terminal) => terminal.status), + ["completed", "completed"], + ); + }).pipe(Effect.scoped), + ); + + it.effect("replays only its own background reply into a continuation turn", () => + Effect.gen(function* () { + const CHILD_B = "ses_f1485c529ffeBBBBBBBBBBBBBB"; + const toolB = { sessionID: SESSION, assistantMessageID: "msg_assistant", id: "call-b" }; + const report = (inboxID: string, child: string, text: string) => + event("session.inbox.enqueued", { + inboxID, + sessionID: SESSION, + item: { + type: "synthetic", + payload: { + text: `\n${text}\n`, + description: "Sleep", + metadata: { + source: "subagent", + childID: child, + agent: "General", + state: "completed", + }, + }, + delivery: "steer", + }, + }); + const reply = (assistantMessageID: string, text: string) => + event("session.text.ended", { sessionID: SESSION, assistantMessageID, ordinal: 0, text }); + const offers: Array = []; + const bothOffered = yield* Deferred.make(); + const { runtime, thread } = yield* resumed([ + ...backgroundLaunch(CHILD), + // The same turn starts a second background subagent. + event("session.tool.input.started", { ...toolB, name: "subagent" }), + event("session.tool.called", { + ...toolB, + name: "subagent", + input: { description: "Sleep", prompt: "sleep", background: true }, + executed: false, + }), + event("session.created", { ...childCreated(CHILD_B), sessionID: CHILD_B }), + event("session.tool.progress", { + ...toolB, + metadata: { sessionID: CHILD_B, status: "running" }, + }), + event("session.execution.succeeded", { sessionID: SESSION }), + // Each subagent's report wakes the parent into a reply OpenCode runs on its own. + event("session.execution.succeeded", { sessionID: CHILD }), + report("msg_report_a", CHILD, "A_OK"), + event("session.execution.started", { sessionID: SESSION }), + event("session.inbox.delivered", { sessionID: SESSION, inboxID: "msg_report_a" }), + reply("msg_reply_a", "REPLY_A"), + event("session.execution.succeeded", { sessionID: SESSION }), + event("session.execution.succeeded", { sessionID: CHILD_B }), + report("msg_report_b", CHILD_B, "B_OK"), + event("session.execution.started", { sessionID: SESSION }), + event("session.inbox.delivered", { sessionID: SESSION, inboxID: "msg_report_b" }), + reply("msg_reply_b", "REPLY_B"), + event("session.execution.succeeded", { sessionID: SESSION }), + ]).pipe( + Effect.provideService(ProviderContinuationRequests.ProviderContinuationRequests, { + offer: (request) => + Effect.suspend(() => + offers.push(request) === 2 + ? Deferred.succeed(bothOffered, undefined).pipe(Effect.asVoid) + : Effect.void, + ), + take: Effect.never, + }), + ); + const collected: Array = []; + const ended = yield* Deferred.make(); + yield* runtime.events.pipe( + Stream.tap((event) => + Effect.gen(function* () { + collected.push(event); + if (collected.filter((entry) => entry.type === "turn.terminal").length === 2) { + yield* Deferred.succeed(ended, undefined); + } + }), + ), + Stream.runDrain, + Effect.forkScoped, + ); + yield* runtime.startTurn(withLineage(thread)); + yield* Deferred.await(bothOffered); + // A's continuation was cancelled before it started; B's runs. + yield* runtime.startTurn(continuationTurn(thread, offers[1]!, "wake-b")); + yield* Deferred.await(ended); + const continuationId = (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({ + driver: OPENCODE_PROVIDER, + nativeTurnId: `${SESSION}:attempt:attempt:opencode2-adapter:wake-b`, + }); + assert.deepEqual( + collected.flatMap((event) => + event.type === "turn_item.updated" && + event.turnItem.type === "assistant_message" && + event.turnItem.providerTurnId === continuationId + ? [event.turnItem.text] + : [], + ), + ["REPLY_B"], + ); + // Nothing is left held for a continuation that will never run. + assert.isFalse(yield* runtime.hasPendingBackgroundWork!); + }).pipe(Effect.scoped, Effect.provide(IdAllocator.layer)), + ); + it.effect("ends a follow-up turn a steer joined exactly once", () => Effect.gen(function* () { const reportText = `\nCHILD_OK\n`; @@ -4534,19 +5194,7 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { yield* Effect.gen(function* () { while (offers.length === 0) yield* Effect.yieldNow; }).pipe(Effect.timeout("2 seconds"), Effect.orDie); - const followUp = { - ...withLineage(thread), - runId: RunId.make("run:opencode2-adapter:wake"), - runOrdinal: 2, - providerTurnOrdinal: 2, - attemptId: RunAttemptId.make("attempt:opencode2-adapter:wake"), - message: { - ...turnInput(thread).message, - messageId: MessageId.make("message:opencode2-adapter:wake"), - createdBy: "agent" as const, - creationSource: "provider" as const, - }, - }; + const followUp = continuationTurn(thread, offers[0]!); yield* runtime.startTurn(followUp); const followUpTurnId = (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({ driver: OPENCODE_PROVIDER, diff --git a/apps/server/src/orchestration-v2/ProviderContinuationService.test.ts b/apps/server/src/orchestration-v2/ProviderContinuationService.test.ts index a6afc4a4b2ff..3cc41af2e7ab 100644 --- a/apps/server/src/orchestration-v2/ProviderContinuationService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderContinuationService.test.ts @@ -180,6 +180,25 @@ describe("ProviderContinuationService", () => { }); }); + it.effect("dispatches a wake under the message its adapter named", () => { + return Effect.gen(function* () { + const dispatched = yield* Queue.unbounded(); + const messageId = MessageId.make("message-provider-continuation-adapter-named"); + yield* Effect.gen(function* () { + const requests = yield* ProviderContinuationRequests.ProviderContinuationRequests; + yield* requests.offer({ ...request(), messageId }); + const command = (yield* Queue.take(dispatched)) as { readonly messageId: MessageId }; + // The adapter matches the continuation run's message to the wake it holds. + assert.equal(command.messageId, messageId); + }).pipe( + Effect.provide( + layerTest({ dispatched, getThreadRecords: () => Effect.succeed(projection) }), + ), + Effect.scoped, + ); + }); + }); + it.effect("delivers a message_text wake as a real prompt, not a buffered wake", () => { return Effect.gen(function* () { const dispatched = yield* Queue.unbounded(); diff --git a/apps/server/src/orchestration-v2/ProviderContinuationService.ts b/apps/server/src/orchestration-v2/ProviderContinuationService.ts index b8a57a88937f..8e97da83df97 100644 --- a/apps/server/src/orchestration-v2/ProviderContinuationService.ts +++ b/apps/server/src/orchestration-v2/ProviderContinuationService.ts @@ -147,10 +147,12 @@ export const layer = Layer.effectDiscard( } // The ordinal is display metadata only: allocate.message appends a // random UUID, so a stale projection read here cannot collide ids. - const messageId = yield* ids.allocate.message({ - threadId: request.threadId, - ordinal: projection.messages.length + 1, - }); + const messageId = + request.messageId ?? + (yield* ids.allocate.message({ + threadId: request.threadId, + ordinal: projection.messages.length + 1, + })); const commandId = CommandId.make(`provider-continuation:${messageId}`); const dispatch = threads.dispatch({ type: "message.dispatch", diff --git a/packages/provider-core/src/server/ProviderContinuationRequests.ts b/packages/provider-core/src/server/ProviderContinuationRequests.ts index 638068eb621f..5d08f22c13fb 100644 --- a/packages/provider-core/src/server/ProviderContinuationRequests.ts +++ b/packages/provider-core/src/server/ProviderContinuationRequests.ts @@ -41,6 +41,12 @@ export interface ProviderContinuationRequest { * reach the provider as a real prompt. */ readonly delivery?: "adapter_buffered" | "message_text"; + /** + * The message the continuation run starts from, when the adapter picks it: + * its turn input carries this id, so the adapter can tell which of its held + * wakes a run was dispatched for. Otherwise the service allocates one. + */ + readonly messageId?: MessageId; readonly dispatchIfCurrent?: ( effect: Effect.Effect, ) => Effect.Effect, E, R>; diff --git a/packages/provider-opencode/src/server/v2/adapter.ts b/packages/provider-opencode/src/server/v2/adapter.ts index bf4cdceebae3..785bf1a06e70 100644 --- a/packages/provider-opencode/src/server/v2/adapter.ts +++ b/packages/provider-opencode/src/server/v2/adapter.ts @@ -49,6 +49,7 @@ import { type OrchestrationV2Subagent, type OrchestrationV2TurnItem, type OrchestrationV2UserInputQuestion, + type MessageId, type ModelSelection, type ProviderApprovalDecision, type ProviderInstanceId, @@ -305,6 +306,8 @@ interface SubagentCall { * the continuation turn T3 opens for it takes them. */ interface Wake { + /** The message its continuation run starts from, which names the wake in that turn's input. */ + readonly messageId: MessageId | undefined; readonly events: Array; running: boolean; /** The background subagents whose end it answers, as its turn's notification names them. */ @@ -317,6 +320,11 @@ interface Wake { readonly after: string | undefined; /** The first report it delivered, where fork and rollback cut before its turn. */ readonly first: string | undefined; + /** + * Subagent sessions OpenCode announced while it was held: their calls are + * in its events, so only its replay names them. + */ + readonly children: Set; } interface OpenBlock { @@ -907,6 +915,11 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr const childOwners = new Map(); // What OpenCode announced about a subagent's session, until its call names it. const announced = new Map["data"]>(); + // Subagent sessions a dropped held execution started, and the subagents + // they start, by the session the first of them was called from. Nothing + // names them, so they no longer route to its thread; they stay here until + // a Stop of that thread stops them or they are gone. + const strays = new Map(); // Sessions of these threads with an execution running, seen on the stream. const busy = new Set(); const pending = new Map(); @@ -1687,13 +1700,14 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr */ const offerWake = Effect.fnUntraced(function* (state: ThreadState, wake: Wake) { const route = state.providerThread.appThreadId; - if (route === null) return; + if (route === null || wake.messageId === undefined) return; const notification = backgroundWorkNotification(wake.reports); yield* continuationRequests.offer({ threadId: route, providerThreadId: state.providerThread.id, driver, detail: wake.detail, + messageId: wake.messageId, ...(notification === null ? {} : { notification }), dispatchIfCurrent: (dispatch) => wake.dropped ? Effect.succeed(Option.none()) : Effect.map(dispatch, Option.some), @@ -2153,6 +2167,15 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr | { readonly type: "form"; readonly form: NativeForm }, ) { const asking = asked.type === "permission" ? asked.request.sessionID : asked.form.sessionID; + // A stray belongs to no turn: its request is refused, which ends its run. + if (strays.has(asking)) { + return yield* stopStaleRequest( + asking, + asked.type === "permission" + ? { type: "permission", id: asked.request.id } + : { type: "form", id: asked.form.id }, + ); + } const state = ownerOf(asking); if (state === undefined) return; const target = requestTurn(asking); @@ -2381,7 +2404,13 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr const onWake = Effect.fnUntraced(function* (state: ThreadState) { const { delivered, stopped } = yield* takeReports(state); if (stopped) return; + const route = state.providerThread.appThreadId; const wake: Wake = { + // Only a thread's wake is offered a continuation; the ordinal is display metadata. + messageId: + route === null + ? undefined + : yield* idAllocator.allocate.message({ threadId: route, ordinal: 0 }), events: [], running: true, reports: delivered.map((entry) => entry.report), @@ -2389,6 +2418,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr dropped: false, after: delivered.at(-1)?.inboxId, first: delivered[0]?.inboxId, + children: new Set(), }; state.wakes.push(wake); yield* Effect.logInfo("OpenCode started a turn on its own; asking for a continuation.", { @@ -2408,6 +2438,8 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr ) { const childId = stringField(payload.metadata, "childID"); if (stringField(payload.metadata, "source") !== "subagent" || childId === undefined) return; + // A stray that reports has ended: no Stop needs to reach it. + strays.delete(childId); const call = [...state.calls.values()].find( (candidate) => candidate.child?.sessionId === childId, ); @@ -2513,10 +2545,17 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr } // A subagent's session: named on its parent's `subagent` call just after. if (event.type === "session.created" && event.data.parentID !== undefined) { + // A stray's subagent is a stray too: no call of the thread's names it. + const strayCaller = strays.get(event.data.parentID); + if (strayCaller !== undefined) { + strays.set(event.data.sessionID, strayCaller); + return; + } const owner = ownerOf(event.data.parentID); if (owner !== undefined) { childOwners.set(event.data.sessionID, owner); announced.set(event.data.sessionID, event.data); + holderOf(event.data.parentID)?.wakes.at(-1)?.children.add(event.data.sessionID); } return; } @@ -2806,7 +2845,15 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr (state) => state.subagent === undefined && hasBackground(state), ); const marked = [...threads.values()].some((state) => state.unsettled); - if (running.length === 0 && background.length === 0 && busy.size === 0 && !marked) return; + if ( + running.length === 0 && + background.length === 0 && + busy.size === 0 && + !marked && + strays.size === 0 + ) { + return; + } const active = yield* client.session.active(); // An execution that ended while the stream was down never said so. for (const sessionId of busy) if (!(sessionId in active)) busy.delete(sessionId); @@ -2817,6 +2864,8 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr for (const state of threads.values()) { if (state.unsettled && !(state.sessionId in active)) state.unsettledStarted = true; } + // A stray that stopped meanwhile runs nothing to stop. + for (const childId of strays.keys()) if (!(childId in active)) strays.delete(childId); for (const [sessionId, state] of running) { const turn = state.active; if (turn === undefined) continue; @@ -2848,10 +2897,14 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr } // A subagent's session holds reports of its own background subagents. for (const session of sessionsOf(state)) { - for (const wake of session.wakes.splice(0)) wake.dropped = true; + discardWakes(session, session.wakes.splice(0)); session.reports.clear(); } } + // Stopped off the lock this reconcile holds; one still running waits for a Stop. + for (const thread of new Set([...strays.values()].map(rootOf))) { + yield* Effect.forkIn(stopStrays(thread), sessionScope); + } // A request asked while the stream was down was never shown, and the // run waits on it; one T3 shows that OpenCode no longer lists was // answered elsewhere or dropped with its execution. A session that @@ -3442,6 +3495,62 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr for (const event of wake.events) yield* route(event, sessionOfEvent(event), true); }); + /** + * Drops held executions no turn will take. The subagents one started are + * named only by its replay, so they stop routing to the thread at once and + * become strays for {@link stopStrays}. A subagent that registered its own + * call outside the held execution is the thread's, and is left alone. + */ + const discardWakes = (caller: ThreadState, wakes: ReadonlyArray) => { + const retired = new Set(); + const retire = (childId: string) => { + const info = announced.get(childId); + if (info === undefined) return; + announced.delete(childId); + childOwners.delete(childId); + strays.set(childId, caller); + retired.add(childId); + // OpenCode reports its end to the session that called it; that + // report answers no turn, so the execution it wakes is stopped. A + // deeper one reports to a stray, which T3 does not track. + threads.get(info.parentID ?? "")?.stoppedChildren.add(childId); + }; + for (const wake of wakes) { + wake.dropped = true; + for (const childId of wake.children) retire(childId); + } + // Their own subagents, at any depth, were announced live but no call + // of the thread's names them either. + for (let grew = retired.size > 0; grew;) { + grew = false; + for (const [childId, info] of announced) { + if (info.parentID === undefined || !retired.has(info.parentID)) continue; + retire(childId); + grew = true; + } + } + }; + + /** + * Stops a thread's strays one by one, as a Stop stops background + * subagents, and never under `lock`: one that does not answer would + * otherwise hold up every event. A stray is forgotten once stopped or + * gone; one still running waits for the next Stop. + */ + const stopStrays = (thread: ThreadState) => + Effect.forEach( + [...strays].flatMap(([childId, caller]) => (rootOf(caller) === thread ? [childId] : [])), + (childId) => + client.session.interrupt({ sessionID: Session.ID.make(childId) }).pipe( + Effect.asVoid, + Effect.catchTags({ SessionNotFoundError: () => Effect.void }), + Effect.timeout(INTERRUPT_TIMEOUT), + Effect.tap(() => Effect.sync(() => strays.delete(childId))), + Effect.ignore({ log: true }), + ), + { discard: true }, + ); + /** * A user turn that starts while OpenCode runs an execution on its own: the * prompt joins that execution, so the turn takes it, and its continuation @@ -3456,8 +3565,11 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr /** * The continuation turn for an execution OpenCode started on its own: it - * takes the oldest one held and ends with it. One already taken (a user - * turn joined it) leaves nothing to run, so the turn ends at once. + * takes the wake it was dispatched for, named by its message, and ends + * with it. Wakes held before that one had their continuation cancelled or + * dropped before it started, so nothing will replay them: they are + * dropped. One already taken (a user turn joined it) leaves nothing to + * run, so the turn ends at once. */ const runWake = Effect.fnUntraced(function* ( state: ThreadState, @@ -3466,8 +3578,16 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr yield* beginTurn(state, turnInput, false); yield* lock.withPermit( Effect.gen(function* () { - const wake = state.wakes.shift(); - if (wake === undefined) return yield* finishTurn(state, { status: "completed" }); + const index = state.wakes.findIndex( + (held) => held.messageId === turnInput.message.messageId, + ); + if (index < 0) return yield* finishTurn(state, { status: "completed" }); + // Queued continuations can't be reordered (they carry a notification), + // so an earlier wake here lost its continuation to a cancel or a failed + // dispatch. Dropping it keeps that wake from pinning the session. + discardWakes(state, state.wakes.splice(0, index)); + yield* Effect.forkIn(stopStrays(state), sessionScope); + const wake = state.wakes.shift()!; const turn = state.active; if (turn !== undefined && wake.after !== undefined) turn.before = wake.after; // Its first report is where its history begins, so fork and rollback cut there. @@ -3557,7 +3677,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr } // A held wake no turn will take: its execution is stopped, not replayed. const running = state.wakes.some((wake) => wake.running); - for (const wake of state.wakes.splice(0)) wake.dropped = true; + discardWakes(state, state.wakes.splice(0)); if (running) { markUnsettled(state); yield* client.session @@ -3566,6 +3686,8 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr } }), ); + // Subagents of held executions this or an earlier Stop dropped. + yield* stopStrays(state); }); /** From f0a47b3172c4a73e1932b23d37c3345481c5a67e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 10 Oct 2026 22:46:38 -0700 Subject: [PATCH 14/66] fix(server): ACP reapplies a model after a switch away from it failed partway (#14723) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/server/scripts/acp-mock-agent.ts | 3 +- .../Adapters/AcpAdapterV2.test.ts | 90 +++++++++++++++++++ packages/provider-acp/src/server/adapter.ts | 4 + 3 files changed, 96 insertions(+), 1 deletion(-) diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index 5548bebac84c..dc85161ad351 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -94,6 +94,7 @@ const emitOverlappingXAiPromptCompleteOutOfOrder = process.env.T3_ACP_EMIT_OVERLAPPING_XAI_PROMPT_COMPLETE_OUT_OF_ORDER === "1"; const failPrompt = process.env.T3_ACP_FAIL_PROMPT === "1"; const failSetConfigOption = process.env.T3_ACP_FAIL_SET_CONFIG_OPTION === "1"; +const failSetModeConfigOption = process.env.T3_ACP_FAIL_SET_MODE_CONFIG_OPTION === "1"; const exitOnSetConfigOption = process.env.T3_ACP_EXIT_ON_SET_CONFIG_OPTION === "1"; const omitModelConfigOption = process.env.T3_ACP_OMIT_MODEL_CONFIG_OPTION === "1"; const promptResponseText = process.env.T3_ACP_PROMPT_RESPONSE_TEXT; @@ -775,7 +776,7 @@ const program = Effect.gen(function* () { process.exit(7); }); } - if (failSetConfigOption) { + if (failSetConfigOption || (failSetModeConfigOption && request.configId === "mode")) { return yield* AcpError.AcpRequestError.invalidParams( "Mock invalid params for session/set_config_option", { diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index 369f1c29a569..e8ae5a0c9dd0 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -58,6 +58,7 @@ import type * as EffectAcpSchema from "effect-acp/compat"; import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; +import { ACP_SESSION_MODE_OPTION_ID } from "@t3tools/provider-acp/server/sessionConfig"; import { extractXAiAcpSubagentEndNotice, extractXAiAcpSubagentUpdate, @@ -880,6 +881,95 @@ describe("AcpAdapterV2", () => { }).pipe(Effect.provide(layerTest), Effect.scoped), ); + it.live("reapplies a selection after a switch away from it failed partway", () => + Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const path = yield* Path.Path; + const mockAgentPath = yield* path.fromFileUrl( + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), + ); + const protocolEvents = yield* Queue.unbounded(); + const instanceId = ProviderInstanceId.make("acp-test-partial-selection-reapply"); + const threadId = ThreadId.make("thread-acp-partial-selection-reapply"); + const adapter = yield* makeAcpAdapterV2({ + instanceId, + flavor: { + driver: ACP_TEST_DRIVER, + capabilities: AcpProviderCapabilitiesV2, + makeRuntime: makeMockRuntime({ + childProcessSpawner, + mockAgentPath, + protocolEvents, + // The model switch lands, then the agent rejects the mode step. + environment: { T3_ACP_FAIL_SET_MODE_CONFIG_OPTION: "1" }, + }), + }, + selfInvocation: yield* resolveSelfInvocation(), + }); + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), + }); + const selectionA = { instanceId, model: "composer-2" } as const; + const selectionB = { + instanceId, + model: "composer-2[fast=true]", + options: [{ id: ACP_SESSION_MODE_OPTION_ID, value: "code" }], + } as const; + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("provider-session-acp-partial-selection"), + modelSelection: selectionA, + runtimePolicy, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection: selectionA, + runtimePolicy, + }); + const now = yield* DateTime.now; + const turnInput = (ordinal: number, modelSelection: ModelSelection) => + makeTurnInput({ + threadId, + providerThread, + instanceId, + runtimePolicy, + now, + ordinal, + modelSelection, + }); + + const switchToB = yield* Effect.exit(runtime.startTurn(turnInput(1, selectionB))); + assert.isTrue(Exit.isFailure(switchToB), "the agent rejected B's mode step"); + yield* Queue.clear(protocolEvents); + + yield* runtime.startTurn(turnInput(2, selectionA)); + yield* runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runDrain, + ); + const modelSetsBeforePrompt: Array = []; + while (true) { + const event = yield* Queue.take(protocolEvents); + if (event.direction !== "outgoing") continue; + const method = rawProtocolMethod(event); + if (method === "session/prompt") break; + if ( + method === "session/set_config_option" && + rawProtocolRequestParam(event, "configId") === "model" + ) { + modelSetsBeforePrompt.push(rawProtocolRequestParam(event, "value")); + } + } + assert.deepEqual( + modelSetsBeforePrompt, + ["composer-2"], + "the native model was left on B, so the turn on A must set it back first", + ); + }).pipe(Effect.provide(layerTest), Effect.scoped), + ); + it.effect("starts a new replay message after ACP v2 plan boundaries", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; diff --git a/packages/provider-acp/src/server/adapter.ts b/packages/provider-acp/src/server/adapter.ts index c1208b49556e..6e125b2318cf 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -6283,6 +6283,10 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( modelSelection: ModelSelection, runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy, ) { + // A step failing below leaves the native config partly changed, so + // the previously applied selection no longer describes the session. + // Callers record the new selection only once this completes. + yield* Ref.set(activeSelection, null); const requestedModel = flavor.resolveModelId?.(modelSelection) ?? modelSelection.model; let appliedModel: string | undefined; if (flavor.applyModelSelection !== undefined) { From f769e01e28c80745621a699ed5ee021cc78039b6 Mon Sep 17 00:00:00 2001 From: Exotic <118054752+extoci@users.noreply.github.com> Date: Sun, 11 Oct 2026 08:47:53 +0300 Subject: [PATCH 15/66] fix(web): focus settings search with command-f (#17859) --- .../settings/KeybindingsSettings.tsx | 23 ----------------- .../settings/SettingsSidebarNav.tsx | 25 +++++++++++++------ 2 files changed, 18 insertions(+), 30 deletions(-) diff --git a/apps/web/src/components/settings/KeybindingsSettings.tsx b/apps/web/src/components/settings/KeybindingsSettings.tsx index b710c135ab2d..1aa0dc0e9d3a 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.tsx +++ b/apps/web/src/components/settings/KeybindingsSettings.tsx @@ -17,7 +17,6 @@ import { type ReactNode, type RefObject, useCallback, - useEffect, useMemo, useReducer, useRef, @@ -1340,28 +1339,6 @@ export function KeybindingsSettingsPanel() { const commandOptions = useMemo(() => buildKeybindingCommandOptions(keybindings), [keybindings]); const whenVariables = useMemo(() => buildWhenVariableOptions(), []); - useEffect(() => { - const handleKeyDown = (event: globalThis.KeyboardEvent) => { - const isMod = event.metaKey || event.ctrlKey; - if (!isMod || event.altKey || event.key.toLowerCase() !== "f") return; - - const target = event.target; - if ( - target !== searchInputRef.current && - target instanceof HTMLElement && - (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable) - ) { - return; - } - - event.preventDefault(); - searchInputRef.current?.focus(); - searchInputRef.current?.select(); - }; - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, []); - const openKeybindingsFile = useCallback(() => { if ( !keybindingsConfigPath || diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx index d10abed376ba..035e42b53cc2 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -140,17 +140,28 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { useEffect(() => { const handleKeyDown = (event: globalThis.KeyboardEvent) => { - if (event.key !== "/" || event.metaKey || event.ctrlKey || event.altKey) return; + if (event.defaultPrevented || event.isComposing) return; + const isFindShortcut = + event.key.toLowerCase() === "f" && + (event.metaKey || event.ctrlKey) && + !event.altKey && + !event.shiftKey; + const isSlashShortcut = + event.key === "/" && !event.metaKey && !event.ctrlKey && !event.altKey; + if (!isFindShortcut && !isSlashShortcut) return; const target = event.target; if ( target instanceof HTMLElement && - (target.tagName === "INPUT" || - target.tagName === "TEXTAREA" || - target.isContentEditable || - // Keep focus inside open dialogs and popups instead of escaping - // their focus trap into the sidebar search. - target.closest('[role="dialog"], [aria-modal="true"], [data-slot$="popup"]') !== null) + ((isSlashShortcut && + (target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.isContentEditable)) || + // Preserve dialog and popup focus traps, and let shortcut recorders + // capture the keypress without moving focus into the sidebar search. + target.closest( + '[role="dialog"], [aria-modal="true"], [data-slot$="popup"], [data-keybinding-capture]', + ) !== null) ) { return; } From 5047ee78858bfdeb254c7386e2d427d3bccb8430 Mon Sep 17 00:00:00 2001 From: wellorbetter <1419919418@qq.com> Date: Sat, 10 Oct 2026 23:02:34 -0700 Subject: [PATCH 16/66] fix(mobile): keep Android project paths on one line (#14178) --- apps/mobile/src/components/MaterialListRow.tsx | 10 +++++++++- .../mobile/src/features/threads/NewTaskRouteScreen.tsx | 2 ++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/components/MaterialListRow.tsx b/apps/mobile/src/components/MaterialListRow.tsx index cef104a02f50..447d192438d1 100644 --- a/apps/mobile/src/components/MaterialListRow.tsx +++ b/apps/mobile/src/components/MaterialListRow.tsx @@ -12,6 +12,8 @@ export function MaterialListRow({ title, titleClassName, subtitle, + subtitleNumberOfLines, + subtitleEllipsizeMode, leading, trailing, className, @@ -20,6 +22,8 @@ export function MaterialListRow({ readonly title: string; readonly titleClassName?: string; readonly subtitle?: string | null; + readonly subtitleNumberOfLines?: ComponentProps["numberOfLines"]; + readonly subtitleEllipsizeMode?: ComponentProps["ellipsizeMode"]; readonly leading?: ReactNode; readonly trailing?: ReactNode; }) { @@ -46,7 +50,11 @@ export function MaterialListRow({ {title} {subtitle ? ( - + {subtitle} ) : null} diff --git a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx index 1a5323281c47..b015b0508bb5 100644 --- a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx @@ -463,6 +463,8 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps void selectProject(selectionTarget)} leading={ From 0cb38403feb8f2bbc29dc78dea1649af696b6f08 Mon Sep 17 00:00:00 2001 From: Matthew Feroz <136640686+MatthewFeroz@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:36:13 -0700 Subject: [PATCH 17/66] fix(web): unpin preview dragging on narrow chat canvases (#15556) --- .../components/chat/chatCanvasLayout.test.ts | 19 +++++++++++++++++-- .../src/components/chat/chatCanvasLayout.ts | 9 ++++++--- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/chat/chatCanvasLayout.test.ts b/apps/web/src/components/chat/chatCanvasLayout.test.ts index 936f2193a7b6..4be02ee322a9 100644 --- a/apps/web/src/components/chat/chatCanvasLayout.test.ts +++ b/apps/web/src/components/chat/chatCanvasLayout.test.ts @@ -76,6 +76,21 @@ describe("chat canvas layout", () => { expect(result.chat.left).toBe(48); expectClear(result); }); + it.each([900, 1000, 1004])("allows dragging across a %spx chat canvas", (width) => { + const start = resolve(width, { ...preview, position: { x: width - 332, y: 200 } }).frame!; + const moved = resolve(width, { + ...preview, + position: { x: start.x - 300, y: start.y }, + lastInteraction: "drag", + }); + expect(moved.frame).toEqual({ ...start, x: start.x - 300 }); + const returned = resolve(width, { + ...preview, + position: { x: start.x, y: start.y }, + lastInteraction: "drag", + }); + expect(returned.frame).toEqual(start); + }); it("stops leftward dragging before the player can push chat to the right", () => { const centered = resolve(1344, null).chat; for (const x of [1100, 800, 700, 600, 400, 12, -100]) { @@ -91,7 +106,7 @@ describe("chat canvas layout", () => { it("keeps a dragged player above the composer when no readable lane fits beside it", () => { const result = resolve(1000, { ...preview, position: { x: 12, y: 700 } }); expect(result.chat).toEqual(resolve(1000, null).chat); - expect(result.frame!.x).toBe(668); + expect(result.frame!.x).toBe(12); expect(result.frame!.y + result.frame!.height).toBeLessThanOrEqual(708); }); it("allows message overlap on narrow screens while excluding the composer", () => { @@ -299,7 +314,7 @@ describe("chat canvas layout", () => { }); expect(result.overlapsDetailsCard).toBe(true); expect(result.overlapsChat).toBe(true); - expect(result.frame).toEqual({ x: 272, y: 396, width: 1300, height: 400 }); + expect(result.frame).toEqual({ x: 12, y: 396, width: 1300, height: 400 }); }); }); diff --git a/apps/web/src/components/chat/chatCanvasLayout.ts b/apps/web/src/components/chat/chatCanvasLayout.ts index c13af0765dac..79b5afd561e1 100644 --- a/apps/web/src/components/chat/chatCanvasLayout.ts +++ b/apps/web/src/components/chat/chatCanvasLayout.ts @@ -66,10 +66,13 @@ export function resolveChatCanvasLayout({ let overlapsChat = false; if (preview && container.width > 0 && container.height > 0) { frame = resolvePreviewMiniPlayerFrame({ ...preview, container }); - // Dragging stops at a readable chat lane on the left. Resizing can still - // consume that space when the container requires message overlap. + // Reserve a readable chat lane only while it leaves room to drag. Otherwise + // the container's right edge would pin the preview at every pointer position. const minimumPreviewX = - preview.lastInteraction === "resize" ? GAP : padding + minChatWidth + GAP; + preview.lastInteraction === "resize" || + container.width <= padding + minChatWidth + frame.width + GAP * 2 + ? GAP + : padding + minChatWidth + GAP; // New players start beside the composer, with the workspace card above them. if (preview.position === null) frame = { ...frame, y: container.height - frame.height - GAP }; if (preview.lastInteraction === "resize" && composerHeight > 0) { From 8f43ceda4034c3f660dfd3fa3ca4abe67141b67f Mon Sep 17 00:00:00 2001 From: freddy-d <38051127+freddy-d@users.noreply.github.com> Date: Sun, 11 Oct 2026 03:37:44 -0300 Subject: [PATCH 18/66] fix(web): thread details toggle no longer covers the thread title on Windows desktop (#16869) Co-authored-by: Claude Opus 5.5 (1M context) Co-authored-by: Yash Singh --- apps/web/src/components/ChatView.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index bf4fa0ae7164..20c4d08b5bcf 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -11250,7 +11250,9 @@ export default function ChatView(props: ChatViewProps) { ); const threadPanelHeaderControl = (
    Date: Sun, 11 Oct 2026 09:38:03 +0300 Subject: [PATCH 19/66] fix(server): a directory no longer resolves as the resource monitor binary (#16838) Co-authored-by: Yash Singh --- .../ResourceMonitorBinary.test.ts | 22 +++++++++++++++++++ .../ResourceMonitorBinary.ts | 11 +++++++--- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/apps/server/src/resourceTelemetry/ResourceMonitorBinary.test.ts b/apps/server/src/resourceTelemetry/ResourceMonitorBinary.test.ts index d53e1a091f47..1f30be947718 100644 --- a/apps/server/src/resourceTelemetry/ResourceMonitorBinary.test.ts +++ b/apps/server/src/resourceTelemetry/ResourceMonitorBinary.test.ts @@ -115,6 +115,28 @@ describe("ResourceMonitorBinary", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + it.effect("rejects a directory override", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-resource-monitor-binary-", + }); + + const service = yield* ResourceMonitorBinary.make().pipe( + Effect.provide(ServerConfig.layerTest(process.cwd(), baseDir)), + Effect.provideService(HostProcess.Platform, "win32"), + Effect.provideService(HostProcess.Architecture, "arm64"), + Effect.provideService(HostProcess.Environment, { + T3CODE_RESOURCE_MONITOR_PATH: baseDir, + }), + ); + const error = yield* Effect.flip(service.resolve); + + assert.instanceOf(error, ResourceMonitorBinary.ResourceMonitorBinaryNotExecutable); + assert.equal(error.path, baseDir); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + it.effect("rejects unsupported platform and architecture pairs", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/resourceTelemetry/ResourceMonitorBinary.ts b/apps/server/src/resourceTelemetry/ResourceMonitorBinary.ts index 035a9364a497..c7f69f5668c2 100644 --- a/apps/server/src/resourceTelemetry/ResourceMonitorBinary.ts +++ b/apps/server/src/resourceTelemetry/ResourceMonitorBinary.ts @@ -196,9 +196,14 @@ export const make = Effect.fn("resourceTelemetry.resourceMonitorBinary.make")(fu const exists = yield* fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false)); if (!exists) continue; - if (platform !== "win32") { - const stat = yield* fileSystem.stat(candidate).pipe(Effect.option); - if (Option.isSome(stat) && (stat.value.mode & 0o111) === 0) { + const stat = yield* fileSystem.stat(candidate).pipe(Effect.option); + if (Option.isSome(stat)) { + // Directories are executable-looking on win32, where the exec-bit test + // below is skipped, and only fail later at spawn. + const unusable = + stat.value.type === "Directory" || + (platform !== "win32" && (stat.value.mode & 0o111) === 0); + if (unusable) { return yield* new ResourceMonitorBinaryNotExecutable({ path: candidate, mode: stat.value.mode, From 119e61b6c1ee642fa48b4447812fb77f17b0bfe5 Mon Sep 17 00:00:00 2001 From: Vantrongs Date: Sun, 11 Oct 2026 09:41:30 +0300 Subject: [PATCH 20/66] fix(server): a Claude usage limit no longer resets the context meter to 0% (#16394) Co-authored-by: Claude Opus 5.5 (1M context) Co-authored-by: Yash Singh --- .../Adapters/ClaudeAdapterV2.test.ts | 68 +++++++++++++++++++ .../Adapters/ClaudeAdapterV2.ts | 8 ++- 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 6a9a5c20edee..7652b5636926 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -3355,6 +3355,74 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + it.effect("keeps the measured context when a usage limit ends the turn", () => + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-limit-context"), + text: "Continue.", + attachments: [], + }), + ); + yield* Queue.offer( + harness.sdkMessages, + makeAssistantTextFrame({ uuid: "00000000-0000-4000-8000-000000000660", text: "Working." }), + ); + // The CLI answers a 429 with a locally generated frame that reports no usage. + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "assistant", + message: { + model: "", + id: "00000000-0000-4000-8000-000000000661", + type: "message", + role: "assistant", + content: [{ type: "text", text: "You've hit your session limit" }], + stop_reason: "stop_sequence", + stop_sequence: "", + usage: { + input_tokens: 0, + output_tokens: 0, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + }, + parent_tool_use_id: null, + error: "rate_limit", + uuid: "00000000-0000-4000-8000-000000000661", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000662", + result: "You've hit your session limit", + terminalReason: "api_error", + isError: true, + apiErrorStatus: 429, + }), + ); + const terminal = yield* Queue.take(harness.terminalReceipts); + assert.equal(terminal.status, "failed"); + const reportedContext = harness.events.flatMap((event) => + event.type === "provider_turn.updated" && event.providerTurn.tokenUsage + ? [event.providerTurn.tokenUsage.usedTokens] + : [], + ); + assert.deepEqual(reportedContext, [2]); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ); + it.effect("surfaces a Claude safety model fallback without failing the turn", () => Effect.gen(function* () { const harness = yield* makeWakeHarness; diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index b0f4a23a7884..80eb47436534 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -6933,7 +6933,13 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( if (message.type === "assistant" && input.replayed !== true) { const now = yield* DateTime.now; yield* completeProviderRetry(context, now); - if (message.parent_tool_use_id === null && message.message.usage !== undefined) { + // `` frames (usage limits, sign-in errors, /goal status) + // are generated by the CLI and report zero usage, not the context. + if ( + message.parent_tool_use_id === null && + message.message.usage !== undefined && + message.message.model !== "" + ) { yield* emitProviderEvent({ type: "provider_turn.updated", driver: CLAUDE_PROVIDER, From bf725cb80d79334601c2833cb599524a3669e03b Mon Sep 17 00:00:00 2001 From: Scott Norteman Date: Sun, 11 Oct 2026 02:45:48 -0400 Subject: [PATCH 21/66] fix(server): GitLab merge requests can expand unchanged lines (#17528) Co-authored-by: Claude Opus 5.5 --- .../server/GitLabPullRequestProvider.test.ts | 66 +++++++++++++++++++ .../src/server/GitLabPullRequestProvider.ts | 3 + 2 files changed, 69 insertions(+) diff --git a/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.test.ts b/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.test.ts index d506aace9489..6800bc467541 100644 --- a/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.test.ts +++ b/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.test.ts @@ -212,3 +212,69 @@ describe("rewriting what has already been said", () => { }), ); }); + +describe("expanding unchanged lines", () => { + const change = { + cwd: "/w", + repository: "acme/web", + host: "gitlab.com", + number: 7, + changeType: "change" as const, + oldPath: "src/page.ts", + newPath: "src/page.ts", + }; + + it.effect("reads both sides of a file through the merge request or one of its commits", () => + Effect.gen(function* () { + const getMergeRequestDiffFileContents = vi.fn(() => + Effect.succeed({ oldContents: "before\n", newContents: "after\n" }), + ); + const provider = yield* make.pipe( + Effect.provide( + Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({ + getMergeRequestDiffFileContents, + }), + ), + ); + assert.isDefined(provider.getDiffFileContents); + const oneCommit = { ...change, commit: "a1b2c3d" }; + + expect(yield* provider.getDiffFileContents(change)).toEqual({ + oldContents: "before\n", + newContents: "after\n", + }); + yield* provider.getDiffFileContents(oneCommit); + expect(getMergeRequestDiffFileContents).toHaveBeenNthCalledWith(1, change); + expect(getMergeRequestDiffFileContents).toHaveBeenNthCalledWith(2, oneCommit); + }), + ); + + it.effect("reports a file GitLab cannot expand as a failed read", () => + Effect.gen(function* () { + const provider = yield* make.pipe( + Effect.provide( + Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({ + getMergeRequestDiffFileContents: () => + Effect.fail( + new GitLabPullRequestCli.GitLabDiffFileContentsUnavailableError({ + command: "glab", + cwd: "/w", + path: "assets/logo.png", + reason: "binary", + }), + ), + }), + ), + ); + assert.isDefined(provider.getDiffFileContents); + + expect(yield* Effect.flip(provider.getDiffFileContents(change))).toMatchObject({ + _tag: "PullRequestProviderError", + provider: "gitlab", + operation: "getDiffFileContents", + reason: "failed", + detail: "The diff file 'assets/logo.png' is binary.", + }); + }), + ); +}); diff --git a/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.ts b/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.ts index 89307f696d7a..a25d5314ef4d 100644 --- a/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.ts +++ b/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.ts @@ -223,6 +223,9 @@ export const make = Effect.gen(function* () { getDiff: (input) => cli.getMergeRequestDiff(input).pipe(Effect.mapError(fail("getDiff"))), + getDiffFileContents: (input) => + cli.getMergeRequestDiffFileContents(input).pipe(Effect.mapError(fail("getDiffFileContents"))), + // What each marked file is at the head, which is what tells a mark that still stands from one // the branch has moved past. GitLab's own local-storage marks are keyed on the blob id too, // so this stales at the same moment its web UI would. From 6fa0dd8ccabfee961ba01ea92267eded7d07cc51 Mon Sep 17 00:00:00 2001 From: Scott Norteman Date: Sun, 11 Oct 2026 02:46:54 -0400 Subject: [PATCH 22/66] fix(provider-cursor): a skill tree deeper than the scan limit no longer hides every skill (#17389) Co-authored-by: Claude Opus 5.5 --- .../provider-cursor/src/server/skills.test.ts | 83 +++++++++++++++++++ packages/provider-cursor/src/server/skills.ts | 7 +- 2 files changed, 86 insertions(+), 4 deletions(-) diff --git a/packages/provider-cursor/src/server/skills.test.ts b/packages/provider-cursor/src/server/skills.test.ts index 7831e5909e0f..7b6df4dcc0ae 100644 --- a/packages/provider-cursor/src/server/skills.test.ts +++ b/packages/provider-cursor/src/server/skills.test.ts @@ -102,6 +102,89 @@ describe("Cursor skills", () => { }), )); + it("skips a skill tree deeper than the limit instead of failing discovery", async () => + await runNode( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDirectory = (prefix: string) => + fileSystem + .makeTempDirectoryScoped({ directory: NodeOS.tmpdir(), prefix }) + .pipe(Effect.flatMap((directory) => fileSystem.realPath(directory))); + const userHome = yield* tempDirectory("cursor-skills-home-"); + const workspace = yield* tempDirectory("cursor-skills-workspace-"); + const writeSkill = Effect.fn("writeCursorSkill")(function* (directory: string) { + yield* fileSystem.makeDirectory(directory, { recursive: true }); + yield* fileSystem.writeFileString(path.join(directory, "SKILL.md"), "---\n---\n"); + }); + const userRoot = path.join(userHome, ".claude", "skills"); + // The root is depth 0, so `deep-skill` sits at the depth limit and `below` past it. + const atLimit = path.join( + userRoot, + "deep", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "deep-skill", + ); + + yield* writeSkill(path.join(workspace, ".agents", "skills", "review")); + yield* writeSkill(atLimit); + yield* writeSkill(path.join(atLimit, "below")); + yield* writeSkill(path.join(userRoot, "later")); + + const expected = [ + { + name: "deep-skill", + path: path.join(atLimit, "SKILL.md"), + scope: "user", + enabled: true, + }, + { + name: "later", + path: path.join(userRoot, "later", "SKILL.md"), + scope: "user", + enabled: true, + }, + { + name: "review", + path: path.join(workspace, ".agents", "skills", "review", "SKILL.md"), + scope: "project", + enabled: true, + }, + ]; + expect(yield* discoverCursorSkills(workspace, { HOME: userHome })).toEqual(expected); + expect(yield* probeCursorSkills(workspace, { HOME: userHome })).toEqual(expected); + }), + )); + + it("still fails discovery when a root holds more entries than the scan allows", async () => + await runNode( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const userHome = yield* fileSystem.makeTempDirectoryScoped({ + directory: NodeOS.tmpdir(), + prefix: "cursor-skills-home-", + }); + const root = path.join(userHome, ".cursor", "skills"); + yield* fileSystem.makeDirectory(root, { recursive: true }); + yield* Effect.forEach( + Array.from({ length: 10_001 }, (_, index) => path.join(root, `entry-${index}`)), + (file) => fileSystem.writeFileString(file, ""), + { concurrency: 64, discard: true }, + ); + + const error = yield* Effect.flip(probeCursorSkills(undefined, { HOME: userHome })); + expect(error.message).toContain("scan-budget-exhausted"); + }), + )); + it.skipIf(!symlinksSupported)( "treats a symlinked skill outside the root as a package boundary", async () => diff --git a/packages/provider-cursor/src/server/skills.ts b/packages/provider-cursor/src/server/skills.ts index 371aa75352d6..9f119f28088d 100644 --- a/packages/provider-cursor/src/server/skills.ts +++ b/packages/provider-cursor/src/server/skills.ts @@ -204,10 +204,9 @@ const discoverSkillsInRoot = Effect.fn("discoverCursorSkillsInRoot")(function* ( const child = path.join(directory, entry); const info = yield* orUndefined(fileSystem.stat(child), input.budget); if (info?.type !== "Directory") continue; - if (depth >= MAX_SKILL_DEPTH) { - input.budget.exhausted = true; - return; - } + // A tree deeper than the limit is skipped rather than fatal: the same tree would fail + // every refresh and hide every other skill with it. + if (depth >= MAX_SKILL_DEPTH) continue; yield* visit(child, depth + 1); } }); From 5310a4a64fd308236e819e192968a9f9d7bd3c45 Mon Sep 17 00:00:00 2001 From: Cameron Varley Date: Sun, 11 Oct 2026 02:47:38 -0400 Subject: [PATCH 23/66] fix(web): pass hex theme colors to HTML renders (#16325) --- apps/web/src/hooks/useHtmlRenderTheme.test.ts | 100 ++++++++++++++++++ apps/web/src/hooks/useHtmlRenderTheme.ts | 15 ++- 2 files changed, 111 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/hooks/useHtmlRenderTheme.test.ts diff --git a/apps/web/src/hooks/useHtmlRenderTheme.test.ts b/apps/web/src/hooks/useHtmlRenderTheme.test.ts new file mode 100644 index 000000000000..0da9b2d111ec --- /dev/null +++ b/apps/web/src/hooks/useHtmlRenderTheme.test.ts @@ -0,0 +1,100 @@ +import { HTML_RENDER_DEFAULT_FONTS, type HtmlRenderTheme } from "@t3tools/shared/htmlRender"; +import { BUILT_IN_THEME_IDS } from "@t3tools/shared/themePalettes"; +import { act, createElement, useEffect } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { + getStandardThemeColors, + setEnvironmentThemes, + type ThemeAppearance, + type ThemeHalves, + type ThemePreference, +} from "../themePalette"; +import { useHtmlRenderTheme } from "./useHtmlRenderTheme"; + +const selection = vi.hoisted(() => ({ + theme: "ocean" as ThemePreference, + resolvedTheme: "dark" as ThemeAppearance, + themeHalves: null as ThemeHalves | null, +})); + +vi.mock("./useTheme", () => ({ useTheme: () => selection })); +vi.mock("./useSettings", () => ({ + useClientSettings: ( + select: (settings: { fontFamilySans: string; fontFamilyCode: string }) => string, + ) => select({ fontFamilySans: "", fontFamilyCode: "" }), +})); + +let renderer: ReactTestRenderer | undefined; + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + selection.themeHalves = null; +}); + +afterEach(() => { + act(() => renderer?.unmount()); + renderer = undefined; + setEnvironmentThemes([]); + vi.unstubAllGlobals(); +}); + +/** Returns the committed hook result after React has flushed mount effects. */ +function readRenderTheme(): HtmlRenderTheme { + let theme: HtmlRenderTheme | undefined; + /** Captures the theme in an effect to avoid mutating test state during render. */ + function Probe() { + const resolved = useHtmlRenderTheme(); + useEffect(() => { + theme = resolved; + }, [resolved]); + return null; + } + act(() => { + renderer = create(createElement(Probe)); + }); + if (!theme) throw new Error("The HTML render theme was not resolved."); + return theme; +} + +describe("useHtmlRenderTheme", () => { + for (const appearance of ["light", "dark"] as const) { + it.each([appearance, ...BUILT_IN_THEME_IDS])( + `injects hex colors for %s in ${appearance} mode`, + (preference) => { + selection.theme = preference; + selection.resolvedTheme = appearance; + + const theme = readRenderTheme(); + + expect(theme.appearance).toBe(appearance); + for (const [variable, value] of Object.entries(theme.variables)) { + if (variable === "--radius" || variable.startsWith("--font-")) continue; + expect(value, variable).toMatch(/^#[\da-f]{6}(?:[\da-f]{2})?$/i); + } + expect(theme.variables["--font-sans"]).toBe(HTML_RENDER_DEFAULT_FONTS.sans); + expect(theme.variables["--font-mono"]).toBe(HTML_RENDER_DEFAULT_FONTS.mono); + expect(theme.variables["--radius"]).toBe("0.625rem"); + }, + ); + } + + it("preserves alpha when converting a published palette", () => { + setEnvironmentThemes([ + { + id: "transparent", + label: "Transparent", + appearance: "dark", + colors: { ...getStandardThemeColors("dark"), accent: "oklch(0.5 0 0 / 0.5)" }, + }, + ]); + selection.theme = "transparent"; + selection.resolvedTheme = "dark"; + + const theme = readRenderTheme(); + + expect(theme.variables["--accent"]).toBe("#63636380"); + expect(theme.variables["--chart-1"]).toBe("#63636380"); + }); +}); diff --git a/apps/web/src/hooks/useHtmlRenderTheme.ts b/apps/web/src/hooks/useHtmlRenderTheme.ts index d7492eb6f9b8..838ec6308aba 100644 --- a/apps/web/src/hooks/useHtmlRenderTheme.ts +++ b/apps/web/src/hooks/useHtmlRenderTheme.ts @@ -8,6 +8,7 @@ import { getThemeDefinition, resolveThemeHalf, subscribeToCustomThemes, + themeColorToHex, type ThemeAppearance, type ThemeHalves, type ThemePreference, @@ -40,10 +41,16 @@ export function useHtmlRenderTheme() { const mono = useClientSettings((settings) => settings.fontFamilyCode); return useMemo( () => - htmlRenderTheme(colors, resolvedTheme, { - sans: appearanceFontStack(sans, HTML_RENDER_DEFAULT_FONTS.sans), - mono: appearanceFontStack(mono, HTML_RENDER_DEFAULT_FONTS.mono), - }), + htmlRenderTheme( + Object.fromEntries( + Object.entries(colors).map(([role, color]) => [role, themeColorToHex(color) ?? color]), + ) as typeof colors, + resolvedTheme, + { + sans: appearanceFontStack(sans, HTML_RENDER_DEFAULT_FONTS.sans), + mono: appearanceFontStack(mono, HTML_RENDER_DEFAULT_FONTS.mono), + }, + ), [colors, resolvedTheme, sans, mono], ); } From 78089500e40e9d0ce6e9c1610e52f9c0d656a673 Mon Sep 17 00:00:00 2001 From: Rakshith Bhat <88523594+RakshithBhat03@users.noreply.github.com> Date: Sun, 11 Oct 2026 12:18:22 +0530 Subject: [PATCH 24/66] fix(web): align the API estimate info icon (#15655) --- apps/web/src/components/usage/UsagePage.tsx | 46 +++++++++------------ 1 file changed, 20 insertions(+), 26 deletions(-) diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 26a042d7a721..08a07241b612 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -587,33 +587,27 @@ export function UsagePage() { ? formatUsd(merged.costUsd) : formatTokens(merged.totalTokens)} - - - {formatCount(merged.sessions)} sessions + + + + {formatCount(merged.sessions)} sessions + + {metric === "cost" && " · API estimate"} - {metric === "cost" && ( - <> - {" · API estimate"} - {merged.costQuality.unpricedShare > 0 && ( - <> - {" "} - - } - aria-label="Unpriced usage details" - > - - - - API estimate excludes{" "} - {formatPercent(merged.costQuality.unpricedShare)} unpriced - records. - - - - )} - + {metric === "cost" && merged.costQuality.unpricedShare > 0 && ( + + } + aria-label="Unpriced usage details" + > + + + + API estimate excludes{" "} + {formatPercent(merged.costQuality.unpricedShare)} unpriced records. + + )}
    From cf821991863d5e374f698cb45bcef4759d3a0b7f Mon Sep 17 00:00:00 2001 From: Sheehan Munim <80079528+sheehanmunim@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:49:25 -0500 Subject: [PATCH 25/66] test(server): ACP teardown tests pass on Windows hosts (#17080) Co-authored-by: Claude Opus 5.5 Co-authored-by: Yash Singh --- .../src/orchestration-v2/Adapters/AcpAdapterV2.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index e8ae5a0c9dd0..cfde3367a593 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -12316,7 +12316,11 @@ describe("AcpAdapterV2", () => { }); assert.equal(taskkillCommands.length, 1); yield* Scope.close(runtimeScope, Exit.void); - assert.equal(taskkillCommands.length, 1); + // The scope finalizer picks its kill path from the host, not processGroupPlatform, + // so only a Windows host sends it through taskkill a second time. + const finalizerTaskkills = (yield* HostProcess.Platform) === "win32" ? 1 : 0; + assert.equal(taskkillCommands.length, 1 + finalizerTaskkills); + assert.deepEqual(taskkillCommands.at(-1), taskkillCommands[0]); }).pipe(Effect.provide(layerTest)), ); @@ -12433,6 +12437,7 @@ describe("AcpAdapterV2", () => { it.live("poisons the session when hard teardown defects and blocks replacement work", () => Effect.gen(function* () { + if ((yield* HostProcess.Platform) !== "linux") return; const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; From bb9b9aae57ce5614c9c26d540ca51c805cdbf09b Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Sun, 11 Oct 2026 02:02:11 -0500 Subject: [PATCH 26/66] fix(server): sync device tool license versions with installer pins (#18121) --- apps/server/src/device/DeviceToolchain.ts | 5 ++- docs/internals/open-source-licenses.md | 4 ++ packages/shared/package.json | 4 ++ packages/shared/src/deviceToolVersions.ts | 5 +++ scripts/lib/third-party-licenses.test.ts | 51 +++++++++++++++++++++++ scripts/lib/third-party-licenses.ts | 4 +- third-party-licenses.config.json | 10 ----- 7 files changed, 70 insertions(+), 13 deletions(-) create mode 100644 packages/shared/src/deviceToolVersions.ts diff --git a/apps/server/src/device/DeviceToolchain.ts b/apps/server/src/device/DeviceToolchain.ts index e302a4524336..132fbcebdb1f 100644 --- a/apps/server/src/device/DeviceToolchain.ts +++ b/apps/server/src/device/DeviceToolchain.ts @@ -1,4 +1,5 @@ import type { DeviceToolVersions } from "@t3tools/contracts"; +import { DEVICE_TOOL_VERSIONS } from "@t3tools/shared/deviceToolVersions"; /** * Pinned installs of the two external tools device support is built on. * @@ -26,9 +27,9 @@ import * as Semaphore from "effect/Semaphore"; import * as ProcessRunner from "../processRunner.ts"; const DEVICE_HUB_PACKAGE = "expo-device-hub"; -export const DEVICE_HUB_VERSION = "0.15.3"; +export const DEVICE_HUB_VERSION = DEVICE_TOOL_VERSIONS[DEVICE_HUB_PACKAGE]; const AGENT_DEVICE_PACKAGE = "agent-device"; -export const AGENT_DEVICE_VERSION = "0.21.24"; +export const AGENT_DEVICE_VERSION = DEVICE_TOOL_VERSIONS[AGENT_DEVICE_PACKAGE]; const INSTALL_TIMEOUT = Duration.minutes(10); const installLock = Semaphore.makeUnsafe(1); diff --git a/docs/internals/open-source-licenses.md b/docs/internals/open-source-licenses.md index 8bac6010bfad..904c9be4471e 100644 --- a/docs/internals/open-source-licenses.md +++ b/docs/internals/open-source-licenses.md @@ -60,6 +60,10 @@ relative to the config file. `bundles` controls which generated manifests includ supplies the label shown to users. Use `includeInBundles` when those differ, such as an optional server tool that should appear in both client manifests but is not bundled into either client. +The `agent-device` and `expo-device-hub` custom notices take their versions from the +[shared installer pins](../../packages/shared/src/deviceToolVersions.ts). Keep their package names +in the config; their versions do not need manual updates. + Use `packageOverrides` only when an installed npm archive omits its notice or has incorrect metadata: diff --git a/packages/shared/package.json b/packages/shared/package.json index 4ccc2b691023..7b750e7a3c50 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -359,6 +359,10 @@ "types": "./src/thirdPartyLicenses.ts", "import": "./src/thirdPartyLicenses.ts" }, + "./deviceToolVersions": { + "types": "./src/deviceToolVersions.ts", + "import": "./src/deviceToolVersions.ts" + }, "./claudeCompaction": { "types": "./src/claudeCompaction.ts", "import": "./src/claudeCompaction.ts" diff --git a/packages/shared/src/deviceToolVersions.ts b/packages/shared/src/deviceToolVersions.ts new file mode 100644 index 000000000000..12a7071831d2 --- /dev/null +++ b/packages/shared/src/deviceToolVersions.ts @@ -0,0 +1,5 @@ +// The installer and third-party notices share these pins for tools installed outside the workspace. +export const DEVICE_TOOL_VERSIONS = { + "agent-device": "0.21.24", + "expo-device-hub": "0.15.3", +} as const; diff --git a/scripts/lib/third-party-licenses.test.ts b/scripts/lib/third-party-licenses.test.ts index e1607ebc6eb3..638dd60e15d4 100644 --- a/scripts/lib/third-party-licenses.test.ts +++ b/scripts/lib/third-party-licenses.test.ts @@ -5,6 +5,7 @@ import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import * as NodeURL from "node:url"; +import { DEVICE_TOOL_VERSIONS } from "@t3tools/shared/deviceToolVersions"; import { afterEach, describe, expect, it } from "vite-plus/test"; import { @@ -77,6 +78,56 @@ afterEach(async () => { }); describe("third-party license generation", () => { + it.each(["web", "mobile"])( + "uses installer pins for device tool notices in the %s manifest", + async (bundle) => { + const fixture = await createFixture(); + await writeJson(fixture.configFile, { + customNotices: [ + { + name: "agent-device", + license: "MIT", + noticeFile: "asset-notice.txt", + bundles: ["device-tools"], + includeInBundles: ["web", "mobile"], + }, + { + name: "expo-device-hub", + license: "MIT", + noticeFile: "asset-notice.txt", + version: "0.0.0", + bundles: ["device-tools"], + includeInBundles: ["web", "mobile"], + }, + { + name: "vendored-asset", + license: "MIT", + noticeFile: "asset-notice.txt", + version: "1.2.3", + bundles: ["web", "mobile"], + }, + ], + }); + + const manifest = await generateThirdPartyLicenseManifest({ + configFile: fixture.configFile, + packageManifests: [{ bundle, path: fixture.appManifest }], + }); + + expect(manifest.entries.find((entry) => entry.name === "agent-device")).toMatchObject({ + version: DEVICE_TOOL_VERSIONS["agent-device"], + bundles: ["device-tools"], + }); + expect(manifest.entries.find((entry) => entry.name === "expo-device-hub")).toMatchObject({ + version: DEVICE_TOOL_VERSIONS["expo-device-hub"], + bundles: ["device-tools"], + }); + expect(manifest.entries.find((entry) => entry.name === "vendored-asset")?.version).toBe( + "1.2.3", + ); + }, + ); + it("keeps the GhosttyKit notice pinned to the vendored framework revision", async () => { const [config, revision] = await Promise.all([ NodeFSP.readFile(NodePath.join(REPOSITORY_ROOT, "third-party-licenses.config.json"), "utf8"), diff --git a/scripts/lib/third-party-licenses.ts b/scripts/lib/third-party-licenses.ts index c1a5199f41b4..124f16a3ce4e 100644 --- a/scripts/lib/third-party-licenses.ts +++ b/scripts/lib/third-party-licenses.ts @@ -5,12 +5,14 @@ import * as NodePath from "node:path"; import * as NodeURL from "node:url"; import * as NodeModule from "node:module"; +import { DEVICE_TOOL_VERSIONS } from "@t3tools/shared/deviceToolVersions"; import type { Plugin } from "vite-plus"; export const THIRD_PARTY_LICENSES_FILE_NAME = "third-party-licenses.json"; const SPDX_LICENSE_LIST_VERSION = "v3.28.0"; const SPDX_LICENSE_LIST_REVISION = "c4a7237ec8f4654e867546f9f409749300f1bf4c"; const GENERATED_NOTICE_CACHE_DIRECTORY = ".generated/third-party-licenses/spdx"; +const DEVICE_TOOL_NOTICE_VERSIONS = new Map(Object.entries(DEVICE_TOOL_VERSIONS)); export interface ThirdPartyLicenseEntry { readonly bundles: ReadonlyArray; @@ -868,7 +870,7 @@ async function customEntries( name: notice.name, noticeText, sourceUrl: notice.sourceUrl ?? null, - version: notice.version ?? null, + version: DEVICE_TOOL_NOTICE_VERSIONS.get(notice.name) ?? notice.version ?? null, }; }), ); diff --git a/third-party-licenses.config.json b/third-party-licenses.config.json index 9102be3af278..a2eb54574ffe 100644 --- a/third-party-licenses.config.json +++ b/third-party-licenses.config.json @@ -62,7 +62,6 @@ "license": "MIT", "name": "agent-device", "sourceUrl": "https://github.com/callstack/agent-device", - "version": "0.21.7", "generatedNotices": [ { "licenseId": "MIT", @@ -76,7 +75,6 @@ "license": "MIT AND Apache-2.0 AND BSD-3-Clause", "name": "expo-device-hub", "sourceUrl": "https://github.com/expo/expo-device-hub", - "version": "0.10.1", "generatedNotices": [ { "licenseId": "MIT", @@ -174,14 +172,6 @@ "copyrights": ["Copyright 2021-2024 The Connect Authors"] } }, - { - "name": "@connectrpc/connect-node", - "version": "1.7.0", - "generatedNotice": { - "licenseId": "Apache-2.0", - "copyrights": ["Copyright 2021-2024 The Connect Authors"] - } - }, { "name": "@connectrpc/connect-web", "version": "1.7.0", From f8458952710cc70894bdeb5853d700524a1c7c49 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 11 Oct 2026 03:04:56 -0400 Subject: [PATCH 27/66] fix: sync model favorites and visibility across clients (#16816) Co-authored-by: Claude Opus 5.5 Co-authored-by: Julius Marminge --- apps/mobile/src/components/AppSymbol.tsx | 2 + .../src/features/threads/ThreadComposer.tsx | 2 + .../threads/ThreadSettingsRows.shared.tsx | 26 +- .../features/threads/ThreadSettingsSheet.tsx | 222 ++++++++++++--- .../thread-settings-sheet-state.test.ts | 28 +- .../threads/thread-settings-sheet-state.ts | 20 +- apps/mobile/src/lib/modelOptions.test.ts | 94 ++++++- apps/mobile/src/lib/modelOptions.ts | 24 +- .../src/persistence/mobile-preferences.ts | 2 +- .../OrchestratorMcpService.activity.test.ts | 5 + .../src/mcp/OrchestratorMcpService.test.ts | 62 +++++ apps/server/src/mcp/OrchestratorMcpService.ts | 28 +- ...OrchestratorMcpToolkit.integration.test.ts | 3 + ...skCancelNativeSubagent.integration.test.ts | 2 + apps/server/src/mcp/toolkits/core.test.ts | 5 + apps/server/src/serverSettings.test.ts | 82 ++++++ apps/web/src/components/chat/ChatComposer.tsx | 1 + .../components/chat/ModelPickerContent.tsx | 25 +- .../chat/ProviderModelPicker.test.tsx | 2 + .../components/chat/ProviderModelPicker.tsx | 3 + .../settings/ProjectDefaultsSettings.tsx | 4 +- .../settings/ProviderInstanceCard.test.ts | 2 + .../settings/ProviderInstanceCard.tsx | 12 +- .../settings/ProviderModelsSection.tsx | 19 +- ...ProviderSettingsPanel.environment.test.tsx | 17 +- .../settings/ProviderSettingsPanel.tsx | 111 +++----- .../settings/ScheduledTasksSettings.tsx | 1 + .../components/settings/SettingsPanels.tsx | 1 + .../settings/SourceControlWritingSettings.tsx | 1 + .../settings/scopedSettings.test.ts | 14 + .../src/components/settings/scopedSettings.ts | 2 +- .../settings/useScopedModelAvailability.ts | 3 +- .../components/settings/useScopedSettings.ts | 2 + .../src/hooks/useSettings.favorites.test.tsx | 252 ++++++++++++++++++ apps/web/src/hooks/useSettings.test.ts | 57 ++++ apps/web/src/hooks/useSettings.ts | 175 +++++++++++- packages/contracts/src/settings.test.ts | 31 +++ packages/contracts/src/settings.ts | 98 +++---- packages/shared/src/serverSettings.test.ts | 174 ++++++++++++ packages/shared/src/serverSettings.ts | 90 +++++++ 40 files changed, 1460 insertions(+), 244 deletions(-) create mode 100644 apps/web/src/hooks/useSettings.favorites.test.tsx diff --git a/apps/mobile/src/components/AppSymbol.tsx b/apps/mobile/src/components/AppSymbol.tsx index c9457a55c83a..740e059d1b8c 100644 --- a/apps/mobile/src/components/AppSymbol.tsx +++ b/apps/mobile/src/components/AppSymbol.tsx @@ -53,6 +53,7 @@ import IconDotsVertical from "@tabler/icons-react-native/IconDotsVertical"; import IconDotsCircleHorizontal from "@tabler/icons-react-native/IconDotsCircleHorizontal"; import IconEdit from "@tabler/icons-react-native/IconEdit"; import IconExternalLink from "@tabler/icons-react-native/IconExternalLink"; +import IconEyeOff from "@tabler/icons-react-native/IconEyeOff"; import IconEye from "@tabler/icons-react-native/IconEye"; import IconFileText from "@tabler/icons-react-native/IconFileText"; import IconFilter from "@tabler/icons-react-native/IconFilter"; @@ -177,6 +178,7 @@ const ANDROID_ICON_BY_SF_SYMBOL = { "exclamationmark.triangle": IconAlertTriangle, "exclamationmark.circle": IconAlertCircle, eye: IconEye, + "eye.slash": IconEyeOff, folder: IconFolder, "folder.badge.plus": IconFolderPlus, "folder.fill": IconFolder, diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 6873926fd5dd..4000571c1e80 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -675,6 +675,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ownerId: settingsOwnerId, environmentId: props.environmentId, providerInstanceId: currentModelSelection.instanceId, + ...(lockedProviderInstanceId ? { lockedProviderInstanceId } : {}), providerGroups: threadProviderGroups, selectedModel: currentModelSelection, reportedModelSelection: props.reportedModelSelection, @@ -700,6 +701,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer props.onUpdateRuntimeMode, providerOptionDescriptors, settingsOwnerId, + lockedProviderInstanceId, threadProviderGroups, ], ); diff --git a/apps/mobile/src/features/threads/ThreadSettingsRows.shared.tsx b/apps/mobile/src/features/threads/ThreadSettingsRows.shared.tsx index e9625e2f1556..af2d148f2677 100644 --- a/apps/mobile/src/features/threads/ThreadSettingsRows.shared.tsx +++ b/apps/mobile/src/features/threads/ThreadSettingsRows.shared.tsx @@ -12,6 +12,8 @@ export type ModelRowProps = { readonly isFavorite: boolean; readonly favoritesLoaded: boolean; readonly onToggleFavorite: () => void; + readonly onToggleHidden?: () => void; + readonly hiddenUpdatePending?: boolean; readonly isFirst: boolean; readonly isLast: boolean; }; @@ -52,10 +54,10 @@ export function ModelRowContent( accessibilityRole="radio" accessibilityState={{ checked: props.selected, - disabled: props.option.isUnavailable === true, + disabled: props.option.isUnavailable === true || props.option.isHidden === true, }} className="min-h-11 min-w-0 flex-1 flex-row items-center gap-2 active:opacity-70" - disabled={props.option.isUnavailable} + disabled={props.option.isUnavailable || props.option.isHidden} onPress={props.onPress} > {props.leadingSelection} @@ -77,6 +79,9 @@ export function ModelRowContent( Legacy ) : null} + {props.option.isHidden ? ( + Hidden + ) : null} {props.option.isUnavailable ? ( Unavailable ) : null} @@ -92,6 +97,23 @@ export function ModelRowContent( {props.trailingSelection} + {props.onToggleHidden ? ( + + + + ) : null} ; readonly selectedModel: ModelSelection | null; readonly reportedModelSelection?: ModelSelection | null; @@ -300,6 +312,10 @@ type ThreadSettingsSessionValue = { readonly favoriteKeys: ReadonlySet; readonly favoritesLoaded: boolean; readonly toggleFavorite: (option: ModelOption) => void; + readonly toggleHidden: (option: ModelOption) => void; + readonly pendingHiddenKeys: ReadonlySet; + readonly manageHidden: boolean; + readonly setManageHidden: (value: boolean) => void; readonly runtimeMode: RuntimeMode; readonly runtimeModeChoices: ReturnType; readonly onUpdateRuntimeMode: (mode: RuntimeMode) => void; @@ -329,12 +345,46 @@ const ThreadSettingsSessionContext = createContext()); + const pendingFavorites = useRef( + new Map(), + ); + const [pendingHiddenKeys, setPendingHiddenKeys] = useState>(() => new Set()); + const providerGroups = useMemo( + () => + manageHidden && config + ? groupByProvider( + buildModelOptions(config, props.selectedModel, props.lockedProviderInstanceId, true), + ) + : props.providerGroups, + [ + config, + manageHidden, + props.providerGroups, + props.selectedModel, + props.lockedProviderInstanceId, + ], + ); + const legacyFavorites = AsyncResult.isSuccess(preferences) ? (preferences.value.modelFavorites ?? EMPTY_MODEL_FAVORITES) : EMPTY_MODEL_FAVORITES; + const favoritesLoaded = + settings !== undefined && (settings.favorites !== null || AsyncResult.isSuccess(preferences)); + const modelFavorites = resolveModelPreferences(settings ?? DEFAULT_SERVER_SETTINGS, { + favorites: legacyFavorites, + }).favorites; + const saveSettings = useAtomCommand(serverEnvironment.updateSettings, "model favorites update"); + useEffect(() => { + if (!props.environmentId || !settings || !AsyncResult.isSuccess(preferences)) return; + const patch = modelPreferencesMigrationPatch(settings, { favorites: legacyFavorites }); + if (patch) void saveSettings({ environmentId: props.environmentId, input: { patch } }); + }, [legacyFavorites, preferences, props.environmentId, saveSettings, settings]); const favoriteKeys = useMemo( () => new Set( @@ -343,19 +393,105 @@ function ThreadSettingsSessionProvider( [modelFavorites], ); const toggleFavorite = useCallback( + async (option: ModelOption) => { + if (!favoritesLoaded || !props.environmentId) return; + const currentSettings = registry + .get(environmentServerConfigsAtom) + .get(props.environmentId)?.settings; + if (!currentSettings) return; + const favorites = resolveModelPreferences(currentSettings, { + favorites: legacyFavorites, + }).favorites; + const key = JSON.stringify([ + props.environmentId, + option.selection.instanceId, + option.selection.model, + ]); + const previous = pendingFavorites.current.get(key); + const previousFavorite = + previous && (previous.pending || previous.settings.favorites === currentSettings.favorites) + ? previous.favorite + : favorites.some( + (favorite) => + favorite.provider === option.selection.instanceId && + favorite.model === option.selection.model, + ); + const pending = { favorite: !previousFavorite, settings: currentSettings, pending: true }; + pendingFavorites.current.set(key, pending); + void Haptics.selectionAsync(); + try { + const result = await saveSettings({ + environmentId: props.environmentId, + input: { + patch: { + setModelFavorites: [ + { + provider: option.selection.instanceId, + model: option.selection.model, + favorite: pending.favorite, + }, + ], + ...(currentSettings.favorites === null + ? { migrateModelPreferences: { favorites } } + : {}), + }, + }, + }); + if ( + AsyncResult.isSuccess(result) && + registry.get(environmentServerConfigsAtom).get(props.environmentId)?.settings + .favorites === currentSettings.favorites + ) { + pending.pending = false; + } + } finally { + if (pending.pending && pendingFavorites.current.get(key) === pending) { + if ( + previous && + !previous.pending && + registry.get(environmentServerConfigsAtom).get(props.environmentId)?.settings + .favorites === previous.settings.favorites + ) { + pendingFavorites.current.set(key, previous); + } else { + pendingFavorites.current.delete(key); + } + } + } + }, + [favoritesLoaded, legacyFavorites, props.environmentId, registry, saveSettings], + ); + const toggleHidden = useCallback( (option: ModelOption) => { - if (!favoritesLoaded) return; + if ( + !settings || + !props.environmentId || + !option.canHide || + pendingHidden.current.has(option.key) + ) + return; + pendingHidden.current.add(option.key); + setPendingHiddenKeys(new Set(pendingHidden.current)); void Haptics.selectionAsync(); - savePreferences({ - transform: (current) => ({ - modelFavorites: toggleModelFavorite( - current.modelFavorites ?? EMPTY_MODEL_FAVORITES, - option, - ), - }), + void saveSettings({ + environmentId: props.environmentId, + input: { + patch: { + setModelsHidden: [ + { + provider: option.selection.instanceId, + model: option.selection.model, + hidden: !option.isHidden, + }, + ], + }, + }, + }).then(() => { + pendingHidden.current.delete(option.key); + setPendingHiddenKeys(new Set(pendingHidden.current)); }); }, - [favoritesLoaded, savePreferences], + [props.environmentId, saveSettings, settings], ); const [showLegacyToggle, setShowLegacyToggle] = useState(false); const [providerFilter, setProviderFilter] = useState(null); @@ -473,7 +609,11 @@ function ThreadSettingsSessionProvider( () => ({ environmentId: props.environmentId, providerInstanceId: props.providerInstanceId, - providerGroups: props.providerGroups, + providerGroups, + manageHidden, + setManageHidden, + toggleHidden, + pendingHiddenKeys, runtimeMode: compatibleRuntimeMode, runtimeModeChoices, onUpdateRuntimeMode: props.onUpdateRuntimeMode, @@ -518,7 +658,10 @@ function ThreadSettingsSessionProvider( pressModel, providerFilter, props.onUpdateRuntimeMode, - props.providerGroups, + providerGroups, + manageHidden, + toggleHidden, + pendingHiddenKeys, runtimeModeChoices, searchQuery, showLegacyToggle, @@ -599,6 +742,12 @@ function ThreadSettingsModelListRow(props: { isFavorite={session.favoriteKeys.has(props.option.key)} favoritesLoaded={session.favoritesLoaded} onToggleFavorite={() => session.toggleFavorite(props.option)} + onToggleHidden={ + session.manageHidden && props.option.canHide + ? () => session.toggleHidden(props.option) + : undefined + } + hiddenUpdatePending={session.pendingHiddenKeys.has(props.option.key)} option={props.option} selected={session.isDisplayed(props.option)} /> @@ -642,7 +791,9 @@ function useThreadSettingsCatalogItems( } const driver = group.models[0]?.providerDriver ?? group.providerKey; const catalogModels = - session.showLegacy || session.providerFilter === FAVORITES_PROVIDER_FILTER + session.manageHidden || + session.showLegacy || + session.providerFilter === FAVORITES_PROVIDER_FILTER ? group.models : group.models.filter( (model) => @@ -726,6 +877,7 @@ function useThreadSettingsCatalogItems( session.providerGroups, session.searchQuery, session.showLegacy, + session.manageHidden, ], ); } @@ -806,21 +958,23 @@ function ThreadSettingsOptionsItem(props: { - {Platform.OS !== "ios" && session.hasLegacyModels ? ( - <> - - Catalog - - - - - - ) : null} + Catalog + + + {Platform.OS !== "ios" && session.hasLegacyModels ? ( + + ) : null} + ); } diff --git a/apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts b/apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts index d26882762cb3..61e606aa6dd6 100644 --- a/apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts +++ b/apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts @@ -6,10 +6,8 @@ import type { ModelOption } from "../../lib/modelOptions"; import { canCommitPendingModel, favoritesFirst, - modelFavoriteKey, modelMatchesCatalogQuery, pendingModelAfterPress, - toggleModelFavorite, } from "./thread-settings-sheet-state"; function modelOption( @@ -58,23 +56,6 @@ describe("thread settings sheet state", () => { ]); }); - it("adds and removes favorites for one provider instance", () => { - const codexModel = modelOption("shared"); - const otherProvider = ProviderInstanceId.make("codex_personal"); - const personalModel = { - ...codexModel, - key: modelFavoriteKey(otherProvider, "shared"), - selection: { ...codexModel.selection, instanceId: otherProvider }, - }; - const favorites = toggleModelFavorite([], codexModel); - - expect(toggleModelFavorite(favorites, personalModel)).toEqual([ - { provider: ProviderInstanceId.make("codex"), model: "shared" }, - { provider: otherProvider, model: "shared" }, - ]); - expect(toggleModelFavorite(favorites, codexModel)).toEqual([]); - }); - it("matches visible model and provider terms", () => { const model = modelOption("gpt-next"); @@ -144,6 +125,15 @@ describe("thread settings sheet state", () => { ).toBe(pressed); }); + it("cannot select a hidden model exposed only for visibility management", () => { + const pending = modelOption("gpt-next"); + expect( + canCommitPendingModel(pending, [ + { providerKey: "codex", providerLabel: "Codex", models: [{ ...pending, isHidden: true }] }, + ]), + ).toBe(false); + }); + it("cannot save a staged model after sign-out removes it from the catalog", () => { const pending = modelOption("gemini-native"); const group = { providerKey: "codex", providerLabel: "Codex", models: [pending] }; diff --git a/apps/mobile/src/features/threads/thread-settings-sheet-state.ts b/apps/mobile/src/features/threads/thread-settings-sheet-state.ts index e600eb3980b8..b85d808093d2 100644 --- a/apps/mobile/src/features/threads/thread-settings-sheet-state.ts +++ b/apps/mobile/src/features/threads/thread-settings-sheet-state.ts @@ -1,26 +1,10 @@ import type { ModelOption, ProviderGroup } from "../../lib/modelOptions"; import type { ProviderInstanceId } from "@t3tools/contracts"; -export type ModelFavorite = { - readonly provider: ProviderInstanceId; - readonly model: string; -}; - export function modelFavoriteKey(provider: ProviderInstanceId, model: string): string { return `${provider}:${model}`; } -export function toggleModelFavorite( - favorites: ReadonlyArray, - option: ModelOption, -): ReadonlyArray { - const provider = option.selection.instanceId; - const model = option.selection.model; - return favorites.some((favorite) => favorite.provider === provider && favorite.model === model) - ? favorites.filter((favorite) => favorite.provider !== provider || favorite.model !== model) - : [...favorites, { provider, model }]; -} - /** Keep catalog order within each group when favorites move to the front. */ export function favoritesFirst( models: ReadonlyArray, @@ -71,7 +55,9 @@ export function canCommitPendingModel( groups: ReadonlyArray, ): boolean { return groups.some((group) => - group.models.some((model) => model.key === pending.key && !model.isUnavailable), + group.models.some( + (model) => model.key === pending.key && !model.isUnavailable && !model.isHidden, + ), ); } diff --git a/apps/mobile/src/lib/modelOptions.test.ts b/apps/mobile/src/lib/modelOptions.test.ts index 463ff5cc3662..ccf1e7bfd820 100644 --- a/apps/mobile/src/lib/modelOptions.test.ts +++ b/apps/mobile/src/lib/modelOptions.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "vite-plus/test"; -import { ProviderInstanceId, type ModelSelection, type ServerConfig } from "@t3tools/contracts"; +import { + DEFAULT_SERVER_SETTINGS, + ProviderInstanceId, + type ModelSelection, + type ServerConfig, +} from "@t3tools/contracts"; import { buildModelOptions, @@ -504,3 +509,90 @@ describe("mobile model options", () => { ).toBeNull(); }); }); + +describe("synchronized mobile model visibility", () => { + const provider = ProviderInstanceId.make("codex_work"); + const other = ProviderInstanceId.make("codex_personal"); + const models = [ + { slug: "hidden", name: "Hidden", isCustom: false, capabilities: null }, + { slug: "first", name: "First", isCustom: false, capabilities: null }, + { slug: "second", name: "Second", isCustom: false, capabilities: null }, + { slug: "custom", name: "Custom", isCustom: true, capabilities: null }, + ]; + const config = { + settings: { + ...DEFAULT_SERVER_SETTINGS, + providerModelPreferences: { + [provider]: { + hiddenModels: ["hidden", "custom", "missing"], + modelOrder: ["second", "first"], + }, + }, + }, + providers: [provider, other].map((instanceId) => ({ + instanceId, + driver: "codex", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + models, + })), + } as unknown as ServerConfig; + + it("applies server visibility and ordering independently to each provider instance", () => { + const options = buildModelOptions(config, null); + expect( + options + .filter((option) => option.providerKey === provider) + .map((option) => option.selection.model), + ).toEqual(["second", "first", "custom"]); + expect( + options + .filter((option) => option.providerKey === other) + .map((option) => option.selection.model), + ).toEqual(["hidden", "first", "second", "custom"]); + }); + + it("exposes hidden built-ins for management without offering custom visibility controls", () => { + const options = buildModelOptions( + config, + { instanceId: provider, model: "hidden" }, + provider, + true, + ); + expect(options.map((option) => option.selection.model)).toEqual([ + "second", + "first", + "hidden", + "custom", + ]); + expect(options.find((option) => option.selection.model === "hidden")).toMatchObject({ + isHidden: true, + canHide: true, + }); + expect(options.find((option) => option.selection.model === "custom")).toMatchObject({ + isHidden: false, + canHide: false, + }); + }); + + it.each(["hidden", "missing"])("does not resurrect the hidden fallback %s", (model) => { + expect( + buildModelOptions(config, { instanceId: provider, model }, provider).map( + (option) => option.selection.model, + ), + ).toEqual(["second", "first", "custom"]); + }); + + it("keeps custom models visible and restores built-in models when the server clears hidden models", () => { + expect( + buildModelOptions(config, { instanceId: provider, model: "custom" }, provider).map( + (option) => option.selection.model, + ), + ).toContain("custom"); + const cleared = { ...config, settings: { ...config.settings, providerModelPreferences: {} } }; + expect( + buildModelOptions(cleared, null, provider).map((option) => option.selection.model), + ).toEqual(["hidden", "first", "second", "custom"]); + }); +}); diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index 436d6b9996fb..eeae36a336b0 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -22,6 +22,8 @@ export type ModelOption = { readonly isDefault: boolean; readonly isLegacy: boolean; readonly isUnavailable?: boolean; + readonly isHidden?: boolean; + readonly canHide?: boolean; readonly capabilities: ModelCapabilities | null; readonly selection: ModelSelection; readonly providerUpdateRequired?: ProviderUpdateRequired; @@ -167,6 +169,7 @@ export function buildModelOptions( config: T3ServerConfig | null | undefined, fallbackModelSelection: ModelSelection | null, providerInstanceId?: ModelSelection["instanceId"], + includeHidden = false, ): ReadonlyArray { const options = new Map(); @@ -185,7 +188,15 @@ export function buildModelOptions( const updateRequired = provider.updateRequiredModels?.length ? { driver: provider.driver, updateRequiredModels: provider.updateRequiredModels } : undefined; - for (const model of provider.models) { + const preferences = config?.settings?.providerModelPreferences?.[provider.instanceId]; + const hiddenModels = new Set(preferences?.hiddenModels ?? []); + const order = new Map((preferences?.modelOrder ?? []).map((slug, index) => [slug, index])); + const models = provider.models.filter( + (model) => includeHidden || model.isCustom || !hiddenModels.has(model.slug), + ); + if (order.size > 0) + models.sort((a, b) => (order.get(a.slug) ?? Infinity) - (order.get(b.slug) ?? Infinity)); + for (const model of models) { const key = `${provider.instanceId}:${model.slug}`; options.set(key, { key, @@ -198,6 +209,8 @@ export function buildModelOptions( ? {} : { supportedRuntimeModes: provider.supportedRuntimeModes }), ...(provider.iconUrl ? { providerIconUrl: provider.iconUrl } : {}), + isHidden: !model.isCustom && hiddenModels.has(model.slug), + canHide: !model.isCustom, isDefault: model.isDefault === true, isLegacy: model.isLegacy === true, ...(updateRequired ? { providerUpdateRequired: updateRequired } : {}), @@ -215,7 +228,14 @@ export function buildModelOptions( if ( fallbackModelSelection && - (providerInstanceId === undefined || fallbackModelSelection.instanceId === providerInstanceId) + (providerInstanceId === undefined || + fallbackModelSelection.instanceId === providerInstanceId) && + (!config?.settings?.providerModelPreferences?.[ + fallbackModelSelection.instanceId + ]?.hiddenModels.includes(fallbackModelSelection.model) || + config?.providers + .find((provider) => provider.instanceId === fallbackModelSelection.instanceId) + ?.models.find((model) => model.slug === fallbackModelSelection.model)?.isCustom) ) { const key = `${fallbackModelSelection.instanceId}:${fallbackModelSelection.model}`; const existing = options.get(key); diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index 98f6b3d1e8d1..c5b41fa5a18a 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -46,7 +46,7 @@ export interface Preferences { readonly planModeEnabled?: boolean; /** Device-local counterpart of web's `sidebarWorkingShelfEnabled` beta. */ readonly workingShelfEnabled?: boolean; - /** Model favorites belong to this device, like the web client setting. */ + /** @deprecated Retained to migrate favorites to the connected environment. */ readonly modelFavorites?: ReadonlyArray<{ readonly provider: ProviderInstanceId; readonly model: string; diff --git a/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts b/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts index 7c6f9d28c950..ce6921d02da4 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts @@ -20,6 +20,7 @@ import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterReg import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; import * as ProjectService from "../project/ProjectService.ts"; import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; +import * as ServerSettings from "../serverSettings.ts"; import * as SecretRequests from "../secrets/SecretRequests.ts"; import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; import type * as McpInvocationContext from "./McpInvocationContext.ts"; @@ -146,6 +147,7 @@ it("readThread prefers activity-run status over a newer cancelled queued run", a ? Effect.succeed(projection) : Effect.die(`unexpected thread ${input.threadId}`), } satisfies Partial), + ServerSettings.layerTest(), Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]), } satisfies Partial), @@ -211,6 +213,7 @@ it("readThread prefers waiting activity status over a newer cancelled queued run ? Effect.succeed(projection) : Effect.die(`unexpected thread ${input.threadId}`), } satisfies Partial), + ServerSettings.layerTest(), Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]), } satisfies Partial), @@ -324,6 +327,7 @@ it("taskStatus returns task.providerInstanceId rather than the driver kind", asy return Effect.die(`unexpected thread ${threadId}`); }, } satisfies Partial), + ServerSettings.layerTest(), Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]), } satisfies Partial), @@ -447,6 +451,7 @@ it("readThread and sendToThread reach threads in other projects", async () => { delivery: "started", } as unknown as ThreadManagementService.ThreadManagementSendResult), } satisfies Partial), + ServerSettings.layerTest(), Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]), } satisfies Partial), diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index bc9ab50276a3..d0a85e352a29 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -34,6 +34,7 @@ import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; import { buildUnavailableProviderSnapshot } from "../provider/unavailableProviderSnapshot.ts"; import * as ProjectService from "../project/ProjectService.ts"; import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; +import * as ServerSettings from "../serverSettings.ts"; import * as SecretRequests from "../secrets/SecretRequests.ts"; import type { McpInvocationScope } from "./McpInvocationContext.ts"; import { idleThreadProjection, liveThreadShell } from "./McpToolAccess.testkit.ts"; @@ -128,6 +129,7 @@ describe("OrchestratorMcpService", () => { ), ), }), + ServerSettings.layerTest(), Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({ list: () => Effect.succeed([]), @@ -227,6 +229,7 @@ describe("OrchestratorMcpService", () => { : Effect.succeed(awaitsRestart), dispatch: () => Ref.update(dispatched, (count) => count + 1).pipe(Effect.as({} as never)), }), + ServerSettings.layerTest(), Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({ list: () => Effect.succeed([]), @@ -311,6 +314,7 @@ describe("OrchestratorMcpService", () => { Effect.as({} as never), ), }), + ServerSettings.layerTest(), Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({ list: () => Effect.succeed([]), @@ -387,6 +391,7 @@ describe("OrchestratorMcpService", () => { Effect.andThen(Effect.fail(new Error("simulated stop failure") as never)), ), }), + ServerSettings.layerTest(), Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({ list: () => Effect.succeed([]), @@ -471,6 +476,7 @@ describe("OrchestratorMcpService", () => { ), stopDelegatedTasks: () => Effect.void, }), + ServerSettings.layerTest(), Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({ list: () => Effect.succeed([]), @@ -557,6 +563,7 @@ describe("OrchestratorMcpService", () => { ), stopDelegatedTasks: () => Effect.void, }), + ServerSettings.layerTest(), Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({ list: () => Effect.succeed([]), @@ -697,6 +704,7 @@ describe("OrchestratorMcpService", () => { yield* Ref.set(stoppedBelow, true); }), }), + ServerSettings.layerTest(), Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({ list: () => Effect.succeed([]), @@ -927,6 +935,7 @@ describe("OrchestratorMcpService provider resolution", () => { Layer.mock(ThreadManagementService.ThreadManagementService)({ getThreadRecords: () => Effect.succeed(parentProjection([])), }), + ServerSettings.layerTest(), Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.sync(() => providers), }), @@ -1016,6 +1025,51 @@ describe("OrchestratorMcpService provider resolution", () => { }), ); + it.effect("leaves models the user hid out of the capability catalog", () => + Effect.gen(function* () { + const codex = providerSnapshot({ + instanceId: codexInstanceId, + driver: ProviderDriverKind.make("codex"), + model: "gpt-5.4", + }); + const layerDependencies = Layer.mergeAll( + NodeServices.layer, + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadRecords: () => Effect.succeed(parentProjection([])), + }), + ServerSettings.layerTest({ + providerModelPreferences: { + [codexInstanceId]: { hiddenModels: ["gpt-5.4-mini", "my-model"], modelOrder: [] }, + }, + }), + providerRegistryLayer([ + { + ...codex, + models: [ + ...codex.models, + { slug: "gpt-5.4-mini", name: "Mini", isCustom: false, capabilities: null }, + // Custom models cannot be hidden, matching the client pickers. + { slug: "my-model", name: "Mine", isCustom: true, capabilities: null }, + ], + }, + ]), + adapterRegistryLayer([codexInstanceId]), + Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + ); + + yield* Effect.gen(function* () { + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + const capabilities = yield* service.capabilities(scope); + assert.deepEqual( + capabilities.providers[0]?.models.map((model) => model.id), + ["gpt-5.4", "my-model"], + ); + }).pipe(Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(layerDependencies)))); + }), + ); + it.effect( "delegates to an Antigravity instance whose adapter resolves through the registry", () => @@ -1070,6 +1124,7 @@ describe("OrchestratorMcpService provider resolution", () => { } as never), ), }), + ServerSettings.layerTest(), Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([ providerSnapshot({ @@ -1167,6 +1222,7 @@ describe("OrchestratorMcpService provider resolution", () => { } as never), ), }), + ServerSettings.layerTest(), Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([ providerSnapshot({ @@ -1221,6 +1277,7 @@ describe("OrchestratorMcpService provider resolution", () => { Layer.mock(ThreadManagementService.ThreadManagementService)({ getThreadRecords: () => Effect.succeed(parentProjection([])), }), + ServerSettings.layerTest(), providerRegistryLayer([ providerSnapshot({ instanceId: codexInstanceId, @@ -1329,6 +1386,7 @@ describe("OrchestratorMcpService provider resolution", () => { } as never), ), }), + ServerSettings.layerTest(), Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([codex, missingCli]), refreshInstance: () => @@ -1480,6 +1538,7 @@ describe("OrchestratorMcpService provider resolution", () => { } as never), ), }), + ServerSettings.layerTest(), providerRegistryLayer([ providerSnapshot({ instanceId: codexInstanceId, @@ -1632,6 +1691,7 @@ describe("OrchestratorMcpService provider resolution", () => { getThreadShell: (threadId) => Effect.succeed(threadId === boundThreadId ? boundThread : null), }), + ServerSettings.layerTest(), Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({ list: () => Effect.succeed([]), @@ -1700,6 +1760,7 @@ describe("OrchestratorMcpService provider resolution", () => { // Its turn ended: no run is active. getThreadRecords: () => Effect.succeed(idleThreadProjection(shell)), }), + ServerSettings.layerTest(), Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]), }), @@ -1789,6 +1850,7 @@ describe("OrchestratorMcpService provider resolution", () => { ), ), }), + ServerSettings.layerTest(), Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]), }), diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index bb4bedb74e01..64c8cc020086 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -85,6 +85,7 @@ import { isSnoozed } from "../orchestration-v2/ThreadSettlementService.ts"; import * as ProjectService from "../project/ProjectService.ts"; import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; +import * as ServerSettings from "../serverSettings.ts"; import { clientRuntimeModeCeiling, type McpInvocationScope, @@ -901,6 +902,7 @@ const make = Effect.gen(function* () { const providerAdapters = yield* ProviderAdapterRegistry.ProviderAdapterRegistryV2; const scheduledTasks = yield* ScheduledTaskService.ScheduledTaskService; const projects = yield* ProjectService.ProjectService; + const serverSettings = yield* ServerSettings.ServerSettingsService; /** A caller-named project, which must exist before anything is recorded against it. */ const requireProject = (projectId: ProjectId) => @@ -1851,6 +1853,11 @@ const make = Effect.gen(function* () { const { parent, limits } = yield* loadCaller(scope); const providers = yield* loadProviders; const orchestrationCapableInstanceIds = yield* loadOrchestrationCapableInstanceIds(); + // Models the user hid from pickers stay out of the catalog agents choose from. + const modelPreferences = yield* serverSettings.getSettings.pipe( + Effect.map((settings) => settings.providerModelPreferences ?? {}), + Effect.mapError(() => failure("orchestration_error", "Unable to read server settings.")), + ); return { parentThreadId: parent?.thread.id ?? null, inheritedProviderInstanceId: parent?.thread.modelSelection.instanceId ?? null, @@ -1867,13 +1874,19 @@ const make = Effect.gen(function* () { driverKind: provider.driver, displayName: provider?.displayName ?? null, models: - provider?.models.map((model) => ({ - id: model.slug, - label: model.name ?? null, - ...(model.capabilities?.optionDescriptors === undefined - ? {} - : { options: model.capabilities.optionDescriptors }), - })) ?? [], + provider?.models + .filter( + (model) => + model.isCustom || + !modelPreferences[provider.instanceId]?.hiddenModels.includes(model.slug), + ) + .map((model) => ({ + id: model.slug, + label: model.name ?? null, + ...(model.capabilities?.optionDescriptors === undefined + ? {} + : { options: model.capabilities.optionDescriptors }), + })) ?? [], ...(provider.supportedRuntimeModes === undefined || provider.supportedRuntimeModes.length === 0 ? {} @@ -2594,4 +2607,5 @@ export const layer: Layer.Layer< | ScheduledTaskService.ScheduledTaskService | ProjectService.ProjectService | SecretRequests.SecretRequests + | ServerSettings.ServerSettingsService > = Layer.effect(OrchestratorMcpService, make); diff --git a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts index e7e82eaddffb..d0b1753eddc0 100644 --- a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts @@ -70,6 +70,7 @@ import { import * as ProviderRegistryMock from "../provider/testUtils/providerRegistryMock.ts"; import * as ProjectService from "../project/ProjectService.ts"; import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; +import * as ServerSettings from "../serverSettings.ts"; import * as SecretRequests from "../secrets/SecretRequests.ts"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as McpHttpServer from "./McpHttpServer.ts"; @@ -680,6 +681,7 @@ describe("orchestrator MCP toolkit", () => { Layer.provideMerge(layerOrchestration), Layer.provide(layerRegistry), Layer.provide(layerProviderRegistry), + Layer.provide(ServerSettings.layerTest()), Layer.provide(layerScheduledTaskStub), Layer.provide(Layer.mock(ThreadSearch.ThreadSearch)({})), Layer.provide( @@ -3825,6 +3827,7 @@ describe("orchestrator MCP toolkit", () => { ), ), Layer.provide(layerProviderRegistry), + Layer.provide(ServerSettings.layerTest()), Layer.provide(layerUnusedScheduledTaskStub), Layer.provide(Layer.mock(ProjectService.ProjectService)({})), Layer.provideMerge( diff --git a/apps/server/src/mcp/TaskCancelNativeSubagent.integration.test.ts b/apps/server/src/mcp/TaskCancelNativeSubagent.integration.test.ts index 04c862940ac8..16830049662c 100644 --- a/apps/server/src/mcp/TaskCancelNativeSubagent.integration.test.ts +++ b/apps/server/src/mcp/TaskCancelNativeSubagent.integration.test.ts @@ -33,6 +33,7 @@ import * as ProjectService from "../project/ProjectService.ts"; import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; import * as SecretRequests from "../secrets/SecretRequests.ts"; +import * as ServerSettings from "../serverSettings.ts"; import type { McpInvocationScope } from "./McpInvocationContext.ts"; import * as OrchestratorMcpService from "./OrchestratorMcpService.ts"; @@ -142,6 +143,7 @@ it.effect("task_cancel ends a delegated child's background native subagent", () Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), Layer.mock(ProjectService.ProjectService)({}), Layer.mock(SecretRequests.SecretRequests)({}), + ServerSettings.layerTest(), Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), NodeServices.layer, ), diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index e2b058da5068..a9e2997282ed 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -42,6 +42,7 @@ import * as ProjectService from "../../project/ProjectService.ts"; import * as ProviderRegistry from "../../provider/ProviderRegistry.ts"; import * as SecretRequests from "../../secrets/SecretRequests.ts"; import * as ScheduledTaskService from "../../scheduledTasks/ScheduledTaskService.ts"; +import * as ServerSettings from "../../serverSettings.ts"; import * as McpHttpServer from "../McpHttpServer.ts"; import * as McpInvocationContext from "../McpInvocationContext.ts"; import * as McpToolAccessTestkit from "../McpToolAccess.testkit.ts"; @@ -581,6 +582,7 @@ it.effect("refuses act-as-caller tools to a client caller", () => Layer.provideMerge(McpServer.McpServer.layer), Layer.provide(NodeCrypto.layer), Layer.provide(Layer.mock(ThreadManagement.ThreadManagementService)({})), + Layer.provide(ServerSettings.layerTest()), Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})), Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})), Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})), @@ -619,6 +621,7 @@ it.effect("a caller cannot rewrite a scheduled task that runs above its own mode Layer.provideMerge(McpServer.McpServer.layer), Layer.provide(NodeCrypto.layer), Layer.provide(Layer.mock(ThreadManagement.ThreadManagementService)({})), + Layer.provide(ServerSettings.layerTest()), Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})), Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})), Layer.provide( @@ -716,6 +719,7 @@ it.effect.each(["t3_thread_interrupt", "t3_workflow_stop"])( interruptThread: () => Effect.die("interrupt must not dispatch above the ceiling"), }), ), + Layer.provide(ServerSettings.layerTest()), Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})), Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})), Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})), @@ -807,6 +811,7 @@ it.effect("discovers workflow stop targets and requires orchestration capability }), }), ), + Layer.provide(ServerSettings.layerTest()), Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})), Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})), Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})), diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index d29db34e0664..5c7466b472db 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -98,6 +98,88 @@ const recordProviderUsage = (provider: string, instanceId: string | null = provi }); it.layer(NodeServices.layer)("server settings", (it) => { + it.effect("serializes concurrent model preference edits without cancelling duplicate adds", () => + Effect.gen(function* () { + const service = yield* ServerSettingsModule.ServerSettingsService; + const provider = ProviderInstanceId.make("codex_work"); + yield* Effect.all( + [ + service.updateSettings({ + setModelFavorites: [{ provider, model: "sol", favorite: true }], + }), + service.updateSettings({ + setModelFavorites: [{ provider, model: "sol", favorite: true }], + }), + service.updateSettings({ + setModelFavorites: [{ provider, model: "luna", favorite: true }], + }), + service.updateSettings({ setModelsHidden: [{ provider, model: "astra", hidden: true }] }), + service.updateSettings({ setModelsHidden: [{ provider, model: "luna", hidden: true }] }), + service.updateSettings({ + setProviderModelOrder: { provider, modelOrder: ["luna", "sol"] }, + }), + ], + { concurrency: "unbounded" }, + ); + const settings = yield* service.getSettings; + assert.deepEqual(settings.favorites?.map((entry) => entry.model).sort(), ["luna", "sol"]); + assert.deepEqual(settings.providerModelPreferences?.[provider]?.hiddenModels.toSorted(), [ + "astra", + "luna", + ]); + assert.deepEqual(settings.providerModelPreferences?.[provider]?.modelOrder, ["luna", "sol"]); + }).pipe(Effect.provide(layerServerSettings())), + ); + it.effect("persists and broadcasts model preferences to other connected clients", () => + Effect.scoped( + Effect.gen(function* () { + const service = yield* ServerSettingsModule.ServerSettingsService; + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const provider = ProviderInstanceId.make("codex_work"); + const patch = { + favorites: [{ provider, model: "sol" }], + providerModelPreferences: { + [provider]: { hiddenModels: ["astra"], modelOrder: ["sol"] }, + }, + }; + const changes = yield* service.subscribeChanges; + yield* service.updateSettings(patch); + const received = Option.getOrThrow(yield* Stream.runHead(changes)); + assert.deepEqual(received.favorites, patch.favorites); + assert.deepEqual(received.providerModelPreferences, patch.providerModelPreferences); + const saved = yield* decodeServerSettingsJson( + yield* fs.readFileString(config.settingsPath), + ); + assert.deepEqual(saved.favorites, patch.favorites); + assert.deepEqual(saved.providerModelPreferences, patch.providerModelPreferences); + const hidden = yield* service.updateSettings({ + setModelsHidden: [{ provider, model: "sol", hidden: true }], + }); + assert.deepEqual(hidden.providerModelPreferences?.[provider], { + hiddenModels: ["astra", "sol"], + modelOrder: ["sol"], + }); + const shown = yield* service.updateSettings({ + setModelsHidden: [{ provider, model: "astra", hidden: false }], + }); + assert.deepEqual(shown.providerModelPreferences?.[provider], { + hiddenModels: ["sol"], + modelOrder: ["sol"], + }); + assert.deepEqual( + (yield* decodeServerSettingsJson(yield* fs.readFileString(config.settingsPath))) + .providerModelPreferences, + shown.providerModelPreferences, + ); + yield* service.updateSettings({ favorites: [], providerModelPreferences: {} }); + // An older device's delayed seed cannot undo a clear, even after persistence. + const migrated = yield* service.updateSettings({ migrateModelPreferences: patch }); + assert.deepEqual(migrated.favorites, []); + assert.deepEqual(migrated.providerModelPreferences, {}); + }), + ).pipe(Effect.provide(layerServerSettings())), + ); it.effect("migrates saved token delivery to paragraph buffering without resetting settings", () => Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index bb10c0aed844..6d7646e521d7 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -5524,6 +5524,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) /> ) : null} (null); const pickerContentRef = useRef(null); const highlightedModelKeyRef = useRef(null); - const favorites = useClientSettings((s) => s.favorites ?? []); + const { favorites } = useEnvironmentModelPreferences(props.environmentId); const activeEntry = props.instanceEntries.find( (entry) => entry.instanceId === props.activeInstanceId, ); @@ -276,8 +281,8 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { ); const serverKeybindings = useAtomValue(primaryServerKeybindingsAtom); const keybindings = providedKeybindings ?? serverKeybindings; - const updateSettings = useUpdateClientSettings(); const navigate = useNavigate(); + const toggleFavorite = useToggleEnvironmentModelFavorite(props.environmentId); const focusSearchInput = useCallback(() => { searchInputRef.current?.focus({ preventScroll: true }); @@ -652,20 +657,6 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { ], ); - const toggleFavorite = useCallback( - (instanceId: ProviderInstanceId, model: string) => { - const newFavorites = [...favorites]; - const index = newFavorites.findIndex((f) => f.provider === instanceId && f.model === model); - if (index >= 0) { - newFavorites.splice(index, 1); - } else { - newFavorites.push({ provider: instanceId, model }); - } - updateSettings({ favorites: newFavorites }); - }, - [favorites, updateSettings], - ); - const modelJumpCommandByKey = useMemo(() => { const mapping = new Map< string, diff --git a/apps/web/src/components/chat/ProviderModelPicker.test.tsx b/apps/web/src/components/chat/ProviderModelPicker.test.tsx index 54aa051aa639..3867fcb2bad2 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.test.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.test.tsx @@ -40,6 +40,7 @@ function renderPicker(input: { const entry = providerEntry(input.instanceId, input.driver); return renderToStaticMarkup( { const activeEntry = providerEntry("codex_personal", "codex"); const markup = renderToStaticMarkup( option.instanceId === instanceId); const options = getCustomModelOptionsByInstance( - { ...settings, ...candidate.settings }, + mergeEnvironmentSettings(candidate.settings, settings), config.providers, ).get(instanceId); if ( @@ -158,6 +159,7 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting selection && activeEntry ? (
    { modelOrder: [], onHiddenModelsChange: () => undefined, onFavoriteModelsChange: () => undefined, + onToggleFavorite: () => undefined, onModelOrderChange: () => undefined, }), ); @@ -157,6 +158,7 @@ describe("deriveProviderModelsForDisplay", () => { modelOrder: [], onHiddenModelsChange: () => undefined, onFavoriteModelsChange: () => undefined, + onToggleFavorite: () => undefined, onModelOrderChange: () => undefined, } as const; diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index 45d4eb8c6fe1..5bbbc8c222ad 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -503,8 +503,12 @@ interface ProviderInstanceCardProps { readonly hiddenModels: ReadonlyArray; readonly favoriteModels: ReadonlyArray; readonly modelOrder: ReadonlyArray; - readonly onHiddenModelsChange: (next: ReadonlyArray) => void; + readonly onHiddenModelsChange: ( + next: ReadonlyArray, + affectedModels?: ReadonlyArray, + ) => void; readonly onFavoriteModelsChange: (next: ReadonlyArray) => void; + readonly onToggleFavorite: (model: string) => void; readonly onModelOrderChange: (next: ReadonlyArray) => void; readonly onRunUpdate?: (() => void) | undefined; readonly onInstallRecommended?: (() => void) | undefined; @@ -561,6 +565,7 @@ export function ProviderInstanceCard({ modelOrder, onHiddenModelsChange, onFavoriteModelsChange, + onToggleFavorite, onModelOrderChange, onRunUpdate, onInstallRecommended, @@ -1173,8 +1178,8 @@ export function ProviderInstanceCard({

    - Favorites, visibility, and ordering are saved on this device. Custom models are saved - on the selected environment. + Model preferences and custom models are saved on the selected environment and shared + across connected devices.

    diff --git a/apps/web/src/components/settings/ProviderModelsSection.tsx b/apps/web/src/components/settings/ProviderModelsSection.tsx index 1cb3e311147e..5c584bbad510 100644 --- a/apps/web/src/components/settings/ProviderModelsSection.tsx +++ b/apps/web/src/components/settings/ProviderModelsSection.tsx @@ -145,8 +145,12 @@ interface ProviderModelsSectionProps { * `providerInstances[id].config`). */ readonly onChange: (next: ReadonlyArray) => void; - readonly onHiddenModelsChange: (next: ReadonlyArray) => void; + readonly onHiddenModelsChange: ( + next: ReadonlyArray, + affectedModels?: ReadonlyArray, + ) => void; readonly onFavoriteModelsChange: (next: ReadonlyArray) => void; + readonly onToggleFavorite: (model: string) => void; readonly onModelOrderChange: (next: ReadonlyArray) => void; } @@ -174,6 +178,7 @@ export function ProviderModelsSection({ onChange, onHiddenModelsChange, onFavoriteModelsChange, + onToggleFavorite, onModelOrderChange, }: ProviderModelsSectionProps) { const [input, setInput] = useState(""); @@ -282,16 +287,13 @@ export function ProviderModelsSection({ if (!canWritePreferences || hidden === hiddenModelSet.has(slug)) return; onHiddenModelsChange( hidden ? [...hiddenModels, slug] : hiddenModels.filter((model) => model !== slug), + [slug], ); }; const handleToggleFavorite = (slug: string) => { if (!canWritePreferences) return; - if (favoriteModelSet.has(slug)) { - onFavoriteModelsChange(favoriteModels.filter((model) => model !== slug)); - return; - } - onFavoriteModelsChange([...favoriteModels, slug]); + onToggleFavorite(slug); }; // Rows only trade places with a neighbour in the same group (favorites, @@ -533,7 +535,10 @@ export function ProviderModelsSection({ size="xs" variant="ghost-muted" onClick={() => - onHiddenModelsChange(nextHiddenModelsForBulkToggle(models, hiddenModels)) + onHiddenModelsChange( + nextHiddenModelsForBulkToggle(models, hiddenModels), + builtInModels.map((model) => model.slug), + ) } > {allBuiltInModelsHidden ? "Enable all" : "Disable all"} diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx index 35a4bcbd33a9..bee9a6f49326 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx @@ -108,6 +108,8 @@ vi.mock("../../hooks/useSettings", () => ({ settingsState.updateEnvironmentIds.push(environmentId); return settingsState.updateSettings; }, + useUpdateEnvironmentModelPreferences: () => settingsState.updateSettings, + useToggleEnvironmentModelFavorite: () => () => undefined, usePersistEnvironmentProviderInstanceMutation: (environmentId: EnvironmentId) => { settingsState.mutationEnvironmentIds.push(environmentId); return settingsState.mutateProviderInstance; @@ -344,16 +346,19 @@ describe("EnvironmentProviderSettings routing", () => { }); it.each([ - ["onFavoriteModelsChange", { favorites: [{ provider: codexId, model: "chosen" }] }], + [ + "onFavoriteModelsChange", + { setModelFavorites: [{ provider: codexId, model: "chosen", favorite: true }] }, + ], [ "onHiddenModelsChange", - { providerModelPreferences: { [codexId]: { hiddenModels: ["chosen"], modelOrder: [] } } }, + { setModelsHidden: [{ provider: codexId, model: "chosen", hidden: true }] }, ], [ "onModelOrderChange", - { providerModelPreferences: { [codexId]: { hiddenModels: [], modelOrder: ["chosen"] } } }, + { setProviderModelOrder: { provider: codexId, modelOrder: ["chosen"] } }, ], - ])("saves %s on this device without changing the selected server", (action, expected) => { + ])("saves %s on the selected environment for every connected client", (action, expected) => { atoms.providers = [provider()]; const panel = renderPanel(); const editor = visitElements( @@ -363,8 +368,8 @@ describe("EnvironmentProviderSettings routing", () => { expect(editor).not.toBeNull(); if (!editor) throw new Error("Provider editor was not rendered"); (editor.props[action] as (models: string[]) => void)(["chosen"]); - expect(settingsState.updateClientSettings).toHaveBeenCalledExactlyOnceWith(expected); - expect(settingsState.updateSettings).not.toHaveBeenCalled(); + expect(settingsState.updateSettings).toHaveBeenCalledExactlyOnceWith(expected); + expect(settingsState.updateClientSettings).not.toHaveBeenCalled(); }); it("does not substitute another account when the requested instance was removed", () => { diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.tsx index 73a40f2ba4df..76938b559990 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.tsx @@ -40,8 +40,9 @@ import { isDesktopLocalConnectionTarget } from "../../connection/desktopLocal"; import { useEnvironmentSettings, usePersistEnvironmentProviderInstanceMutation, - useUpdateClientSettings, useUpdateEnvironmentSettings, + useUpdateEnvironmentModelPreferences, + useToggleEnvironmentModelFavorite, } from "../../hooks/useSettings"; import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; import { cn } from "../../lib/utils"; @@ -123,20 +124,12 @@ import { resolveSelectedProviderEnvironmentId, } from "./ProviderSettingsPanel.logic"; -function withoutProviderInstanceKey( - record: Readonly> | undefined, - key: ProviderInstanceId, -): Record { - const next = { ...record } as Record; - delete next[key]; - return next; -} - -function withoutProviderInstanceFavorites( - favorites: ReadonlyArray<{ readonly provider: ProviderInstanceId; readonly model: string }>, - instanceId: ProviderInstanceId, -) { - return favorites.filter((favorite) => favorite.provider !== instanceId); +function modelListChanges(current: ReadonlyArray, next: ReadonlyArray) { + const currentSet = new Set(current); + const nextSet = new Set(next.map((model) => model.trim()).filter(Boolean)); + return [...new Set([...currentSet, ...nextSet])] + .filter((model) => currentSet.has(model) !== nextSet.has(model)) + .map((model) => ({ model, selected: nextSet.has(model) })); } function providerConfigString(config: unknown, key: string): string | null { @@ -565,8 +558,9 @@ export function EnvironmentProviderSettings({ const canWriteSettings = useEnvironmentScope(environmentId, AuthSettingsWriteScope); const canRefreshProviders = useEnvironmentScope(environmentId, AuthOrchestrationReadScope); const updateSettings = useUpdateEnvironmentSettings(environmentId); + const updateModelPreferences = useUpdateEnvironmentModelPreferences(environmentId); + const toggleModelFavorite = useToggleEnvironmentModelFavorite(environmentId); const persistProviderInstance = usePersistEnvironmentProviderInstanceMutation(environmentId); - const updateClientSettings = useUpdateClientSettings(); const serverProviders = useAtomValue(serverEnvironment.providersValueAtom(environmentId)) ?? EMPTY_SERVER_PROVIDERS; const projects = useProjects().filter((project) => project.environmentId === environmentId); @@ -873,50 +867,6 @@ export function EnvironmentProviderSettings({ } }; - const updateProviderModelPreferences = ( - instanceId: ProviderInstanceId, - next: { - readonly hiddenModels: ReadonlyArray; - readonly modelOrder: ReadonlyArray; - }, - ) => { - const hiddenModels = [...new Set(next.hiddenModels.filter((slug) => slug.trim().length > 0))]; - const modelOrder = [...new Set(next.modelOrder.filter((slug) => slug.trim().length > 0))]; - const rest = withoutProviderInstanceKey(settings.providerModelPreferences, instanceId); - updateClientSettings({ - providerModelPreferences: - hiddenModels.length === 0 && modelOrder.length === 0 - ? rest - : { - ...rest, - [instanceId]: { - hiddenModels, - modelOrder, - }, - }, - }); - }; - - const updateProviderFavoriteModels = ( - instanceId: ProviderInstanceId, - nextFavoriteModels: ReadonlyArray, - ) => { - const favoriteModels = [ - ...new Set( - Arr.filterMap(nextFavoriteModels, (slug) => { - const trimmedSlug = slug.trim(); - return trimmedSlug.length > 0 ? Result.succeed(trimmedSlug) : Result.failVoid; - }), - ), - ]; - updateClientSettings({ - favorites: [ - ...withoutProviderInstanceFavorites(settings.favorites ?? [], instanceId), - ...favoriteModels.map((model) => ({ provider: instanceId, model })), - ], - }); - }; - const resetDefaultInstance = async (driverKind: ProviderDriverKind) => { const result = await persistProviderInstance({ operation: "remove", @@ -1072,19 +1022,34 @@ export function EnvironmentProviderSettings({ hiddenModels={modelPreferences.hiddenModels} favoriteModels={favoriteModels} modelOrder={modelPreferences.modelOrder} - onHiddenModelsChange={(hiddenModels) => - updateProviderModelPreferences(row.instanceId, { - ...modelPreferences, - hiddenModels, - }) - } - onFavoriteModelsChange={(next) => updateProviderFavoriteModels(row.instanceId, next)} - onModelOrderChange={(modelOrder) => - updateProviderModelPreferences(row.instanceId, { - ...modelPreferences, - modelOrder, - }) - } + onHiddenModelsChange={(next, affectedModels) => { + const hidden = new Set(next); + const models = + affectedModels ?? + modelListChanges(modelPreferences.hiddenModels, next).map((change) => change.model); + void updateModelPreferences({ + setModelsHidden: models.map((model) => ({ + provider: row.instanceId, + model, + hidden: hidden.has(model), + })), + }); + }} + onToggleFavorite={(model) => { + void toggleModelFavorite(row.instanceId, model); + }} + onFavoriteModelsChange={(next) => { + void updateModelPreferences({ + setModelFavorites: modelListChanges(favoriteModels, next).map( + ({ model, selected }) => ({ provider: row.instanceId, model, favorite: selected }), + ), + }); + }} + onModelOrderChange={(modelOrder) => { + void updateModelPreferences({ + setProviderModelOrder: { provider: row.instanceId, modelOrder }, + }); + }} onInstallRecommended={ !readOnly && liveProvider?.compatibilityAdvisory?.message && diff --git a/apps/web/src/components/settings/ScheduledTasksSettings.tsx b/apps/web/src/components/settings/ScheduledTasksSettings.tsx index 34c3896d87fc..7b941f97a79e 100644 --- a/apps/web/src/components/settings/ScheduledTasksSettings.tsx +++ b/apps/web/src/components/settings/ScheduledTasksSettings.tsx @@ -1122,6 +1122,7 @@ function ScheduledTaskEditorDialog({ { }); }); }); + +it("routes legacy model preference keys exclusively to the selected environment", () => { + const patch = { + favorites: [{ provider: ProviderInstanceId.make("codex"), model: "sol" }], + providerModelPreferences: {}, + }; + const plan = planScopedSettingsPatch(named, environments, patch); + expect(plan.clientPatch).toEqual({}); + expect(plan.hasClientWrite).toBe(false); + expect(plan.serverWrites).toEqual([ + { environmentId: server.environmentId, label: server.label, patch }, + ]); +}); diff --git a/apps/web/src/components/settings/scopedSettings.ts b/apps/web/src/components/settings/scopedSettings.ts index 537c3bb381b3..f4fe6e41905c 100644 --- a/apps/web/src/components/settings/scopedSettings.ts +++ b/apps/web/src/components/settings/scopedSettings.ts @@ -216,7 +216,7 @@ export function planScopedSettingsPatch( patch: ScopedSettingsPatch, ) { const clientPatch = Object.fromEntries( - Object.entries(patch).filter(([key]) => CLIENT_KEYS.has(key)), + Object.entries(patch).filter(([key]) => CLIENT_KEYS.has(key) && !SERVER_KEYS.has(key)), ) as ClientSettingsPatch; const serverPatch = Object.fromEntries( Object.entries(patch).filter(([key]) => SERVER_KEYS.has(key)), diff --git a/apps/web/src/components/settings/useScopedModelAvailability.ts b/apps/web/src/components/settings/useScopedModelAvailability.ts index a851a1876adf..d188d29f2285 100644 --- a/apps/web/src/components/settings/useScopedModelAvailability.ts +++ b/apps/web/src/components/settings/useScopedModelAvailability.ts @@ -1,6 +1,7 @@ import type { ProviderInstanceId, UnifiedSettings } from "@t3tools/contracts"; import { useCallback } from "react"; +import { mergeEnvironmentSettings } from "../../hooks/useSettings"; import { getCustomModelOptionsByInstance } from "../../modelSelection"; import { applyProviderInstanceSettings, @@ -36,7 +37,7 @@ export function useScopedModelDisabledReason( candidate.settings, ).find((option) => option.instanceId === instanceId); const options = getCustomModelOptionsByInstance( - { ...settings, ...candidate.settings }, + mergeEnvironmentSettings(candidate.settings, settings), config.providers, ).get(instanceId); if ( diff --git a/apps/web/src/components/settings/useScopedSettings.ts b/apps/web/src/components/settings/useScopedSettings.ts index 4bed07267b13..a67d88fb9bcd 100644 --- a/apps/web/src/components/settings/useScopedSettings.ts +++ b/apps/web/src/components/settings/useScopedSettings.ts @@ -19,6 +19,7 @@ import { mergeEnvironmentSettings, persistClientSettingsPatch, useClientSettings, + useMigrateModelPreferences, } from "../../hooks/useSettings"; import { serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; @@ -42,6 +43,7 @@ export function useScopedSettings( const { target } = useSettingsScope(); const clientSettings = useClientSettings(); const serverSettings = target?.settings ?? DEFAULT_SERVER_SETTINGS; + useMigrateModelPreferences(target?.environmentId ?? null, target?.settings ?? null); const settings = useMemo( () => mergeEnvironmentSettings(serverSettings, clientSettings), [clientSettings, serverSettings], diff --git a/apps/web/src/hooks/useSettings.favorites.test.tsx b/apps/web/src/hooks/useSettings.favorites.test.tsx new file mode 100644 index 000000000000..ce5a9665c87d --- /dev/null +++ b/apps/web/src/hooks/useSettings.favorites.test.tsx @@ -0,0 +1,252 @@ +import { RegistryContext } from "@effect/atom-react"; +import { + DEFAULT_SERVER_SETTINGS, + EnvironmentId, + ProviderInstanceId, + type ServerSettings, + type ServerSettingsPatch, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; +import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; +import { act } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const state = vi.hoisted(() => ({ + settings: new Map>(), + persist: vi.fn(), +})); + +vi.mock("~/state/server", () => ({ + serverEnvironment: { + settingsValueAtom: (id: EnvironmentId) => state.settings.get(id)!, + updateSettings: Symbol("updateSettings"), + }, + primaryServerSettingsAtom: undefined, +})); +vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => state.persist })); +vi.mock("~/localApi", () => ({ + ensureLocalApi: () => ({ persistence: { getClientSettings: async () => null } }), +})); + +import { useToggleEnvironmentModelFavorite } from "./useSettings"; + +const primaryId = EnvironmentId.make("primary"); +const remoteId = EnvironmentId.make("remote"); +const provider = ProviderInstanceId.make("codex_work"); +const model = "gpt-6.1-sol"; +const initialSettings = { + ...DEFAULT_SERVER_SETTINGS, + favorites: [], + providerModelPreferences: {}, +} satisfies ServerSettings; +let registry: AtomRegistry.AtomRegistry; +let renderer: ReactTestRenderer | undefined; +let durable: Map; +let writes: Array<{ + environmentId: EnvironmentId; + patch: ServerSettingsPatch; + finish: (success?: boolean) => void; +}>; + +function FavoriteEditor({ environmentId }: { environmentId: EnvironmentId }) { + const toggle = useToggleEnvironmentModelFavorite(environmentId); + return ; +} + +function editor(environmentId: EnvironmentId) { + return ( + + + + ); +} + +async function mount(environmentId = primaryId) { + await act(() => { + renderer = create(editor(environmentId)); + }); +} + +function click(): Promise { + return renderer!.root.findByType("button").props.onClick(); +} + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + registry = AtomRegistry.make(); + state.settings.clear(); + durable = new Map(); + writes = []; + for (const id of [primaryId, remoteId]) { + state.settings.set(id, Atom.make(initialSettings)); + durable.set(id, initialSettings); + } + state.persist.mockReset().mockImplementation( + ({ + environmentId, + input, + }: { + environmentId: EnvironmentId; + input: { patch: ServerSettingsPatch }; + }) => + new Promise((resolve) => { + writes.push({ + environmentId, + patch: input.patch, + finish: (success = true) => { + if (!success) { + resolve(AsyncResult.failure(Cause.fail(new Error("save failed")))); + return; + } + const settings = applyServerSettingsPatch(durable.get(environmentId)!, input.patch); + durable.set(environmentId, settings); + resolve(AsyncResult.success(settings)); + }, + }); + }), + ); +}); + +afterEach(async () => { + await act(() => renderer?.unmount()); + renderer = undefined; + registry.dispose(); + vi.unstubAllGlobals(); +}); + +describe("environment favorite toggles", () => { + it.each([false, true])( + "toggles twice before any server echo when initially favorited=%s", + async (initiallyFavorited) => { + const favorites = initiallyFavorited ? [{ provider, model }] : []; + const settings = { ...initialSettings, favorites }; + registry.set(state.settings.get(primaryId)!, settings); + durable.set(primaryId, settings); + await mount(); + const first = click(); + const second = click(); + // Preparing the patch is asynchronous even when migration has already completed. + await act(async () => {}); + expect(writes.map((write) => write.patch.setModelFavorites?.[0]?.favorite)).toEqual([ + !initiallyFavorited, + initiallyFavorited, + ]); + writes[0]!.finish(); + await first; + writes[1]!.finish(); + await second; + expect(durable.get(primaryId)!.favorites).toEqual(favorites); + }, + ); + + it("keeps the newest pending intent when an earlier write completes", async () => { + await mount(); + const first = click(); + const second = click(); + await act(async () => {}); + writes[0]!.finish(); + await first; + const third = click(); + await act(async () => {}); + expect(writes.map((write) => write.patch.setModelFavorites?.[0]?.favorite)).toEqual([ + true, + false, + true, + ]); + writes[1]!.finish(); + await second; + writes[2]!.finish(); + await third; + expect(durable.get(primaryId)!.favorites).toEqual([{ provider, model }]); + }); + + it("clears failed intent so a retry still adds the favorite", async () => { + await mount(); + const failed = click(); + await act(async () => {}); + writes[0]!.finish(false); + await failed; + const retry = click(); + await act(async () => {}); + writes[1]!.finish(); + await retry; + expect(durable.get(primaryId)!.favorites).toEqual([{ provider, model }]); + }); + + it("toggles the acknowledged choice when the RPC reply precedes its server echo", async () => { + await mount(); + const first = click(); + await act(async () => {}); + writes[0]!.finish(); + await first; + expect(registry.get(state.settings.get(primaryId)!)!.favorites).toEqual([]); + const second = click(); + await act(async () => {}); + writes[1]!.finish(); + await second; + expect(durable.get(primaryId)!.favorites).toEqual([]); + }); + + it("uses a later remote change instead of a cached acknowledged choice", async () => { + await mount(); + const first = click(); + await act(async () => {}); + writes[0]!.finish(); + await first; + // Another client removed the favorite after this client's add was acknowledged. + const remoteSettings = { ...initialSettings, favorites: [] }; + durable.set(primaryId, remoteSettings); + await act(() => registry.set(state.settings.get(primaryId)!, remoteSettings)); + const second = click(); + await act(async () => {}); + writes[1]!.finish(); + await second; + expect(durable.get(primaryId)!.favorites).toEqual([{ provider, model }]); + }); + + it("preserves an acknowledged add when a later remove fails before its echo", async () => { + await mount(); + const first = click(); + await act(async () => {}); + writes[0]!.finish(); + await first; + const failed = click(); + await act(async () => {}); + writes[1]!.finish(false); + await failed; + const retry = click(); + await act(async () => {}); + writes[2]!.finish(); + await retry; + expect(durable.get(primaryId)!.favorites).toEqual([]); + }); + + it("does not reuse another environment's pending intent for the same model", async () => { + await mount(); + const primary = click(); + await act(() => renderer!.update(editor(remoteId))); + const remote = click(); + await act(async () => {}); + writes[0]!.finish(); + await primary; + writes[1]!.finish(); + await remote; + expect(durable.get(primaryId)!.favorites).toEqual([{ provider, model }]); + expect(durable.get(remoteId)!.favorites).toEqual([{ provider, model }]); + }); + + it("reads the latest server state even when the click callback predates its echo", async () => { + await mount(); + const previousClick = renderer!.root.findByType("button").props.onClick as () => Promise; + const updated = { ...initialSettings, favorites: [{ provider, model }] }; + durable.set(primaryId, updated); + await act(() => registry.set(state.settings.get(primaryId)!, updated)); + const clicked = previousClick(); + await act(async () => {}); + writes[0]!.finish(); + await clicked; + expect(durable.get(primaryId)!.favorites).toEqual([]); + }); +}); diff --git a/apps/web/src/hooks/useSettings.test.ts b/apps/web/src/hooks/useSettings.test.ts index eeb0ca524a64..d90aa58c4d90 100644 --- a/apps/web/src/hooks/useSettings.test.ts +++ b/apps/web/src/hooks/useSettings.test.ts @@ -23,8 +23,10 @@ import { mergeEnvironmentSettings, persistClientSettingsPatch, persistClientSettingsUpdate, + prepareModelPreferencesPatch, resolveEnvironmentIdentificationMode, } from "./useSettings"; +import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; beforeEach(() => { persistenceMocks.getClientSettings.mockReset().mockResolvedValue(null); @@ -45,6 +47,44 @@ describe("client settings hydration", () => { const onboardingCompletedAt = "2026-09-05T12:00:00.000Z"; const complete = (current: ClientSettings) => ({ ...current, onboardingCompletedAt }); + it("waits for saved model preferences before applying the first server edit", async () => { + const provider = ProviderInstanceId.make("codex_work"); + let finishRead!: (settings: ClientSettings) => void; + persistenceMocks.getClientSettings.mockReturnValueOnce( + new Promise((resolve) => { + finishRead = resolve; + }), + ); + const patch = prepareModelPreferencesPatch(DEFAULT_SERVER_SETTINGS, { + setModelFavorites: [{ provider, model: "new", favorite: true }], + setModelsHidden: [{ provider, model: "new", hidden: true }], + }); + expect(getClientSettings()).toBe(DEFAULT_CLIENT_SETTINGS); + finishRead({ + ...savedSettings, + providerModelPreferences: { + [provider]: { hiddenModels: ["saved-hidden"], modelOrder: ["saved-order"] }, + }, + }); + const edited = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, await patch); + expect(edited.favorites).toEqual([...savedSettings.favorites, { provider, model: "new" }]); + expect(edited.providerModelPreferences?.[provider]).toEqual({ + hiddenModels: ["saved-hidden", "new"], + modelOrder: ["saved-order"], + }); + }); + + it("does not prepare a model preference write after a failed legacy read", async () => { + const failure = new Error("storage unavailable"); + vi.spyOn(console, "error").mockImplementation(() => undefined); + persistenceMocks.getClientSettings.mockRejectedValueOnce(failure); + await expect( + prepareModelPreferencesPatch(DEFAULT_SERVER_SETTINGS, { + setProviderModelOrder: { provider: ProviderInstanceId.make("codex_work"), modelOrder: [] }, + }), + ).rejects.toBe(failure); + }); + it("rejects completion after a failed read and preserves saved preferences on retry", async () => { const failure = new Error("storage unavailable"); vi.spyOn(console, "error").mockImplementation(() => undefined); @@ -451,3 +491,20 @@ describe("onboarding completion persistence", () => { expect(persist).toHaveBeenLastCalledWith(completedSettings); }); }); + +it("uses the environment's model preferences after migration, including empty lists", () => { + const client = { + ...DEFAULT_CLIENT_SETTINGS, + favorites: [{ provider: ProviderInstanceId.make("codex_work"), model: "old" }], + providerModelPreferences: { codex_work: { hiddenModels: ["old"], modelOrder: [] } }, + }; + expect(mergeEnvironmentSettings(DEFAULT_SERVER_SETTINGS, client).favorites).toEqual( + client.favorites, + ); + const merged = mergeEnvironmentSettings( + { ...DEFAULT_SERVER_SETTINGS, favorites: [], providerModelPreferences: {} }, + client, + ); + expect(merged.favorites).toEqual([]); + expect(merged.providerModelPreferences).toEqual({}); +}); diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index d61abb34f553..027373a45562 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -9,14 +9,15 @@ * access is intentionally named as such so environment-sensitive consumers * cannot silently read the wrong server's settings. */ -import { useCallback, useMemo, useSyncExternalStore } from "react"; -import { useAtomValue } from "@effect/atom-react"; +import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore, use } from "react"; +import { RegistryContext, useAtomValue } from "@effect/atom-react"; import { DEFAULT_SERVER_SETTINGS, AuthSettingsWriteScope, requiredScopesForServerSettingsPatch, type EnvironmentId, type ProviderInstanceMutation, + type ProviderInstanceId, ServerSettings, type ServerSettingsPatch, sessionGrantsScope, @@ -46,6 +47,10 @@ import { subscribeToThemePreview, themeAllowsSidebarArtwork, } from "~/themePalette"; +import { + modelPreferencesMigrationPatch, + resolveModelPreferences, +} from "@t3tools/shared/serverSettings"; import * as Struct from "effect/Struct"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/reactivity"; @@ -318,7 +323,38 @@ export function mergeEnvironmentSettings( ): UnifiedSettings { // Decode drops retired client keys, but older untyped persistence adapters // can still return them. Server-owned values must always win. - return { ...clientSettings, ...serverSettings }; + return { + ...clientSettings, + ...serverSettings, + ...resolveModelPreferences(serverSettings, clientSettings), + }; +} + +const pendingModelPreferencesMigrations = new Set(); + +/** Seed saved client preferences once without overwriting another client's edits. */ +export function useMigrateModelPreferences( + environmentId: EnvironmentId | null, + settings: ServerSettings | null, +) { + const clientSettings = useClientSettingsValue(); + const hydrated = useClientSettingsHydrated(); + const migrate = useAtomCommand(serverEnvironment.updateSettings, { reportFailure: false }); + useEffect(() => { + if ( + !environmentId || + !settings || + !hydrated || + pendingModelPreferencesMigrations.has(environmentId) + ) + return; + const patch = modelPreferencesMigrationPatch(settings, clientSettings); + if (!patch) return; + pendingModelPreferencesMigrations.add(environmentId); + void migrate({ environmentId, input: { patch } }).finally(() => + pendingModelPreferencesMigrations.delete(environmentId), + ); + }, [clientSettings, environmentId, hydrated, migrate, settings]); } function useMergedSettings( @@ -391,12 +427,135 @@ export function useLegacySidebarEnabled(): boolean { return settingsHydrated && legacySidebarEnabled; } +/** Hydrate legacy preferences before a mutation can claim either server-owned list. */ +export async function prepareModelPreferencesPatch( + settings: ServerSettings, + patch: ServerSettingsPatch, +): Promise { + if (settings.favorites === null || settings.providerModelPreferences === null) { + await ensureClientSettingsHydrated(); + } + return { ...modelPreferencesMigrationPatch(settings, getClientSettings()), ...patch }; +} + +export function useUpdateEnvironmentModelPreferences(environmentId: EnvironmentId | null) { + const settings = useAtomValue( + environmentId ? serverEnvironment.settingsValueAtom(environmentId) : noEnvironmentSettingsAtom, + ); + const save = useAtomCommand(serverEnvironment.updateSettings, "model preferences update"); + return useCallback( + async (patch: ServerSettingsPatch) => { + if (!environmentId || !settings) return; + let prepared: ServerSettingsPatch; + try { + prepared = await prepareModelPreferencesPatch(settings, patch); + } catch { + toastManager.add({ + type: "error", + title: "Model preferences not saved", + description: "Could not load saved model preferences. Try again.", + }); + return; + } + return save({ environmentId, input: { patch: prepared } }); + }, + [environmentId, save, settings], + ); +} + +/** Send the desired state so two clients adding a favorite do not cancel each other. */ +export function useToggleEnvironmentModelFavorite(environmentId: EnvironmentId | null) { + const registry = use(RegistryContext); + const pendingFavorites = useRef( + new Map(), + ); + const save = useUpdateEnvironmentModelPreferences(environmentId); + return useCallback( + async (provider: ProviderInstanceId, model: string) => { + if (!environmentId) return; + const settingsAtom = serverEnvironment.settingsValueAtom(environmentId); + const settings = registry.get(settingsAtom); + if (!settings) return; + if (settings.favorites === null) { + try { + await ensureClientSettingsHydrated(); + } catch { + toastManager.add({ + type: "error", + title: "Favorites not saved", + description: "Could not load saved model preferences. Try again.", + }); + return; + } + } + const currentSettings = registry.get(settingsAtom) ?? settings; + const favorites = resolveModelPreferences(currentSettings, getClientSettings()).favorites; + const key = JSON.stringify([environmentId, provider, model]); + const previous = pendingFavorites.current.get(key); + // Keep the acknowledged choice until the config stream catches up with the RPC reply. + const previousFavorite = + previous && (previous.pending || previous.settings.favorites === currentSettings.favorites) + ? previous.favorite + : favorites.some( + (favorite) => favorite.provider === provider && favorite.model === model, + ); + const pending = { + favorite: !previousFavorite, + settings: currentSettings, + pending: true, + }; + pendingFavorites.current.set(key, pending); + try { + const result = await save({ + setModelFavorites: [{ provider, model, favorite: pending.favorite }], + }); + if ( + result && + AsyncResult.isSuccess(result) && + registry.get(settingsAtom)?.favorites === currentSettings.favorites + ) { + pending.pending = false; + } + } finally { + if (pending.pending && pendingFavorites.current.get(key) === pending) { + if ( + previous && + !previous.pending && + registry.get(settingsAtom)?.favorites === previous.settings.favorites + ) { + pendingFavorites.current.set(key, previous); + } else { + pendingFavorites.current.delete(key); + } + } + } + }, + [environmentId, registry, save], + ); +} + +const noEnvironmentSettingsAtom = Atom.make(null); + +/** Model pickers may render before a settings target has been selected. */ +export function useEnvironmentModelPreferences(environmentId: EnvironmentId | null) { + const settings = useAtomValue( + environmentId ? serverEnvironment.settingsValueAtom(environmentId) : noEnvironmentSettingsAtom, + ); + const legacy = useClientSettingsValue(); + useMigrateModelPreferences(environmentId, settings); + return useMemo( + () => resolveModelPreferences(settings ?? DEFAULT_SERVER_SETTINGS, legacy), + [legacy, settings], + ); +} + /** Read current settings for one environment, merged with client-local preferences. */ export function useEnvironmentSettings( environmentId: EnvironmentId, selector?: (settings: UnifiedSettings) => T, ): T { const serverSettings = useAtomValue(serverEnvironment.settingsValueAtom(environmentId)); + useMigrateModelPreferences(environmentId, serverSettings); return useMergedSettings(serverSettings ?? DEFAULT_SERVER_SETTINGS, selector); } @@ -419,7 +578,13 @@ export function usePersistEnvironmentProviderInstanceMutation(environmentId: Env export function usePrimarySettings( selector?: (settings: UnifiedSettings) => T, ): T { - return useMergedSettings(useAtomValue(primaryServerSettingsAtom), selector); + const environment = usePrimaryEnvironment(); + const settings = useAtomValue(primaryServerSettingsAtom); + useMigrateModelPreferences( + environment?.environmentId ?? null, + environment?.serverConfig?.settings ?? null, + ); + return useMergedSettings(settings, selector); } export const PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE = @@ -618,7 +783,7 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { return updateSettings; } -export function useUpdateEnvironmentSettings(environmentId: EnvironmentId) { +export function useUpdateEnvironmentSettings(environmentId: EnvironmentId | null) { return useUpdateSettingsTarget(environmentId); } diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 675344df4189..60145dba1a05 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -1080,3 +1080,34 @@ describe("requiredScopesForProjectMutation", () => { ).toEqual(["orchestration:operate", "settings:write"]); }); }); + +describe("server model preferences", () => { + it("decodes a visibility update scoped to a provider instance and model", () => { + const patch = { setModelsHidden: [{ provider: "codex_work", model: "sol", hidden: false }] }; + expect(decodeServerSettingsPatch(patch)).toEqual(patch); + expect(() => + decodeServerSettingsPatch({ setModelsHidden: [{ provider: "codex_work", model: "sol" }] }), + ).toThrow(); + }); + it("distinguishes unmigrated lists from deliberately cleared lists", () => { + expect(decodeServerSettings({})).toMatchObject({ + favorites: null, + providerModelPreferences: null, + }); + const input = { favorites: [], providerModelPreferences: {} }; + expect(encodeServerSettings(decodeServerSettings(input))).toMatchObject(input); + expect(decodeServerSettingsPatch(input)).toEqual(input); + }); + + it("round trips favorites and hidden models keyed by provider instance", () => { + const input = { + favorites: [{ provider: "codex_work", model: "sol" }], + providerModelPreferences: { codex_work: { hiddenModels: ["astra"], modelOrder: ["sol"] } }, + }; + expect(encodeServerSettings(decodeServerSettings(input))).toMatchObject(input); + expect(decodeServerSettingsPatch(input)).toEqual(input); + expect(decodeServerSettingsPatch({ migrateModelPreferences: input })).toEqual({ + migrateModelPreferences: input, + }); + }); +}); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index a8f3a74eb011..e325729428ae 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -299,6 +299,25 @@ export const DiffColorScheme = Schema.Literals(["red-green", "blue-orange"]); export const ChatWidth = Schema.Literals(["comfortable", "wide", "full"]); export type ChatWidth = typeof ChatWidth.Type; +/** Preferences shared by clients connected to the same environment. */ +export const ModelFavorite = Schema.Struct({ + // The historical field name is retained; default instance ids also decode old provider-kind favorites. + provider: ProviderInstanceId, + model: TrimmedNonEmptyString, +}); +export const ModelFavorites = Schema.Array(ModelFavorite); +export const ProviderModelPreferences = Schema.Record( + ProviderInstanceId, + Schema.Struct({ + hiddenModels: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed([]))), + modelOrder: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed([]))), + }), +); +export type ModelPreferences = { + readonly favorites: typeof ModelFavorites.Type; + readonly providerModelPreferences: typeof ProviderModelPreferences.Type; +}; + export const ClientSettingsSchema = Schema.Struct({ notificationMode: NotificationMode.pipe( Schema.withDecodingDefault(Effect.succeed("off" as const)), @@ -413,31 +432,11 @@ export const ClientSettingsSchema = Schema.Struct({ persistComposerContextStrip: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(false)), ), - // Model favorites. Historically keyed by provider kind, now - // widened to `ProviderInstanceId` so users can favorite a specific model - // on a custom provider instance (e.g. "Codex Personal · gpt-5") without - // the UI collapsing it into the same bucket as the default Codex. The - // widening is backward-compatible by construction: prior provider-kind - // strings satisfy the `ProviderInstanceId` slug schema, so previously - // persisted favorites decode unchanged and continue to point at the - // default instance for their kind (because `defaultInstanceIdForDriver(kind)` - // uses the same slug). The field name is kept as `provider` for storage - // stability; new call sites should treat the value as an instance id. - favorites: Schema.Array( - Schema.Struct({ - provider: ProviderInstanceId, - model: TrimmedNonEmptyString, - }), - ).pipe(Schema.withDecodingDefault(Effect.succeed([]))), - providerModelPreferences: Schema.Record( - ProviderInstanceId, - Schema.Struct({ - hiddenModels: Schema.Array(Schema.String).pipe( - Schema.withDecodingDefault(Effect.succeed([])), - ), - modelOrder: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed([]))), - }), - ).pipe(Schema.withDecodingDefault(Effect.succeed({}))), + // Legacy client values retained for migration to the connected environment. + favorites: ModelFavorites.pipe(Schema.withDecodingDefault(Effect.succeed([]))), + providerModelPreferences: ProviderModelPreferences.pipe( + Schema.withDecodingDefault(Effect.succeed({})), + ), pullRequestMergeMethodOverrides: Schema.Record( TrimmedNonEmptyString, PullRequestMergeMethod, @@ -1060,6 +1059,12 @@ export const StorageCleanupSettings = Schema.Struct({ export type StorageCleanupSettings = typeof StorageCleanupSettings.Type; export const ServerSettings = Schema.Struct({ + // Null means no client has migrated this preference yet; an empty value is + // authoritative so another device cannot resurrect cleared preferences. + favorites: Schema.NullOr(ModelFavorites).pipe(Schema.withDecodingDefault(Effect.succeed(null))), + providerModelPreferences: Schema.NullOr(ProviderModelPreferences).pipe( + Schema.withDecodingDefault(Effect.succeed(null)), + ), worktreeCleanup: WorktreeCleanup.pipe(Schema.withDecodingDefault(Effect.succeed(null))), storageCleanup: StorageCleanupSettings.pipe( Schema.withDecodingDefault(Effect.succeed(Schema.decodeSync(StorageCleanupSettings)({}))), @@ -1384,7 +1389,7 @@ export class ServerSettingsError extends Schema.TaggedError // ── Unified type ───────────────────────────────────────────────────── -export type UnifiedSettings = ServerSettings & ClientSettings; +export type UnifiedSettings = Omit & ClientSettings; export const DEFAULT_UNIFIED_SETTINGS: UnifiedSettings = { ...DEFAULT_SERVER_SETTINGS, ...DEFAULT_CLIENT_SETTINGS, @@ -1399,6 +1404,24 @@ const ModelSelectionPatch = Schema.Struct({ }); export const ServerSettingsPatch = Schema.Struct({ + setModelFavorites: Schema.optionalKey( + Schema.Array(Schema.Struct({ ...ModelFavorite.fields, favorite: Schema.Boolean })), + ), + setModelsHidden: Schema.optionalKey( + Schema.Array(Schema.Struct({ ...ModelFavorite.fields, hidden: Schema.Boolean })), + ), + setProviderModelOrder: Schema.optionalKey( + Schema.Struct({ provider: ProviderInstanceId, modelOrder: Schema.Array(TrimmedString) }), + ), + favorites: Schema.optionalKey(ModelFavorites), + providerModelPreferences: Schema.optionalKey(ProviderModelPreferences), + // Applied under the settings service's write lock, only while still unset. + migrateModelPreferences: Schema.optionalKey( + Schema.Struct({ + favorites: Schema.optionalKey(ModelFavorites), + providerModelPreferences: Schema.optionalKey(ProviderModelPreferences), + }), + ), worktreeCleanup: Schema.optionalKey( Schema.NullOr( Schema.Union([ @@ -1606,27 +1629,8 @@ export const ClientSettingsPatch = Schema.Struct({ fontFamilyTerminal: Schema.optionalKey(FontFamilyPreference), fontSmoothing: Schema.optionalKey(Schema.Boolean), persistComposerContextStrip: Schema.optionalKey(Schema.Boolean), - favorites: Schema.optionalKey( - Schema.Array( - Schema.Struct({ - provider: ProviderInstanceId, - model: TrimmedNonEmptyString, - }), - ), - ), - providerModelPreferences: Schema.optionalKey( - Schema.Record( - ProviderInstanceId, - Schema.Struct({ - hiddenModels: Schema.Array(Schema.String).pipe( - Schema.withDecodingDefault(Effect.succeed([])), - ), - modelOrder: Schema.Array(Schema.String).pipe( - Schema.withDecodingDefault(Effect.succeed([])), - ), - }), - ), - ), + favorites: Schema.optionalKey(ModelFavorites), + providerModelPreferences: Schema.optionalKey(ProviderModelPreferences), pullRequestMergeMethodOverrides: Schema.optionalKey( Schema.Record(TrimmedNonEmptyString, PullRequestMergeMethod), ), diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index fe58c361d4a4..8a0d21470d6b 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -13,6 +13,8 @@ import { createModelSelection } from "./model.ts"; import { resolveProjectScripts, projectScriptsInheritDefaults } from "./projectScripts.ts"; import { applyServerSettingsPatch, + modelPreferencesMigrationPatch, + resolveModelPreferences, isModelSelectionProviderEnabled, parsePersistedServerObservabilitySettings, resolveSourceControlWriterModelSelection, @@ -823,3 +825,175 @@ describe("worktreesDirectory", () => { expect(back.previousWorktreesDirectories).toEqual(["/b"]); }); }); + +describe("synchronized model preferences", () => { + const provider = ProviderInstanceId.make("codex_work"); + const legacy = { + favorites: [{ provider, model: "sol" }], + providerModelPreferences: { [provider]: { hiddenModels: ["astra"], modelOrder: ["sol"] } }, + }; + + it("keeps concurrent adds and removes of the same favorite idempotent", () => { + const add = { setModelFavorites: [{ provider, model: "sol", favorite: true }] }; + const first = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, add); + expect(applyServerSettingsPatch(first, add).favorites).toEqual([{ provider, model: "sol" }]); + const remove = { setModelFavorites: [{ provider, model: "sol", favorite: false }] }; + const removed = applyServerSettingsPatch(first, remove); + expect(applyServerSettingsPatch(removed, remove).favorites).toEqual([]); + }); + + it("preserves unrelated models and providers during bulk visibility and order edits", () => { + const otherProvider = ProviderInstanceId.make("claude_work"); + const initial = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + providerModelPreferences: { + ...legacy.providerModelPreferences, + [otherProvider]: { hiddenModels: ["opus"], modelOrder: ["sonnet", "opus"] }, + }, + }); + const mobile = applyServerSettingsPatch(initial, { + setModelsHidden: [{ provider, model: "luna", hidden: true }], + }); + const web = applyServerSettingsPatch(mobile, { + setModelsHidden: [ + { provider, model: "astra", hidden: false }, + { provider, model: "sol", hidden: true }, + ], + setProviderModelOrder: { provider, modelOrder: ["sol", "luna", "astra"] }, + }); + expect(web.providerModelPreferences?.[provider]).toEqual({ + hiddenModels: ["luna", "sol"], + modelOrder: ["sol", "luna", "astra"], + }); + expect(web.providerModelPreferences?.[otherProvider]).toEqual( + initial.providerModelPreferences?.[otherProvider], + ); + expect( + applyServerSettingsPatch(web, { + setProviderModelOrder: { provider, modelOrder: [] }, + }).providerModelPreferences?.[provider], + ).toEqual({ hiddenModels: ["luna", "sol"], modelOrder: [] }); + }); + + it("migrates saved lists independently without claiming empty legacy lists", () => { + expect(modelPreferencesMigrationPatch(DEFAULT_SERVER_SETTINGS, {})).toBeNull(); + const patch = modelPreferencesMigrationPatch(DEFAULT_SERVER_SETTINGS, legacy)!; + const migrated = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, patch); + expect(resolveModelPreferences(migrated)).toEqual(legacy); + expect(modelPreferencesMigrationPatch(migrated, legacy)).toBeNull(); + const favoritesOnly = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { favorites: [] }); + expect(modelPreferencesMigrationPatch(favoritesOnly, legacy)).toEqual({ + migrateModelPreferences: { providerModelPreferences: legacy.providerModelPreferences }, + }); + }); + + it("keeps explicit clears when a delayed migration arrives from another device", () => { + const staleMigration = modelPreferencesMigrationPatch(DEFAULT_SERVER_SETTINGS, legacy)!; + const cleared = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + favorites: [], + providerModelPreferences: {}, + }); + expect( + resolveModelPreferences(applyServerSettingsPatch(cleared, staleMigration), legacy), + ).toEqual({ + favorites: [], + providerModelPreferences: {}, + }); + }); + + it("replaces visibility records so showing every model clears removed entries", () => { + const migrated = applyServerSettingsPatch( + DEFAULT_SERVER_SETTINGS, + modelPreferencesMigrationPatch(DEFAULT_SERVER_SETTINGS, legacy)!, + ); + const cleared = applyServerSettingsPatch(migrated, { providerModelPreferences: {} }); + expect(cleared.providerModelPreferences).toEqual({}); + expect(cleared.favorites).toEqual(legacy.favorites); + }); + + it("sets favorites without losing changes from other clients", () => { + const first = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + setModelFavorites: [{ provider, model: "sol", favorite: true }], + }); + const second = applyServerSettingsPatch(first, { + setModelFavorites: [{ provider, model: "astra", favorite: true }], + migrateModelPreferences: { favorites: [] }, + }); + expect(second.favorites).toEqual([ + { provider, model: "sol" }, + { provider, model: "astra" }, + ]); + const removed = applyServerSettingsPatch(second, { + setModelFavorites: [{ provider, model: "sol", favorite: false }], + }); + expect(removed.favorites).toEqual([{ provider, model: "astra" }]); + expect( + applyServerSettingsPatch(removed, { + setModelFavorites: [{ provider, model: "astra", favorite: false }], + }).favorites, + ).toEqual([]); + }); + + it("sets visibility against the latest state without replacing other models or their order", () => { + const initial = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + providerModelPreferences: legacy.providerModelPreferences, + }); + const first = applyServerSettingsPatch(initial, { + setModelsHidden: [{ provider, model: "sol", hidden: true }], + }); + const second = applyServerSettingsPatch(first, { + setModelsHidden: [{ provider, model: "luna", hidden: true }], + }); + expect(second.providerModelPreferences?.[provider]).toEqual({ + hiddenModels: ["astra", "sol", "luna"], + modelOrder: ["sol"], + }); + const restored = applyServerSettingsPatch(second, { + setModelsHidden: [{ provider, model: "sol", hidden: false }], + }); + expect(restored.providerModelPreferences?.[provider]).toEqual({ + hiddenModels: ["astra", "luna"], + modelOrder: ["sol"], + }); + expect( + applyServerSettingsPatch(restored, { + setModelsHidden: [{ provider, model: "sol", hidden: false }], + }).providerModelPreferences, + ).toEqual(restored.providerModelPreferences); + expect( + applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + setModelsHidden: [{ provider, model: "sol", hidden: true }], + }).providerModelPreferences?.[provider]?.hiddenModels, + ).toEqual(["sol"]); + }); + + it("keeps the same model on different provider instances as separate favorites", () => { + const otherProvider = ProviderInstanceId.make("codex_personal"); + const first = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + setModelFavorites: [{ provider, model: "sol", favorite: true }], + }); + const second = applyServerSettingsPatch(first, { + setModelFavorites: [{ provider: otherProvider, model: "sol", favorite: true }], + }); + expect(second.favorites).toEqual([ + { provider, model: "sol" }, + { provider: otherProvider, model: "sol" }, + ]); + expect( + applyServerSettingsPatch(second, { + setModelFavorites: [{ provider, model: "sol", favorite: false }], + }).favorites, + ).toEqual([{ provider: otherProvider, model: "sol" }]); + }); + + it("keeps explicit edits when the same patch also carries a legacy migration", () => { + const edited = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + ...modelPreferencesMigrationPatch(DEFAULT_SERVER_SETTINGS, legacy), + favorites: [], + providerModelPreferences: {}, + }); + expect(resolveModelPreferences(edited, legacy)).toEqual({ + favorites: [], + providerModelPreferences: {}, + }); + }); +}); diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index 85af5bb1fdd6..e6a6660de57f 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -1,3 +1,4 @@ +import type { ModelPreferences } from "@t3tools/contracts/settings"; import { isProviderAvailable, isUnconfiguredDefaultInstanceEnabled, @@ -260,6 +261,11 @@ export function applyServerSettingsPatch( const patch = translateLegacyProjectOverridePatch(current, rawPatch); const selectionPatch = patch.textGenerationModelSelection; const { + migrateModelPreferences, + setModelFavorites, + setModelsHidden, + setProviderModelOrder, + providerModelPreferences, automaticGitFetchInterval, providerHealthRefreshInterval, backgroundActivityProfile, @@ -314,9 +320,57 @@ export function applyServerSettingsPatch( }, } : undefined; + const initialFavorites = + patch.favorites ?? current.favorites ?? migrateModelPreferences?.favorites ?? null; + const favorites = + setModelFavorites?.reduce((current, change) => { + const exists = current.some( + (favorite) => favorite.provider === change.provider && favorite.model === change.model, + ); + return change.favorite + ? exists + ? current + : [...current, { provider: change.provider, model: change.model }] + : current.filter( + (favorite) => favorite.provider !== change.provider || favorite.model !== change.model, + ); + }, initialFavorites ?? []) ?? initialFavorites; + const initialModelPreferences = + providerModelPreferences ?? + current.providerModelPreferences ?? + migrateModelPreferences?.providerModelPreferences ?? + null; + let nextModelPreferences = initialModelPreferences; + for (const change of setModelsHidden ?? []) { + const preferences = nextModelPreferences?.[change.provider] ?? { + hiddenModels: [], + modelOrder: [], + }; + const hiddenModels = preferences.hiddenModels.filter((model) => model !== change.model); + if (change.hidden) hiddenModels.push(change.model); + nextModelPreferences = { + ...nextModelPreferences, + [change.provider]: { ...preferences, hiddenModels }, + }; + } + if (setProviderModelOrder) { + const preferences = nextModelPreferences?.[setProviderModelOrder.provider] ?? { + hiddenModels: [], + modelOrder: [], + }; + nextModelPreferences = { + ...nextModelPreferences, + [setProviderModelOrder.provider]: { + ...preferences, + modelOrder: [...setProviderModelOrder.modelOrder], + }, + }; + } const next = deepMerge(current, patchForMerge); const nextWithReplacementsBase = { ...next, + favorites, + providerModelPreferences: nextModelPreferences, ...(worktreeCleanupPatch === undefined ? {} : { @@ -459,3 +513,39 @@ export function applyServerSettingsPatch( textGenerationModelSelection: createModelSelection(instanceId, model, options), }; } + +/** Resolve legacy local preferences only until the environment has a saved value. */ +export function resolveModelPreferences( + settings: Pick, + legacy: Partial = {}, +): ModelPreferences { + return { + favorites: settings.favorites ?? legacy.favorites ?? [], + providerModelPreferences: + settings.providerModelPreferences ?? legacy.providerModelPreferences ?? {}, + }; +} + +/** Empty legacy values do not claim ownership before another client can migrate its saved list. */ +export function modelPreferencesMigrationPatch( + settings: Pick, + legacy: Partial, +): ServerSettingsPatch | null { + const favorites = + settings.favorites === null && (legacy.favorites?.length ?? 0) > 0 + ? legacy.favorites + : undefined; + const providerModelPreferences = + settings.providerModelPreferences === null && + Object.keys(legacy.providerModelPreferences ?? {}).length > 0 + ? legacy.providerModelPreferences + : undefined; + return favorites === undefined && providerModelPreferences === undefined + ? null + : { + migrateModelPreferences: { + ...(favorites === undefined ? {} : { favorites }), + ...(providerModelPreferences === undefined ? {} : { providerModelPreferences }), + }, + }; +} From 4b6d9a09f80c4e782bdcf27ed1ef5c4f8fdd98db Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:11:12 -0500 Subject: [PATCH 28/66] chore(deps-dev): bump compression from 1.8.1 to 1.8.2 in the npm_and_yarn group across 1 directory (#16309) Signed-off-by: dependabot[bot] --- apps/web/package.json | 2 +- pnpm-lock.yaml | 13 +++++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index 1d2d93ed36a9..4d10f89743b6 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -88,7 +88,7 @@ "@vercel/config": "^0.3.0", "@vitejs/plugin-react": "^6.0.0", "babel-plugin-react-compiler": "1.0.0", - "compression": "^1.8.1", + "compression": "^1.8.2", "jsdom": "^30.0.1", "react-test-renderer": "19.2.6", "tailwindcss": "^4.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 43b9dc0cddd8..b151892fbae4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -969,8 +969,8 @@ importers: specifier: 1.0.0 version: 1.0.0 compression: - specifier: ^1.8.1 - version: 1.8.1 + specifier: ^1.8.2 + version: 1.8.2 jsdom: specifier: ^30.0.1 version: 30.1.0(@noble/hashes@1.8.0) @@ -7667,8 +7667,8 @@ packages: resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==} engines: {node: '>= 0.6'} - compression@1.8.1: - resolution: {integrity: sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==} + compression@1.8.2: + resolution: {integrity: sha512-o8vI5RE5A6EVVOd9o41jKp41aJom+QTEO/Bx8MYNjexMo/Bv2WOjUfZr+aL0WnYSgymUy6zeguqLTsIhV0gMvQ==} engines: {node: '>= 0.8.0'} concat-map@0.0.1: @@ -14348,7 +14348,7 @@ snapshots: bplist-parser: 0.3.2 chalk: 4.1.2 ci-info: 3.9.0 - compression: 1.8.1 + compression: 1.8.2 connect: 3.7.0 dnssd-advertise: 1.1.6 expo: 58.0.2(bd4444e61fed72e958d45f73733b7f54) @@ -18235,11 +18235,12 @@ snapshots: dependencies: mime-db: 1.54.0 - compression@1.8.1: + compression@1.8.2: dependencies: bytes: 3.1.2 compressible: 2.0.18 debug: 2.6.9 + destroy: 1.2.0 negotiator: 0.6.4 on-headers: 1.1.0 safe-buffer: 5.2.1 From 5584d21688fc0e4cf6cd8057167df2514b759d3b Mon Sep 17 00:00:00 2001 From: Hasan Shoaib <142208891+realhasanshoaib@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:28:42 +0400 Subject: [PATCH 29/66] fix(server): update fff to stop runaway watcher rescans (#14543) Co-authored-by: Claude Opus 5.5 (1M context) Co-authored-by: Yash Singh --- apps/server/package.json | 2 +- .../src/workspace/WorkspaceEntries.test.ts | 36 ++++++++ patches/@ff-labs__fff-node@0.11.0.patch | 52 +++++++++++ patches/@ff-labs__fff-node@0.9.4.patch | 53 ----------- pnpm-lock.yaml | 87 ++++++++++--------- pnpm-workspace.yaml | 2 +- scripts/build-desktop-artifact.test.ts | 2 +- scripts/build-desktop-artifact.ts | 5 +- 8 files changed, 140 insertions(+), 99 deletions(-) create mode 100644 patches/@ff-labs__fff-node@0.11.0.patch delete mode 100644 patches/@ff-labs__fff-node@0.9.4.patch diff --git a/apps/server/package.json b/apps/server/package.json index 1147b5e06233..cc1bddb2ee6d 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -33,7 +33,7 @@ "@cursor/sdk": "1.0.35", "@effect/platform-node": "catalog:", "@effect/platform-node-shared": "catalog:", - "@ff-labs/fff-node": "0.9.4", + "@ff-labs/fff-node": "0.11.0", "@muse-code/sdk": "1.4.2", "@napi-rs/keyring": "^1.3.0", "@opencode-ai/sdk": "^1.3.15", diff --git a/apps/server/src/workspace/WorkspaceEntries.test.ts b/apps/server/src/workspace/WorkspaceEntries.test.ts index e827ae920985..454392357fb5 100644 --- a/apps/server/src/workspace/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/WorkspaceEntries.test.ts @@ -192,6 +192,42 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { 60_000, ); + it.effect("includes images and other binary files in non-git workspace indexes", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir(); + const imagePaths = ["photo.png", "photo.jpg", "photo.jpeg"]; + const binaryPaths = [...imagePaths, "document.pdf", "archive.zip", "data.bin"]; + for (const filePath of binaryPaths) { + yield* writeTextFile(cwd, filePath, "\0binary contents\n"); + } + yield* writeTextFile(cwd, "notes.txt", "plain text\n"); + + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const expectedPaths = [...binaryPaths, "notes.txt"].toSorted(); + const listed = yield* workspaceEntries.list({ cwd }); + expect(listed.entries.map((entry) => entry.path).toSorted()).toEqual(expectedPaths); + expect(listed.truncated).toBe(false); + + const searched = yield* workspaceEntries.search({ + cwd, + query: "", + limit: 100, + kind: "file", + }); + expect(searched.entries.map((entry) => entry.path).toSorted()).toEqual(expectedPaths); + expect(searched.truncated).toBe(false); + + const images = yield* workspaceEntries.search({ + cwd, + query: "", + limit: 100, + imageOnly: true, + }); + expect(images.entries.map((entry) => entry.path).toSorted()).toEqual(imagePaths.toSorted()); + expect(images.truncated).toBe(false); + }), + ); + it.effect("returns the complete cached workspace index", () => Effect.gen(function* () { const cwd = yield* makeTempDir(); diff --git a/patches/@ff-labs__fff-node@0.11.0.patch b/patches/@ff-labs__fff-node@0.11.0.patch new file mode 100644 index 000000000000..d3db21525440 --- /dev/null +++ b/patches/@ff-labs__fff-node@0.11.0.patch @@ -0,0 +1,52 @@ +diff --git a/package.json b/package.json +index e000a78..8432546 100644 +--- a/package.json ++++ b/package.json +@@ -11,7 +11,7 @@ + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", +- "require": "./dist/index.cjs", ++ "require": "./dist/index.js", + "default": "./dist/index.js" + }, + "./package.json": "./package.json" +diff --git a/dist/index.js b/dist/index.js +index 7c1d722..1d0bc27 100644 +--- a/dist/index.js ++++ b/dist/index.js +@@ -1,7 +1,7 @@ + // src/binary.ts + import { existsSync, readFileSync } from "fs"; + import { createRequire } from "module"; +-import { dirname, join } from "path"; ++import { dirname, join, sep } from "path"; + import { fileURLToPath } from "url"; + + // src/platform.ts +@@ -120,6 +120,16 @@ function getPackageDir() { + function binaryExists() { + return findBinary() !== null; + } ++function resolveUnpackedAsarPath(binaryPath) { ++ const pathSegments = binaryPath.split(sep); ++ const asarIndex = pathSegments.findLastIndex((segment) => segment.endsWith(".asar")); ++ if (asarIndex === -1) { ++ return binaryPath; ++ } ++ pathSegments[asarIndex] = `${pathSegments[asarIndex]}.unpacked`; ++ const unpackedPath = pathSegments.join(sep); ++ return existsSync(unpackedPath) ? unpackedPath : binaryPath; ++} + function resolveFromNpmPackage() { + const packageName = getNpmPackageName(); + try { +@@ -128,7 +138,7 @@ function resolveFromNpmPackage() { + const packageDir = dirname(packageJsonPath); + const binaryPath = join(packageDir, getLibFilename()); + if (existsSync(binaryPath)) { +- return binaryPath; ++ return resolveUnpackedAsarPath(binaryPath); + } + } catch { + } diff --git a/patches/@ff-labs__fff-node@0.9.4.patch b/patches/@ff-labs__fff-node@0.9.4.patch deleted file mode 100644 index 04c2a803056c..000000000000 --- a/patches/@ff-labs__fff-node@0.9.4.patch +++ /dev/null @@ -1,53 +0,0 @@ -diff --git a/dist/src/binary.js b/dist/src/binary.js -index ee181aef5007e4bf34a49479c089ca30f73a320b..327e2c55c83cc4c50d396a3109190ef10af75ca7 100644 ---- a/dist/src/binary.js -+++ b/dist/src/binary.js -@@ -7,7 +7,7 @@ - */ - import { existsSync, readFileSync } from "node:fs"; - import { createRequire } from "node:module"; --import { dirname, join } from "node:path"; -+import { dirname, join, sep } from "node:path"; - import { fileURLToPath } from "node:url"; - import { getLibFilename, getNpmPackageName } from "./platform.js"; - /** -@@ -46,6 +46,16 @@ function getPackageDir() { - // Fallback: assume we're one level deep in src/ - return dirname(currentDir); - } -+function resolveUnpackedAsarPath(binaryPath) { -+ const pathSegments = binaryPath.split(sep); -+ const asarIndex = pathSegments.findLastIndex((segment) => segment.endsWith(".asar")); -+ if (asarIndex === -1) { -+ return binaryPath; -+ } -+ pathSegments[asarIndex] = `${pathSegments[asarIndex]}.unpacked`; -+ const unpackedPath = pathSegments.join(sep); -+ return existsSync(unpackedPath) ? unpackedPath : binaryPath; -+} - /** - * Check if the binary exists in any known location - */ -@@ -69,7 +77,7 @@ function resolveFromNpmPackage() { - const packageDir = dirname(packageJsonPath); - const binaryPath = join(packageDir, getLibFilename()); - if (existsSync(binaryPath)) { -- return binaryPath; -+ return resolveUnpackedAsarPath(binaryPath); - } - } - catch { -diff --git a/package.json b/package.json ---- a/package.json -+++ b/package.json -@@ -8,8 +8,9 @@ - "types": "dist/src/index.d.ts", - "exports": { - ".": { -+ "types": "./dist/src/index.d.ts", - "import": "./dist/src/index.js", -- "types": "./dist/src/index.d.ts" -+ "require": "./dist/src/index.js" - } - }, - "files": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b151892fbae4..7e6efe075030 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -146,7 +146,7 @@ patchedDependencies: '@clerk/expo@4.6.8': b8b22ab733de5bea846ad9e88046c10582352fe9ad86f314f368b2a823f0f281 '@effect/vitest@4.0.2': 595b7d26fb635caba9c3d39fcb189bb78a000192f8d3b60ead7174b310d1ade9 '@expo/metro-config@58.0.6': 90fa980e8b6480631fb2fbb04559834e3c612db40c550cd828f11be297d8d760 - '@ff-labs/fff-node@0.9.4': c4e3cc2420ceb9dc650f9d189e9c24baf7e83f342998bacda5f459a4ce7927a8 + '@ff-labs/fff-node@0.11.0': 8956d6ed6938a1bce69b2d0b067ff258dd9ca753c5bbe7006f7b68f760ea3ad2 '@legendapp/list@3.3.5': 680cc6a5c5b4a4032e467e7b3fde22f89a84c0ee2e6eac6fda737d6277cc0806 '@opencode/client@2.0.23': f725d6e6d9af0d56dc6ed71b863518fbfc751af1773cff2311209d2c404cf4cd '@opencode/protocol@2.0.23': 17a82f6c2c98fd0371e786c211a4fbdaaba403577a25733e5e99a0c25a16a685 @@ -613,8 +613,8 @@ importers: specifier: 4.0.2 version: 4.0.2(bufferutil@4.1.0)(effect@4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688))(utf-8-validate@6.0.6) '@ff-labs/fff-node': - specifier: 0.9.4 - version: 0.9.4(patch_hash=c4e3cc2420ceb9dc650f9d189e9c24baf7e83f342998bacda5f459a4ce7927a8) + specifier: 0.11.0 + version: 0.11.0(patch_hash=8956d6ed6938a1bce69b2d0b067ff258dd9ca753c5bbe7006f7b68f760ea3ad2) '@muse-code/sdk': specifier: 1.4.2 version: 1.4.2(@types/node@24.12.4) @@ -3815,55 +3815,60 @@ packages: resolution: {integrity: sha512-4aQzz9vgxcNXFfo/iyNgDDYfsU5XGKKxWxZopw0cVotHiW+U8IJbIxMaxsINs6bHhtkG3StKNPcOrn3eBuxKPw==} hasBin: true - '@ff-labs/fff-bin-darwin-arm64@0.9.4': - resolution: {integrity: sha512-xyivu2xB++O5xXDx5Qm50JsU2aXt8YgXlGVhH/HE7UMYDrE6L6f1RYdYs8Y0bn0D3D0+bFBrN5ELPszK9E4Wbw==} + '@ff-labs/fff-bin-android-arm64@0.11.0': + resolution: {integrity: sha512-7CmURAtJg1xCETKE8d46CUpeql9tpdsdbyoNCrNRkH+t608jMy+mLQxmyw7cBDOI0ljg5BaffxGZfOS2E6kasA==} + cpu: [arm64] + os: [android] + + '@ff-labs/fff-bin-darwin-arm64@0.11.0': + resolution: {integrity: sha512-C2VLBldxqNhTMtp1/NXULdEWpqfUKugP9E8BOMUDPw5qZH/YGC0tZQmeOlQUYOSScFQOiXZLJGXJMp3Uk+FGxg==} cpu: [arm64] os: [darwin] - '@ff-labs/fff-bin-darwin-x64@0.9.4': - resolution: {integrity: sha512-xLooAhCnTDCipPSMMZz7kGF3lhRHx6aP5fb6DJ0Ipyw/w/UWJb+xITJFszUl/QnIBoJ/qjDc93/FZMo1dk6gVA==} + '@ff-labs/fff-bin-darwin-x64@0.11.0': + resolution: {integrity: sha512-DOnG46YoSOR8SxNVMMe+xhPS2JjFUigMS2r0M4oCGfHDaWVY2mnYK/g1YxrAeJS3cRGO5N8Aj4RP1zq5Z2f6jQ==} cpu: [x64] os: [darwin] - '@ff-labs/fff-bin-linux-arm64-gnu@0.9.4': - resolution: {integrity: sha512-m5+8vA+1veaUUWonwva1WsU6m1HRm8CpYUzr06KDB65mewlmPbqz7+Fh7hjEfiD8C4mHVHe6RysULvAH1yhsdw==} + '@ff-labs/fff-bin-linux-arm64-gnu@0.11.0': + resolution: {integrity: sha512-wOTyNzovqN1+Kg3kPqfKqZ2+A262Um4bHrEIeAwKU9R7YoyMXI9oo6DTbEl2ullF3G01FaBt/PPHOZYi1q8ubw==} cpu: [arm64] os: [linux] libc: [glibc] - '@ff-labs/fff-bin-linux-arm64-musl@0.9.4': - resolution: {integrity: sha512-EMeWm7CSTVkizy4ZEzUkLDP024tVcbCUthduuIhekFQRDsiaAze0YboIylWb9HBHJCZlCCoZrWAl4nnJbsX7AA==} + '@ff-labs/fff-bin-linux-arm64-musl@0.11.0': + resolution: {integrity: sha512-dtVOgT/mKN4VTqOh4i3AWjc3Kd9uDX9xz9YsiPs33lNXpEi/x68qynuaqb7JM7x/eD/nYrsJRsb5ivtH1D6nrQ==} cpu: [arm64] os: [linux] libc: [musl] - '@ff-labs/fff-bin-linux-x64-gnu@0.9.4': - resolution: {integrity: sha512-pglE0uLkhnlE6bStXqfgUjYTSj+2sVwXaPfoA0QksidAsQor6NRt8004mygzC9DPubgHq5B9QezPfEwigKaP9Q==} + '@ff-labs/fff-bin-linux-x64-gnu@0.11.0': + resolution: {integrity: sha512-MJ1AJz9c5kxdTiIhmlEwA+hw57vBDoa+VQSr4F+1inaEiFDtem7EnXE1P8Bdhf4okGM557mCXgRrvCt4kM5pSg==} cpu: [x64] os: [linux] libc: [glibc] - '@ff-labs/fff-bin-linux-x64-musl@0.9.4': - resolution: {integrity: sha512-VNKxgl8qs3aTfXViX7lqRK1aLu311h8dtBFqG4Scv+9Oi7WprybUp5L7IZ8sxKERaDAaiJMXHodXa1c90QdK8w==} + '@ff-labs/fff-bin-linux-x64-musl@0.11.0': + resolution: {integrity: sha512-UjqEBtZphjH6MTK18kTlA+5ccUAu3PBATerAKh9otEWbLCwqpFV0ke8LON2+UavaO5+VhBUCk7Zi97AOQhn/9Q==} cpu: [x64] os: [linux] libc: [musl] - '@ff-labs/fff-bin-win32-arm64@0.9.4': - resolution: {integrity: sha512-uFEt0aNL54vQxq1ivjxRuo+thnhS4wLqa4INl4VXnXJUmwB42XXxD+gsj7vzhBLLx4cFf0aWgy/+TVDR8yjZtQ==} + '@ff-labs/fff-bin-win32-arm64@0.11.0': + resolution: {integrity: sha512-IF54PgYYi+AsCGseNG5kYpMqkwTmp/9bB8zvndp6tXZp4xLQ4G6x/NgHgM4vyK85Sv3FWr6Sf4OZQhP4mSSipA==} cpu: [arm64] os: [win32] - '@ff-labs/fff-bin-win32-x64@0.9.4': - resolution: {integrity: sha512-Yd2Eyxj+slWv+0QDW9/xBpu9FXq+hwD0rXQD5184/88d+xwWCLKhEP2w8I6OO9XCg+kLT79UJb+k0WwXUtBtMw==} + '@ff-labs/fff-bin-win32-x64@0.11.0': + resolution: {integrity: sha512-NFnFcueeRZ+QHvBg/k3KhduH20zFRCGqPHqjslk/3H0CHOMyP5AMTbscNwn5TZUhMutIRCfFzVI7mm+oU8jvCQ==} cpu: [x64] os: [win32] - '@ff-labs/fff-node@0.9.4': - resolution: {integrity: sha512-yUphwzcVBuNrg1iEAE5XVPOWfCLuq/BE1scUxtHMmLo+e1KuKWBRgSKFXGjNBfL8lV3c5VGwIgK66aBqmhqVBQ==} + '@ff-labs/fff-node@0.11.0': + resolution: {integrity: sha512-/7fX4kgy28pFZghxuXCAcIPQKb2+VqVirpFcDqMKgrFLTz56tVzHjOkFSdKGq7fa6wRb6eS5Dg9SaX1qyCcKxQ==} engines: {node: '>=18.0.0'} cpu: [x64, arm64] - os: [darwin, linux, win32] + os: [darwin, linux, win32, android] '@floating-ui/core@1.7.5': resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} @@ -14721,42 +14726,46 @@ snapshots: chalk: 4.1.2 js-yaml: 4.3.2 - '@ff-labs/fff-bin-darwin-arm64@0.9.4': + '@ff-labs/fff-bin-android-arm64@0.11.0': + optional: true + + '@ff-labs/fff-bin-darwin-arm64@0.11.0': optional: true - '@ff-labs/fff-bin-darwin-x64@0.9.4': + '@ff-labs/fff-bin-darwin-x64@0.11.0': optional: true - '@ff-labs/fff-bin-linux-arm64-gnu@0.9.4': + '@ff-labs/fff-bin-linux-arm64-gnu@0.11.0': optional: true - '@ff-labs/fff-bin-linux-arm64-musl@0.9.4': + '@ff-labs/fff-bin-linux-arm64-musl@0.11.0': optional: true - '@ff-labs/fff-bin-linux-x64-gnu@0.9.4': + '@ff-labs/fff-bin-linux-x64-gnu@0.11.0': optional: true - '@ff-labs/fff-bin-linux-x64-musl@0.9.4': + '@ff-labs/fff-bin-linux-x64-musl@0.11.0': optional: true - '@ff-labs/fff-bin-win32-arm64@0.9.4': + '@ff-labs/fff-bin-win32-arm64@0.11.0': optional: true - '@ff-labs/fff-bin-win32-x64@0.9.4': + '@ff-labs/fff-bin-win32-x64@0.11.0': optional: true - '@ff-labs/fff-node@0.9.4(patch_hash=c4e3cc2420ceb9dc650f9d189e9c24baf7e83f342998bacda5f459a4ce7927a8)': + '@ff-labs/fff-node@0.11.0(patch_hash=8956d6ed6938a1bce69b2d0b067ff258dd9ca753c5bbe7006f7b68f760ea3ad2)': dependencies: ffi-rs: 1.3.2 optionalDependencies: - '@ff-labs/fff-bin-darwin-arm64': 0.9.4 - '@ff-labs/fff-bin-darwin-x64': 0.9.4 - '@ff-labs/fff-bin-linux-arm64-gnu': 0.9.4 - '@ff-labs/fff-bin-linux-arm64-musl': 0.9.4 - '@ff-labs/fff-bin-linux-x64-gnu': 0.9.4 - '@ff-labs/fff-bin-linux-x64-musl': 0.9.4 - '@ff-labs/fff-bin-win32-arm64': 0.9.4 - '@ff-labs/fff-bin-win32-x64': 0.9.4 + '@ff-labs/fff-bin-android-arm64': 0.11.0 + '@ff-labs/fff-bin-darwin-arm64': 0.11.0 + '@ff-labs/fff-bin-darwin-x64': 0.11.0 + '@ff-labs/fff-bin-linux-arm64-gnu': 0.11.0 + '@ff-labs/fff-bin-linux-arm64-musl': 0.11.0 + '@ff-labs/fff-bin-linux-x64-gnu': 0.11.0 + '@ff-labs/fff-bin-linux-x64-musl': 0.11.0 + '@ff-labs/fff-bin-win32-arm64': 0.11.0 + '@ff-labs/fff-bin-win32-x64': 0.11.0 '@floating-ui/core@1.7.5': dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 59358907b7c4..edbfad72d8e6 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -292,7 +292,7 @@ patchedDependencies: "@expo/metro-config@58.0.6": patches/@expo__metro-config@58.0.6.patch expo-sharing@58.0.13: patches/expo-sharing@58.0.13.patch expo-widgets@58.0.11: patches/expo-widgets@58.0.11.patch - "@ff-labs/fff-node@0.9.4": patches/@ff-labs__fff-node@0.9.4.patch + "@ff-labs/fff-node@0.11.0": patches/@ff-labs__fff-node@0.11.0.patch "@legendapp/list@3.3.5": patches/@legendapp__list@3.3.5.patch "@opencode/client@2.0.23": patches/@opencode__client@2.0.23.patch "@opencode/protocol@2.0.23": patches/@opencode__protocol@2.0.23.patch diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index b17c51ce2dba..434f7a886a3d 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -1527,7 +1527,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { primaryProbe.args[4], path.join( fixture.packagedAppDir, - "resources/server.asar/node_modules/@ff-labs/fff-node/dist/src/index.js", + "resources/server.asar/node_modules/@ff-labs/fff-node/dist/index.js", ), ); assert.equal(primaryProbe.options.cwd, fixture.packagedAppDir); diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 837308f3b1f4..c4ca2e6dd7dc 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -3236,10 +3236,7 @@ export const verifyWindowsPrimaryFffNativeLoad = Effect.fn( const probeRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-windows-primary-native-probe-", }); - const fffEntryPath = path.join( - input.asarPath, - "node_modules/@ff-labs/fff-node/dist/src/index.js", - ); + const fffEntryPath = path.join(input.asarPath, "node_modules/@ff-labs/fff-node/dist/index.js"); const probeEnv = { ...process.env }; delete probeEnv.ELECTRON_NO_ASAR; delete probeEnv.NODE_OPTIONS; From 20254307084f1101ced00dfadeddc7804a7de45d Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Sun, 11 Oct 2026 02:31:06 -0500 Subject: [PATCH 30/66] fix(mobile): license generation works with filtered installs (#18127) --- apps/server/src/device/DeviceToolchain.ts | 2 +- .../server/src/device/deviceToolVersions.json | 4 + docs/internals/open-source-licenses.md | 2 +- packages/shared/package.json | 4 - packages/shared/src/deviceToolVersions.ts | 5 -- scripts/lib/third-party-licenses.test.ts | 79 ++++++++++++++++++- scripts/lib/third-party-licenses.ts | 14 +++- 7 files changed, 95 insertions(+), 15 deletions(-) create mode 100644 apps/server/src/device/deviceToolVersions.json delete mode 100644 packages/shared/src/deviceToolVersions.ts diff --git a/apps/server/src/device/DeviceToolchain.ts b/apps/server/src/device/DeviceToolchain.ts index 132fbcebdb1f..bd9a771483f4 100644 --- a/apps/server/src/device/DeviceToolchain.ts +++ b/apps/server/src/device/DeviceToolchain.ts @@ -1,5 +1,4 @@ import type { DeviceToolVersions } from "@t3tools/contracts"; -import { DEVICE_TOOL_VERSIONS } from "@t3tools/shared/deviceToolVersions"; /** * Pinned installs of the two external tools device support is built on. * @@ -25,6 +24,7 @@ import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; import * as ProcessRunner from "../processRunner.ts"; +import DEVICE_TOOL_VERSIONS from "./deviceToolVersions.json" with { type: "json" }; const DEVICE_HUB_PACKAGE = "expo-device-hub"; export const DEVICE_HUB_VERSION = DEVICE_TOOL_VERSIONS[DEVICE_HUB_PACKAGE]; diff --git a/apps/server/src/device/deviceToolVersions.json b/apps/server/src/device/deviceToolVersions.json new file mode 100644 index 000000000000..8d2e5a3efebc --- /dev/null +++ b/apps/server/src/device/deviceToolVersions.json @@ -0,0 +1,4 @@ +{ + "agent-device": "0.21.24", + "expo-device-hub": "0.15.3" +} diff --git a/docs/internals/open-source-licenses.md b/docs/internals/open-source-licenses.md index 904c9be4471e..ef7a6dba4296 100644 --- a/docs/internals/open-source-licenses.md +++ b/docs/internals/open-source-licenses.md @@ -61,7 +61,7 @@ supplies the label shown to users. Use `includeInBundles` when those differ, suc server tool that should appear in both client manifests but is not bundled into either client. The `agent-device` and `expo-device-hub` custom notices take their versions from the -[shared installer pins](../../packages/shared/src/deviceToolVersions.ts). Keep their package names +[installer pins](../../apps/server/src/device/deviceToolVersions.json). Keep their package names in the config; their versions do not need manual updates. Use `packageOverrides` only when an installed npm archive omits its notice or has incorrect diff --git a/packages/shared/package.json b/packages/shared/package.json index 7b750e7a3c50..4ccc2b691023 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -359,10 +359,6 @@ "types": "./src/thirdPartyLicenses.ts", "import": "./src/thirdPartyLicenses.ts" }, - "./deviceToolVersions": { - "types": "./src/deviceToolVersions.ts", - "import": "./src/deviceToolVersions.ts" - }, "./claudeCompaction": { "types": "./src/claudeCompaction.ts", "import": "./src/claudeCompaction.ts" diff --git a/packages/shared/src/deviceToolVersions.ts b/packages/shared/src/deviceToolVersions.ts deleted file mode 100644 index 12a7071831d2..000000000000 --- a/packages/shared/src/deviceToolVersions.ts +++ /dev/null @@ -1,5 +0,0 @@ -// The installer and third-party notices share these pins for tools installed outside the workspace. -export const DEVICE_TOOL_VERSIONS = { - "agent-device": "0.21.24", - "expo-device-hub": "0.15.3", -} as const; diff --git a/scripts/lib/third-party-licenses.test.ts b/scripts/lib/third-party-licenses.test.ts index 638dd60e15d4..ae2403877e56 100644 --- a/scripts/lib/third-party-licenses.test.ts +++ b/scripts/lib/third-party-licenses.test.ts @@ -1,11 +1,11 @@ // @effect-diagnostics nodeBuiltinImport:off - Tests exercise the Node filesystem build boundary. import * as NodeFSP from "node:fs/promises"; +import * as NodeChildProcess from "node:child_process"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import * as NodeURL from "node:url"; -import { DEVICE_TOOL_VERSIONS } from "@t3tools/shared/deviceToolVersions"; import { afterEach, describe, expect, it } from "vite-plus/test"; import { @@ -19,6 +19,12 @@ const REPOSITORY_ROOT = NodePath.resolve( NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)), "../..", ); +const DEVICE_TOOL_VERSIONS = JSON.parse( + await NodeFSP.readFile( + NodePath.join(REPOSITORY_ROOT, "apps/server/src/device/deviceToolVersions.json"), + "utf8", + ), +) as { readonly "agent-device": string; readonly "expo-device-hub": string }; async function writeJson(path: string, value: unknown): Promise { await NodeFSP.mkdir(NodePath.dirname(path), { recursive: true }); @@ -78,6 +84,77 @@ afterEach(async () => { }); describe("third-party license generation", () => { + it("generates mobile notices without scripts workspace dependency links", async () => { + const fixture = await createFixture(); + const generatorPath = NodePath.join(fixture.root, "scripts/lib/third-party-licenses.ts"); + const versionsPath = NodePath.join( + fixture.root, + "apps/server/src/device/deviceToolVersions.json", + ); + await Promise.all([ + NodeFSP.mkdir(NodePath.dirname(generatorPath), { recursive: true }), + NodeFSP.mkdir(NodePath.dirname(versionsPath), { recursive: true }), + ]); + await Promise.all([ + writeJson(NodePath.join(fixture.root, "scripts/package.json"), { type: "module" }), + NodeFSP.copyFile( + NodePath.join(REPOSITORY_ROOT, "scripts/lib/third-party-licenses.ts"), + generatorPath, + ), + NodeFSP.copyFile( + NodePath.join(REPOSITORY_ROOT, "apps/server/src/device/deviceToolVersions.json"), + versionsPath, + ), + writeJson(fixture.configFile, { + customNotices: Object.keys(DEVICE_TOOL_VERSIONS).map((name) => ({ + name, + license: "MIT", + noticeFile: "asset-notice.txt", + bundles: ["device-tools"], + includeInBundles: ["mobile", "web"], + })), + }), + ]); + + const output = NodeChildProcess.execFileSync( + process.execPath, + [ + "--input-type=module", + "--eval", + ` + import { pathToFileURL } from "node:url"; + const { generateThirdPartyLicenseManifest } = await import( + pathToFileURL(process.argv[1]).href + "?version=1" + ); + const manifest = await generateThirdPartyLicenseManifest({ + configFile: process.argv[2], + packageManifests: [{ bundle: "mobile", path: process.argv[3] }], + }); + process.stdout.write(JSON.stringify(manifest)); + `, + generatorPath, + fixture.configFile, + fixture.appManifest, + ], + { cwd: fixture.root, encoding: "utf8" }, + ); + const manifest = JSON.parse(output) as { + entries: ReadonlyArray<{ name: string; version: string }>; + }; + expect(manifest.entries).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "agent-device", + version: DEVICE_TOOL_VERSIONS["agent-device"], + }), + expect.objectContaining({ + name: "expo-device-hub", + version: DEVICE_TOOL_VERSIONS["expo-device-hub"], + }), + ]), + ); + }); + it.each(["web", "mobile"])( "uses installer pins for device tool notices in the %s manifest", async (bundle) => { diff --git a/scripts/lib/third-party-licenses.ts b/scripts/lib/third-party-licenses.ts index 124f16a3ce4e..c0332e494f3a 100644 --- a/scripts/lib/third-party-licenses.ts +++ b/scripts/lib/third-party-licenses.ts @@ -5,14 +5,12 @@ import * as NodePath from "node:path"; import * as NodeURL from "node:url"; import * as NodeModule from "node:module"; -import { DEVICE_TOOL_VERSIONS } from "@t3tools/shared/deviceToolVersions"; import type { Plugin } from "vite-plus"; export const THIRD_PARTY_LICENSES_FILE_NAME = "third-party-licenses.json"; const SPDX_LICENSE_LIST_VERSION = "v3.28.0"; const SPDX_LICENSE_LIST_REVISION = "c4a7237ec8f4654e867546f9f409749300f1bf4c"; const GENERATED_NOTICE_CACHE_DIRECTORY = ".generated/third-party-licenses/spdx"; -const DEVICE_TOOL_NOTICE_VERSIONS = new Map(Object.entries(DEVICE_TOOL_VERSIONS)); export interface ThirdPartyLicenseEntry { readonly bundles: ReadonlyArray; @@ -825,6 +823,13 @@ async function customEntries( includedBundles: ReadonlySet, allowMissingGeneratedNotices: boolean, ): Promise> { + const deviceToolVersions: unknown = JSON.parse( + await NodeFSP.readFile( + new URL("../../apps/server/src/device/deviceToolVersions.json", import.meta.url), + "utf8", + ), + ); + if (!isRecord(deviceToolVersions)) throw new Error("Device tool versions must be an object."); const entries = await Promise.all( config.customNotices .filter( @@ -870,7 +875,10 @@ async function customEntries( name: notice.name, noticeText, sourceUrl: notice.sourceUrl ?? null, - version: DEVICE_TOOL_NOTICE_VERSIONS.get(notice.name) ?? notice.version ?? null, + version: + readOptionalString(deviceToolVersions, notice.name, "Device tool versions") ?? + notice.version ?? + null, }; }), ); From 8d199b4f7b918079161d5f43e0146c099301be08 Mon Sep 17 00:00:00 2001 From: Ali Younes <159104116+whoisaldo@users.noreply.github.com> Date: Sun, 11 Oct 2026 03:40:36 -0400 Subject: [PATCH 31/66] fix(mobile): image-only messages ask the agent to respond, like desktop (#16480) Co-authored-by: Yash Singh --- apps/mobile/src/lib/composerContext.test.ts | 15 +++++++++++++++ apps/mobile/src/lib/composerContext.ts | 13 +++++++++++-- apps/web/src/components/ChatView.tsx | 6 ++---- .../components/chat/composerPromptHistory.test.ts | 2 +- .../src/components/chat/composerPromptHistory.ts | 9 ++++----- .../shared/src/composerContextReferences.test.ts | 12 ++++++++++++ packages/shared/src/composerContextReferences.ts | 14 ++++++++++++++ 7 files changed, 59 insertions(+), 12 deletions(-) diff --git a/apps/mobile/src/lib/composerContext.test.ts b/apps/mobile/src/lib/composerContext.test.ts index d28b58d055b0..3b00f7c5e7c4 100644 --- a/apps/mobile/src/lib/composerContext.test.ts +++ b/apps/mobile/src/lib/composerContext.test.ts @@ -8,6 +8,7 @@ import { } from "@t3tools/contracts"; import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens"; import { + ATTACHMENT_ONLY_BOOTSTRAP_PROMPT, collectComposerContextReferences, formatComposerContextReference, projectComposerContextForProvider, @@ -207,6 +208,20 @@ describe("mobile composer context", () => { }); describe("host context compatibility", () => { + it("sends an image-only prompt as the attachment-only prompt and keeps the image", () => { + const context: OrchestrationMessageContext = { version: 1, records: [image] }; + const reference = formatComposerContextReference(image); + expect(serializeComposerMessageForServer(reference, context, true)).toEqual({ + text: ATTACHMENT_ONLY_BOOTSTRAP_PROMPT, + context, + }); + const withProse = `${reference} what is this?`; + expect(serializeComposerMessageForServer(withProse, context, true)).toEqual({ + text: withProse, + context, + }); + }); + it.each(["existing-thread", "new-task"])("serializes %s sends for an older host", (path) => { const pr = pullRequestComposerContext( { diff --git a/apps/mobile/src/lib/composerContext.ts b/apps/mobile/src/lib/composerContext.ts index c5422405c966..f15d9cc300ea 100644 --- a/apps/mobile/src/lib/composerContext.ts +++ b/apps/mobile/src/lib/composerContext.ts @@ -13,8 +13,10 @@ import { } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import { + ATTACHMENT_ONLY_BOOTSTRAP_PROMPT, collectComposerContextReferences, formatComposerContextReference, + isImageReferencesOnly, replaceComposerContextReferences, sanitizeComposerContextLabel, } from "@t3tools/shared/composerContextReferences"; @@ -229,12 +231,19 @@ export function reidentifyComposerContext( }; } -/** Keep queued records canonical; choose the wire format against the host at dispatch time. */ +/** + * Keep queued records canonical; choose the wire format against the host at dispatch time. + * An image-only prompt goes out as the attachment-only prompt web sends, so the agent is still + * asked to respond. The images stay in the message's attachments and context records. + */ export function serializeComposerMessageForServer( - text: string, + composerText: string, context: OrchestrationMessageContext | undefined, supportsInlineMessageContext: boolean, ): { text: string; context?: OrchestrationMessageContext } { + const text = isImageReferencesOnly(composerText) + ? ATTACHMENT_ONLY_BOOTSTRAP_PROMPT + : composerText; return supportsInlineMessageContext ? { text, ...(context ? { context } : {}) } : { text: serializeLegacyContextMessage({ text, records: context?.records ?? [] }) }; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 20c4d08b5bcf..84f5b476a133 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -607,10 +607,8 @@ import { supportsServerUpdateThreadContinuation, } from "../versionSkew"; import { useAssetUrls } from "../assets/assetUrls"; -import { - ATTACHMENT_ONLY_BOOTSTRAP_PROMPT, - recallableComposerPrompt, -} from "./chat/composerPromptHistory"; +import { ATTACHMENT_ONLY_BOOTSTRAP_PROMPT } from "@t3tools/shared/composerContextReferences"; +import { recallableComposerPrompt } from "./chat/composerPromptHistory"; import { observeResize } from "~/lib/observeResize"; const EMPTY_PROVIDERS: ServerProvider[] = []; diff --git a/apps/web/src/components/chat/composerPromptHistory.test.ts b/apps/web/src/components/chat/composerPromptHistory.test.ts index b3752cf7dbeb..deddc4679412 100644 --- a/apps/web/src/components/chat/composerPromptHistory.test.ts +++ b/apps/web/src/components/chat/composerPromptHistory.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from "vite-plus/test"; +import { ATTACHMENT_ONLY_BOOTSTRAP_PROMPT } from "@t3tools/shared/composerContextReferences"; import { buildPlanImplementationPrompt } from "../../proposedPlan"; import { - ATTACHMENT_ONLY_BOOTSTRAP_PROMPT, buildComposerPromptHistoryEntries, recallableComposerPrompt, stepComposerPromptHistory, diff --git a/apps/web/src/components/chat/composerPromptHistory.ts b/apps/web/src/components/chat/composerPromptHistory.ts index f50280f6d242..0977637cdf16 100644 --- a/apps/web/src/components/chat/composerPromptHistory.ts +++ b/apps/web/src/components/chat/composerPromptHistory.ts @@ -1,4 +1,7 @@ -import { collectComposerContextReferences } from "@t3tools/shared/composerContextReferences"; +import { + ATTACHMENT_ONLY_BOOTSTRAP_PROMPT, + collectComposerContextReferences, +} from "@t3tools/shared/composerContextReferences"; import { PLAN_IMPLEMENTATION_PROMPT_PREFIX } from "../../proposedPlan"; /** @@ -15,10 +18,6 @@ const REVIEW_COMMENT_BLOCK_PATTERN = /]*>[\s\S]*?<\/review_c const TRAILING_LEGACY_CONTEXT = /\n*<(terminal_context|element_context|preview_annotation)>\n([\s\S]*?)\n<\/\1>\s*$/; -/** Text sent in place of an empty prompt when a message is attachments only. */ -export const ATTACHMENT_ONLY_BOOTSTRAP_PROMPT = - "[User attached one or more files without additional text. Respond using the conversation context and the attached files.]"; - export interface ComposerPromptHistoryMessage { readonly id: string; readonly role: string; diff --git a/packages/shared/src/composerContextReferences.test.ts b/packages/shared/src/composerContextReferences.test.ts index bd078c8f1d44..8b59a6c261b9 100644 --- a/packages/shared/src/composerContextReferences.test.ts +++ b/packages/shared/src/composerContextReferences.test.ts @@ -6,6 +6,7 @@ import { formatComposerContextHref, formatComposerContextProviderMarker, formatComposerContextReference, + isImageReferencesOnly, parseComposerContextHref, projectComposerContextForProvider, replaceComposerContextReferences, @@ -86,6 +87,17 @@ describe("labels and reference links", () => { expect(collectComposerContextReferences("[x](https://example.com)")).toEqual([]); }); + it("recognizes a prompt made only of image references", () => { + const image = "![a.png](t3-context://v1/image/ctx_1)"; + const file = "[notes.md](t3-context://v1/file/ctx_3)"; + expect(isImageReferencesOnly(image)).toBe(true); + expect(isImageReferencesOnly(` ${image}\n![b.png](t3-context://v1/image/ctx_2) `)).toBe(true); + expect(isImageReferencesOnly(`${image} what is this?`)).toBe(false); + expect(isImageReferencesOnly(file)).toBe(false); + expect(isImageReferencesOnly(`${image} ${file}`)).toBe(false); + expect(isImageReferencesOnly(" ")).toBe(false); + }); + it("replaces occurrences in place", () => { const text = "a [x](t3-context://v1/skill/ctx_1) b [y](t3-context://v1/file/ctx_2) c"; expect(replaceComposerContextReferences(text, (o) => `<${o.contextId}>`)).toBe( diff --git a/packages/shared/src/composerContextReferences.ts b/packages/shared/src/composerContextReferences.ts index f7877fe937a6..ae5864899080 100644 --- a/packages/shared/src/composerContextReferences.ts +++ b/packages/shared/src/composerContextReferences.ts @@ -92,6 +92,20 @@ export function collectComposerContextReferences( return occurrences; } +/** Text sent in place of an empty prompt when a message is attachments only. */ +export const ATTACHMENT_ONLY_BOOTSTRAP_PROMPT = + "[User attached one or more files without additional text. Respond using the conversation context and the attached files.]"; + +/** Whether `text` is image references and nothing else, which is how an image-only prompt reads. */ +export function isImageReferencesOnly(text: string): boolean { + const references = collectComposerContextReferences(text); + return ( + references.length > 0 && + references.every((reference) => reference.kind === "image") && + replaceComposerContextReferences(text, () => "").trim() === "" + ); +} + export function replaceComposerContextReferences( text: string, replace: (occurrence: ComposerContextReferenceOccurrence) => string, From 1f6df6bf1f50ab5b8e3253716773da2571f8243e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 11 Oct 2026 02:58:31 -0700 Subject: [PATCH 32/66] feat(pull-requests): guided reviews, a beta tour of a pull request (#18124) Co-authored-by: Claude Opus 5.5 --- apps/server/src/auth/RpcAuthorization.ts | 1 + apps/server/src/git/GitManager.test.ts | 2 + .../src/observability/RpcInstrumentation.ts | 2 + apps/server/src/persistence/Errors.ts | 1 + apps/server/src/persistence/Migrations.ts | 2 + .../Migrations/055_OrchestrationV2.test.ts | 4 +- .../Migrations/061_PullRequestReviewGuides.ts | 19 + .../persistence/PullRequestReviewGuides.ts | 84 +++ .../reconcileV2PreviewMigration.test.ts | 2 + .../provider/Drivers/CodexManagedProvider.ts | 2 + .../PullRequestReviewGuideService.test.ts | 505 ++++++++++++++++++ .../PullRequestReviewGuideService.ts | 484 +++++++++++++++++ .../pullRequestReviewGuideInput.test.ts | 57 ++ .../pullRequestReviewGuideInput.ts | 47 ++ .../pullRequestReviewGuideNormalize.test.ts | 212 ++++++++ .../pullRequestReviewGuideNormalize.ts | 184 +++++++ apps/server/src/server.ts | 9 +- apps/server/src/serverSettings.ts | 6 +- .../AntigravityTextGeneration.ts | 5 +- .../textGeneration/ClaudeTextGeneration.ts | 33 +- .../src/textGeneration/CodexTextGeneration.ts | 4 +- .../src/textGeneration/TextGeneration.test.ts | 1 + .../src/textGeneration/TextGeneration.ts | 9 +- apps/server/src/ws.ts | 14 + .../components/diffs/StyledDiffCodeView.tsx | 101 +--- .../pullRequest/PullRequestCodeTab.tsx | 45 +- .../pullRequest/PullRequestComposer.tsx | 12 + .../PullRequestDetailPanel.test.tsx | 10 +- .../pullRequest/PullRequestDetailPanel.tsx | 159 +++++- .../pullRequest/PullRequestGuideTab.tsx | 183 +++++++ .../PullRequestReviewAnnotation.tsx | 24 + .../pullRequest/guide/GuideExcerpt.tsx | 362 +++++++++++++ .../pullRequest/guide/GuideFileDiff.tsx | 50 ++ .../pullRequest/guide/GuideNotes.tsx | 58 ++ .../pullRequest/guide/GuideProse.tsx | 40 ++ .../pullRequest/guide/GuideRail.tsx | 108 ++++ .../pullRequest/guide/GuideStop.tsx | 310 +++++++++++ .../pullRequest/guide/GuideTour.tsx | 174 ++++++ .../guide/PullRequestGuideRunCoordinator.tsx | 140 +++++ .../pullRequest/guide/guideRunStore.ts | 86 +++ .../pullRequest/guide/guideTour.logic.test.ts | 178 ++++++ .../pullRequest/guide/guideTour.logic.ts | 224 ++++++++ .../pullRequest/guide/useGuideDiffFiles.ts | 87 +++ .../pullRequest/guide/useReviewGuideWriter.ts | 78 +++ .../pullRequestDetail.logic.test.ts | 49 ++ .../pullRequest/pullRequestDetail.logic.ts | 64 +++ .../settings/SettingInheritance.tsx | 8 +- .../SourceControlWritingSettings.test.tsx | 5 +- .../settings/SourceControlWritingSettings.tsx | 167 +++++- .../settings/settingsSearch.test.ts | 2 + .../src/components/settings/settingsSearch.ts | 26 + apps/web/src/routes/__root.tsx | 2 + apps/web/src/routes/_chat.pull-requests.tsx | 42 +- docs/user/source-control.md | 28 + .../src/state/pullRequestRouting.ts | 2 + .../client-runtime/src/state/pullRequests.ts | 18 + .../contracts/src/clientRpcPermissions.ts | 2 + packages/contracts/src/index.ts | 1 + .../contracts/src/pullRequestReviewGuide.ts | 115 ++++ packages/contracts/src/rpc.ts | 17 + packages/contracts/src/settings.ts | 26 + .../src/server/driver.ts | 1 + .../src/server/textGeneration.ts | 46 ++ .../src/server/textGenerationOperations.ts | 42 ++ .../src/server/textGenerationPrompts.test.ts | 55 ++ .../src/server/textGenerationPrompts.ts | 149 ++++++ .../src/server/textGeneration.ts | 5 +- .../src/server/textGeneration.ts | 5 +- .../src/server/textGeneration.ts | 5 + .../provider-opencode/src/server/driver.ts | 5 + .../src/server/textGeneration.ts | 6 +- .../src/server/v2/textGeneration.ts | 5 +- .../provider-pi/src/server/textGeneration.ts | 5 +- packages/shared/src/serverSettings.ts | 26 + 74 files changed, 4913 insertions(+), 134 deletions(-) create mode 100644 apps/server/src/persistence/Migrations/061_PullRequestReviewGuides.ts create mode 100644 apps/server/src/persistence/PullRequestReviewGuides.ts create mode 100644 apps/server/src/pullRequest/PullRequestReviewGuideService.test.ts create mode 100644 apps/server/src/pullRequest/PullRequestReviewGuideService.ts create mode 100644 apps/server/src/pullRequest/pullRequestReviewGuideInput.test.ts create mode 100644 apps/server/src/pullRequest/pullRequestReviewGuideInput.ts create mode 100644 apps/server/src/pullRequest/pullRequestReviewGuideNormalize.test.ts create mode 100644 apps/server/src/pullRequest/pullRequestReviewGuideNormalize.ts create mode 100644 apps/web/src/components/pullRequest/PullRequestGuideTab.tsx create mode 100644 apps/web/src/components/pullRequest/guide/GuideExcerpt.tsx create mode 100644 apps/web/src/components/pullRequest/guide/GuideFileDiff.tsx create mode 100644 apps/web/src/components/pullRequest/guide/GuideNotes.tsx create mode 100644 apps/web/src/components/pullRequest/guide/GuideProse.tsx create mode 100644 apps/web/src/components/pullRequest/guide/GuideRail.tsx create mode 100644 apps/web/src/components/pullRequest/guide/GuideStop.tsx create mode 100644 apps/web/src/components/pullRequest/guide/GuideTour.tsx create mode 100644 apps/web/src/components/pullRequest/guide/PullRequestGuideRunCoordinator.tsx create mode 100644 apps/web/src/components/pullRequest/guide/guideRunStore.ts create mode 100644 apps/web/src/components/pullRequest/guide/guideTour.logic.test.ts create mode 100644 apps/web/src/components/pullRequest/guide/guideTour.logic.ts create mode 100644 apps/web/src/components/pullRequest/guide/useGuideDiffFiles.ts create mode 100644 apps/web/src/components/pullRequest/guide/useReviewGuideWriter.ts create mode 100644 packages/contracts/src/pullRequestReviewGuide.ts diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 7d67d92f4f71..5b8d483dc102 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -132,6 +132,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.pullRequestsPreview]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsChecks]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsActivity]: AuthOrchestrationReadScope, + [WS_METHODS.pullRequestsReviewGuide]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsThreadComments]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsDiffFileContents]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsFilesViewed]: AuthOrchestrationReadScope, diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 53bb49c346e3..73d23c415c17 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -347,6 +347,7 @@ function createTextGeneration( Effect.succeed({ title: "Update workflow", }), + generateReviewGuide: () => Effect.succeed({ stops: [] }), ...overrides, }; @@ -395,6 +396,7 @@ function createTextGeneration( }), ), ), + generateReviewGuide: (input) => implementation.generateReviewGuide(input), }; } diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index 69b8b3d325ab..167ce8d11fff 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -107,6 +107,8 @@ const RPC_AGGREGATES = { [WS_METHODS.pullRequestsPreview]: "pull-requests", [WS_METHODS.pullRequestsChecks]: "pull-requests", [WS_METHODS.pullRequestsActivity]: "pull-requests", + [WS_METHODS.pullRequestsReviewGuide]: "pull-requests", + [WS_METHODS.pullRequestsGenerateReviewGuide]: "pull-requests", [WS_METHODS.pullRequestsThreadComments]: "pull-requests", [WS_METHODS.pullRequestsDiffFileContents]: "pull-requests", [WS_METHODS.pullRequestsFilesViewed]: "pull-requests", diff --git a/apps/server/src/persistence/Errors.ts b/apps/server/src/persistence/Errors.ts index 67290369b0f4..cf9d743ea45d 100644 --- a/apps/server/src/persistence/Errors.ts +++ b/apps/server/src/persistence/Errors.ts @@ -129,5 +129,6 @@ export type ProviderSessionRuntimeRepositoryError = PersistenceSqlError | Persis export type AuthPairingLinkRepositoryError = PersistenceSqlError | PersistenceDecodeError; export type AuthSessionRepositoryError = PersistenceSqlError | PersistenceDecodeError; export type PullRequestFilesViewedRepositoryError = PersistenceSqlError | PersistenceDecodeError; +export type PullRequestReviewGuideRepositoryError = PersistenceSqlError | PersistenceDecodeError; export type ProjectionRepositoryError = PersistenceSqlError | PersistenceDecodeError; diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 35c1a3fdce4b..7990c53ac454 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -74,6 +74,7 @@ import Migration0057 from "./Migrations/057_ScheduledTaskWebhooks.ts"; import Migration0058 from "./Migrations/058_WebhookRelayDeliveries.ts"; import Migration0059 from "./Migrations/059_McpAppModelContext.ts"; import Migration0060 from "./Migrations/060_ThreadSnapshotWindowIndexes.ts"; +import Migration0061 from "./Migrations/061_PullRequestReviewGuides.ts"; /** * Migration loader with all migrations defined inline. @@ -148,6 +149,7 @@ export const migrationEntries = [ [58, "WebhookRelayDeliveries", Migration0058], [59, "McpAppModelContext", Migration0059], [60, "ThreadSnapshotWindowIndexes", Migration0060], + [61, "PullRequestReviewGuides", Migration0061], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts b/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts index 5e45ed05d302..7d5c845cbf22 100644 --- a/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts +++ b/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts @@ -13,7 +13,7 @@ layer("055_OrchestrationV2", (it) => { Effect.sync(() => { assert.deepStrictEqual( migrationEntries.map(([id]) => id), - Array.from({ length: 60 }, (_, index) => index + 1), + Array.from({ length: 61 }, (_, index) => index + 1), ); }), ); @@ -32,6 +32,7 @@ layer("055_OrchestrationV2", (it) => { [58, "WebhookRelayDeliveries"], [59, "McpAppModelContext"], [60, "ThreadSnapshotWindowIndexes"], + [61, "PullRequestReviewGuides"], ]); assert.deepStrictEqual(yield* runMigrations(), []); @@ -58,6 +59,7 @@ layer("055_OrchestrationV2", (it) => { { migration_id: 58, name: "WebhookRelayDeliveries" }, { migration_id: 59, name: "McpAppModelContext" }, { migration_id: 60, name: "ThreadSnapshotWindowIndexes" }, + { migration_id: 61, name: "PullRequestReviewGuides" }, ]); const tables = yield* sql<{ readonly name: string }>` diff --git a/apps/server/src/persistence/Migrations/061_PullRequestReviewGuides.ts b/apps/server/src/persistence/Migrations/061_PullRequestReviewGuides.ts new file mode 100644 index 000000000000..bf8476670204 --- /dev/null +++ b/apps/server/src/persistence/Migrations/061_PullRequestReviewGuides.ts @@ -0,0 +1,19 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + // The latest guide per pull request, keyed by its host-level identity so every project that + // checks out the same repository shares one. Guides cost a model run, so a push leaves the old + // one in place for the page to call stale rather than deleting it. + yield* sql` + CREATE TABLE IF NOT EXISTS pull_request_review_guides ( + host TEXT NOT NULL, + repository TEXT NOT NULL, + number INTEGER NOT NULL, + guide_json TEXT NOT NULL, + PRIMARY KEY (host, repository, number) + ) WITHOUT ROWID + `; +}); diff --git a/apps/server/src/persistence/PullRequestReviewGuides.ts b/apps/server/src/persistence/PullRequestReviewGuides.ts new file mode 100644 index 000000000000..dd8cd0708046 --- /dev/null +++ b/apps/server/src/persistence/PullRequestReviewGuides.ts @@ -0,0 +1,84 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/sql/SqlClient"; +import * as SqlSchema from "effect/sql/SqlSchema"; + +import { PullRequestReviewGuide, ThreadPullRequestKey } from "@t3tools/contracts"; + +import { + PersistenceDecodeError, + PersistenceSqlError, + type PullRequestReviewGuideRepositoryError, +} from "./Errors.ts"; + +const GuideRow = Schema.Struct({ + guide: Schema.fromJsonString(PullRequestReviewGuide), +}); + +/** The latest review guide per pull request, keyed by host-level identity. */ +export class PullRequestReviewGuideRepository extends Context.Service< + PullRequestReviewGuideRepository, + { + readonly get: ( + key: ThreadPullRequestKey, + ) => Effect.Effect< + Option.Option, + PullRequestReviewGuideRepositoryError + >; + readonly put: ( + key: ThreadPullRequestKey, + guide: PullRequestReviewGuide, + ) => Effect.Effect; + } +>()("t3/persistence/PullRequestReviewGuides/PullRequestReviewGuideRepository") {} + +const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const findGuide = SqlSchema.findOneOption({ + Request: ThreadPullRequestKey, + Result: GuideRow, + execute: ({ host, repository, number }) => + sql` + SELECT guide_json AS "guide" + FROM pull_request_review_guides + WHERE host = ${host} AND repository = ${repository} AND number = ${number} + `, + }); + const encodeGuide = Schema.encodeEffect(Schema.fromJsonString(PullRequestReviewGuide)); + + return PullRequestReviewGuideRepository.of({ + get: (key) => + findGuide(key).pipe( + Effect.map(Option.map((row) => row.guide)), + Effect.mapError((cause) => + Schema.isSchemaError(cause) + ? PersistenceDecodeError.fromSchemaError("PullRequestReviewGuide", cause) + : new PersistenceSqlError({ operation: "getPullRequestReviewGuide", cause }), + ), + ), + put: (key, guide) => + encodeGuide(guide).pipe( + Effect.mapError((cause) => + PersistenceDecodeError.fromSchemaError("PullRequestReviewGuide", cause), + ), + Effect.flatMap((guideJson) => + sql` + INSERT INTO pull_request_review_guides (host, repository, number, guide_json) + VALUES (${key.host}, ${key.repository}, ${key.number}, ${guideJson}) + ON CONFLICT (host, repository, number) DO UPDATE SET guide_json = excluded.guide_json + `.pipe( + Effect.mapError( + (cause) => new PersistenceSqlError({ operation: "putPullRequestReviewGuide", cause }), + ), + ), + ), + Effect.asVoid, + ), + }); +}); + +export const layer = Layer.effect(PullRequestReviewGuideRepository, make); diff --git a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts index 675d5f5db021..5881fdfda408 100644 --- a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts +++ b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts @@ -41,6 +41,7 @@ describe("V2 preview upgrade", () => { [58, "WebhookRelayDeliveries"], [59, "McpAppModelContext"], [60, "ThreadSnapshotWindowIndexes"], + [61, "PullRequestReviewGuides"], ]); assert.deepStrictEqual(yield* runMigrations(), []); assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_legacy_imports`, imports); @@ -124,6 +125,7 @@ describe("V2 preview upgrade", () => { [58, "WebhookRelayDeliveries"], [59, "McpAppModelContext"], [60, "ThreadSnapshotWindowIndexes"], + [61, "PullRequestReviewGuides"], ]); }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), ); diff --git a/apps/server/src/provider/Drivers/CodexManagedProvider.ts b/apps/server/src/provider/Drivers/CodexManagedProvider.ts index 92c461390aea..d7b20bf2e007 100644 --- a/apps/server/src/provider/Drivers/CodexManagedProvider.ts +++ b/apps/server/src/provider/Drivers/CodexManagedProvider.ts @@ -260,6 +260,8 @@ export const makeManagedCodexProvider = Effect.fn("makeManagedCodexProvider")(fu protect("generateBranchName", nativeGeneration.generateBranchName(value)), generateThreadTitle: (value) => protect("generateThreadTitle", nativeGeneration.generateThreadTitle(value)), + generateReviewGuide: (value) => + protect("generateReviewGuide", nativeGeneration.generateReviewGuide(value)), }; return { instanceId, diff --git a/apps/server/src/pullRequest/PullRequestReviewGuideService.test.ts b/apps/server/src/pullRequest/PullRequestReviewGuideService.test.ts new file mode 100644 index 000000000000..aa41705db29d --- /dev/null +++ b/apps/server/src/pullRequest/PullRequestReviewGuideService.test.ts @@ -0,0 +1,505 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Context from "effect/Context"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as Schedule from "effect/Schedule"; +import { + ProjectId, + type PullRequestActivity, + type PullRequestDetail, + type PullRequestStack, + type PullRequestRef, +} from "@t3tools/contracts"; +import type { ReviewGuideGenerationInput } from "@t3tools/provider-core/server/textGeneration"; + +import * as ServerConfig from "../config.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; +import * as PullRequestReviewGuides from "../persistence/PullRequestReviewGuides.ts"; +import * as ProjectService from "../project/ProjectService.ts"; +import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import * as TextGeneration from "../textGeneration/TextGeneration.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import * as PullRequestReviewGuideService from "./PullRequestReviewGuideService.ts"; +import * as PullRequestService from "./PullRequestService.ts"; + +const projectId = ProjectId.make("project-1"); +/** The test project is a checkout of the repository its pull requests come from. */ +const ACME_APP = { + canonicalKey: "github.com/acme/app", + locator: { + source: "git-remote" as const, + remoteName: "origin", + remoteUrl: "https://github.com/acme/app.git", + }, + provider: "github", + displayName: "acme/app", + owner: "acme", + name: "app", +}; + +const reference: PullRequestRef = { + projectId, + host: "github.com", + repository: "acme/app", + number: 7, +}; + +const git = (cwd: string, args: ReadonlyArray) => + Effect.gen(function* () { + const driver = yield* GitVcsDriver.GitVcsDriver; + const result = yield* driver.execute({ operation: "test.git", cwd, args, timeoutMs: 10_000 }); + return result.stdout.trim(); + }); + +/** + * A remote whose `main` has moved on past where the change's base branch left it, with the + * change stacked on that base branch. A guide for the change must see only its own commit. + */ +const makeRepositories = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-guide-" }); + const remote = `${root}/remote`; + const workspace = `${root}/workspace`; + yield* fs.makeDirectory(remote); + yield* git(remote, ["init", "--quiet", "--initial-branch=main"]); + yield* git(remote, ["config", "user.email", "test@example.com"]); + yield* git(remote, ["config", "user.name", "Test"]); + const commit = (path: string, message: string, contents = message) => + fs + .writeFileString(`${remote}/${path}`, contents) + .pipe( + Effect.andThen(git(remote, ["add", path])), + Effect.andThen(git(remote, ["commit", "--quiet", "-m", message])), + Effect.andThen(git(remote, ["rev-parse", "HEAD"])), + ); + yield* commit("base.txt", "base"); + yield* git(remote, ["checkout", "--quiet", "-b", "stack-base"]); + const lowerSha = yield* commit("lower.txt", "lower layer"); + yield* git(remote, ["checkout", "--quiet", "-b", "feature"]); + // Ten lines, so an excerpt reaching past them has something to be clamped to. + const headSha = yield* commit( + "feature.txt", + "the change", + Array.from({ length: 10 }, (_, index) => `line ${index + 1}`).join("\n") + "\n", + ); + // A layer above the change, which uses what the change adds. + yield* git(remote, ["checkout", "--quiet", "-b", "upper"]); + const upperSha = yield* commit("upper.txt", "upper layer"); + yield* git(remote, ["checkout", "--quiet", "main"]); + yield* commit("unrelated.txt", "main moved on"); + yield* git(root, ["clone", "--quiet", remote, workspace]); + return { root, workspace, headSha, lowerSha, upperSha }; +}); + +const detail = (workspaceRoot: string, headSha: string): PullRequestDetail => + ({ + number: 7, + title: "Add the feature", + body: "Adds the feature.", + baseBranch: "stack-base", + headBranch: "feature", + headSha, + workspaceRoot, + }) as PullRequestDetail; + +const activity: PullRequestActivity = { + comments: [], + commentCount: 0, + commentsTruncated: false, + reviewThreads: [], + commits: [], +}; + +/** The change as the middle layer of three; the top layer's head is not on any fetched branch. */ +const stack = (repositories: { + readonly headSha: string; + readonly lowerSha: string; + readonly upperSha: string; +}): PullRequestStack => ({ + id: "stack", + number: 1, + url: "https://example.com/stack/1", + base: "main", + layers: [ + { + number: 6, + title: "Lower", + headSha: repositories.lowerSha, + headBranch: "stack-base", + state: "open", + }, + { + number: 7, + title: "Add the feature", + headSha: repositories.headSha, + headBranch: "feature", + state: "open", + }, + { + number: 8, + title: "Upper", + headSha: repositories.upperSha, + headBranch: "upper", + state: "open", + }, + { number: 9, title: "Unpushed", headSha: "f".repeat(40), headBranch: "gone", state: "open" }, + ], +}); + +const makeLayer = (input: { + readonly root: string; + readonly workspace: string; + readonly headSha: string; + readonly lowerSha: string; + readonly upperSha: string; + readonly generate: TextGeneration.TextGeneration["Service"]["generateReviewGuide"]; + /** Completed when a run stores its guide, which is the last thing a run does but forget it. */ + readonly stored: Deferred.Deferred; + /** The server's beta switch; on unless a test is about it being off. */ + readonly enabled?: boolean; + /** The head the host reports now, for a test that pushes between runs. */ + readonly currentHead?: Ref.Ref; + /** The project's own repository; defaults to the one the test reference names. */ + readonly identity?: typeof ACME_APP; +}) => + PullRequestReviewGuideService.layer.pipe( + Layer.provide( + Layer.effect( + PullRequestReviewGuides.PullRequestReviewGuideRepository, + Effect.gen(function* () { + const repository = yield* PullRequestReviewGuides.PullRequestReviewGuideRepository; + return { + get: repository.get, + put: (key, guide) => + repository + .put(key, guide) + .pipe(Effect.tap(() => Deferred.succeed(input.stored, undefined))), + } satisfies PullRequestReviewGuides.PullRequestReviewGuideRepository["Service"]; + }), + ).pipe(Layer.provide(PullRequestReviewGuides.layer)), + ), + Layer.provide( + Layer.succeed(PullRequestService.PullRequestService, { + detail: () => + (input.currentHead === undefined + ? Effect.succeed(input.headSha) + : Ref.get(input.currentHead) + ).pipe(Effect.map((head) => detail(input.workspace, head))), + activity: () => Effect.succeed(activity), + invalidate: () => Effect.void, + stack: () => Effect.succeed(stack(input)), + } as unknown as PullRequestService.PullRequestService["Service"]), + ), + Layer.provide( + Layer.succeed(ProjectService.ProjectService, { + getShell: () => + Effect.succeedSome({ + id: projectId, + workspaceRoot: input.workspace, + repositoryIdentity: input.identity ?? ACME_APP, + }), + } as unknown as ProjectService.ProjectService["Service"]), + ), + Layer.provide( + Layer.succeed(ProviderRegistry.ProviderRegistry, { + getProviders: Effect.succeed([]), + } as unknown as ProviderRegistry.ProviderRegistry["Service"]), + ), + Layer.provide( + Layer.succeed(TextGeneration.TextGeneration, { + generateReviewGuide: input.generate, + } as unknown as TextGeneration.TextGeneration["Service"]), + ), + Layer.provide(ServerSettings.layerTest({ reviewGuidesEnabled: input.enabled ?? true })), + Layer.provide(ServerConfig.layerTest(input.root, { prefix: "t3-review-guide-config-" })), + ); + +const layerBase = Layer.mergeAll( + GitVcsDriver.layer.pipe( + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-review-guide-git-" })), + Layer.provideMerge(VcsProcess.layer), + ), + SqlitePersistence.layerMemory, +).pipe(Layer.provideMerge(NodeServices.layer)); + +// Runs drive real git, which the test clock does not advance, so waits use real time. +it.layer(layerBase, { excludeTestServices: true })("PullRequestReviewGuideService", (it) => { + it.effect("briefs the change against its own base in a checkout it then removes", () => + Effect.scoped( + Effect.gen(function* () { + const repositories = yield* makeRepositories; + const seen = yield* Deferred.make<{ + readonly input: ReviewGuideGenerationInput; + readonly changed: string; + readonly head: string; + readonly upper: string; + }>(); + const finish = yield* Deferred.make(); + // The fake model reads the checkout with the same driver, captured so it needs nothing. + const driver = yield* GitVcsDriver.GitVcsDriver; + const readCheckout = (cwd: string, args: ReadonlyArray) => + git(cwd, args).pipe( + Effect.provideService(GitVcsDriver.GitVcsDriver, driver), + Effect.orDie, + ); + const stored = yield* Deferred.make(); + + // Built in this test's scope: runs are forked into the service's scope and must outlive + // the call that obtains the service. + const context = yield* Layer.build( + makeLayer({ + ...repositories, + stored, + generate: (input) => + Effect.gen(function* () { + const changed = yield* readCheckout(input.cwd, [ + "diff", + "--name-only", + `${input.baseCommit}...HEAD`, + ]); + const head = yield* readCheckout(input.cwd, ["rev-parse", "HEAD"]); + // Another layer's head is readable from the checkout, to check a seam. + const upper = yield* readCheckout(input.cwd, [ + "show", + `${repositories.upperSha}:upper.txt`, + ]); + yield* Deferred.succeed(seen, { input, changed, head, upper }); + yield* Deferred.await(finish); + // Written the way a model gets it wrong: an excerpt past the end of the file, a + // note outside what is left of it, and a changed file left off the tour. + return { + stops: [ + { + title: "The feature", + summary: "The change.", + attention: null, + excerpts: [ + { path: "feature.txt", side: "new", start: 4, end: 40, caption: "" }, + ], + files: [], + notes: [ + { path: "feature.txt", line: 5, title: "Kept", body: "Inside." }, + { path: "feature.txt", line: 30, title: "Dropped", body: "Outside." }, + ], + questions: [], + }, + ], + }; + }), + }), + ); + const service = Context.get( + context, + PullRequestReviewGuideService.PullRequestReviewGuideService, + ); + + // Generate returns while the run is still reading the change. + const started = yield* service.generate(reference); + assert.strictEqual(started.run?._tag, "Running"); + assert.strictEqual(started.guide, null); + + // A run that fails before reaching the model reports why rather than leaving this waiting. + const failedRun = service.get(reference).pipe( + Effect.flatMap((result) => + result.run?._tag === "Failed" + ? Effect.die(`run failed: ${result.run.detail}`) + : Effect.fail("still running" as const), + ), + Effect.retry(Schedule.spaced("20 millis")), + ); + const observed = yield* Effect.raceFirst(Deferred.await(seen), failedRun); + assert.strictEqual(observed.head, repositories.headSha); + assert.strictEqual(observed.changed, "feature.txt"); + assert.strictEqual(observed.input.baseBranch, "stack-base"); + // The stack comes along, bottom first; a layer whose head could not be fetched is named + // without one. + assert.deepStrictEqual(observed.input.stack, [ + { number: 6, title: "Lower", state: "open", headSha: repositories.lowerSha }, + { number: 7, title: "Add the feature", state: "open", headSha: repositories.headSha }, + { number: 8, title: "Upper", state: "open", headSha: repositories.upperSha }, + { number: 9, title: "Unpushed", state: "open", headSha: null }, + ]); + assert.strictEqual(observed.upper, "upper layer"); + + // A second press while running joins the run instead of starting another. + const joined = yield* service.generate(reference); + assert.strictEqual(joined.run?._tag, "Running"); + + yield* Deferred.succeed(finish, undefined); + yield* Deferred.await(stored); + const finished = yield* service.get(reference); + assert.strictEqual(finished.guide?.headSha, repositories.headSha); + const guide = finished.guide; + assert.isNotNull(guide); + const [story] = guide.stops; + assert.deepStrictEqual(story?.excerpts, [ + { path: "feature.txt", side: "new", start: 4, end: 10, caption: "" }, + ]); + assert.deepStrictEqual( + story?.notes.map((note) => note.title), + ["Kept"], + ); + // lower.txt is below the change's base, so it is neither in the change nor on the tour. + assert.strictEqual(guide.stops.length, 1); + + const fs = yield* FileSystem.FileSystem; + assert.isFalse(yield* fs.exists(observed.input.cwd)); + const worktrees = yield* git(repositories.workspace, ["worktree", "list", "--porcelain"]); + assert.notInclude(worktrees, observed.input.cwd); + }), + ), + ); + + it.effect("refuses to write a guide while the server has review guides off", () => + Effect.scoped( + Effect.gen(function* () { + const repositories = yield* makeRepositories; + const stored = yield* Deferred.make(); + const context = yield* Layer.build( + makeLayer({ + ...repositories, + stored, + enabled: false, + generate: () => Effect.die("a guide must not be written while the beta is off"), + }), + ); + const service = Context.get( + context, + PullRequestReviewGuideService.PullRequestReviewGuideService, + ); + const result = yield* Effect.flip(service.generate(reference)); + assert.include(result.message, "Review guides are off"); + }), + ), + ); + + it.effect( + "keeps a newer run's guide when an older run for the same pull request finishes late", + () => + Effect.scoped( + Effect.gen(function* () { + const repositories = yield* makeRepositories; + const stored = yield* Deferred.make(); + const currentHead = yield* Ref.make(repositories.headSha); + // The old run waits on this; the new one answers at once. + const releaseOld = yield* Deferred.make(); + const oldStarted = yield* Deferred.make(); + const answer = (title: string) => ({ + stops: [ + { + title, + summary: "", + attention: null, + excerpts: [], + files: [], + notes: [], + questions: [], + }, + ], + }); + const context = yield* Layer.build( + makeLayer({ + ...repositories, + stored, + currentHead, + generate: (input) => + input.cwd.length > 0 && input.baseCommit.length > 0 + ? Ref.get(currentHead).pipe( + Effect.flatMap((head) => + head === repositories.headSha + ? Deferred.succeed(oldStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseOld)), + Effect.as(answer("old head")), + ) + : Effect.succeed(answer("new head")), + ), + ) + : Effect.die("unreachable"), + }), + ); + const service = Context.get( + context, + PullRequestReviewGuideService.PullRequestReviewGuideService, + ); + + yield* service.generate(reference); + const firstFailed = service.get(reference).pipe( + Effect.flatMap((result) => + result.run?._tag === "Failed" + ? Effect.die(`first run failed: ${result.run.detail}`) + : Effect.fail("still running" as const), + ), + Effect.retry(Schedule.spaced("20 millis")), + ); + yield* Effect.raceFirst(Deferred.await(oldStarted), firstFailed); + // A push: the host reports the upper layer's commit as the head now, and a new run starts. + yield* Ref.set(currentHead, repositories.upperSha); + const second = yield* service.generate(reference); + assert.strictEqual(second.run?._tag, "Running"); + assert.strictEqual(second.run?.headSha, repositories.upperSha); + // A run that fails reports why rather than leaving this waiting. + const failedRun = service.get(reference).pipe( + Effect.flatMap((result) => + result.run?._tag === "Failed" + ? Effect.die(`run failed: ${result.run.detail}`) + : Effect.fail("still running" as const), + ), + Effect.retry(Schedule.spaced("20 millis")), + ); + yield* Effect.raceFirst(Deferred.await(stored), failedRun); + + // The old run finishes last. Its guide is for a head nobody is looking at any more. + yield* Deferred.succeed(releaseOld, undefined); + const settled = yield* service.get(reference).pipe( + Effect.filterOrFail( + (result) => result.run === null, + () => "still running" as const, + ), + Effect.retry({ schedule: Schedule.spaced("20 millis"), times: 250 }), + ); + assert.strictEqual(settled.guide?.headSha, repositories.upperSha); + // Give the late run every chance to write over the newer guide; it must not. + yield* Effect.sleep("200 millis"); + const after = yield* service.get(reference); + assert.isNull(after.run); + assert.strictEqual(after.guide?.headSha, repositories.upperSha); + assert.strictEqual(after.guide?.stops[0]?.title, "new head"); + }), + ), + ); + + it.effect("refuses a pull request from a repository the project is not a checkout of", () => + Effect.scoped( + Effect.gen(function* () { + const repositories = yield* makeRepositories; + const stored = yield* Deferred.make(); + const context = yield* Layer.build( + makeLayer({ + ...repositories, + stored, + identity: { + ...ACME_APP, + canonicalKey: "github.com/acme/other", + displayName: "acme/other", + name: "other", + }, + generate: () => + Effect.die("a guide must not be written from another repository's checkout"), + }), + ); + const service = Context.get( + context, + PullRequestReviewGuideService.PullRequestReviewGuideService, + ); + const result = yield* Effect.flip(service.generate(reference)); + assert.include(result.message, "in another repository"); + }), + ), + ); +}); diff --git a/apps/server/src/pullRequest/PullRequestReviewGuideService.ts b/apps/server/src/pullRequest/PullRequestReviewGuideService.ts new file mode 100644 index 000000000000..b181926f4e8d --- /dev/null +++ b/apps/server/src/pullRequest/PullRequestReviewGuideService.ts @@ -0,0 +1,484 @@ +/** + * Guided reviews: a maintainer-facing brief on a pull request, written by the environment's + * source-control writer model. See `PullRequestReviewGuide` in contracts for what a guide is for. + * + * The model reads the change itself, read-only, from a disposable checkout of the head, so a + * guide has no size limit. That takes minutes on a large change, so a run belongs to the server + * rather than to the request that started it: Generate returns as soon as the run is under way + * and readers watch its state through `get`. + * + * Generation is explicit and the result is stored per pull request, so opening a guide never + * spends a model run. A push leaves the stored guide in place and the reader sees it as stale. + * + * @module pullRequest/PullRequestReviewGuideService + */ +import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Semaphore from "effect/Semaphore"; + +import { + PullRequestOperationError, + type PullRequestDetail, + type PullRequestRef, + type PullRequestReviewGuide, + type PullRequestReviewGuideResult, + type PullRequestReviewGuideRun, + type PullRequestStack, + ProjectId, + type RepositoryIdentity, + type ThreadPullRequestKey, +} from "@t3tools/contracts"; +import type { ReviewGuideStackLayer } from "@t3tools/provider-core/server/textGeneration"; +import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; +import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; + +import * as ServerConfig from "../config.ts"; +import * as PullRequestReviewGuides from "../persistence/PullRequestReviewGuides.ts"; +import * as ProjectService from "../project/ProjectService.ts"; +import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import * as TextGeneration from "../textGeneration/TextGeneration.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import { PATCH_RENDER_PREFIX_ARGS } from "../vcs/GitVcsDriverCore.ts"; +import * as PullRequestService from "./PullRequestService.ts"; +import { pullRequestSyncKey } from "./pullRequestSyncKey.ts"; +import { reviewGuideInputFromPullRequest } from "./pullRequestReviewGuideInput.ts"; +import { normalizeReviewGuide, parseReviewGuideChange } from "./pullRequestReviewGuideNormalize.ts"; + +export class PullRequestReviewGuideService extends Context.Service< + PullRequestReviewGuideService, + { + /** The stored guide, any run in progress, and the head now. Never runs a model. */ + readonly get: ( + input: PullRequestRef, + ) => Effect.Effect; + /** + * Start writing a guide for the current head and return once it is under way. Joins a run + * already going for that head. A stored guide stays readable until the new one replaces it, + * so a reader can rewrite one after changing the guide model or instructions. + */ + readonly generate: ( + input: PullRequestRef, + ) => Effect.Effect; + } +>()("t3/pullRequest/PullRequestReviewGuideService") {} + +const keyString = (key: ThreadPullRequestKey) => `${key.host}/${key.repository}#${key.number}`; + +const nowIso = Clock.currentTimeMillis.pipe( + Effect.map((millis) => DateTime.formatIso(DateTime.makeUnsafe(millis))), +); + +const make = Effect.gen(function* () { + const pullRequests = yield* PullRequestService.PullRequestService; + const guides = yield* PullRequestReviewGuides.PullRequestReviewGuideRepository; + const projects = yield* ProjectService.ProjectService; + const serverSettings = yield* ServerSettings.ServerSettingsService; + const providerRegistry = yield* ProviderRegistry.ProviderRegistry; + const textGeneration = yield* TextGeneration.TextGeneration; + const git = yield* GitVcsDriver.GitVcsDriver; + const fileSystem = yield* FileSystem.FileSystem; + const config = yield* ServerConfig.ServerConfig; + // Runs outlive the request that started them and end with the server. + const serviceScope = yield* Effect.scope; + // In memory on purpose: a restart drops a run in progress, and a reader presses Generate again. + const runs = yield* Ref.make>(new Map()); + // Names each run's private git refs; unique for the server's lifetime, like the runs map. + let runCounter = 0; + // Claiming a run and storing a finished guide take turns, so a run that still owns its pull + // request when it checks is the one whose guide is stored: a newer claim cannot land between. + const ownership = yield* Semaphore.make(1); + + const keyFor = Effect.fn("PullRequestReviewGuideService.keyFor")(function* ( + input: PullRequestRef, + ) { + const project = Option.getOrUndefined( + yield* projects.getShell(input.projectId).pipe(Effect.orElseSucceed(() => Option.none())), + ); + const key = pullRequestSyncKey(input, project?.repositoryIdentity ?? undefined); + if (key === null || project === undefined) { + return yield* new PullRequestOperationError({ + operation: "reviewGuide", + detail: "Could not identify this pull request's host.", + }); + } + return { key, workspaceRoot: project.workspaceRoot, identity: project.repositoryIdentity }; + }); + + /** + * A guide reads the change in a checkout fetched from the project's own remote, so the pull + * request has to be in that repository. One from another repository is refused rather than + * measured against commits that are not its own. + */ + const requireOwnRepository = Effect.fn("PullRequestReviewGuideService.requireOwnRepository")( + function* (key: ThreadPullRequestKey, identity: RepositoryIdentity | null | undefined) { + const own = + identity == null + ? null + : pullRequestSyncKey( + { + projectId: ProjectId.make("own"), + number: key.number, + repository: sourceControlRepositorySelector(identity) ?? "", + }, + identity, + ); + if (own === null || own.host !== key.host || own.repository !== key.repository) { + return yield* new PullRequestOperationError({ + operation: "generateReviewGuide", + detail: + "Guides are written from this project's checkout, and this pull request is in another repository. Write it from a project that is a checkout of that repository.", + }); + } + }, + ); + + const readStored = (key: ThreadPullRequestKey) => + guides.get(key).pipe( + Effect.mapError( + (cause) => + new PullRequestOperationError({ + operation: "reviewGuide", + detail: "Could not read the stored review guide.", + cause, + }), + ), + Effect.map(Option.getOrNull), + ); + + const get: PullRequestReviewGuideService["Service"]["get"] = Effect.fn( + "PullRequestReviewGuideService.get", + )(function* (input) { + const { key } = yield* keyFor(input); + const [guide, detail, current] = yield* Effect.all( + [readStored(key), pullRequests.detail(input), Ref.get(runs)], + { concurrency: "unbounded" }, + ); + return { + guide, + run: current.get(keyString(key)) ?? null, + currentHeadSha: detail.headSha ?? null, + }; + }); + + /** + * A detached checkout of the head beside the project, removed when the run ends. Fetched by + * commit rather than by branch so a fork's head and a branch checked out elsewhere both work, + * and so the checkout is exactly what the host reported. + */ + const checkoutHead = Effect.fn("PullRequestReviewGuideService.checkoutHead")(function* ( + workspaceRoot: string, + detail: PullRequestDetail, + headSha: string, + stack: PullRequestStack | null, + runId: string, + ) { + const remote = yield* git.resolvePrimaryRemoteName(workspaceRoot); + const fetch = (args: ReadonlyArray) => + git.execute({ + operation: "PullRequestReviewGuideService.fetch", + cwd: workspaceRoot, + args: ["fetch", "--quiet", "--no-tags", remote, ...args], + timeoutMs: 5 * 60_000, + }); + // The base branch is what the change is measured from; the head commit is the change. Hosts + // that do not serve an unadvertised commit still serve their pull refs, which GitHub and + // Forgejo name the same way. + // The host names the base branch. A name git rejects, or one that would change the refspec + // (a leading `+`, a `:`), is refused before it reaches `git fetch`. + yield* git + .execute({ + operation: "PullRequestReviewGuideService.checkBaseBranch", + cwd: workspaceRoot, + args: ["check-ref-format", "--branch", detail.baseBranch], + }) + .pipe( + Effect.filterOrFail( + () => !detail.baseBranch.startsWith("+") && !detail.baseBranch.includes(":"), + () => + new PullRequestOperationError({ + operation: "generateReviewGuide", + detail: "The pull request's base branch name is not one git can fetch.", + }), + ), + ); + // Fetched into a ref of this run's own: FETCH_HEAD is shared by every fetch in the + // workspace, so a concurrent run could otherwise measure this change from its base. + const baseRef = `refs/t3/review-guide/${runId}/base`; + yield* fetch([`+refs/heads/${detail.baseBranch}:${baseRef}`]); + yield* Effect.addFinalizer(() => + git + .execute({ + operation: "PullRequestReviewGuideService.dropBaseRef", + cwd: workspaceRoot, + args: ["update-ref", "-d", baseRef], + }) + .pipe(Effect.ignore({ log: true })), + ); + const baseTip = yield* git.resolveCommit({ cwd: workspaceRoot, revision: baseRef }); + const fetchHead = (number: number, sha: string) => + fetch([sha]).pipe(Effect.catch(() => fetch([`refs/pull/${number}/head`]))); + yield* fetchHead(detail.number, headSha); + // The other layers of a stack are context the model may read across a seam. A layer that + // cannot be fetched is still named in the prompt, without a head to read. + const layers: ReadonlyArray = yield* Effect.forEach( + stack?.layers ?? [], + (layer) => { + const described = { number: layer.number, title: layer.title ?? null, state: layer.state }; + if (layer.number === detail.number) return Effect.succeed({ ...described, headSha }); + const sha = layer.headSha; + if (sha === undefined) return Effect.succeed({ ...described, headSha: null }); + return fetchHead(layer.number, sha).pipe( + Effect.andThen(git.resolveCommit({ cwd: workspaceRoot, revision: sha })), + Effect.as({ ...described, headSha: sha }), + Effect.orElseSucceed(() => ({ ...described, headSha: null })), + ); + }, + { concurrency: 4 }, + ); + const directory = yield* fileSystem.makeTempDirectoryScoped({ + directory: config.worktreesDir, + prefix: "review-guide-", + }); + const path = `${directory}/checkout`; + yield* git.execute({ + operation: "PullRequestReviewGuideService.addWorktree", + cwd: workspaceRoot, + args: ["worktree", "add", "--detach", "--quiet", path, headSha], + timeoutMs: 5 * 60_000, + }); + yield* Effect.addFinalizer(() => + git + .removeWorktree({ cwd: workspaceRoot, path, force: true }) + .pipe(Effect.ignore({ log: true })), + ); + // Measured from where the head left the base, so a stacked change is briefed alone rather + // than with everything below it that the base has not taken yet. + const mergeBase = yield* git.execute({ + operation: "PullRequestReviewGuideService.mergeBase", + cwd: path, + args: ["merge-base", baseTip.commitSha, "HEAD"], + }); + return { path, baseCommit: mergeBase.stdout.trim(), stack: layers }; + }); + + /** The changed paths and their hunks, which a generated guide is held to. */ + const readChange = Effect.fn("PullRequestReviewGuideService.readChange")(function* ( + cwd: string, + baseCommit: string, + ) { + const diff = (args: ReadonlyArray) => + git + .execute({ + operation: "PullRequestReviewGuideService.readChange", + cwd, + // Fixed prefixes: a user's diff.noPrefix or custom prefixes would hide the headers the + // parser reads. + args: [ + "-c", + "core.quotePath=false", + "diff", + ...PATCH_RENDER_PREFIX_ARGS, + ...args, + `${baseCommit}...HEAD`, + ], + timeoutMs: 60_000, + // Hunk headers only, but a large change still has many of them. + maxOutputBytes: 64 * 1024 * 1024, + }) + .pipe(Effect.map((result) => result.stdout)); + const [nameOnly, patch] = yield* Effect.all( + [diff(["--name-only", "-z"]), diff(["--unified=0", "--no-color", "--no-ext-diff"])], + { concurrency: "unbounded" }, + ); + return parseReviewGuideChange(nameOnly, patch); + }); + + const write = Effect.fn("PullRequestReviewGuideService.write")(function* ( + input: PullRequestRef, + key: ThreadPullRequestKey, + workspaceRoot: string, + detail: PullRequestDetail, + headSha: string, + runId: string, + owns: Effect.Effect, + ) { + const [activity, settings, providers, stack] = yield* Effect.all( + [ + pullRequests.activity(input), + serverSettings.getSettings, + providerRegistry.getProviders, + // A guide without its stack is still a guide; the stack only adds seams to point at. + pullRequests.stack(input).pipe(Effect.orElseSucceed(() => null)), + ], + { concurrency: "unbounded" }, + ); + const projectSettings = resolveProjectSettings(settings, input.projectId).settings; + const modelSelection = ServerSettings.resolveReviewGuideModelSelection( + projectSettings, + providers, + ); + // The checkout lives only as long as the model is reading it. The change's hunks are read + // from it too, so the guide can be held to the lines the change actually touched. + const { generated, change } = yield* checkoutHead( + workspaceRoot, + detail, + headSha, + stack, + runId, + ).pipe( + Effect.flatMap((checkout) => + Effect.all( + { + generated: textGeneration.generateReviewGuide({ + ...reviewGuideInputFromPullRequest(detail, activity, checkout.baseCommit), + cwd: checkout.path, + modelSelection, + instructions: projectSettings.reviewGuideInstructions, + stack: checkout.stack, + }), + change: readChange(checkout.path, checkout.baseCommit), + }, + { concurrency: "unbounded" }, + ), + ), + Effect.scoped, + ); + const guide: PullRequestReviewGuide = { + headSha, + generatedAt: yield* nowIso, + modelSelection, + ...normalizeReviewGuide(generated, change), + }; + // A newer run for this pull request, say after a push, owns its guide now; this one's result + // is for an older head and is dropped rather than written over it. + yield* ownership.withPermits(1)( + Effect.gen(function* () { + if (yield* owns) yield* guides.put(key, guide); + }), + ); + }); + + /** What a reader is told when a run fails; the full cause goes to the log. */ + const failureDetail = (cause: Cause.Cause): string => { + const error = Cause.squash(cause); + if (error instanceof Error && "detail" in error && typeof error.detail === "string") { + return error.detail; + } + return "The review guide could not be written."; + }; + + const generate: PullRequestReviewGuideService["Service"]["generate"] = Effect.fn( + "PullRequestReviewGuideService.generate", + )(function* (input) { + // A beta the server owns, so every client and agent sees the same answer. + const settings = yield* serverSettings.getSettings.pipe( + Effect.mapError( + (cause) => + new PullRequestOperationError({ + operation: "generateReviewGuide", + detail: "Could not read the server settings.", + cause, + }), + ), + ); + if (!settings.reviewGuidesEnabled) { + return yield* new PullRequestOperationError({ + operation: "generateReviewGuide", + detail: "Review guides are off. Turn them on in Settings → Source Control.", + }); + } + const { key, workspaceRoot, identity } = yield* keyFor(input); + yield* requireOwnRepository(key, identity); + const runKey = keyString(key); + // A press asks for the head as it is now, not a read shared from the last minute, so a guide + // is never started for a commit the pull request has already moved past. + yield* pullRequests.invalidate({ reference: input }); + const [detail, stored] = yield* Effect.all([pullRequests.detail(input), readStored(key)], { + concurrency: "unbounded", + }); + const currentHeadSha = detail.headSha ?? null; + if (currentHeadSha === null) { + return yield* new PullRequestOperationError({ + operation: "generateReviewGuide", + detail: "This host did not report the pull request's head commit.", + }); + } + const running: PullRequestReviewGuideRun = { + _tag: "Running", + headSha: currentHeadSha, + startedAt: yield* nowIso, + }; + // Claimed atomically, so two readers pressing Generate start one run between them. The head + // is read again under the claim: a press that read an older head before a push must not + // replace a run another press started for the new one, so it asks to be pressed again. + const claimed = yield* ownership.withPermits(1)( + pullRequests.detail(input).pipe( + Effect.flatMap((latest) => + (latest.headSha ?? null) !== currentHeadSha + ? Effect.fail( + new PullRequestOperationError({ + operation: "generateReviewGuide", + detail: "The pull request changed while the guide was starting. Try again.", + }), + ) + : Ref.modify(runs, (current) => { + const existing = current.get(runKey); + if (existing?._tag === "Running" && existing.headSha === currentHeadSha) { + return [existing, current] as const; + } + return [running, new Map(current).set(runKey, running)] as const; + }), + ), + ), + ); + if (claimed !== running) return { guide: stored, run: claimed, currentHeadSha }; + + // The run is identified by its own Running value: a later claim replaces it, and from then + // on this run's completion leaves the newer run's state and guide alone. + const owns = Ref.get(runs).pipe(Effect.map((current) => current.get(runKey) === running)); + const finish = (next: PullRequestReviewGuideRun | null) => + Ref.update(runs, (current) => { + if (current.get(runKey) !== running) return current; + const updated = new Map(current); + if (next === null) updated.delete(runKey); + else updated.set(runKey, next); + return updated; + }); + const runId = `${runCounter++}`; + yield* write(input, key, workspaceRoot, detail, currentHeadSha, runId, owns).pipe( + Effect.andThen(finish(null)), + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? finish(null) + : Effect.logWarning("Review guide generation failed", { + key: runKey, + cause: Cause.pretty(cause), + }).pipe( + Effect.andThen(nowIso), + Effect.flatMap((failedAt) => + finish({ + _tag: "Failed", + headSha: currentHeadSha, + failedAt, + detail: failureDetail(cause), + }), + ), + ), + ), + Effect.forkIn(serviceScope), + ); + return { guide: stored, run: running, currentHeadSha }; + }); + + return PullRequestReviewGuideService.of({ get, generate }); +}); + +export const layer = Layer.effect(PullRequestReviewGuideService, make); diff --git a/apps/server/src/pullRequest/pullRequestReviewGuideInput.test.ts b/apps/server/src/pullRequest/pullRequestReviewGuideInput.test.ts new file mode 100644 index 000000000000..3db791492c6d --- /dev/null +++ b/apps/server/src/pullRequest/pullRequestReviewGuideInput.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vite-plus/test"; +import type { PullRequestActivity, PullRequestReviewThread } from "@t3tools/contracts"; + +import { settledReviewSummary } from "./pullRequestReviewGuideInput.ts"; + +const thread = ( + overrides: Partial & { body?: string; login?: string }, +): PullRequestReviewThread => ({ + id: overrides.id ?? "t1", + path: overrides.path ?? "src/a.ts", + line: 1, + side: "right", + isResolved: overrides.isResolved ?? false, + isOutdated: overrides.isOutdated ?? false, + comments: [ + { + id: "c1", + author: { login: overrides.login ?? "macroscope[bot]", name: null, avatarUrl: null }, + body: overrides.body ?? "Possible null dereference", + createdAt: "2026-10-01T00:00:00.000Z", + url: null, + }, + ], +}); + +const activity = (reviewThreads: ReadonlyArray): PullRequestActivity => ({ + comments: [], + commentCount: 0, + commentsTruncated: false, + reviewThreads, + commits: [], +}); + +describe("settledReviewSummary", () => { + it("lists resolved and outdated threads and leaves open ones for the guide", () => { + const summary = settledReviewSummary( + activity([ + thread({ id: "open", path: "src/open.ts", body: "Still open" }), + thread({ id: "resolved", path: "src/a.ts", isResolved: true }), + thread({ + id: "outdated", + path: "src/b.ts", + isOutdated: true, + login: "julius", + body: "Rename this \n\nplease", + }), + ]), + ); + + expect(summary).toBe( + [ + "- src/a.ts (resolved, macroscope[bot]): Possible null dereference", + "- src/b.ts (outdated, julius): Rename this please", + ].join("\n"), + ); + }); +}); diff --git a/apps/server/src/pullRequest/pullRequestReviewGuideInput.ts b/apps/server/src/pullRequest/pullRequestReviewGuideInput.ts new file mode 100644 index 000000000000..76d0d4cb4026 --- /dev/null +++ b/apps/server/src/pullRequest/pullRequestReviewGuideInput.ts @@ -0,0 +1,47 @@ +import type { PullRequestActivity, PullRequestDetail } from "@t3tools/contracts"; +import type { ReviewGuideGenerationInput } from "@t3tools/provider-core/server/textGeneration"; + +/** Longest a settled remark is quoted. The guide needs what was raised, not the argument. */ +const SETTLED_REMARK_CHARS = 200; + +function oneLine(text: string): string { + const flat = text + .replace(//g, "") + .replace(/\s+/g, " ") + .trim(); + return flat.length > SETTLED_REMARK_CHARS ? `${flat.slice(0, SETTLED_REMARK_CHARS)}…` : flat; +} + +/** + * What review already settled, one line per conversation: resolved threads, and threads whose + * line a later push removed. These are the review loop's finished work; the guide is told about + * them so it does not raise them again. + */ +export function settledReviewSummary(activity: PullRequestActivity): string { + return activity.reviewThreads + .filter((thread) => thread.isResolved || thread.isOutdated) + .flatMap((thread) => { + const opener = thread.comments[0]; + if (opener === undefined) return []; + const who = opener.author?.login ?? "ghost"; + const state = thread.isResolved ? "resolved" : "outdated"; + return [`- ${thread.path} (${state}, ${who}): ${oneLine(opener.body)}`]; + }) + .join("\n"); +} + +/** The prompt input for a guide, minus the checkout and model the caller provides. */ +export function reviewGuideInputFromPullRequest( + detail: PullRequestDetail, + activity: PullRequestActivity, + baseCommit: string, +): Omit { + return { + number: detail.number, + title: detail.title, + description: detail.body, + baseBranch: detail.baseBranch, + baseCommit, + settledReview: settledReviewSummary(activity), + }; +} diff --git a/apps/server/src/pullRequest/pullRequestReviewGuideNormalize.test.ts b/apps/server/src/pullRequest/pullRequestReviewGuideNormalize.test.ts new file mode 100644 index 000000000000..71bef8c44805 --- /dev/null +++ b/apps/server/src/pullRequest/pullRequestReviewGuideNormalize.test.ts @@ -0,0 +1,212 @@ +import { describe, expect, it } from "vite-plus/test"; +import type { PullRequestReviewGuideStop } from "@t3tools/contracts"; +import type { ReviewGuideGenerationResult } from "@t3tools/provider-core/server/textGeneration"; + +import { normalizeReviewGuide, parseReviewGuideChange } from "./pullRequestReviewGuideNormalize.ts"; + +// `git diff --unified=0` of: src/a.ts with lines 10-14 and 40-59 added and an old line 30 +// removed; src/gone.ts deleted (old lines 1-20); docs/readme.md with line 3 added. +const patch = [ + "diff --git a/src/a.ts b/src/a.ts", + "index 1111111..2222222 100644", + "--- a/src/a.ts", + "+++ b/src/a.ts", + "@@ -9,0 +10,5 @@ export const x = 1;", + "+added", + "@@ -30 +34,0 @@ function y() {", + "-removed", + "@@ -35,0 +40,20 @@", + "+added", + "diff --git a/src/gone.ts b/src/gone.ts", + "deleted file mode 100644", + "--- a/src/gone.ts", + "+++ /dev/null", + "@@ -1,20 +0,0 @@", + "-removed", + "diff --git a/docs/readme.md b/docs/readme.md", + "--- a/docs/readme.md", + "+++ b/docs/readme.md", + "@@ -2,0 +3 @@", + "+added", + "", +].join("\n"); +const change = parseReviewGuideChange("src/a.ts\0src/gone.ts\0docs/readme.md\0", patch); + +const stop = (overrides: Partial): PullRequestReviewGuideStop => ({ + title: "Stop", + summary: "", + attention: null, + excerpts: [], + files: [], + notes: [], + questions: [], + ...overrides, +}); + +const guide = (overrides: Partial): ReviewGuideGenerationResult => ({ + stops: [], + ...overrides, +}); + +const excerpt = (path: string, start: number, end: number, side: "new" | "old" = "new") => ({ + path, + side, + start, + end, + caption: "", +}); + +describe("parseReviewGuideChange", () => { + it("reads a removed or added line that looks like a file header as a source line", () => { + const tricky = [ + "diff --git a/notes.md b/notes.md", + "--- a/notes.md", + "+++ b/notes.md", + "@@ -3,1 +3,2 @@", + "--- a/old-heading", + "+++ b/other.ts", + "+tail", + "@@ -9,0 +10,1 @@", + "+later", + "", + ].join("\n"); + const parsed = parseReviewGuideChange("notes.md\0", tricky); + expect([...parsed.hunks.keys()]).toEqual(["notes.md"]); + expect(parsed.hunks.get("notes.md")).toEqual({ + new: [ + { start: 3, end: 4 }, + { start: 10, end: 10 }, + ], + old: [{ start: 3, end: 3 }], + }); + }); + + it("reads each side's hunks, keying a deleted file by its old path", () => { + expect(change.files).toEqual(["src/a.ts", "src/gone.ts", "docs/readme.md"]); + expect(change.hunks.get("src/a.ts")).toEqual({ + new: [ + { start: 10, end: 14 }, + { start: 40, end: 59 }, + ], + old: [{ start: 30, end: 30 }], + }); + expect(change.hunks.get("src/gone.ts")).toEqual({ new: [], old: [{ start: 1, end: 20 }] }); + expect(change.hunks.get("docs/readme.md")).toEqual({ new: [{ start: 3, end: 3 }], old: [] }); + }); +}); + +describe("normalizeReviewGuide", () => { + it("clamps excerpts to changed lines, merges touching ones, and caps their length", () => { + const result = normalizeReviewGuide( + guide({ + stops: [ + stop({ + excerpts: [ + // Starts on context before the hunk: clamped to the hunk. + excerpt("src/a.ts", 5, 12), + // Touches the previous one once clamped: merged. + excerpt("src/a.ts", 13, 14), + // Spans the whole second hunk and beyond: clamped to it. + excerpt("src/a.ts", 38, 80), + // On no changed line: dropped. + excerpt("src/a.ts", 20, 25), + // Removed code shown from the old side. + excerpt("src/gone.ts", 2, 8, "old"), + ], + files: [{ path: "docs/readme.md", note: "" }], + }), + ], + }), + change, + ); + expect(result.stops[0]?.excerpts).toEqual([ + excerpt("src/a.ts", 10, 14), + excerpt("src/a.ts", 40, 59), + excerpt("src/gone.ts", 2, 8, "old"), + ]); + expect(result.stops[0]?.files).toEqual([{ path: "docs/readme.md" }]); + expect(result.stops).toHaveLength(1); + }); + + it("keeps a long excerpt to its first 25 lines", () => { + const long = parseReviewGuideChange( + "big.ts\0", + "--- a/big.ts\n+++ b/big.ts\n@@ -0,0 +1,100 @@\n", + ); + const result = normalizeReviewGuide( + guide({ stops: [stop({ excerpts: [excerpt("big.ts", 1, 100)] })] }), + long, + ); + expect(result.stops[0]?.excerpts).toEqual([excerpt("big.ts", 1, 25)]); + }); + + it("drops notes outside the stop's remaining excerpts", () => { + const result = normalizeReviewGuide( + guide({ + stops: [ + stop({ + excerpts: [excerpt("src/a.ts", 5, 12)], + notes: [ + { path: "src/a.ts", line: 11, title: "Inside", body: "" }, + { path: "src/a.ts", line: 6, title: "Clamped away", body: "" }, + { path: "src/other.ts", line: 11, title: "Other file", body: "" }, + ], + }), + ], + }), + change, + ); + expect(result.stops[0]?.notes.map((note) => note.title)).toEqual(["Inside"]); + }); + + it("drops excerpts and files outside the change", () => { + const result = normalizeReviewGuide( + guide({ + stops: [ + stop({ + excerpts: [excerpt("src/unchanged.ts", 1, 5)], + files: [{ path: "src/a.ts", note: "edited" }, { path: "src/unchanged.ts" }], + }), + ], + }), + change, + ); + expect(result.stops[0]?.excerpts).toEqual([]); + expect(result.stops[0]?.files).toEqual([{ path: "src/a.ts", note: "edited" }]); + }); + + it("puts changed files the tour left out into a closing stop", () => { + const result = normalizeReviewGuide( + guide({ stops: [stop({ excerpts: [excerpt("src/a.ts", 10, 12)] })] }), + change, + ); + expect(result.stops.at(-1)).toMatchObject({ + title: "Other files", + files: [{ path: "src/gone.ts" }, { path: "docs/readme.md" }], + }); + }); + + it("lets stops show different parts of one file and lists an unshown file once", () => { + const result = normalizeReviewGuide( + guide({ + stops: [ + stop({ + title: "Shape", + excerpts: [excerpt("src/a.ts", 10, 14)], + files: [{ path: "docs/readme.md" }], + }), + stop({ + title: "Implementation", + excerpts: [excerpt("src/a.ts", 40, 50)], + files: [{ path: "src/a.ts" }, { path: "docs/readme.md" }, { path: "src/gone.ts" }], + }), + ], + }), + change, + ); + expect(result.stops.map((s) => [s.title, s.excerpts.map((e) => e.start), s.files])).toEqual([ + ["Shape", [10], [{ path: "docs/readme.md" }]], + ["Implementation", [40], [{ path: "src/gone.ts" }]], + ]); + }); +}); diff --git a/apps/server/src/pullRequest/pullRequestReviewGuideNormalize.ts b/apps/server/src/pullRequest/pullRequestReviewGuideNormalize.ts new file mode 100644 index 000000000000..fdcf526b31b3 --- /dev/null +++ b/apps/server/src/pullRequest/pullRequestReviewGuideNormalize.ts @@ -0,0 +1,184 @@ +import type { PullRequestReviewGuideExcerpt, PullRequestReviewGuideStop } from "@t3tools/contracts"; +import type { ReviewGuideGenerationResult } from "@t3tools/provider-core/server/textGeneration"; + +/** Longest excerpt kept. The reader wants the lines that carry the idea, not the whole hunk. */ +const MAX_EXCERPT_LINES = 25; + +interface LineRange { + readonly start: number; + readonly end: number; +} + +/** The lines a change touched in one file, on each side of the diff. */ +interface FileHunks { + readonly new: Array; + readonly old: Array; +} + +/** What a guide is held to: the changed paths, and the lines each touched. */ +export interface ReviewGuideChange { + /** Every changed path, in diff order, named as `git diff --name-only` names it. */ + readonly files: ReadonlyArray; + readonly hunks: ReadonlyMap; +} + +const HUNK_HEADER = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/; + +const hunkRange = (start: string, count: string | undefined): LineRange | null => { + const lines = count === undefined ? 1 : Number(count); + return lines === 0 ? null : { start: Number(start), end: Number(start) + lines - 1 }; +}; + +/** + * Reads `git diff --name-only -z` and `git diff --unified=0` of the same range. Hunks are keyed + * by the new path, or the old one for a deleted file, so both sides of a renamed file sit under + * the name the model sees in `--stat`. + */ +export function parseReviewGuideChange(nameOnly: string, patch: string): ReviewGuideChange { + const hunks = new Map(); + let oldPath: string | null = null; + let current: FileHunks | null = null; + // Inside a file's hunks a `--- ` or `+++ ` line is a removed or added source line, not a header. + let inHunks = false; + for (const line of patch.split("\n")) { + if (line.startsWith("diff --git ")) { + oldPath = null; + current = null; + inHunks = false; + } else if (!inHunks && line.startsWith("--- ")) { + oldPath = line.startsWith("--- a/") ? line.slice(6) : null; + } else if (!inHunks && line.startsWith("+++ ")) { + const path = line.startsWith("+++ b/") ? line.slice(6) : oldPath; + if (path === null) continue; + current = hunks.get(path) ?? { new: [], old: [] }; + hunks.set(path, current); + } else if (current !== null) { + const header = HUNK_HEADER.exec(line); + if (header === null) continue; + inHunks = true; + const removed = hunkRange(header[1]!, header[2]); + const added = hunkRange(header[3]!, header[4]); + if (removed !== null) current.old.push(removed); + if (added !== null) current.new.push(added); + } + } + return { files: nameOnly.split("\0").filter((path) => path !== ""), hunks }; +} + +/** + * Narrows an excerpt to the changed lines it covers: from the first changed line it overlaps to + * the last. Null when it covers none, which means the model pointed somewhere the change is not. + */ +function clampExcerpt( + excerpt: PullRequestReviewGuideExcerpt, + change: ReviewGuideChange, +): PullRequestReviewGuideExcerpt | null { + const start = Math.min(excerpt.start, excerpt.end); + const end = Math.max(excerpt.start, excerpt.end); + const overlapping = (change.hunks.get(excerpt.path)?.[excerpt.side] ?? []).filter( + (hunk) => hunk.start <= end && start <= hunk.end, + ); + const first = overlapping[0]; + const last = overlapping.at(-1); + if (first === undefined || last === undefined) return null; + return { ...excerpt, start: Math.max(start, first.start), end: Math.min(end, last.end) }; +} + +/** Joins a stop's excerpts that overlap or touch in one file, then caps each one's length. */ +function mergeExcerpts( + excerpts: ReadonlyArray, +): Array { + const merged: Array = []; + for (const excerpt of excerpts) { + const index = merged.findIndex( + (other) => + other.path === excerpt.path && + other.side === excerpt.side && + other.start <= excerpt.end + 1 && + excerpt.start <= other.end + 1, + ); + const other = merged[index]; + if (other === undefined) { + merged.push(excerpt); + continue; + } + merged[index] = { + ...other, + start: Math.min(other.start, excerpt.start), + end: Math.max(other.end, excerpt.end), + caption: other.caption || excerpt.caption, + }; + } + return merged.map((excerpt) => ({ + ...excerpt, + end: Math.min(excerpt.end, excerpt.start + MAX_EXCERPT_LINES - 1), + })); +} + +const nonEmpty = (value: string) => value.trim() !== ""; + +/** + * Holds a generated guide to the change it describes. A model's line numbers and ids are + * suggestions: excerpts are clamped to changed lines, notes must sit inside what is left, and any + * changed file the tour skipped lands in a closing skim stop. + */ +export function normalizeReviewGuide( + guide: ReviewGuideGenerationResult, + change: ReviewGuideChange, +): ReviewGuideGenerationResult { + const changed = new Set(change.files); + const inChange = (path: string) => changed.has(path); + + const shaped: Array = guide.stops + .filter((stop) => nonEmpty(stop.title)) + .map((stop) => { + const excerpts = mergeExcerpts( + stop.excerpts.flatMap((excerpt) => { + const clamped = inChange(excerpt.path) ? clampExcerpt(excerpt, change) : null; + return clamped === null ? [] : [clamped]; + }), + ); + return { + ...stop, + attention: stop.attention !== null && nonEmpty(stop.attention.note) ? stop.attention : null, + excerpts, + files: stop.files + .filter((file) => inChange(file.path)) + .map(({ path, note }) => (note?.trim() ? { path, note } : { path })), + notes: stop.notes.filter( + (note) => + nonEmpty(note.title) && + excerpts.some( + (excerpt) => + excerpt.path === note.path && + excerpt.start <= note.line && + note.line <= excerpt.end, + ), + ), + questions: stop.questions.filter(nonEmpty), + }; + }); + + // A file can be excerpted in several stops, its shape in one and its implementation in another. + // A files entry is for a file the tour never shows, so it is listed once and only then. + const placed = new Set(shaped.flatMap((stop) => stop.excerpts.map((excerpt) => excerpt.path))); + const stops = shaped.map((stop) => { + const files = stop.files.filter((file) => !placed.has(file.path)); + for (const file of files) placed.add(file.path); + return { ...stop, files }; + }); + const unplaced = change.files.filter((path) => !placed.has(path)); + if (unplaced.length > 0) { + stops.push({ + title: "Other files", + summary: "Changed files the tour does not otherwise cover.", + attention: null, + excerpts: [], + files: unplaced.map((path) => ({ path })), + notes: [], + questions: [], + }); + } + + return { ...guide, stops }; +} diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index f1daa720016f..9f997b1aa30f 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -39,6 +39,8 @@ import * as PullRequestProviderRegistry from "./pullRequest/PullRequestProviderR import * as PullRequestService from "./pullRequest/PullRequestService.ts"; import * as SqlitePersistence from "./persistence/Sqlite.ts"; import * as PullRequestFilesViewed from "./persistence/PullRequestFilesViewed.ts"; +import * as PullRequestReviewGuides from "./persistence/PullRequestReviewGuides.ts"; +import * as PullRequestReviewGuideService from "./pullRequest/PullRequestReviewGuideService.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import * as ProviderEventIngestor from "./orchestration-v2/ProviderEventIngestor.ts"; @@ -717,8 +719,13 @@ const layerMakeRoutes = Layer.mergeAll( Layer.provide(McpOAuth.layerMcpClientAuthenticator), ), ).pipe( + // Guides read through the same pull request service as every other surface. + Layer.provide( + PullRequestReviewGuideService.layer.pipe(Layer.provide(PullRequestReviewGuides.layer)), + ), // Both transports consume the same service instance, so caches single-flight across clients - // and mutations observed on WebSocket invalidate patches subsequently read over HTTP. + // and mutations observed on WebSocket invalidate patches subsequently read over HTTP. Provided + // after the guide service so it reaches that too. Layer.provide(layerPullRequestService), // The stream route and the WebSocket RPCs share one browser. Layer.provide(ServerBrowser.layer.pipe(Layer.provide(DesktopBrowserChannel.layer))), diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 5a36f77e4220..0d39ee4ee79e 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -72,7 +72,10 @@ import { writeSecretFieldValues, } from "./sourceControl/sourceControlHostSecrets.ts"; -export { resolveSourceControlWriterModelSelection } from "@t3tools/shared/serverSettings"; +export { + resolveReviewGuideModelSelection, + resolveSourceControlWriterModelSelection, +} from "@t3tools/shared/serverSettings"; const encodeServerSettings = Schema.encodeEffect(ServerSettings); const encodeServerSettingsJson = Schema.encodeUnknownEffect(fromJsonStringPretty(ServerSettings)); @@ -613,6 +616,7 @@ const ATOMIC_SETTINGS_KEYS: ReadonlySet = new Set([ "automaticGitFetchInterval", "providerHealthRefreshInterval", "sourceControlWriterModelSelection", + "reviewGuideModelSelection", "textGenerationModelSelection", "pullRequestMergeMethod", ]); diff --git a/apps/server/src/textGeneration/AntigravityTextGeneration.ts b/apps/server/src/textGeneration/AntigravityTextGeneration.ts index 2c06b082feef..b7c2ed4acc62 100644 --- a/apps/server/src/textGeneration/AntigravityTextGeneration.ts +++ b/apps/server/src/textGeneration/AntigravityTextGeneration.ts @@ -306,5 +306,8 @@ export const makeAntigravityTextGeneration = Effect.fn("makeAntigravityTextGener ), ); - return TextGenerationOperations.fromRunner("AntigravityTextGeneration", runAntigravityJson); + return TextGenerationOperations.fromRunner( + "AntigravityTextGeneration", + TextGenerationOperations.withoutInvestigation("Antigravity", runAntigravityJson), + ); }); diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.ts index 25695f7fdefa..7c6eb7532490 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.ts @@ -40,6 +40,22 @@ import { import { makeClaudeEnvironment } from "../provider/Drivers/ClaudeHome.ts"; const CLAUDE_TIMEOUT_MS = 180_000; +/** The tools an investigation can see. `--tools` takes bare names; patterns go in the allow list. */ +const CLAUDE_INVESTIGATION_TOOLS = "Read,Grep,Glob,Bash"; +/** What it may run without asking. In dontAsk mode every other call, Bash included, is refused. */ +const CLAUDE_INVESTIGATION_ALLOWED = [ + "Read", + "Grep", + "Glob", + "Bash(git diff:*)", + "Bash(git log:*)", + "Bash(git show:*)", +].join(","); +/** + * The allowed git commands accept any trailing arguments, and `--output` makes each of them write + * a file anywhere. Deny rules win over allow rules, so this keeps the investigation read-only. + */ +const CLAUDE_INVESTIGATION_DENIED = ["Bash(git * --output*)", "Bash(git * -o *)"].join(","); /** * Schema for the wrapper JSON returned by `claude -p --output-format json`. @@ -112,6 +128,7 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu prompt, outputSchema: outputSchemaJson, modelSelection, + investigate, }: TextGenerationOperations.Request): Effect.fn.Return< S["Type"], TextGenerationError, @@ -189,8 +206,18 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu "--settings", settingsJson, // Metadata prompts need no executable capabilities, even when they contain a skill name. - "--tools", - "", + // An investigation gets the read-only file tools and Git's read commands, and nothing + // that writes: in dontAsk mode anything not allowed here is refused. + ...(investigate + ? [ + "--tools", + CLAUDE_INVESTIGATION_TOOLS, + "--allowedTools", + CLAUDE_INVESTIGATION_ALLOWED, + "--disallowedTools", + CLAUDE_INVESTIGATION_DENIED, + ] + : ["--tools", ""]), "--disable-slash-commands", "--strict-mcp-config", "--permission-mode", @@ -246,7 +273,7 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu const rawStdout = yield* runClaudeCommand().pipe( Effect.scoped, - Effect.timeoutOption(CLAUDE_TIMEOUT_MS), + Effect.timeoutOption(investigate?.timeoutMs ?? CLAUDE_TIMEOUT_MS), Effect.flatMap( Option.match({ onNone: () => diff --git a/apps/server/src/textGeneration/CodexTextGeneration.ts b/apps/server/src/textGeneration/CodexTextGeneration.ts index 3902b0c74d78..16d8bf9a794e 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.ts @@ -150,6 +150,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func outputSchema: outputSchemaJson, modelSelection, attachments, + investigate, }: TextGenerationOperations.Request): Effect.fn.Return< S["Type"], TextGenerationError, @@ -272,7 +273,8 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func return yield* Effect.gen(function* () { yield* runCodexCommand().pipe( Effect.scoped, - Effect.timeoutOption(CODEX_TIMEOUT_MS), + // The read-only sandbox already holds an investigation to reading; it only needs longer. + Effect.timeoutOption(investigate?.timeoutMs ?? CODEX_TIMEOUT_MS), Effect.flatMap( Option.match({ onNone: () => diff --git a/apps/server/src/textGeneration/TextGeneration.test.ts b/apps/server/src/textGeneration/TextGeneration.test.ts index 61baaebe4dc2..64d80e1b3e6e 100644 --- a/apps/server/src/textGeneration/TextGeneration.test.ts +++ b/apps/server/src/textGeneration/TextGeneration.test.ts @@ -24,6 +24,7 @@ const makeStubTextGeneration = ( generatePrContent: () => Effect.die("generatePrContent stub not configured for this test"), generateBranchName: () => Effect.die("generateBranchName stub not configured for this test"), generateThreadTitle: () => Effect.die("generateThreadTitle stub not configured for this test"), + generateReviewGuide: () => Effect.die("generateReviewGuide stub not configured for this test"), ...overrides, }); diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index ee6f18e7b217..11359145b54b 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -17,6 +17,8 @@ export type { CommitMessageGenerationResult, PrContentGenerationInput, PrContentGenerationResult, + ReviewGuideGenerationInput, + ReviewGuideGenerationResult, ThreadTitleGenerationInput, ThreadTitleGenerationResult, } from "@t3tools/provider-core/server/textGeneration"; @@ -32,7 +34,8 @@ type TextGenerationOp = | "generateCommitMessage" | "generatePrContent" | "generateBranchName" - | "generateThreadTitle"; + | "generateThreadTitle" + | "generateReviewGuide"; const resolveInstance = ( registry: ProviderInstanceRegistry.ProviderInstanceRegistry["Service"], @@ -69,6 +72,10 @@ export const make = Effect.gen(function* () { resolveInstance(registry, "generateBranchName", input.modelSelection.instanceId).pipe( Effect.flatMap((textGeneration) => textGeneration.generateBranchName(input)), ), + generateReviewGuide: (input) => + resolveInstance(registry, "generateReviewGuide", input.modelSelection.instanceId).pipe( + Effect.flatMap((textGeneration) => textGeneration.generateReviewGuide(input)), + ), generateThreadTitle: (input) => resolveInstance(registry, "generateThreadTitle", input.modelSelection.instanceId).pipe( Effect.flatMap((textGeneration) => diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 61bad3faf318..80921ec4b5a2 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -224,6 +224,7 @@ import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import * as UsageService from "./usage/UsageService.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; import * as PullRequestService from "./pullRequest/PullRequestService.ts"; +import * as PullRequestReviewGuideService from "./pullRequest/PullRequestReviewGuideService.ts"; import { listLinkedPullRequestThreads } from "./pullRequest/linkedThreads.ts"; import { pullRequestSyncKey } from "./pullRequest/pullRequestSyncKey.ts"; import * as SqlClient from "effect/sql/SqlClient"; @@ -1225,6 +1226,7 @@ const layerWsRpc = ( const scheduledTasks = yield* ScheduledTasks.ScheduledTaskService; const secretRequests = yield* SecretRequests.SecretRequests; const pullRequests = yield* PullRequestService.PullRequestService; + const reviewGuides = yield* PullRequestReviewGuideService.PullRequestReviewGuideService; const pullRequestSync = yield* PullRequestSyncReactor.PullRequestSyncReactor; const deviceService = yield* DeviceService.DeviceService; const deviceHostContext = @@ -2490,6 +2492,10 @@ const layerWsRpc = ( withPullRequestViewer(input, pullRequests.checks(input)), [WS_METHODS.pullRequestsActivity]: (input) => withPullRequestViewer(input, pullRequests.activity(input)), + [WS_METHODS.pullRequestsReviewGuide]: (input) => + withPullRequestViewer(input, reviewGuides.get(input)), + [WS_METHODS.pullRequestsGenerateReviewGuide]: (input) => + withPullRequestViewer(input, reviewGuides.generate(input)), [WS_METHODS.pullRequestsThreadComments]: (input) => withPullRequestViewer(input, pullRequests.threadComments(input)), [WS_METHODS.pullRequestsDiffFileContents]: (input) => @@ -3138,6 +3144,7 @@ export const layer = Layer.unwrap( const serverBrowser = yield* ServerBrowser.ServerBrowser; const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate; const pullRequests = yield* PullRequestService.PullRequestService; + const reviewGuides = yield* PullRequestReviewGuideService.PullRequestReviewGuideService; const sql = yield* SqlClient.SqlClient; return HttpRouter.add( "GET", @@ -3205,6 +3212,13 @@ export const layer = Layer.unwrap( // mutation invalidates the HTTP diff cache that every client reads from. Layer.provide(Layer.succeed(PullRequestService.PullRequestService, pullRequests)), Layer.provide(OtlpEndpointCheck.layer), + // Shared like the pull request service, so concurrent generations single-flight. + Layer.provide( + Layer.succeed( + PullRequestReviewGuideService.PullRequestReviewGuideService, + reviewGuides, + ), + ), Layer.provide( SourceControlDiscovery.layer.pipe( Layer.provide( diff --git a/apps/web/src/components/diffs/StyledDiffCodeView.tsx b/apps/web/src/components/diffs/StyledDiffCodeView.tsx index e18d9ca4ba82..9522f53b6d14 100644 --- a/apps/web/src/components/diffs/StyledDiffCodeView.tsx +++ b/apps/web/src/components/diffs/StyledDiffCodeView.tsx @@ -12,7 +12,8 @@ import type { Ref } from "react"; import { DIFF_SURFACE_THEME_UNSAFE_CSS } from "~/lib/diffRendering"; import { DiffWorkerPoolProvider } from "../DiffWorkerPoolProvider"; -const DIFF_VIEW_UNSAFE_CSS = `${DIFF_SURFACE_THEME_UNSAFE_CSS} +/** The app's diff stylesheet, shared by single-file renderers so their separators match. */ +export const DIFF_VIEW_UNSAFE_CSS = `${DIFF_SURFACE_THEME_UNSAFE_CSS} :is( [data-line], [data-line-annotation], @@ -98,101 +99,35 @@ const DIFF_VIEW_UNSAFE_CSS = `${DIFF_SURFACE_THEME_UNSAFE_CSS} box-shadow: inset 3px 0 color-mix(in srgb, var(--code-foreground) 24%, transparent); } +/* Hunk separators are the way to the unchanged lines between hunks. They keep the library's + layout, with the expand button in its own cell, but sit one step off the code surface so they + read as quiet controls; hover brings them forward. */ :is([data-separator="line-info"], [data-separator="line-info-basic"]) { + /* Exact and margin-free: the virtualizer places rows from hunkSeparatorHeight below. */ height: 24px !important; margin-block: 0 !important; - background-color: var(--code-background) !important; -} - -:is([data-separator="line-info"], [data-separator="line-info-basic"]) - [data-separator-wrapper] { - padding-inline: 8px 12px !important; - background-color: transparent !important; + --diffs-bg-separator: color-mix(in srgb, var(--code-background) 97%, var(--code-foreground)); } -:is([data-separator="line-info"], [data-separator="line-info-basic"]) - [data-separator-content] { - gap: 8px; - padding-inline: 0 !important; - background-color: transparent !important; - color: color-mix(in srgb, var(--code-foreground) 52%, var(--code-background)) !important; +:is([data-separator="line-info"], [data-separator="line-info-basic"]) [data-separator-content] { + color: color-mix(in srgb, var(--code-foreground) 50%, var(--code-background)) !important; font-family: var(--font-sans) !important; font-size: 11px !important; text-decoration: none !important; } -:is([data-separator="line-info"], [data-separator="line-info-basic"]) - [data-unmodified-lines] { - display: flex !important; - min-width: 0; - flex: 1 1 auto; - align-items: center; - gap: 8px; -} - -:is([data-separator="line-info"], [data-separator="line-info-basic"])[data-expand-index] - [data-unmodified-lines] { - cursor: pointer; -} - -:is([data-separator="line-info"], [data-separator="line-info-basic"]) - [data-unmodified-lines]::before, -:is([data-separator="line-info"], [data-separator="line-info-basic"]) - [data-unmodified-lines]::after { - width: auto; - height: 1px; - flex: 1 1 auto; - content: ""; - background-color: color-mix(in srgb, var(--code-background) 92%, var(--code-foreground)); -} - -:is([data-separator="line-info"], [data-separator="line-info-basic"])[data-expand-index] - [data-separator-wrapper] { - grid-template-columns: 0 minmax(0, 1fr) !important; +:is([data-separator="line-info"], [data-separator="line-info-basic"]) [data-expand-button] { + color: color-mix(in srgb, var(--code-foreground) 50%, var(--code-background)) !important; } :is([data-separator="line-info"], [data-separator="line-info-basic"])[data-expand-index] - [data-separator-content] { - grid-column: 2 !important; -} - -/* Visually hidden rather than display: none so the expand action stays keyboard-reachable. */ -:is([data-separator="line-info"], [data-separator="line-info-basic"]) - [data-expand-button] { - position: absolute !important; - width: 1px !important; - height: 1px !important; - margin: -1px !important; - padding: 0 !important; - overflow: hidden !important; - clip-path: inset(50%) !important; - border: 0 !important; - white-space: nowrap !important; -} - -:is([data-separator="line-info"], [data-separator="line-info-basic"]):has( - [data-expand-button] - ) - [data-separator-content] { - cursor: pointer; -} - -:is([data-separator="line-info"], [data-separator="line-info-basic"]):has( - [data-expand-button] - ):is(:hover, :focus-within) - [data-separator-content] { - color: color-mix(in srgb, var(--code-foreground) 76%, var(--code-background)) !important; -} - -:is([data-separator="line-info"], [data-separator="line-info-basic"]):has( - [data-expand-button] - ):is(:hover, :focus-within) - [data-unmodified-lines]::before, -:is([data-separator="line-info"], [data-separator="line-info-basic"]):has( - [data-expand-button] - ):is(:hover, :focus-within) - [data-unmodified-lines]::after { - background-color: color-mix(in srgb, var(--code-background) 84%, var(--code-foreground)); + :is([data-separator-content], [data-expand-button]):hover { + color: var(--code-foreground) !important; + background-color: color-mix( + in srgb, + var(--code-background) 92%, + var(--code-foreground) + ) !important; } [data-diffs-header] [data-header-content] { diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index 4b2a878a5ca5..25a8c10ad6b2 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -81,7 +81,12 @@ import { import { toastManager } from "../ui/toast"; import { Toggle, ToggleGroup } from "../ui/toggle-group"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; -import { PendingReviewCommentCard, ReviewThreadCard } from "./PullRequestReviewAnnotation"; +import { + getReviewPositionAnchor, + PendingReviewCommentCard, + ReviewThreadCard, + toViewerSide, +} from "./PullRequestReviewAnnotation"; import { isFileDiffCollapsed, isLineInFileDiff, @@ -162,28 +167,6 @@ export interface PullRequestAgentSelectionInput { readonly request: string; } -/** The contract's sides named the way the diff viewer names them, and back again. */ -function toViewerSide(side: PullRequestDiffSide) { - return side === "left" ? ("deletions" as const) : ("additions" as const); -} - -function getReviewPositionAnchor(position: PullRequestReviewPosition): { - line: number; - side: PullRequestDiffSide; -} { - switch (position.kind) { - case "added": - return { line: position.newLine, side: "right" }; - case "deleted": - return { line: position.oldLine, side: "left" }; - case "context": - return { - line: position.side === "left" ? position.oldLine : position.newLine, - side: position.side, - }; - } -} - /** * Whether the viewer draws this line at all. A line counts the new file on the right and the old * one on the left, and each hunk covers one run of each; a line outside every run — a @@ -208,6 +191,7 @@ function PullRequestCodeTab({ onAddToAgentSelection, onRefresh, refreshToken = 0, + revealRequest = null, }: { scrollerRef: (node: HTMLDivElement | null) => void; environmentId: EnvironmentId; @@ -225,6 +209,8 @@ function PullRequestCodeTab({ onRefresh: () => void; /** Bumped by the panel's refresh button: drop the accumulated pages and re-read the diff. */ refreshToken?: number; + /** A file another tab asked to show. A new nonce asks again for the same path. */ + revealRequest?: { readonly path: string; readonly nonce: number } | null; }) { const { resolvedTheme } = useTheme(); const settings = useClientSettings(); @@ -694,6 +680,19 @@ function PullRequestCodeTab({ }, [items, requestTreeReveal, toggleFile], ); + // Answered once the requested file has arrived: a large diff may still be paging when the + // request comes in, so it waits for the slice that carries the path rather than dropping it. + const answeredReveal = useRef(null); + useEffect(() => { + if (revealRequest === null || answeredReveal.current === revealRequest.nonce) return; + if (!items.some((item) => resolveFileDiffPath(item.fileDiff) === revealRequest.path)) { + // The reader is not scrolling toward it, so the sentinel would never ask for its slice. + if (canLoadNextSlice) loadNextSlice(); + return; + } + answeredReveal.current = revealRequest.nonce; + revealFile(revealRequest.path); + }, [canLoadNextSlice, items, loadNextSlice, revealFile, revealRequest]); const toggleAllFiles = () => { // Held as an override of the default rather than as the file keys on screen: a diff that is diff --git a/apps/web/src/components/pullRequest/PullRequestComposer.tsx b/apps/web/src/components/pullRequest/PullRequestComposer.tsx index f341f475e366..af0d8618539a 100644 --- a/apps/web/src/components/pullRequest/PullRequestComposer.tsx +++ b/apps/web/src/components/pullRequest/PullRequestComposer.tsx @@ -32,6 +32,7 @@ export function PullRequestComposer({ onCommentAction, onCommented, onReviewSubmitted, + openReviewRequest, }: { environmentId: EnvironmentId; reference: PullRequestRef; @@ -43,6 +44,11 @@ export function PullRequestComposer({ ) => Promise<{ readonly commentPosted: boolean }>; onCommented: () => void; onReviewSubmitted: () => void; + /** + * Opens the composer on the review, each time the number changes. The guide's last stop asks + * for it, so finishing the tour lands on the comments it collected and the verdict. + */ + openReviewRequest?: number | undefined; }) { const [open, setOpen] = useState(false); const [reviewPending, setReviewPending] = useState(false); @@ -58,6 +64,12 @@ export function PullRequestComposer({ (store) => (store.summaries[reviewKey] ?? "").trim().length > 0, ); const reviewStarted = pendingComments.length > 0 || summaryStarted; + const [answeredOpenRequest, setAnsweredOpenRequest] = useState(openReviewRequest); + if (openReviewRequest !== answeredOpenRequest) { + setAnsweredOpenRequest(openReviewRequest); + setRequestedMode("review"); + setOpen(true); + } // What is offered is the intersection of two different questions: what this host can do at // all, and what this account may do on this repository. Either one saying no means a control diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.test.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.test.tsx index 176009e1a98a..f16bbd0aa58c 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.test.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.test.tsx @@ -50,7 +50,13 @@ vi.mock("~/lib/sourceControlActions", () => ({ vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); vi.mock("~/state/pullRequests", async (importOriginal) => ({ ...(await importOriginal()), - pullRequestEnvironment: { detail: () => "detail", activity: () => "activity" }, + pullRequestEnvironment: { + detail: () => "detail", + activity: () => "activity", + // The header menu reads the stored guide to label "Write guide" or "Rewrite guide". + reviewGuide: () => "review-guide", + generateReviewGuide: { permissionAtom: () => "generate-review-guide" }, + }, usePullRequestTurnRefresh: () => 0, useSharedPullRequestSummary: () => null, })); @@ -124,6 +130,8 @@ vi.mock("./PullRequestSummaryTab", () => ({
    ), })); +// The guide renders diffs, which start the diff worker; this suite never opens it. +vi.mock("./PullRequestGuideTab", () => ({ PullRequestGuideTab: () => null })); vi.mock("./PullRequestCodeTab", () => ({ default: ({ onAddToAgentSelection, diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 526a1ae9bfbc..d68983937f19 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -2,7 +2,11 @@ import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; import { useAtomValue } from "@effect/atom-react"; import { usePullRequestStack } from "~/state/usePullRequestStack"; import { RefreshIcon } from "~/components/ui/refresh-icon"; -import { scopedThreadKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; +import { + scopedThreadKey, + scopeProjectRef, + scopeThreadRef, +} from "@t3tools/client-runtime/environment"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { AuthOrchestrationOperateScope, @@ -13,6 +17,7 @@ import { type PullRequestListEntry, type PullRequestUpdateMethod, type PullRequestRef, + type PullRequestReviewGuideStop, resolveEnvironmentMachineKind, type ScopedThreadRef, } from "@t3tools/contracts"; @@ -37,6 +42,7 @@ import { PanelRightIcon, PlayIcon, RotateCcwIcon, + SparklesIcon, TriangleAlertIcon, } from "lucide-react"; import { @@ -54,6 +60,8 @@ import { } from "react"; import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; +import { useRightPanelStore } from "~/rightPanelStore"; +import { useEnvironmentSettings } from "~/hooks/useSettings"; import { useNewThreadHandler } from "~/hooks/useHandleNewThread"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { isCommandPaletteOpen } from "~/commandPaletteBus"; @@ -127,6 +135,7 @@ import { PullRequestSummaryTab } from "./PullRequestSummaryTab"; import { PullRequestTimelineTab } from "./PullRequestTimelineTab"; import { buildAddSelectionToAgentHandoff, + buildAskAboutGuideStopHandoff, buildAskAboutPullRequestHandoff, buildExplainPullRequestHandoff, buildFixFindingHandoff, @@ -175,9 +184,12 @@ import { summarizePullRequestChecks, } from "./pullRequestPresentation"; import { PullRequestGlyph } from "./pullRequestIcons"; +import { PullRequestGuideTab } from "./PullRequestGuideTab"; +import { guideRunKey, useGuideRunStore } from "./guide/guideRunStore"; +import { useReviewGuideWriter } from "./guide/useReviewGuideWriter"; import { useInlineActionConfirm } from "./useInlineActionConfirm"; -type DetailTab = "summary" | "timeline" | "code"; +type DetailTab = "summary" | "guide" | "timeline" | "code"; /** Said as the thing that did not happen, rather than as the operation that returned an error. */ const ACTION_FAILURE_LABELS: Record = { @@ -228,6 +240,7 @@ const UPDATE_BRANCH_REBASE_FAILURE_HINT = const TABS: ReadonlyArray<{ value: DetailTab; label: string }> = [ { value: "summary", label: "Summary" }, + { value: "guide", label: "Guide" }, { value: "timeline", label: "Timeline" }, { value: "code", label: "Code" }, ]; @@ -406,6 +419,7 @@ export function PullRequestDetailPanel({ composerDraftTarget, onBack, onSelectPullRequest, + onGuideShown, }: { environmentId: EnvironmentId; shortcutsEnabled: boolean; @@ -453,6 +467,8 @@ export function PullRequestDetailPanel({ * expects to land on it, with this one still open behind. */ onBack?: (() => void) | undefined; + /** Told when a written guide comes on screen. The page maximizes its panel for the guide. */ + onGuideShown?: (() => void) | undefined; }) { const environmentConfigs = useServerConfigs(); const projects = useProjects(); @@ -484,6 +500,8 @@ export function PullRequestDetailPanel({ ? listEntry : null; const [threadPickerOpen, setThreadPickerOpen] = useState(false); + // Counted up by the guide's last stop to open the composer on the review. + const [openReviewRequest, setOpenReviewRequest] = useState(0); const [tab, setTab] = useState("summary"); const [timelineOrder, setTimelineOrder] = useState<"newest" | "oldest">("newest"); const [codeCommitScope, setCodeCommitScope] = useState<{ @@ -499,6 +517,25 @@ export function PullRequestDetailPanel({ selectCodeCommit(oid); setTab("code"); }; + // The guide names files; following one shows it in the whole diff, not a single commit's. + const [codeRevealRequest, setCodeRevealRequest] = useState<{ + readonly path: string; + readonly nonce: number; + } | null>(null); + const openFileInCode = (path: string) => { + selectCodeCommit(null); + setCodeRevealRequest((previous) => ({ path, nonce: (previous?.nonce ?? 0) + 1 })); + void loadCodeTab(); + setTab("code"); + }; + // A guide notice asks for its pull request's Guide tab once the page has opened it. + const guideOpenRequested = useGuideRunStore( + (state) => state.openRequestKey === guideRunKey(environmentId, reference), + ); + if (guideOpenRequested && tab !== "guide") setTab("guide"); + useEffect(() => { + if (guideOpenRequested) useGuideRunStore.getState().requestOpen(null); + }, [guideOpenRequested]); // Every tab the reader has opened stays mounted behind the active one. The diff viewer // always needed this (it virtualizes against its own scroll position); the trace showed the // summary needs it too — a large description re-parses its whole markdown on every return @@ -1070,6 +1107,17 @@ export function PullRequestDetailPanel({ const canPrepareWorktree = prepareThread.isAllowed && canOperateThread; const canFixFindings = attachTarget !== null || canPrepareWorktree; const handoffLabels = pullRequestHandoffLabels(attachTarget !== null); + // The guide is written from the menu as well as its tab, so a reader can ask for a rewrite + // without the tour carrying a control for it. + const guideWriter = useReviewGuideWriter({ + environmentId, + reference, + title: detail?.title ?? null, + }); + const writeGuide = () => { + void guideWriter.write(); + if (guideWriter.data?.guide == null) setTab("guide"); + }; const writeTaskToComposer = (target: ScopedThreadRef | DraftId, task: ThreadTask) => { const store = useComposerDraftStore.getState(); @@ -1136,8 +1184,16 @@ export function PullRequestDetailPanel({ return session; }; - /** A question about the change, which needs a thread and nothing else. */ - const startAsk = async (kind: string, task: ThreadTask) => { + /** + * A question about the change, which needs a thread and nothing else. `keepGuide` keeps the + * reader's place: the new thread opens with this pull request beside it on the Guide tab, at the + * stop they asked from, so the answer and the tour can be read together. + */ + const startAsk = async ( + kind: string, + task: ThreadTask, + options?: { readonly keepGuide?: boolean }, + ) => { if (!detail || handoff !== null) return; if (attachTarget !== null) { writeTaskToComposer(attachTarget, task); @@ -1155,6 +1211,22 @@ export function PullRequestDetailPanel({ const projectRef = scopeProjectRef(actingEnvironmentId, acting?.projectId ?? detail.projectId); const opened = await openThreadWithTask(projectRef, task); setHandoff(null); + if (opened !== null && options?.keepGuide === true) { + const draft = useComposerDraftStore.getState().getDraftSession(opened.draftId); + if (draft !== null) { + useRightPanelStore + .getState() + .openPullRequest(scopeThreadRef(draft.environmentId, draft.threadId), { + environmentId, + projectId: reference.projectId, + ...(reference.host === undefined ? {} : { host: reference.host }), + repository: reference.repository, + number: reference.number, + url: detail.url, + }); + useGuideRunStore.getState().requestOpen(guideRunKey(environmentId, reference)); + } + } if (opened === null) { toastManager.add({ type: "error", @@ -1353,7 +1425,35 @@ export function PullRequestDetailPanel({ }); }; - const addSelectionToAgent = (selection: PullRequestAgentSelectionInput) => { + const askAboutGuideStop = (stop: PullRequestReviewGuideStop, index: number, question: string) => { + if (!detail) return; + void startAsk( + `guide-stop:${index}`, + buildAskAboutGuideStopHandoff({ + number: detail.number, + title: detail.title, + url: detail.url, + headBranch: detail.headBranch, + baseBranch: detail.baseBranch, + state: detail.state, + isDraft: detail.isDraft, + stop: { + index, + title: stop.title, + summary: stop.summary, + attention: stop.attention?.note ?? null, + excerpts: stop.excerpts, + }, + question, + }), + { keepGuide: true }, + ); + }; + + const addSelectionToAgent = ( + selection: PullRequestAgentSelectionInput, + options?: { readonly keepGuide?: boolean }, + ) => { if (!detail) return; void startAsk( `selection:${selection.comment.id}`, @@ -1368,6 +1468,7 @@ export function PullRequestDetailPanel({ comment: selection.comment, request: selection.request, }), + options, ); }; @@ -1453,8 +1554,15 @@ export function PullRequestDetailPanel({ // A host that cannot produce a patch has no Code tab to open. While detail is loading the ghost // uses this optimistic tab set to reserve the same chrome; a host without a patch removes Code // when its capabilities arrive. + // The Guide tab is a beta the pull request's server opts into in Settings → Source Control. + const reviewGuidesEnabled = useEnvironmentSettings( + environmentId, + (settings) => settings.reviewGuidesEnabled, + ); const visibleTabs = TABS.filter( - (item) => item.value !== "code" || detail === null || detail.capabilities.diff, + (item) => + (item.value !== "code" || detail === null || detail.capabilities.diff) && + (item.value !== "guide" || reviewGuidesEnabled), ); // The Code tab can be opened while the detail is still on its way, and the detail may then say // this host has no patch to show. The tab goes, so whoever was standing on it is moved back to @@ -2117,6 +2225,16 @@ export function PullRequestDetailPanel({ {handoff === "findings" ? "Preparing..." : handoffLabels.fixFindings} + {reviewGuidesEnabled && guideWriter.canWrite ? ( + + + {guideWriter.running + ? "Writing guide..." + : guideWriter.data?.guide == null + ? "Write guide" + : "Rewrite guide"} + + ) : null} {pickableEnvironments.length > 0 ? (
    ) : null} + {mountedTabs.has("guide") ? ( +
    + + addSelectionToAgent({ comment, request }, { keepGuide: true }) + } + onAskAboutStop={askAboutGuideStop} + onFinishReview={ + detail.capabilities.review.verdicts.some((verdict) => + detail.viewerPermissions.verdicts.includes(verdict), + ) + ? () => setOpenReviewRequest((count) => count + 1) + : null + } + onOpenFile={openFileInCode} + onTourShown={onGuideShown} + /> +
    + ) : null} {mountedTabs.has("timeline") ? (
    {activityPending ? ( @@ -2861,6 +3006,7 @@ export function PullRequestDetailPanel({ {...(canFixFindings ? { onFixFinding: startFixFinding } : {})} onRefresh={refreshDetail} refreshToken={codeRefreshToken} + revealRequest={codeRevealRequest} />
    @@ -2887,6 +3033,7 @@ export function PullRequestDetailPanel({ onCommentAction={performCommentAction} onCommented={refreshDetail} onReviewSubmitted={refreshDetail} + openReviewRequest={openReviewRequest} />
    ) : null} diff --git a/apps/web/src/components/pullRequest/PullRequestGuideTab.tsx b/apps/web/src/components/pullRequest/PullRequestGuideTab.tsx new file mode 100644 index 000000000000..1ecfc2d07c28 --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestGuideTab.tsx @@ -0,0 +1,183 @@ +import type { EnvironmentId, PullRequestRef, PullRequestReviewGuideStop } from "@t3tools/contracts"; +import { SparklesIcon, TriangleAlertIcon } from "lucide-react"; +import { useEffect, useEffectEvent, useMemo } from "react"; + +import type { ReviewCommentContext } from "~/reviewCommentContext"; + +import { Alert, AlertAction, AlertDescription } from "../ui/alert"; +import { Button } from "../ui/button"; +import { Spinner } from "../ui/spinner"; +import type { ExcerptCommenting } from "./guide/GuideExcerpt"; +import { guideRunKey, useGuideRunStore } from "./guide/guideRunStore"; +import { GuideProseContext } from "./guide/GuideProse"; +import { GuideTour } from "./guide/GuideTour"; +import { useReviewGuideWriter } from "./guide/useReviewGuideWriter"; +import { + nextPendingReviewCommentId, + pullRequestReviewKey, + usePendingReviewComments, + usePullRequestReviewStore, +} from "./pullRequestReviewStore"; + +/** + * A maintainer's tour of the pull request: stops through the diff in reading order with the + * calls only a person can make. Generated on request and stored, so opening the tab is free. + * Rewriting a current guide lives in the header menu; the tab only asks when it has a reason. + */ +export function PullRequestGuideTab({ + environmentId, + reference, + title, + workspaceRoot, + active, + canComment, + onAskAboutLines, + onAskAboutStop, + onFinishReview, + onOpenFile, + onTourShown, +}: { + environmentId: EnvironmentId; + reference: PullRequestRef; + /** The pull request's title for the notice when a guide is ready, null until the detail loads. */ + title: string | null; + /** The checkout the guide's prose renders against, as the description does. */ + workspaceRoot: string; + /** Whether this is the tab on screen. */ + active: boolean; + /** Whether this reader may queue line comments for a review on this host. */ + canComment: boolean; + /** Opens an agent thread about lines of the change. Null where no thread can be opened. */ + onAskAboutLines: ((comment: ReviewCommentContext, request: string) => void) | null; + onAskAboutStop: + | ((stop: PullRequestReviewGuideStop, index: number, question: string) => void) + | null; + /** Opens the review composer with the comments queued so far. Null where the reader cannot review. */ + onFinishReview: (() => void) | null; + /** Shows a file in the Code tab. */ + onOpenFile: (path: string) => void; + /** Told when a written guide comes on screen; a run in progress is not one. */ + onTourShown?: (() => void) | undefined; +}) { + const { query, data, running, canWrite, write } = useReviewGuideWriter({ + environmentId, + reference, + title, + }); + const runKey = guideRunKey(environmentId, reference); + // Line comments join the review the Code tab collects, so either place shows and sends them. + const reviewKey = pullRequestReviewKey(reference); + const pending = usePendingReviewComments(reference); + const addComment = usePullRequestReviewStore((store) => store.addComment); + const removeComment = usePullRequestReviewStore((store) => store.removeComment); + const commenting = useMemo( + () => + !canComment && onAskAboutLines === null + ? null + : { + canComment, + pending, + onComment: ({ path, oldPath, position, body }) => + addComment(reviewKey, { + id: nextPendingReviewCommentId(), + path, + ...(oldPath === null ? {} : { oldPath }), + position, + body, + }), + onRemove: (commentId) => removeComment(reviewKey, commentId), + onAskAgent: onAskAboutLines, + }, + [addComment, canComment, onAskAboutLines, pending, removeComment, reviewKey], + ); + useEffect(() => { + if (!active) return; + useGuideRunStore.getState().setShown(runKey); + return () => { + if (useGuideRunStore.getState().shownKey === runKey) + useGuideRunStore.getState().setShown(null); + }; + }, [active, runKey]); + + const guide = data?.guide ?? null; + const tourShown = active && guide !== null; + const reportTourShown = useEffectEvent(() => onTourShown?.()); + useEffect(() => { + if (tourShown) reportTourShown(); + }, [tourShown]); + const runState = data?.run ?? null; + const currentHeadSha = data?.currentHeadSha ?? null; + const stale = guide !== null && currentHeadSha !== null && currentHeadSha !== guide.headSha; + // A failure is shown only while it is about the head the reader is looking at. + const failure = + runState?._tag === "Failed" && runState.headSha === currentHeadSha ? runState.detail : null; + const writeButton = (label: string, size: "sm" | "xs") => + canWrite ? ( + + ) : null; + + if (guide === null) { + if (query.isPending && data === null) { + return ( +
    + +
    + ); + } + return ( +
    +

    + {running ? "Writing a review guide" : "No review guide yet"} +

    +

    + {failure ?? + query.error ?? + (running + ? "An agent is reading the change. A large one takes a few minutes. You can leave; you will be told when it is ready." + : "A guide is a tour of the change: stops through the code in reading order, with the calls only a maintainer can make.")} +

    + {writeButton(failure === null ? "Write guide" : "Try again", "sm")} +
    + ); + } + + // Only a guide that needs attention says so; a current one is just the tour. + const banner = + stale || failure !== null || running ? ( + + {running ? : } + + {running + ? "Rewriting this guide. You will be told when the new one is ready." + : (failure ?? "Written before the latest push.")} + + {!running && canWrite ? {writeButton("Rewrite", "xs")} : null} + + ) : null; + + return ( + + { + const stop = guide.stops[index]; + if (stop !== undefined) onAskAboutStop(stop, index, question); + } + } + onFinish={onFinishReview} + onOpenFile={onOpenFile} + /> + + ); +} diff --git a/apps/web/src/components/pullRequest/PullRequestReviewAnnotation.tsx b/apps/web/src/components/pullRequest/PullRequestReviewAnnotation.tsx index fd36435962f1..419dd820bbaf 100644 --- a/apps/web/src/components/pullRequest/PullRequestReviewAnnotation.tsx +++ b/apps/web/src/components/pullRequest/PullRequestReviewAnnotation.tsx @@ -4,7 +4,9 @@ */ import type { EnvironmentId, + PullRequestDiffSide, PullRequestRef, + PullRequestReviewPosition, PullRequestReviewThread, PullRequestThreadCommentsResult, PullRequestThreadComment, @@ -53,6 +55,28 @@ function submitKeys(input: { }; } +/** The contract's sides named the way the diff viewer names them, and back again. */ +export function toViewerSide(side: PullRequestDiffSide) { + return side === "left" ? ("deletions" as const) : ("additions" as const); +} + +export function getReviewPositionAnchor(position: PullRequestReviewPosition): { + line: number; + side: PullRequestDiffSide; +} { + switch (position.kind) { + case "added": + return { line: position.newLine, side: "right" }; + case "deleted": + return { line: position.oldLine, side: "left" }; + case "context": + return { + line: position.side === "left" ? position.oldLine : position.newLine, + side: position.side, + }; + } +} + /** A comment waiting to be sent with the rest of the review. */ export function PendingReviewCommentCard({ comment, diff --git a/apps/web/src/components/pullRequest/guide/GuideExcerpt.tsx b/apps/web/src/components/pullRequest/guide/GuideExcerpt.tsx new file mode 100644 index 000000000000..5700272b4e71 --- /dev/null +++ b/apps/web/src/components/pullRequest/guide/GuideExcerpt.tsx @@ -0,0 +1,362 @@ +import type { + DiffLineAnnotation, + FileDiffContentsLoader, + FileDiffMetadata, + SelectedLineRange, +} from "@pierre/diffs"; +import { FileDiff } from "@pierre/diffs/react"; +import type { PullRequestReviewGuideExcerpt, PullRequestReviewPosition } from "@t3tools/contracts"; +import { ArrowUpRightIcon } from "lucide-react"; +import { useCallback, useMemo, useState } from "react"; + +import { useTheme } from "~/hooks/useTheme"; +import { + getRenderablePatch, + resolveDiffThemeName, + resolveFileDiffPreviousPath, +} from "~/lib/diffRendering"; +import { PREFERRED_HIGHLIGHTER } from "~/lib/syntaxHighlighting"; +import { + buildDiffReviewComment, + resolveDiffReviewPosition, + type ReviewCommentContext, +} from "~/reviewCommentContext"; + +import { DiffCommentAnnotation } from "../../diffs/DiffCommentAnnotation"; +import { DIFF_VIEW_UNSAFE_CSS } from "../../diffs/StyledDiffCodeView"; +import { Button } from "../../ui/button"; +import { + getReviewPositionAnchor, + PendingReviewCommentCard, + toViewerSide, +} from "../PullRequestReviewAnnotation"; +import type { PendingReviewComment } from "../pullRequestReviewStore"; +import { + excerptExpansionOldContents, + excerptRows, + fileDiffRows, + rowsToPatch, +} from "./guideTour.logic"; +import { GuideNoteMarker } from "./GuideNotes"; +import { GuideProse } from "./GuideProse"; + +// Long lines scroll inside the excerpt. The renderer only shows its scrollbar on hover, which +// reads as code cut off at the edge, so the excerpt keeps it visible. +const EXCERPT_UNSAFE_CSS = `${DIFF_VIEW_UNSAFE_CSS} +[data-code]::-webkit-scrollbar-thumb { + background-color: var(--diffs-bg-context); +} +`; + +export interface ExcerptNoteMarker { + readonly line: number; + readonly markers: ReadonlyArray<{ readonly marker: string; readonly title: string }>; +} + +/** + * What a reader can do with lines of an excerpt, the same two things the Code tab offers: queue a + * comment for the review, or ask an agent about them. Null where neither is possible. + */ +export interface ExcerptCommenting { + /** Whether a comment can be queued for the review; asking an agent needs no host support. */ + readonly canComment: boolean; + /** Comments already queued for the review, placed on the lines they belong to. */ + readonly pending: ReadonlyArray; + readonly onComment: (comment: { + readonly path: string; + readonly oldPath: string | null; + readonly position: PullRequestReviewPosition; + readonly body: string; + }) => void; + readonly onRemove: (commentId: string) => void; + /** Opens an agent thread about the lines; null where no thread can be opened. */ + readonly onAskAgent: ((comment: ReviewCommentContext, request: string) => void) | null; +} + +interface ExcerptAnnotation { + readonly markers: ExcerptNoteMarker["markers"]; + readonly pending: ReadonlyArray; + readonly draft: boolean; +} + +interface ExcerptDraft { + readonly range: SelectedLineRange; + readonly position: PullRequestReviewPosition; +} + +/** + * The lines of one excerpt, drawn by the same renderer as the Code tab from a patch cut down to + * the excerpt's range, so line numbers, colors and highlighting match the full diff. The cut keeps + * the file's real line numbers, so the bars above and below it load the code around the excerpt + * from the file itself, as they do between hunks in the Code tab. + */ +export function GuideExcerpt({ + excerpt, + file, + loading, + notes, + commenting, + loadDiffFiles, + onOpenFile, +}: { + excerpt: PullRequestReviewGuideExcerpt; + /** The file's parsed diff, null while it loads or when the host withheld it. */ + file: FileDiffMetadata | null; + loading: boolean; + notes: ReadonlyArray; + commenting: ExcerptCommenting | null; + /** Loads the file's unchanged lines for the bars around the excerpt. */ + loadDiffFiles: FileDiffContentsLoader; + onOpenFile: (path: string) => void; +}) { + const [draft, setDraft] = useState(null); + const { resolvedTheme } = useTheme(); + const slice = useMemo(() => { + if (file === null) return null; + const rows = fileDiffRows(file); + const shown = excerptRows(rows, excerpt); + if (shown.length === 0) return null; + const parsed = getRenderablePatch( + rowsToPatch(excerpt.path, resolveFileDiffPreviousPath(file), shown), + `pull-request-guide-excerpt:${excerpt.path}:${excerpt.side}:${excerpt.start}-${excerpt.end}`, + ); + const fileDiff = parsed?.kind === "files" ? parsed.files[0] : undefined; + if (fileDiff === undefined) return null; + // Lines the excerpt draws, by side, so only comments that land on one are placed in it. + const drawn = { + additions: new Set(shown.flatMap((row) => (row.newLine === null ? [] : [row.newLine]))), + deletions: new Set(shown.flatMap((row) => (row.oldLine === null ? [] : [row.oldLine]))), + }; + return { fileDiff, rows: shown, shown: shown.length, total: rows.length, drawn }; + }, [excerpt, file]); + + // The excerpt expands against an old side of its own: the file's real old side differs from + // the new one wherever another hunk changed it, and the renderer needs the two to agree around + // the lines it shows. A slice across hunks has nothing consistent to expand into. + const sliceRows = slice?.rows ?? null; + const loadExcerptFiles = useMemo(() => { + if (sliceRows === null || sliceRows.some((row) => row.hunk !== sliceRows[0]?.hunk)) { + return null; + } + return async (fileDiff) => { + const loaded = await loadDiffFiles(fileDiff); + const oldContents = excerptExpansionOldContents(loaded.newFile.contents, sliceRows); + if (oldContents === null) return loaded; + return { + newFile: loaded.newFile, + oldFile: { + name: loaded.oldFile?.name ?? loaded.newFile.name, + contents: oldContents, + cacheKey: `${loaded.newFile.cacheKey ?? loaded.newFile.name}:excerpt:${excerpt.side}:${excerpt.start}-${excerpt.end}`, + }, + }; + }; + }, [excerpt.end, excerpt.side, excerpt.start, loadDiffFiles, sliceRows]); + + // Notes, queued comments and the draft share one annotation per line, as in the Code tab. + const annotations = useMemo(() => { + const groups = new Map>(); + const at = (side: "additions" | "deletions", line: number) => { + const key = `${side}:${line}`; + const existing = groups.get(key); + if (existing !== undefined) return existing; + const created: DiffLineAnnotation = { + side, + lineNumber: line, + metadata: { markers: [], pending: [], draft: false }, + }; + groups.set(key, created); + return created; + }; + const update = ( + side: "additions" | "deletions", + line: number, + change: (metadata: ExcerptAnnotation) => ExcerptAnnotation, + ) => { + const group = at(side, line); + groups.set(`${side}:${line}`, { ...group, metadata: change(group.metadata) }); + }; + const noteSide = excerpt.side === "new" ? "additions" : "deletions"; + for (const note of notes) { + update(noteSide, note.line, (metadata) => ({ + ...metadata, + markers: [...metadata.markers, ...note.markers], + })); + } + for (const comment of commenting?.pending ?? []) { + if (comment.path !== excerpt.path) continue; + const anchor = getReviewPositionAnchor(comment.position); + const side = toViewerSide(anchor.side); + if (!slice?.drawn[side].has(anchor.line)) continue; + update(side, anchor.line, (metadata) => ({ + ...metadata, + pending: [...metadata.pending, comment], + })); + } + if (draft !== null) { + const anchor = getReviewPositionAnchor(draft.position); + update(toViewerSide(anchor.side), anchor.line, (metadata) => ({ ...metadata, draft: true })); + } + return [...groups.values()]; + }, [commenting?.pending, draft, excerpt.path, excerpt.side, notes, slice]); + + // Positions and the agent's copy of the lines come from the whole file's diff, so a comment + // lands where the Code tab would put it rather than on the excerpt's cut-down patch. + const beginComment = useCallback( + (range: SelectedLineRange | null) => { + if (range === null || file === null) return; + const position = resolveDiffReviewPosition(file, range.end, range.endSide ?? range.side); + if (position === null) return; + setDraft({ range, position }); + }, + [file], + ); + const interactive = commenting !== null && draft === null; + const onAskAgent = commenting?.onAskAgent ?? null; + const askAgent = (request: string) => { + if (draft === null || file === null || onAskAgent === null) return; + const comment = buildDiffReviewComment({ + id: `pull-request-guide:${excerpt.path}:${draft.range.start}:${draft.range.end}`, + sectionId: `pull-request-guide:${excerpt.path}`, + sectionTitle: "Review guide", + filePath: excerpt.path, + fileDiff: file, + range: draft.range, + text: request, + }); + setDraft(null); + if (comment !== null) onAskAgent(comment, request); + }; + + const options = useMemo( + () => ({ + diffStyle: "unified" as const, + lineDiffType: "none" as const, + overflow: "scroll" as const, + theme: resolveDiffThemeName(resolvedTheme), + themeType: resolvedTheme, + preferredHighlighter: PREFERRED_HIGHLIGHTER, + unsafeCSS: EXCERPT_UNSAFE_CSS, + // Hover a line for the gutter button, or drag across line numbers, as in the Code tab. + enableGutterUtility: interactive, + enableLineSelection: interactive, + onGutterUtilityClick: beginComment, + onLineSelectionEnd: beginComment, + // Without a loader there is nothing to expand into, so the bars stay plain. + ...(loadExcerptFiles === null + ? { hunkSeparators: "simple" as const } + : { loadDiffFiles: loadExcerptFiles }), + }), + [resolvedTheme, interactive, beginComment, loadExcerptFiles], + ); + + return ( +
    + {excerpt.caption.trim() !== "" ? ( +
    + +
    + ) : null} + {slice !== null ? ( +
    + + fileDiff={slice.fileDiff} + options={options} + lineAnnotations={annotations} + selectedLines={draft?.range ?? null} + renderAnnotation={(annotation) => ( +
    + {annotation.metadata.markers.length > 0 ? ( +
    + {annotation.metadata.markers.map((entry) => ( + + + {entry.title} + + ))} +
    + ) : null} + {annotation.metadata.pending.map((comment) => ( + commenting?.onRemove(comment.id)} + /> + ))} + {annotation.metadata.draft && draft !== null && commenting !== null ? ( + askAgent(text), + }, + } + : {})} + onCancel={() => setDraft(null)} + onComment={(body) => { + if (!commenting.canComment) { + askAgent(body); + return; + } + commenting.onComment({ + path: excerpt.path, + oldPath: file === null ? null : previousPathOf(file, excerpt.path), + position: draft.position, + body, + }); + setDraft(null); + }} + /> + ) : null} +
    + )} + renderHeaderMetadata={() => ( + + + {excerpt.side === "old" ? "removed code, " : ""}showing {slice.shown} of{" "} + {slice.total} diff lines + + + + )} + /> +
    + ) : loading ? ( +

    + Loading {excerpt.path}... +

    + ) : ( +
    + + {excerpt.path} lines {excerpt.start}- + {excerpt.end} are not in the loaded diff. + + +
    + )} +
    + ); +} + +/** A renamed file's comment carries its old path too, which hosts need to place it. */ +function previousPathOf(file: FileDiffMetadata, path: string): string | null { + const previous = resolveFileDiffPreviousPath(file); + return previous === path ? null : previous; +} diff --git a/apps/web/src/components/pullRequest/guide/GuideFileDiff.tsx b/apps/web/src/components/pullRequest/guide/GuideFileDiff.tsx new file mode 100644 index 000000000000..7079a6c9e408 --- /dev/null +++ b/apps/web/src/components/pullRequest/guide/GuideFileDiff.tsx @@ -0,0 +1,50 @@ +import { + cloneFileDiffMetadata, + type FileDiffContentsLoader, + type FileDiffMetadata, +} from "@pierre/diffs"; +import { FileDiff } from "@pierre/diffs/react"; +import { useMemo } from "react"; + +import { useTheme } from "~/hooks/useTheme"; +import { resolveDiffThemeName } from "~/lib/diffRendering"; +import { PREFERRED_HIGHLIGHTER } from "~/lib/syntaxHighlighting"; + +import { DIFF_VIEW_UNSAFE_CSS } from "../../diffs/StyledDiffCodeView"; + +/** + * A whole file's diff inside a stop, for a file the guide vouched for without excerpting. The row + * above it names the file, so the renderer's own header is left off. The bars between hunks load + * the unchanged lines, as in the Code tab. + */ +export function GuideFileDiff({ + file, + loadDiffFiles, +}: { + file: FileDiffMetadata; + loadDiffFiles: FileDiffContentsLoader; +}) { + const { resolvedTheme } = useTheme(); + // Loading the unchanged lines rewrites the diff it is given; the excerpts read the same file + // as a patch, so this view expands a copy of its own. + const expandable = useMemo(() => cloneFileDiffMetadata(file), [file]); + const options = useMemo( + () => ({ + diffStyle: "unified" as const, + lineDiffType: "none" as const, + overflow: "scroll" as const, + disableFileHeader: true, + theme: resolveDiffThemeName(resolvedTheme), + themeType: resolvedTheme, + preferredHighlighter: PREFERRED_HIGHLIGHTER, + unsafeCSS: DIFF_VIEW_UNSAFE_CSS, + loadDiffFiles, + }), + [loadDiffFiles, resolvedTheme], + ); + return ( +
    + +
    + ); +} diff --git a/apps/web/src/components/pullRequest/guide/GuideNotes.tsx b/apps/web/src/components/pullRequest/guide/GuideNotes.tsx new file mode 100644 index 000000000000..9e1a42611446 --- /dev/null +++ b/apps/web/src/components/pullRequest/guide/GuideNotes.tsx @@ -0,0 +1,58 @@ +import type { PullRequestReviewGuideNote } from "@t3tools/contracts"; + +import { cn } from "~/lib/utils"; + +import { GuideProse } from "./GuideProse"; + +/** The letter that ties a note to its line in an excerpt. */ +export function GuideNoteMarker({ marker }: { marker: string }) { + return ( + + {marker} + + ); +} + +/** A stop's notes, lettered in the order the excerpts mark them. */ +export function GuideNotes({ + notes, + className, +}: { + notes: ReadonlyArray<{ readonly marker: string; readonly note: PullRequestReviewGuideNote }>; + className?: string; +}) { + if (notes.length === 0) return null; + return ( +
    +

    + Notes on this stop + + {notes.length === 1 ? "1 note" : `${notes.length} notes`} + +

    +
      + {notes.map(({ marker, note }) => ( +
    1. + +
      +

      + {note.title} + + {note.path.split("/").at(-1)}:{note.line} + +

      + {note.body.trim() !== "" ? ( +
      + +
      + ) : null} +
      +
    2. + ))} +
    +
    + ); +} diff --git a/apps/web/src/components/pullRequest/guide/GuideProse.tsx b/apps/web/src/components/pullRequest/guide/GuideProse.tsx new file mode 100644 index 000000000000..7b222753b4c6 --- /dev/null +++ b/apps/web/src/components/pullRequest/guide/GuideProse.tsx @@ -0,0 +1,40 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import { createContext, useContext } from "react"; + +import { cn } from "~/lib/utils"; + +import { PullRequestMarkdown } from "../PullRequestMarkdown"; + +/** Where the guide's prose renders from: the pull request's environment and checkout. */ +export const GuideProseContext = createContext<{ + readonly environmentId: EnvironmentId; + readonly cwd: string; +} | null>(null); + +/** + * The guide's sentences, rendered as the pull request's own description is: inline code, and + * `#123` linked to that pull request or issue, opening in the pull request panel like every other + * pull request link in the app. Plain text inside a control, such as a question that opens a + * thread, since a link cannot nest in a button. + */ +export function GuideProse({ + text, + plain = false, + className, +}: { + text: string; + plain?: boolean; + className?: string; +}) { + const context = useContext(GuideProseContext); + if (plain || context === null) return text; + return ( + + ); +} diff --git a/apps/web/src/components/pullRequest/guide/GuideRail.tsx b/apps/web/src/components/pullRequest/guide/GuideRail.tsx new file mode 100644 index 000000000000..01c17652b964 --- /dev/null +++ b/apps/web/src/components/pullRequest/guide/GuideRail.tsx @@ -0,0 +1,108 @@ +import type { PullRequestReviewGuideStop } from "@t3tools/contracts"; +import { CheckIcon } from "lucide-react"; + +import { cn } from "~/lib/utils"; + +import { Badge } from "../../ui/badge"; +import { stopSubline } from "./guideTour.logic"; + +/** + * The tour's table of contents: where the reader is and which stops they have read past. A stop + * with a call to make is marked; that is the only sign of where to slow down. + */ +export function GuideRail({ + stops, + position, + seen, + onSelect, +}: { + stops: ReadonlyArray; + position: number; + /** Stops the reader has moved on from. */ + seen: ReadonlySet; + onSelect: (position: number) => void; +}) { + const reviewed = seen.size; + const renderStop = (stop: PullRequestReviewGuideStop, index: number) => ( +
  • + onSelect(index)} + lead={} + title={stop.title} + detail={ + <> + {stop.attention !== null ? ( + + Look closely + + ) : null} + {stopSubline(stop)} + + } + /> +
  • + ); + + return ( + + ); +} + +/** A semantic row of the tour's list rather than a control styled as a button. */ +function RailRow({ + active, + onClick, + lead, + title, + detail, +}: { + active: boolean; + onClick: () => void; + lead: React.ReactNode; + title: string; + detail: React.ReactNode; +}) { + return ( + + ); +} + +function StopBullet({ number, seen }: { number: number; seen: boolean }) { + return ( + + {seen ? : number} + + ); +} diff --git a/apps/web/src/components/pullRequest/guide/GuideStop.tsx b/apps/web/src/components/pullRequest/guide/GuideStop.tsx new file mode 100644 index 000000000000..2b6f207f8fdb --- /dev/null +++ b/apps/web/src/components/pullRequest/guide/GuideStop.tsx @@ -0,0 +1,310 @@ +import type { FileDiffContentsLoader, FileDiffMetadata } from "@pierre/diffs"; +import type { + PullRequestReviewGuideAttention, + PullRequestReviewGuideStop, +} from "@t3tools/contracts"; +import { + ArrowRightIcon, + ChevronDownIcon, + ChevronRightIcon, + ChevronUpIcon, + EyeIcon, + FileIcon, + MessageCircleQuestionIcon, + MessageSquareIcon, +} from "lucide-react"; +import { useMemo, type ReactNode } from "react"; + +import { getDiffLineStat } from "~/lib/diffRendering"; +import { cn } from "~/lib/utils"; + +import { DiffWorkerPoolProvider } from "../../DiffWorkerPoolProvider"; +import { Alert, AlertDescription, AlertTitle } from "../../ui/alert"; +import { Button } from "../../ui/button"; +import { Kbd } from "../../ui/kbd"; +import { PullRequestDiffStat } from "../pullRequestPresentation"; +import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../../ui/collapsible"; +import { GuideExcerpt, type ExcerptCommenting } from "./GuideExcerpt"; +import { GuideFileDiff } from "./GuideFileDiff"; +import { GuideNotes } from "./GuideNotes"; +import { GuideProse } from "./GuideProse"; +import { letterStopNotes } from "./guideTour.logic"; + +const ATTENTION_LABELS = { + tradeoff: "A tradeoff to weigh", + contract: "A contract to check", + product: "A product call", + risk: "A risk to judge", + accessibility: "An accessibility call", +} as const satisfies Record; + +/** + * One stop of the tour: what it is about, where to look, and the two things a reader does with + * it: comment on its lines for the review, or ask an agent about it. + */ +export function GuideStop({ + stop, + index, + stopCount, + railOpen, + onRailOpenChange, + stopList, + nextTitle, + files, + loadDiffFiles, + diffLoading, + commenting, + onStep, + onAsk, + onFinish, + onOpenFile, +}: { + stop: PullRequestReviewGuideStop; + index: number; + stopCount: number; + /** Whether the stop list is folded open under the header, on a narrow panel. */ + railOpen: boolean; + onRailOpenChange: (open: boolean) => void; + /** The tour's stop list, folded under the header on a narrow panel. */ + stopList: ReactNode; + /** The following stop's title, null on the last stop. */ + nextTitle: string | null; + files: ReadonlyMap; + /** Loads a file's unchanged lines for the bars between hunks. */ + loadDiffFiles: FileDiffContentsLoader; + diffLoading: boolean; + /** Commenting on excerpt lines; null where the reader can neither comment nor ask. */ + commenting: ExcerptCommenting | null; + onStep: (delta: 1 | -1) => void; + /** Opens an agent thread about this stop, with the question prefilled or empty. */ + onAsk: ((question: string) => void) | null; + /** Opens the review composer, on the last stop. Null where the reader cannot review. */ + onFinish: (() => void) | null; + onOpenFile: (path: string) => void; +}) { + const { lettered, byExcerpt } = useMemo(() => letterStopNotes(stop), [stop]); + const excerptPaths = new Set(stop.excerpts.map((excerpt) => excerpt.path)); + // Files the stop owns without excerpting, and excerpted ones listed again with a note. + const otherFiles = stop.files.filter((file) => !excerptPaths.has(file.path)); + + const hasNotes = lettered.length > 0; + + return ( + // On a maximized panel the notes take a sticky column of their own beside the stop; a stop + // without notes keeps the whole width. Narrower, they follow the excerpts. +
    +
    + {/* Narrow, the stop list folds open under this line like an accordion, so one place + says where the reader is. Wide, the list sits beside the stop and this is a label. */} + +
    +

    + Stop {index + 1} of {stopCount} +

    + + } + > + + Stop {index + 1} of {stopCount} + + + +
    + + + + J + K + +
    +
    + +
    {stopList}
    +
    +
    + +
    +

    {stop.title}

    + {stop.summary.trim() !== "" ? ( +
    + +
    + ) : null} +
    + + {stop.attention !== null ? ( + // A call for the reader to make, not a warning, so it reads as a plain notice. + + + {ATTENTION_LABELS[stop.attention.kind]} + +
    + +
    +
    +
    + ) : null} + + {stop.excerpts.length > 0 ? ( +
    + + {stop.excerpts.map((excerpt, excerptIndex) => ( + + ))} + +
    + ) : null} + + + {otherFiles.length > 0 ? ( +
    +

    + {stop.excerpts.length > 0 ? "Also in this stop" : "Files in this stop"} +

    + +
      + {otherFiles.map((file) => { + const diff = files.get(file.path); + const stat = diff === undefined ? null : getDiffLineStat([diff]); + return ( +
    • + + + + + + {file.path} + + {file.note !== undefined && file.note.trim() !== "" ? ( + + + + ) : null} + {stat !== null ? ( + + ) : null} + + + {diff !== undefined ? ( + + ) : ( +

      + {diffLoading + ? "Loading the diff..." + : "This file is not in the loaded diff."} +

      + )} +
      +
      +
    • + ); + })} +
    +
    +
    + ) : null} + + {stop.questions.length > 0 || onAsk !== null ? ( +
    + {stop.questions.length > 0 ? ( +

    Questions to consider

    + ) : null} +
    + {stop.questions.map((question) => + onAsk === null ? ( +
    + +
    + ) : ( + // A question is a sentence, so it wraps rather than truncating as a button would. + + ), + )} + {onAsk !== null ? ( + + ) : null} +
    +
    + ) : null} + +
    + {nextTitle !== null ? ( + + ) : onFinish !== null ? ( + + ) : null} +
    +
    + {hasNotes ? ( + + ) : null} +
    + ); +} diff --git a/apps/web/src/components/pullRequest/guide/GuideTour.tsx b/apps/web/src/components/pullRequest/guide/GuideTour.tsx new file mode 100644 index 000000000000..36effffabb2a --- /dev/null +++ b/apps/web/src/components/pullRequest/guide/GuideTour.tsx @@ -0,0 +1,174 @@ +import type { EnvironmentId, PullRequestRef, PullRequestReviewGuide } from "@t3tools/contracts"; +import { useEffect, useEffectEvent, useMemo, useRef, useState, type ReactNode } from "react"; + +import { createPullRequestDiffFileContentsLoader } from "~/lib/diffFileContents"; +import { isEditableFocused } from "~/lib/editableFocus"; +import { pullRequestEnvironment } from "~/state/pullRequests"; +import { useAtomCommand } from "~/state/use-atom-command"; + +import { isCommandPaletteOpen } from "~/commandPaletteBus"; + +import { GuideRail } from "./GuideRail"; +import { GuideStop } from "./GuideStop"; +import type { ExcerptCommenting } from "./GuideExcerpt"; +import { + guideProgressKey, + guideRunKey, + useGuideProgress, + useGuideRunStore, + type GuideProgress, +} from "./guideRunStore"; +import { stepTourPosition, stopFilePaths } from "./guideTour.logic"; +import { useGuideDiffFiles } from "./useGuideDiffFiles"; + +/** + * The guide as a tour: a rail of stops beside the stop being read. Progress is kept per guide + * head for as long as the app is open, so the same guide in another panel opens where the reader + * is; a new head starts it over. What + * the reader says about the change goes into the review: line comments queue with the Code tab's, + * and the last stop opens the composer that sends them. + */ +export function GuideTour({ + environmentId, + reference, + guide, + banner, + active, + commenting, + onAsk, + onFinish, + onOpenFile, +}: { + environmentId: EnvironmentId; + reference: PullRequestRef; + guide: PullRequestReviewGuide; + /** The stale or failed notice, shown above whichever view is open. */ + banner: ReactNode; + /** Whether the tour is the tab on screen, which is when J and K step through it. */ + active: boolean; + commenting: ExcerptCommenting | null; + /** Opens an agent thread about a stop. Null where the reader cannot start one. */ + onAsk: ((stop: number, question: string) => void) | null; + onFinish: (() => void) | null; + onOpenFile: (path: string) => void; +}) { + const key = guideProgressKey(guideRunKey(environmentId, reference), guide.headSha); + const tour = useGuideProgress(key); + const update = (change: (current: GuideProgress) => Partial) => + useGuideRunStore.getState().setProgress(key, (current) => ({ ...current, ...change(current) })); + const [railOpen, setRailOpen] = useState(false); + + const stopCount = guide.stops.length; + const position = Math.max(Math.min(tour.position, stopCount - 1), 0); + const stop = guide.stops[position] ?? null; + + // Only the open stop's files are read: its excerpts, and the files it lists to open in place. + const wantedPaths = useMemo(() => (stop === null ? [] : stopFilePaths(stop)), [stop]); + const diff = useGuideDiffFiles({ + environmentId, + reference, + headSha: guide.headSha, + wantedPaths, + }); + // The same file reads the Code tab uses, so the bars between hunks load what lies between. + const getDiffFileContents = useAtomCommand(pullRequestEnvironment.diffFileContents); + const loadDiffFiles = useMemo( + () => + createPullRequestDiffFileContentsLoader(getDiffFileContents, { + environmentId, + reference, + commit: null, + cacheKey: `pull-request-guide:${key}`, + }), + [environmentId, getDiffFileContents, key, reference], + ); + + // A new stop starts at its top rather than wherever the last one was scrolled to. + const scrollRef = useRef(null); + const goTo = (next: number) => { + // Leaving a stop for a later one is reading it; jumping back is not. + update((current) => ({ + position: next, + seen: next > position ? new Set(current.seen).add(position) : current.seen, + })); + setRailOpen(false); + scrollRef.current?.scrollTo({ top: 0 }); + }; + const step = (delta: 1 | -1) => goTo(stepTourPosition(position, delta, stopCount)); + + // J and K step through the tour whenever it is the tab on screen, wherever focus is, unless + // the reader is typing: clicking a stop in the rail leaves focus on a button outside any + // element that could hold a key handler of its own. + const stepFromKey = useEffectEvent((event: KeyboardEvent) => { + if (event.defaultPrevented || event.isComposing || isCommandPaletteOpen()) return; + if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return; + if (event.key !== "j" && event.key !== "k") return; + if (isEditableFocused(event.target)) return; + event.preventDefault(); + step(event.key === "j" ? 1 : -1); + }); + useEffect(() => { + if (!active) return; + const onKeyDown = (event: KeyboardEvent) => stepFromKey(event); + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, [active]); + + return ( + // Three widths: one column with the rail folded; the rail beside the stop; and, on a + // maximized panel, the stop's notes in a column of their own (see GuideStop). +
    +
    + {/* Beside the stop on a wide panel; narrow, the stop folds the same list open under its + header instead. */} + +
    + {banner} + {diff.error !== null ? ( +

    + The diff could not be loaded, so excerpts are missing: {diff.error} +

    + ) : null} + {stop === null ? ( +

    This change has no files to tour.

    + ) : ( + + } + nextTitle={guide.stops[position + 1]?.title ?? null} + files={diff.files} + loadDiffFiles={loadDiffFiles} + diffLoading={diff.loading} + commenting={commenting} + onStep={step} + onAsk={onAsk === null ? null : (question) => onAsk(position, question)} + onFinish={ + onFinish === null + ? null + : () => { + update((current) => ({ seen: new Set(current.seen).add(position) })); + onFinish(); + } + } + onOpenFile={onOpenFile} + /> + )} +
    +
    +
    + ); +} diff --git a/apps/web/src/components/pullRequest/guide/PullRequestGuideRunCoordinator.tsx b/apps/web/src/components/pullRequest/guide/PullRequestGuideRunCoordinator.tsx new file mode 100644 index 000000000000..a4b054215b91 --- /dev/null +++ b/apps/web/src/components/pullRequest/guide/PullRequestGuideRunCoordinator.tsx @@ -0,0 +1,140 @@ +import { useNavigate } from "@tanstack/react-router"; +import { CircleAlertIcon, CircleCheckIcon } from "lucide-react"; +import { useEffect, useRef } from "react"; + +import { getClientSettings } from "~/hooks/useSettings"; +import { pullRequestEnvironment } from "~/state/pullRequests"; +import { useEnvironmentQuery } from "~/state/query"; +import { + hasDesktopNotifications, + hasNotificationSound, + playNotificationSound, +} from "~/threadNotifications"; + +import { toastManager } from "../../ui/toast"; +import { readPullRequestListPreferences } from "../pullRequestListPreferences"; +import { useGuideRunStore, type GuideRunWatch } from "./guideRunStore"; + +/** How often a watched run is re-read. A run takes minutes, not seconds. */ +const RUN_POLL_MS = 5_000; +/** A run never seen running after this long was dropped before the first read. */ +const UNSEEN_RUN_MS = 60_000; + +/** + * Watches the guide runs this tab is waiting on and says when each ends, so the reader can leave + * the Guide tab, or the pull request page, while one is written. + */ +export function PullRequestGuideRunCoordinator() { + const watches = useGuideRunStore((state) => state.watches); + return Object.entries(watches).map(([key, watch]) => ( + + )); +} + +function GuideRunWatcher({ runKey, watch }: { runKey: string; watch: GuideRunWatch }) { + const query = useEnvironmentQuery( + pullRequestEnvironment.reviewGuide({ + environmentId: watch.environmentId, + input: watch.reference, + }), + ); + const navigate = useNavigate(); + const refresh = query.refresh; + useEffect(() => { + const timer = setInterval(refresh, RUN_POLL_MS); + return () => clearInterval(timer); + }, [refresh]); + + const data = query.data; + const sawRunning = useRef(false); + useEffect(() => { + if (data === null) return; + if (data.run?._tag === "Running") { + sawRunning.current = true; + return; + } + const failure = + data.run?._tag === "Failed" && data.run.failedAt !== watch.previousFailedAt + ? data.run.detail + : null; + const written = + failure === null && data.guide !== null && data.guide.generatedAt !== watch.previousGuideAt; + // A read from before the run started says nothing yet; a run the server dropped, say on a + // restart, ends with nothing to say. + if (failure === null && !written) { + if (sawRunning.current || Date.now() - watch.since > UNSEEN_RUN_MS) { + useGuideRunStore.getState().settle(runKey); + } + return; + } + const store = useGuideRunStore.getState(); + store.settle(runKey); + const looking = document.visibilityState === "visible" && document.hasFocus(); + if (looking && store.shownKey === runKey) return; + + const title = failure === null ? "Review guide ready" : "Review guide failed"; + const body = watch.title ?? `${watch.reference.repository} #${watch.reference.number}`; + const open = () => { + useGuideRunStore.getState().requestOpen(runKey); + void navigate({ + to: "/pull-requests", + search: { + ...readPullRequestListPreferences(), + repository: watch.reference.repository, + number: watch.reference.number, + selectedProjectId: watch.reference.projectId, + selectedEnvironmentId: watch.environmentId, + ...(watch.reference.host === undefined ? {} : { selectedHost: watch.reference.host }), + }, + }); + }; + const mode = getClientSettings().notificationMode; + if (hasNotificationSound(mode)) { + void playNotificationSound(failure === null ? "completion" : "input", () => + hasNotificationSound(getClientSettings().notificationMode), + ); + } + if (looking) { + const toastId = toastManager.add({ + type: failure === null ? "success" : "error", + title, + description: failure === null ? body : `${body}: ${failure}`, + data: { + hideCopyButton: true, + leadingIcon: + failure === null ? ( + + ) : ( + + ), + }, + actionProps: { + children: "Open guide", + onClick: () => { + toastManager.close(toastId); + open(); + }, + }, + }); + return; + } + if ( + !hasDesktopNotifications(mode) || + typeof Notification === "undefined" || + Notification.permission !== "granted" + ) + return; + try { + const notification = new Notification(title, { body, tag: `guide:${runKey}`, silent: true }); + notification.addEventListener("click", () => { + notification.close(); + window.focus(); + open(); + }); + } catch { + // Some browsers expose Notification but reject desktop presentation. + } + }, [data, navigate, runKey, watch]); + + return null; +} diff --git a/apps/web/src/components/pullRequest/guide/guideRunStore.ts b/apps/web/src/components/pullRequest/guide/guideRunStore.ts new file mode 100644 index 000000000000..58bcaf9abecf --- /dev/null +++ b/apps/web/src/components/pullRequest/guide/guideRunStore.ts @@ -0,0 +1,86 @@ +/** + * Guide runs this tab is waiting on, so the reader can leave and hear when one is done, and where + * the reader is in each guide. + * + * A run takes minutes and finishes on the server. `PullRequestGuideRunCoordinator` watches every + * run listed here, wherever the reader is in the app, and says when it ends. Progress is shared so + * a guide opened beside a new thread picks up at the same stop. Not persisted: a reload forgets + * both, and the guide is still there when the reader comes back. + */ +import type { EnvironmentId, PullRequestRef } from "@t3tools/contracts"; +import { create } from "zustand"; + +import { pullRequestReviewKey } from "../pullRequestReviewStore"; + +export interface GuideRunWatch { + readonly environmentId: EnvironmentId; + readonly reference: PullRequestRef; + /** The pull request's title, for the notice. */ + readonly title: string | null; + /** The guide and failure on record when the wait began; a different one means the run ended. */ + readonly previousGuideAt: string | null; + readonly previousFailedAt: string | null; + readonly since: number; +} + +/** Where a reader is in one guide, kept per guide head so any panel showing it agrees. */ +export interface GuideProgress { + readonly position: number; + /** Stops the reader has moved on from, which the rail checks off. */ + readonly seen: ReadonlySet; +} + +const NO_PROGRESS: GuideProgress = { position: 0, seen: new Set() }; + +interface GuideRunStoreState { + readonly watches: Readonly>; + /** The guide on screen right now, which needs no notice when it lands. */ + readonly shownKey: string | null; + /** A guide a notice asked to open; its panel switches to the Guide tab and clears this. */ + readonly openRequestKey: string | null; + /** Reading progress by guide, keyed by `guideProgressKey`. */ + readonly progress: Readonly>; + readonly setProgress: (key: string, change: (current: GuideProgress) => GuideProgress) => void; + readonly watch: (watch: GuideRunWatch) => void; + readonly settle: (key: string) => void; + readonly setShown: (key: string | null) => void; + readonly requestOpen: (key: string | null) => void; +} + +export function guideRunKey(environmentId: EnvironmentId, reference: PullRequestRef): string { + return `${environmentId}:${pullRequestReviewKey(reference)}`; +} + +export const useGuideRunStore = create()((set) => ({ + watches: {}, + shownKey: null, + openRequestKey: null, + progress: {}, + setProgress: (key, change) => + set((state) => ({ + progress: { ...state.progress, [key]: change(state.progress[key] ?? NO_PROGRESS) }, + })), + watch: (watch) => + set((state) => { + const key = guideRunKey(watch.environmentId, watch.reference); + if (state.watches[key] !== undefined) return state; + return { watches: { ...state.watches, [key]: watch } }; + }), + settle: (key) => + set((state) => { + if (state.watches[key] === undefined) return state; + const { [key]: _settled, ...watches } = state.watches; + return { watches }; + }), + setShown: (shownKey) => set({ shownKey }), + requestOpen: (openRequestKey) => set({ openRequestKey }), +})); + +/** One guide's progress: a new head is a new tour, since its stops are not the ones read. */ +export function guideProgressKey(runKey: string, headSha: string): string { + return `${runKey}:${headSha}`; +} + +export function useGuideProgress(key: string): GuideProgress { + return useGuideRunStore((state) => state.progress[key] ?? NO_PROGRESS); +} diff --git a/apps/web/src/components/pullRequest/guide/guideTour.logic.test.ts b/apps/web/src/components/pullRequest/guide/guideTour.logic.test.ts new file mode 100644 index 000000000000..78e6451310f7 --- /dev/null +++ b/apps/web/src/components/pullRequest/guide/guideTour.logic.test.ts @@ -0,0 +1,178 @@ +import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles"; +import type { PullRequestReviewGuideStop } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + excerptExpansionOldContents, + excerptRows, + fileDiffRows, + letterStopNotes, + rowsToPatch, + stepTourPosition, +} from "./guideTour.logic"; + +const PATCH = `diff --git a/src/tray.ts b/src/tray.ts +--- a/src/tray.ts ++++ b/src/tray.ts +@@ -10,5 +10,6 @@ function tray() + const a = 1; +-const size = pick(); ++const size = "xs"; ++const variant = "tray"; + const b = 2; + const c = 3; + const d = 4; +@@ -40,3 +41,2 @@ function footer() + keep(); +-dropped(); + tail(); +`; + +function parse(patch: string) { + const file = parsePatchFiles(patch, `test:${patch.length}`)[0]?.files[0]; + if (file === undefined) throw new Error("patch did not parse"); + return file; +} + +const rows = fileDiffRows(parse(PATCH)); + +function stop(overrides: Partial): PullRequestReviewGuideStop { + return { + title: "Stop", + summary: "", + attention: null, + excerpts: [], + files: [], + notes: [], + questions: [], + ...overrides, + }; +} + +describe("stepTourPosition", () => { + it("holds the tour to its first and last stops", () => { + expect(stepTourPosition(0, -1, 3)).toBe(0); + expect(stepTourPosition(0, 1, 3)).toBe(1); + expect(stepTourPosition(2, 1, 3)).toBe(2); + }); +}); + +describe("fileDiffRows", () => { + it("numbers both sides through context, deletions and additions", () => { + expect(rows.map((row) => [row.kind, row.oldLine, row.newLine])).toEqual([ + ["context", 10, 10], + ["deletion", 11, null], + ["addition", null, 11], + ["addition", null, 12], + ["context", 12, 13], + ["context", 13, 14], + ["context", 14, 15], + ["context", 40, 41], + ["deletion", 41, null], + ["context", 42, 42], + ]); + }); +}); + +describe("excerptRows", () => { + it("brings the replaced lines along with an excerpt that starts on an addition", () => { + const shown = excerptRows(rows, { side: "new", start: 11, end: 12 }); + expect(shown.map((row) => row.kind)).toEqual(["deletion", "addition", "addition"]); + }); + + it("selects old-side lines for a removal and stops at the hunk boundary", () => { + const shown = excerptRows(rows, { side: "old", start: 41, end: 41 }); + expect(shown.map((row) => [row.kind, row.oldLine])).toEqual([["deletion", 41]]); + }); + + it("returns nothing for a range outside the diff", () => { + expect(excerptRows(rows, { side: "new", start: 100, end: 120 })).toEqual([]); + }); +}); + +describe("rowsToPatch", () => { + it("cuts a slice that parses back to the same lines and numbers", () => { + const shown = excerptRows(rows, { side: "new", start: 10, end: 13 }); + const slice = parse(rowsToPatch("src/tray.ts", "src/tray.ts", shown)); + expect(fileDiffRows(slice).map((row) => [row.kind, row.oldLine, row.newLine])).toEqual( + shown.map((row) => [row.kind, row.oldLine, row.newLine]), + ); + }); + + it("starts a hunk per source hunk so separate hunks stay separate", () => { + const shown = excerptRows(rows, { side: "old", start: 14, end: 42 }); + const patch = rowsToPatch("src/tray.ts", "src/tray.ts", shown); + expect(patch.match(/^@@ /gm)).toHaveLength(2); + expect(patch).toContain("@@ -14,1 +15,1 @@"); + expect(patch).toContain("@@ -40,3 +41,2 @@"); + }); +}); + +describe("excerptExpansionOldContents", () => { + // The new file behind PATCH: lines 1-9, the hunk at 10-15, then lines up to 41-42 around the + // second hunk, which removed a line. + const newFile = Array.from({ length: 50 }, (_, index) => `line ${index + 1}\n`); + newFile.splice( + 9, + 6, + "const a = 1;\n", + 'const size = "xs";\n', + 'const variant = "tray";\n', + "const b = 2;\n", + "const c = 3;\n", + "const d = 4;\n", + ); + newFile.splice(40, 2, "keep();\n", "tail();\n"); + const newContents = newFile.join(""); + + it("puts back only the excerpt's lines and keeps both sides numbered as in the file", () => { + const shown = excerptRows(rows, { side: "new", start: 11, end: 12 }); + const old = excerptExpansionOldContents(newContents, shown); + if (old === null) throw new Error("expected contents"); + const oldLines = old.split("\n"); + // The excerpt's old side sits at its real old line. + expect(oldLines[10]).toBe("const size = pick();"); + // Around it, the new file's lines, so the two sides agree outside the excerpt. + expect(oldLines[9]).toBe("const a = 1;"); + expect(oldLines[11]).toBe("const b = 2;"); + // One line replaced by two: the old side is a line shorter, everything after lines up. + expect(oldLines.length).toBe(newContents.split("\n").length - 1); + }); + + it("does not try to expand an excerpt that spans hunks", () => { + const shown = excerptRows(rows, { side: "old", start: 14, end: 42 }); + expect(excerptExpansionOldContents(newContents, shown)).toBeNull(); + }); +}); + +describe("letterStopNotes", () => { + it("letters notes in guide order and pins each to the excerpt that holds its line", () => { + const { lettered, byExcerpt } = letterStopNotes( + stop({ + excerpts: [ + { path: "a.ts", side: "new", start: 1, end: 5, caption: "" }, + { path: "b.ts", side: "new", start: 10, end: 12, caption: "" }, + ], + notes: [ + { path: "b.ts", line: 11, title: "First", body: "" }, + { path: "a.ts", line: 2, title: "Second", body: "" }, + { path: "a.ts", line: 2, title: "Third", body: "" }, + { path: "c.ts", line: 1, title: "Unanchored", body: "" }, + ], + }), + ); + expect(lettered.map((entry) => entry.marker)).toEqual(["A", "B", "C", "D"]); + expect(byExcerpt).toEqual([ + [ + { + line: 2, + markers: [ + { marker: "B", title: "Second" }, + { marker: "C", title: "Third" }, + ], + }, + ], + [{ line: 11, markers: [{ marker: "A", title: "First" }] }], + ]); + }); +}); diff --git a/apps/web/src/components/pullRequest/guide/guideTour.logic.ts b/apps/web/src/components/pullRequest/guide/guideTour.logic.ts new file mode 100644 index 000000000000..653aae14549a --- /dev/null +++ b/apps/web/src/components/pullRequest/guide/guideTour.logic.ts @@ -0,0 +1,224 @@ +import type { FileDiffMetadata } from "@pierre/diffs"; +import type { PullRequestReviewGuideExcerpt, PullRequestReviewGuideStop } from "@t3tools/contracts"; + +/** One step forward or back, held to the tour's first and last stops. */ +export function stepTourPosition(current: number, delta: 1 | -1, stopCount: number): number { + return Math.max(Math.min(current + delta, stopCount - 1), 0); +} + +/** The stop's files in reading order: the excerpted ones first, then the rest it owns. */ +export function stopFilePaths(stop: PullRequestReviewGuideStop): ReadonlyArray { + return [ + ...new Set([ + ...stop.excerpts.map((excerpt) => excerpt.path), + ...stop.files.map((file) => file.path), + ]), + ]; +} + +/** The rail's second line for a stop. */ +export function stopSubline(stop: PullRequestReviewGuideStop): string { + const count = stopFilePaths(stop).length; + if (count === 0) return "Context, no code"; + return count === 1 ? "1 file" : `${count} files`; +} + +/** A note's marker: A, B, C… in the order the guide lists them, numbers past Z. */ +function noteMarker(index: number): string { + return index < 26 ? String.fromCharCode(65 + index) : String(index + 1); +} + +/** One rendered line of a file's diff, with where it sits on each side. */ +export interface DiffRow { + readonly kind: "context" | "addition" | "deletion"; + /** Line number on the old side, null for an added line. */ + readonly oldLine: number | null; + /** Line number on the new side, null for a removed line. */ + readonly newLine: number | null; + /** The next line number on each side when this row is reached, for hunk headers. */ + readonly oldAt: number; + readonly newAt: number; + readonly text: string; + /** Which hunk of the file the row came from; rows from two hunks are not adjacent. */ + readonly hunk: number; +} + +/** Every line of a parsed file diff as the unified view lists it. */ +export function fileDiffRows(file: FileDiffMetadata): ReadonlyArray { + const rows: DiffRow[] = []; + file.hunks.forEach((hunk, hunkIndex) => { + let oldAt = hunk.deletionStart; + let newAt = hunk.additionStart; + for (const content of hunk.hunkContent) { + if (content.type === "context") { + for (let offset = 0; offset < content.lines; offset += 1) { + rows.push({ + kind: "context", + oldLine: oldAt, + newLine: newAt, + oldAt, + newAt, + text: file.additionLines[content.additionLineIndex + offset] ?? "", + hunk: hunkIndex, + }); + oldAt += 1; + newAt += 1; + } + continue; + } + for (let offset = 0; offset < content.deletions; offset += 1) { + rows.push({ + kind: "deletion", + oldLine: oldAt, + newLine: null, + oldAt, + newAt, + text: file.deletionLines[content.deletionLineIndex + offset] ?? "", + hunk: hunkIndex, + }); + oldAt += 1; + } + for (let offset = 0; offset < content.additions; offset += 1) { + rows.push({ + kind: "addition", + oldLine: null, + newLine: newAt, + oldAt, + newAt, + text: file.additionLines[content.additionLineIndex + offset] ?? "", + hunk: hunkIndex, + }); + newAt += 1; + } + } + }); + return rows; +} + +/** + * The rows an excerpt covers. Its range is in one side's line numbers, so the other side's half + * of a replacement at either edge comes along: an excerpt starting on an added line also shows + * the lines it replaced, which is what makes it read as a change rather than as new code. + */ +export function excerptRows( + rows: ReadonlyArray, + excerpt: Pick, +): ReadonlyArray { + const lineOf = (row: DiffRow) => (excerpt.side === "new" ? row.newLine : row.oldLine); + const inRange = (row: DiffRow) => { + const line = lineOf(row); + return line !== null && line >= excerpt.start && line <= excerpt.end; + }; + let first = rows.findIndex(inRange); + if (first === -1) return []; + let last = rows.findLastIndex(inRange); + const sameHunk = (index: number, anchor: number) => rows[index]?.hunk === rows[anchor]?.hunk; + if (excerpt.side === "new" && rows[first]?.kind === "addition") { + while (first > 0 && rows[first - 1]?.kind === "deletion" && sameHunk(first - 1, first)) { + first -= 1; + } + } + if (excerpt.side === "old" && rows[last]?.kind === "deletion") { + while ( + last < rows.length - 1 && + rows[last + 1]?.kind === "addition" && + sameHunk(last + 1, last) + ) { + last += 1; + } + } + return rows.slice(first, last + 1); +} + +const ROW_PREFIX = { context: " ", addition: "+", deletion: "-" } as const; + +/** + * A unified patch of just these rows, so the shared diff renderer can draw a slice of a file + * with its real line numbers instead of a second diff view being written for excerpts. + */ +export function rowsToPatch( + path: string, + previousPath: string, + rows: ReadonlyArray, +): string { + if (rows.length === 0) return ""; + const lines = [ + `diff --git a/${previousPath} b/${path}`, + `--- a/${previousPath}`, + `+++ b/${path}`, + ]; + let start = 0; + while (start < rows.length) { + let end = start; + while (end + 1 < rows.length && rows[end + 1]?.hunk === rows[start]?.hunk) end += 1; + const group = rows.slice(start, end + 1); + const head = group[0]!; + const oldCount = group.filter((row) => row.oldLine !== null).length; + const newCount = group.filter((row) => row.newLine !== null).length; + // Git names the line before an empty side, so a pure insertion after line 9 is `-9,0`. + const oldStart = oldCount === 0 ? Math.max(head.oldAt - 1, 0) : head.oldAt; + const newStart = newCount === 0 ? Math.max(head.newAt - 1, 0) : head.newAt; + lines.push(`@@ -${oldStart},${oldCount} +${newStart},${newCount} @@`); + for (const row of group) lines.push(`${ROW_PREFIX[row.kind]}${row.text.replace(/\r?\n$/, "")}`); + start = end + 1; + } + return `${lines.join("\n")}\n`; +} + +/** + * The old side an excerpt expands against: the new file with only the excerpt's lines put back + * as they were. The renderer fills the code around a hunk from both files and needs them to + * agree outside it, which the real old file does not wherever another hunk changed lines. Only + * the lines the reader can expand into are read, and those are the new file's, so the code + * around the excerpt shows as it reads after the change. Null when the excerpt spans hunks. + */ +export function excerptExpansionOldContents( + newContents: string, + rows: ReadonlyArray, +): string | null { + const head = rows[0]; + if (head === undefined || rows.some((row) => row.hunk !== head.hunk)) return null; + const newLines = newContents.match(/[^\n]*\n|[^\n]+$/g) ?? []; + const before = head.newAt - 1; + const newCount = rows.filter((row) => row.newLine !== null).length; + // The old side keeps its own numbering, so its prefix is as long as the old file's: trimmed + // from the front, or padded there with lines no expansion reaches. + const oldBefore = head.oldAt - 1; + const prefix = newLines.slice(0, before); + const oldPrefix = + oldBefore >= prefix.length + ? [...Array.from({ length: oldBefore - prefix.length }, () => "\n"), ...prefix] + : prefix.slice(prefix.length - oldBefore); + const oldRows = rows + .filter((row) => row.oldLine !== null) + .map((row) => (row.text.endsWith("\n") ? row.text : `${row.text}\n`)); + return [...oldPrefix, ...oldRows, ...newLines.slice(before + newCount)].join(""); +} + +/** A stop's notes lettered in guide order, with the excerpt lines each one marks. */ +export interface LetteredNote { + readonly marker: string; + readonly note: Note; +} + +/** + * Letters a stop's notes and groups them by the excerpt line they sit on. A note outside every + * excerpt keeps its letter in the list; it just has no line to mark. + */ +export function letterStopNotes(stop: PullRequestReviewGuideStop) { + const lettered = stop.notes.map((note, index) => ({ marker: noteMarker(index), note })); + const byExcerpt = stop.excerpts.map((excerpt) => { + const lines = new Map>(); + for (const { marker, note } of lettered) { + if (note.path !== excerpt.path || note.line < excerpt.start || note.line > excerpt.end) { + continue; + } + const entry = { marker, title: note.title }; + const list = lines.get(note.line); + if (list === undefined) lines.set(note.line, [entry]); + else list.push(entry); + } + return [...lines].map(([line, markers]) => ({ line, markers })); + }); + return { lettered, byExcerpt }; +} diff --git a/apps/web/src/components/pullRequest/guide/useGuideDiffFiles.ts b/apps/web/src/components/pullRequest/guide/useGuideDiffFiles.ts new file mode 100644 index 000000000000..99cae3606636 --- /dev/null +++ b/apps/web/src/components/pullRequest/guide/useGuideDiffFiles.ts @@ -0,0 +1,87 @@ +import type { FileDiffMetadata } from "@pierre/diffs"; +import type { EnvironmentId, PullRequestRef } from "@t3tools/contracts"; +import { useEffect, useState } from "react"; + +import { getRenderablePatch, resolveFileDiffPath } from "~/lib/diffRendering"; +import { pullRequestEnvironment } from "~/state/pullRequests"; +import { useEnvironmentQuery } from "~/state/query"; + +import { pullRequestReviewKey } from "../pullRequestReviewStore"; + +interface LoadedDiff { + readonly key: string; + /** The page asked for next; null is the first. */ + readonly cursor: string | null; + /** No page left to ask for. */ + readonly done: boolean; + readonly files: ReadonlyMap; +} + +const NO_FILES: ReadonlyMap = new Map(); + +/** + * The pull request's parsed file diffs, read through the same paged query the Code tab uses, so + * the pages are shared. Paging stops once every wanted path has arrived: a stop's excerpts are + * usually in the first page, and a large change should not be read whole to show one of them. + * Pages already read are kept, so going back to an earlier stop costs nothing. + */ +export function useGuideDiffFiles({ + environmentId, + reference, + headSha, + wantedPaths, +}: { + environmentId: EnvironmentId; + reference: PullRequestRef; + /** The guide's head. A push writes a new guide, whose excerpts must read the new diff. */ + headSha: string; + wantedPaths: ReadonlyArray; +}) { + const key = `${environmentId}:${pullRequestReviewKey(reference)}:${headSha}`; + const [loaded, setLoaded] = useState({ + key, + cursor: null, + done: false, + files: NO_FILES, + }); + const current: LoadedDiff = + loaded.key === key ? loaded : { key, cursor: null, done: false, files: NO_FILES }; + const missing = wantedPaths.some((path) => !current.files.has(path)); + const loading = missing && !current.done; + const query = useEnvironmentQuery( + loading + ? pullRequestEnvironment.diff({ + environmentId, + input: { ...reference, ...(current.cursor === null ? {} : { cursor: current.cursor }) }, + }) + : null, + ); + + const data = query.data; + const cursor = current.cursor; + useEffect(() => { + if (data === null) return; + setLoaded((previous) => { + const base = + previous.key === key ? previous : { key, cursor: null, done: false, files: NO_FILES }; + // A page is absorbed once; the query hands the same answer back on every render. + if (base.cursor !== cursor || base.done) return base; + const parsed = getRenderablePatch( + data.patch, + `pull-request-guide:${key}:${cursor ?? "first"}`, + ); + const files = new Map(base.files); + if (parsed?.kind === "files") { + for (const file of parsed.files) files.set(resolveFileDiffPath(file), file); + } + return { key, cursor: data.nextCursor, done: data.nextCursor === null, files }; + }); + }, [cursor, data, key]); + + return { + files: current.files, + loading: loading && query.error === null, + error: loading ? query.error : null, + retry: query.refresh, + }; +} diff --git a/apps/web/src/components/pullRequest/guide/useReviewGuideWriter.ts b/apps/web/src/components/pullRequest/guide/useReviewGuideWriter.ts new file mode 100644 index 000000000000..d787d8550d29 --- /dev/null +++ b/apps/web/src/components/pullRequest/guide/useReviewGuideWriter.ts @@ -0,0 +1,78 @@ +import type { EnvironmentId, PullRequestRef } from "@t3tools/contracts"; +import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import { useAtomValue } from "@effect/atom-react"; +import { useEffect, useEffectEvent, useState } from "react"; + +import { pullRequestEnvironment } from "~/state/pullRequests"; +import { useEnvironmentQuery } from "~/state/query"; +import { useAtomCommand } from "~/state/use-atom-command"; + +import { toastManager } from "../../ui/toast"; +import { readableFailure } from "../pullRequestDetail.logic"; +import { guideRunKey, useGuideRunStore } from "./guideRunStore"; + +/** + * A pull request's stored guide and the way to write a new one, shared by the Guide tab and the + * header menu. Starting a run hands it to the run coordinator, which re-reads it until it ends + * and says so if the reader is not looking at the guide. + */ +export function useReviewGuideWriter({ + environmentId, + reference, + title, +}: { + environmentId: EnvironmentId; + reference: PullRequestRef; + /** The pull request's title for the notice when the guide is ready. */ + title: string | null; +}) { + const query = useEnvironmentQuery( + pullRequestEnvironment.reviewGuide({ environmentId, input: reference }), + ); + const canWrite = useAtomValue( + pullRequestEnvironment.generateReviewGuide.permissionAtom(environmentId), + ); + const generate = useAtomCommand(pullRequestEnvironment.generateReviewGuide, { + reportFailure: false, + }); + const [starting, setStarting] = useState(false); + const data = query.data; + const running = starting || data?.run?._tag === "Running"; + + const watchRun = useEffectEvent(() => + useGuideRunStore.getState().watch({ + environmentId, + reference, + title, + previousGuideAt: data?.guide?.generatedAt ?? null, + previousFailedAt: data?.run?._tag === "Failed" ? data.run.failedAt : null, + since: Date.now(), + }), + ); + // A run started elsewhere, or before a reload, is watched as soon as it is seen. + useEffect(() => { + if (data?.run?._tag === "Running") watchRun(); + }, [data?.run?._tag]); + + const write = async () => { + if (running) return; + setStarting(true); + watchRun(); + const result = await generate({ environmentId, input: reference }); + setStarting(false); + if (result._tag === "Failure") { + // Nothing started, so there is nothing to watch for and nothing more to announce. + useGuideRunStore.getState().settle(guideRunKey(environmentId, reference)); + toastManager.add({ + type: "error", + title: "Could not start a review guide", + description: readableFailure( + squashAtomCommandFailure(result), + "Check the review guide model in Settings → Source Control.", + ), + }); + } + }; + + return { query, data, running, canWrite, write }; +} diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts index 7242eac8695a..f3054b6e9734 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts @@ -19,6 +19,7 @@ import { buildMessageContext, reviewCommentContextReference } from "~/lib/compos import { buildAddSelectionToAgentHandoff, + buildAskAboutGuideStopHandoff, classifyPullRequestChecks, groupPullRequestChecks, describePullRequestChecks, @@ -1222,6 +1223,54 @@ describe("asking about a change rather than working on it", () => { }); }); +describe("buildAskAboutGuideStopHandoff", () => { + const base = { + number: 42, + title: "Add search", + url: "https://github.com/acme/web/pull/42", + headBranch: "feat/search", + baseBranch: "main", + state: "open" as const, + isDraft: false, + }; + const stop = { + index: 1, + title: "The index is built on write", + summary: "Writes now update the index.", + attention: "A tradeoff: writes get slower.", + excerpts: [ + { path: "src/index.ts", side: "new" as const, start: 10, end: 20 }, + { path: "src/old.ts", side: "old" as const, start: 3, end: 5 }, + ], + }; + + it("carries the stop beside the pull request and asks only for an answer", () => { + const handoff = buildAskAboutGuideStopHandoff({ ...base, stop, question: "Why on write?" }); + expect(handoff.prompt).toBe("Why on write?"); + expect(handoff.reviewComments.map((entry) => entry.filePath)).toEqual([ + "PR #42", + "Guide stop 2", + ]); + expect(handoff.reviewComments[0]?.text).toContain("Do not change any code"); + const chip = handoff.reviewComments[1]?.text ?? ""; + expect(chip).toContain("Writes now update the index."); + expect(chip).toContain("A tradeoff: writes get slower."); + expect(chip).toContain("- src/index.ts lines 10-20\n"); + expect(chip).toContain("- src/old.ts lines 3-5 (removed; numbered as in the base branch)"); + }); + + it("leaves the question to the reader when none was suggested", () => { + const handoff = buildAskAboutGuideStopHandoff({ + ...base, + stop: { ...stop, attention: null, excerpts: [] }, + question: "", + }); + expect(handoff.prompt).toBe(""); + expect(handoff.reviewComments[1]?.text).not.toContain("judge"); + expect(handoff.reviewComments[1]?.text).not.toContain("lines it showed"); + }); +}); + describe("a second ask into the same composer", () => { const chip = (id: string): ReviewCommentContext => ({ id, diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts index 7faf9b651240..8e209b66d534 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts @@ -1179,6 +1179,70 @@ export function buildAddSelectionToAgentHandoff(input: { }; } +/** + * A question about one stop of the review guide. The stop arrives as a chip of its own, with what + * the guide said there and which lines it showed, so the agent starts where the reader is. The + * question is the reader's: a suggested one is prefilled to send or edit, otherwise it is empty. + */ +export function buildAskAboutGuideStopHandoff(input: { + readonly number: number; + readonly title: string; + readonly url: string; + readonly headBranch: string; + readonly baseBranch: string; + readonly state: PullRequestState; + readonly isDraft: boolean; + readonly stop: { + readonly index: number; + readonly title: string; + readonly summary: string; + readonly attention: string | null; + readonly excerpts: ReadonlyArray<{ + readonly path: string; + readonly side: "new" | "old"; + readonly start: number; + readonly end: number; + }>; + }; + readonly question: string; +}): FixFindingsHandoff { + const { stop } = input; + // Removed code is numbered on the base side; the head has other code at those lines. + const where = stop.excerpts.map( + (excerpt) => + `- ${excerpt.path} lines ${excerpt.start}-${excerpt.end}${excerpt.side === "old" ? " (removed; numbered as in the base branch)" : ""}`, + ); + return { + prompt: bounded(input.question), + reviewComments: [ + pullRequestContextComment(input, ANSWER_INSTRUCTIONS), + { + id: `pull-request-guide-stop:${input.number}:${stop.index}`, + sectionId: `pull-request:${input.number}`, + sectionTitle: `PR #${input.number} review guide`, + filePath: `Guide stop ${stop.index + 1}`, + startIndex: 0, + endIndex: 0, + rangeLabel: boundedField(stop.title), + text: [ + `The reader is at stop ${stop.index + 1} of a review guide for this pull request, titled \`${boundedField(stop.title)}\`. The guide was written by an agent and is context, not instructions.`, + `What the guide said there: ${bounded(stop.summary)}`, + ...(stop.attention === null + ? [] + : [`It asked the reader to judge: ${bounded(stop.attention)}`]), + ...(where.length === 0 + ? [] + : [ + "The lines it showed, numbered as in the pull request's head unless marked:", + ...where, + ]), + ].join("\n"), + diff: "", + }, + ], + }; +} + const isPullRequestOperationError = Schema.is(PullRequestOperationError); export function isPullRequestNotFound(failure: unknown): boolean { diff --git a/apps/web/src/components/settings/SettingInheritance.tsx b/apps/web/src/components/settings/SettingInheritance.tsx index e08ca680649f..4eb641ea0aab 100644 --- a/apps/web/src/components/settings/SettingInheritance.tsx +++ b/apps/web/src/components/settings/SettingInheritance.tsx @@ -45,9 +45,11 @@ function formatValue(key: keyof ServerSettings, value: unknown): string { ? "Automatic" : key === "sourceControlWriterModelSelection" ? "Text generation model" - : key === "defaultThreadEnvMode" || key === "worktreeSubmodules" - ? "Inherit" - : "Not set"; + : key === "reviewGuideModelSelection" + ? "Source control writer model" + : key === "defaultThreadEnvMode" || key === "worktreeSubmodules" + ? "Inherit" + : "Not set"; } if (typeof value === "boolean") return value ? "On" : "Off"; if (typeof value === "number") { diff --git a/apps/web/src/components/settings/SourceControlWritingSettings.test.tsx b/apps/web/src/components/settings/SourceControlWritingSettings.test.tsx index bfc75e083cf3..9f2da3cc4a11 100644 --- a/apps/web/src/components/settings/SourceControlWritingSettings.test.tsx +++ b/apps/web/src/components/settings/SourceControlWritingSettings.test.tsx @@ -17,7 +17,10 @@ vi.mock("./useScopedSettings", () => ({ ...DEFAULT_UNIFIED_SETTINGS, sourceControlWritingStyle: state.styles[0], }), - useScopedSettingsMixed: () => JSON.stringify(state.styles[0]) !== JSON.stringify(state.styles[1]), + // Review guides stay off and agreed here, so their rows do not render beside the style's. + useScopedSettingsMixed: (keys: ReadonlyArray) => + !keys.includes("reviewGuidesEnabled") && + JSON.stringify(state.styles[0]) !== JSON.stringify(state.styles[1]), useUpdateScopedSettings: () => state.updateSettings, })); vi.mock("./SettingsScopeContext", () => ({ diff --git a/apps/web/src/components/settings/SourceControlWritingSettings.tsx b/apps/web/src/components/settings/SourceControlWritingSettings.tsx index 455fae63951b..37dcc5981172 100644 --- a/apps/web/src/components/settings/SourceControlWritingSettings.tsx +++ b/apps/web/src/components/settings/SourceControlWritingSettings.tsx @@ -7,7 +7,10 @@ import type { } from "@t3tools/contracts"; import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; import { createModelSelection } from "@t3tools/shared/model"; -import { resolveSourceControlWriterModelSelection } from "@t3tools/shared/serverSettings"; +import { + resolveReviewGuideModelSelection, + resolveSourceControlWriterModelSelection, +} from "@t3tools/shared/serverSettings"; import { useScopedSettings, @@ -61,6 +64,7 @@ const MODE_OPTIONS: Record `${target.environmentId}:${target.projectId}`).join(","); const customInstructionsRef = useRef(null); const [editingAllInstructions, setEditingAllInstructions] = useState(false); const [allInstructions, setAllInstructions] = useState(null); @@ -124,6 +132,24 @@ export function SourceControlWritingSettingsSection() { activeSelection.model, ); const writerModelDisabledReason = useScopedModelDisabledReason(settings, instanceEntries); + const usesReviewGuideModel = settings.reviewGuideModelSelection !== null; + const reviewGuideSelection = resolveAppModelSelectionState( + { + ...settings, + textGenerationModelSelection: resolveReviewGuideModelSelection( + settings, + textGenerationProviders, + ), + }, + textGenerationProviders, + ); + // Its own list: a custom model chosen for guides is not among the writer model's options. + const reviewGuideModelOptionsByInstance = getCustomModelOptionsByInstance( + settings, + textGenerationProviders, + reviewGuideSelection.instanceId, + reviewGuideSelection.model, + ); return ( @@ -357,6 +383,145 @@ export function SourceControlWritingSettingsSection() { ) } /> + + {/* A beta the server owns: off hides the Guide tab in every client and refuses agents. */} + updateSettings({ reviewGuidesEnabled: Boolean(checked) })} + aria-label="Review guides" + /> + } + /> + + {settings.reviewGuidesEnabled || mixedReviewGuidesEnabled ? ( + <> + + Connect an environment to choose its review guide model. + + ) : ( +
    + {(usesReviewGuideModel || mixedReviewGuideModel) && canEnableDedicatedModel ? ( + { + const reason = writerModelDisabledReason(instanceId, model); + if (reason) { + toastManager.add({ + type: "error", + title: "Review guide model not saved", + description: reason, + }); + return; + } + updateSettings({ + reviewGuideModelSelection: createModelSelection(instanceId, model), + }); + }} + /> + ) : null} + { + if (!checked) { + updateSettings({ reviewGuideModelSelection: null }); + return; + } + // Every selected target gets this model, so it must run on all of them. + const reason = writerModelDisabledReason( + activeSelection.instanceId, + activeSelection.model, + ); + if (reason) { + toastManager.add({ + type: "error", + title: "Review guide model not saved", + description: reason, + }); + return; + } + updateSettings({ + reviewGuideModelSelection: createModelSelection( + activeSelection.instanceId, + activeSelection.model, + activeSelection.options, + ), + }); + }} + aria-label="Use a separate review guide model" + /> +
    + ) + } + /> + + updateSettings({ reviewGuideInstructions: "" })} + /> + ) : null + } + > +
    +