From 9423823beb01283f1070c0fcc5194d3618984959 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Tue, 27 Oct 2026 16:34:45 -0700 Subject: [PATCH 1/4] fix: integrate project intelligence across agents and environments --- .../ConversationSummariesRouteScreen.test.tsx | 67 ++-- .../loom/ProjectLoomRouteScreen.test.tsx | 131 ++++++-- .../features/loom/ProjectLoomRouteScreen.tsx | 52 ++- .../scripts/cliExecutableTarget.test.ts | 68 ++++ apps/server/scripts/cliExecutableTarget.ts | 44 +++ apps/server/src/mcp/McpToolAccess.ts | 16 + .../toolkits/conversationSummaries/tools.ts | 17 +- .../src/mcp/toolkits/intelligence.test.ts | 239 ++++++++++++++ .../src/mcp/toolkits/knowledge/handlers.ts | 7 +- .../src/mcp/toolkits/knowledge/tools.ts | 38 ++- .../src/mcp/toolkits/projectCode/handlers.ts | 69 ++-- .../src/mcp/toolkits/projectCode/tools.ts | 12 +- .../src/mcp/toolkits/projectLoom/tools.ts | 15 +- .../Adapters/OpenCode2AdapterV2.test.ts | 2 + .../ProjectionControlReads.test.ts | 2 + .../ProviderTurnControlService.test.ts | 304 +++++++++++++++++- .../ProviderTurnControlService.ts | 195 ++++++++++- .../ProviderTurnStartService.test.ts | 104 ++++++ .../ProviderTurnStartService.ts | 251 ++++----------- .../testkit/ProviderReplayHarness.ts | 1 + .../server/src/persistence/Migrations.test.ts | 60 +++- apps/server/src/persistence/Migrations.ts | 2 + .../081_ProjectKnowledgeMessageDeliveries.ts | 38 +++ .../ConversationSummaries.test.ts | 119 ++++++- .../ConversationSummaries.ts | 252 +++++++++------ .../knowledge/ProjectKnowledge.test.ts | 132 ++++++++ .../src/project/knowledge/ProjectKnowledge.ts | 33 +- .../src/project/knowledge/delivery.test.ts | 50 +++ apps/server/src/project/knowledge/delivery.ts | 137 ++++++++ .../src/project/knowledge/persistence.ts | 106 +++++- .../src/project/loom/ProjectLoom.test.ts | 83 ++++- apps/server/src/project/loom/ProjectLoom.ts | 44 ++- .../src/provider/Drivers/ClaudeDriver.ts | 1 + .../src/provider/Drivers/CodexDriver.ts | 1 + .../provider/Drivers/CodexManagedProvider.ts | 1 + .../provider/ProviderAccountTransfer.test.ts | 35 ++ .../src/provider/ProviderRegistry.test.ts | 74 +++++ apps/server/src/provider/ProviderRegistry.ts | 11 +- .../src/provider/agentContextBudget.json | 6 +- .../src/provider/builtInProviderCatalog.ts | 8 +- .../AntigravityTextGeneration.ts | 7 +- .../textGeneration/ClaudeTextGeneration.ts | 4 +- .../src/textGeneration/CodexTextGeneration.ts | 4 +- .../src/textGeneration/TextGeneration.test.ts | 79 +++++ .../src/textGeneration/TextGeneration.ts | 44 ++- apps/server/vite.config.ts | 43 +-- .../src/components/loom/LoomEventPanel.tsx | 135 ++++++-- .../loom/ProjectLoomPage.browser.test.tsx | 125 +++++++ .../src/components/loom/ProjectLoomPage.tsx | 11 +- .../components/settings/SettingsPanels.tsx | 27 +- .../settings/SourceControlWritingSettings.tsx | 41 ++- .../settings/useScopedModelAvailability.ts | 26 +- apps/web/src/modelSelection.test.ts | 18 ++ apps/web/src/modelSelection.ts | 5 +- docs/user/loom.md | 8 +- .../cases/project-intelligence.ndjson | 7 + evals/mcp-tool-use/run.ts | 8 + .../src/projectLoom/index.test.ts | 65 +++- .../client-runtime/src/projectLoom/index.ts | 20 +- packages/contracts/src/projectKnowledge.ts | 5 +- packages/contracts/src/projectLoom.ts | 19 +- packages/contracts/src/server.test.ts | 25 ++ packages/contracts/src/server.ts | 25 +- .../src/server/driver.ts | 5 +- packages/provider-core/src/server/driver.ts | 4 +- .../src/server/orchestrationInstructions.ts | 8 + .../src/server/textGeneration.ts | 6 + .../server/textGenerationOperations.test.ts | 49 +++ .../src/server/textGenerationOperations.ts | 34 +- .../src/server/textGeneration.ts | 7 +- packages/provider-fx/src/server/driver.ts | 5 +- .../src/server/textGeneration.ts | 7 +- .../src/server/textGeneration.test.ts | 51 +++ .../src/server/textGeneration.ts | 94 +----- .../provider-opencode/src/server/driver.ts | 11 +- .../src/server/textGeneration.ts | 7 +- .../src/server/v2/adapter.ts | 2 +- .../src/server/v2/textGeneration.ts | 7 +- .../provider-pi/src/server/textGeneration.ts | 7 +- 79 files changed, 3187 insertions(+), 695 deletions(-) create mode 100644 apps/server/scripts/cliExecutableTarget.test.ts create mode 100644 apps/server/scripts/cliExecutableTarget.ts create mode 100644 apps/server/src/mcp/toolkits/intelligence.test.ts create mode 100644 apps/server/src/persistence/Migrations/081_ProjectKnowledgeMessageDeliveries.ts create mode 100644 apps/server/src/project/knowledge/delivery.test.ts create mode 100644 apps/server/src/project/knowledge/delivery.ts create mode 100644 apps/server/src/textGeneration/TextGeneration.test.ts create mode 100644 evals/mcp-tool-use/cases/project-intelligence.ndjson create mode 100644 packages/provider-core/src/server/textGenerationOperations.test.ts diff --git a/apps/mobile/src/features/conversation-summaries/ConversationSummariesRouteScreen.test.tsx b/apps/mobile/src/features/conversation-summaries/ConversationSummariesRouteScreen.test.tsx index 179bf3d21647b..f65373b1cab53 100644 --- a/apps/mobile/src/features/conversation-summaries/ConversationSummariesRouteScreen.test.tsx +++ b/apps/mobile/src/features/conversation-summaries/ConversationSummariesRouteScreen.test.tsx @@ -1,6 +1,8 @@ -import { act, useSyncExternalStore, type ReactNode } from "react"; +import { act, type ReactNode } from "react"; import { createRoot } from "react-dom/client"; import { expect, it, vi } from "vite-plus/test"; +import { RegistryContext } from "@effect/atom-react"; +import { Atom, AtomRegistry } from "effect/reactivity"; import { EnvironmentId, MessageId, @@ -15,8 +17,7 @@ import { } from "@t3tools/contracts"; const state = vi.hoisted(() => ({ - listeners: new Set<() => void>(), - supported: true, + supported: null as unknown, focused: true, list: null as ConversationSummaryList | null, lists: [] as { environmentId: EnvironmentId; input: ConversationSummaryListInput }[], @@ -30,16 +31,6 @@ vi.mock("@react-navigation/native", () => ({ useIsFocused: () => state.focused, useNavigation: () => ({ goBack: () => {} }), })); -vi.mock("@effect/atom-react", () => ({ - useAtomValue: () => - useSyncExternalStore( - (listener) => { - state.listeners.add(listener); - return () => state.listeners.delete(listener); - }, - () => state.supported, - ), -})); vi.mock("../../components/ui/AppBar", () => ({ AppBar: () =>
Conversation summaries
, })); @@ -101,29 +92,33 @@ vi.mock("../../state/query", () => ({ import { ConversationSummariesRouteScreen } from "./ConversationSummariesRouteScreen"; function setup() { - state.listeners.clear(); - state.supported = true; + const supported = Atom.make(true); + const registry = AtomRegistry.make(); + state.supported = supported; state.focused = false; state.lists = []; state.expansions = []; state.list = null; + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); const container = document.createElement("div"); const root = createRoot(container); const rerender = () => - act(() => { - for (const listener of state.listeners) listener(); + act(() => root.render( - , - ); - }); + + + , + ), + ); rerender(); return { container, rerender, + setSupported: (value: boolean) => act(() => registry.set(supported, value)), click: async (label: string) => { const button = [...container.querySelectorAll("button")].find( (node) => node.textContent === label, @@ -131,18 +126,21 @@ function setup() { if (!button) throw new Error("Missing button: " + label); await act(async () => button.click()); }, - [Symbol.dispose]: () => act(() => root.unmount()), + [Symbol.dispose]: () => { + act(() => root.unmount()); + registry.dispose(); + vi.unstubAllGlobals(); + }, }; } it("reads only while open and supported, shows pass/citations/stale reasons and pages exact sources", async () => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); using screen = setup(); expect(state.lists).toEqual([]); state.focused = true; - state.supported = false; - screen.rerender(); + screen.setSupported(false); expect(state.lists).toEqual([]); + expect(screen.container.textContent).toContain("Update the server"); const summary: ConversationSummary = { id: "summary:" + "a".repeat(64), projectId: ProjectId.make("project"), @@ -185,8 +183,7 @@ it("reads only while open and supported, shows pass/citations/stale reasons and ], nextCursor: staleId, }; - state.supported = true; - screen.rerender(); + screen.setSupported(true); expect(state.lists.at(-1)).toEqual({ environmentId: "remote", input: { projectId: "project", threadId: "thread", limit: 20 }, @@ -223,6 +220,16 @@ it("reads only while open and supported, shows pass/citations/stale reasons and expect(state.expansions.at(-1)?.input.offset).toBe(0); await screen.click("Back to summaries"); expect(screen.container.textContent).not.toContain("Exact source"); + await screen.click("Show source messages"); + const listsBeforeCapabilityLoss = state.lists.length; + const expansionsBeforeCapabilityLoss = state.expansions.length; + screen.setSupported(false); + expect(screen.container.textContent).toContain("Update the server"); + expect(screen.container.textContent).not.toContain("Exact source"); + screen.rerender(); + expect(state.lists).toHaveLength(listsBeforeCapabilityLoss); + expect(state.expansions).toHaveLength(expansionsBeforeCapabilityLoss); + screen.setSupported(true); state.focused = false; screen.rerender(); const reads = state.lists.length; diff --git a/apps/mobile/src/features/loom/ProjectLoomRouteScreen.test.tsx b/apps/mobile/src/features/loom/ProjectLoomRouteScreen.test.tsx index 04220ecd50adc..6b8c36d6c4e33 100644 --- a/apps/mobile/src/features/loom/ProjectLoomRouteScreen.test.tsx +++ b/apps/mobile/src/features/loom/ProjectLoomRouteScreen.test.tsx @@ -1,8 +1,12 @@ -import { act, useSyncExternalStore, type ReactNode } from "react"; +import { act, type ReactNode } from "react"; import { createRoot } from "react-dom/client"; import { expect, it, vi } from "vite-plus/test"; +import { RegistryContext } from "@effect/atom-react"; +import { Atom, AtomRegistry } from "effect/reactivity"; import { + EnvironmentId, MessageId, + NodeId, ProjectId, ProviderInstanceId, RunId, @@ -14,12 +18,12 @@ import { } from "@t3tools/contracts"; const state = vi.hoisted(() => ({ - listeners: new Set<() => void>(), window: null as LoomWindow | null, - supported: true, + config: null as unknown, queries: [] as LoomWindowInput[], actions: [] as unknown[], urls: [] as string[], + environments: [] as string[], })); vi.mock("react-native", () => ({ Platform: { OS: "ios" }, @@ -120,27 +124,15 @@ vi.mock("../workspace/ListFilterMenu", () => ({ ), })); -vi.mock("@effect/atom-react", () => ({ - useAtomValue: () => ({ - environment: { - capabilities: { - projectLoom: useSyncExternalStore( - (listener) => { - state.listeners.add(listener); - return () => state.listeners.delete(listener); - }, - () => state.supported, - ), - }, - }, - }), -})); vi.mock("../../state/server", () => ({ serverEnvironment: { - configValueAtom: () => ({ environment: { capabilities: { projectLoom: state.supported } } }), + configValueAtom: () => state.config, }, })); vi.mock("../../state/entities", () => ({ useProject: () => ({ title: "Project" }) })); +vi.mock("../../state/environments", () => ({ + useEnvironments: () => ({ presentationById: new Map(state.environments.map((id) => [id, {}])) }), +})); vi.mock("../../state/projectLoom", () => ({ projectLoom: { window: ({ input }: { input: LoomWindowInput }) => { @@ -190,13 +182,18 @@ const run = (id: string, thread: string, day: number, hour: number): LoomRun => startedAt: at(day, hour), completedAt: at(day, hour + 1), }); -function setup() { - state.listeners.clear(); - state.supported = true; +function setup( + prepare: (window: LoomWindow) => LoomWindow = (window) => window, + environments: ReadonlyArray = ["remote", "origin", "destination"], +) { + state.environments = [...environments]; + const config = Atom.make({ environment: { capabilities: { projectLoom: true } } }); + const registry = AtomRegistry.make(); + state.config = config; state.actions.length = 0; state.queries.length = 0; state.urls.length = 0; - state.window = { + state.window = prepare({ projectId: ProjectId.make("project"), asOf: at(23, 12), from: at(19, 12), @@ -246,19 +243,20 @@ function setup() { commandsTruncated: true, }, nextCursor: null, - }; + }); vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); const container = document.createElement("div"); const root = createRoot(container); const render = () => - act(() => { - for (const listener of state.listeners) listener(); + act(() => root.render( - , - ); - }); + + + , + ), + ); render(); const press = async (label: string) => { const button = [...container.querySelectorAll("button")].find( @@ -270,9 +268,11 @@ function setup() { return { container, press, - render, + setSupported: (projectLoom: boolean) => + act(() => registry.set(config, { environment: { capabilities: { projectLoom } } })), [Symbol.dispose]: () => { act(() => root.unmount()); + registry.dispose(); vi.unstubAllGlobals(); }, }; @@ -318,9 +318,72 @@ it("groups dates, nests delegated activity, collapses descendants and navigates ]); await screen.press("Pull request linked: #1"); expect(state.urls).toEqual(["https://github.com/t3/t3code/pull/1"]); - state.supported = false; state.queries.length = 0; - screen.render(); + screen.setSupported(false); expect(screen.container.textContent).toContain("Update the server"); expect(state.queries).toEqual([]); }); + +const withRemoteTasks = (window: LoomWindow): LoomWindow => ({ + ...window, + lanes: window.lanes.map((entry) => + entry.threadId === "root" + ? { + ...entry, + relationship: "subagent", + delegatedFrom: { + environmentId: EnvironmentId.make("origin"), + threadId: ThreadId.make("root"), + title: "Remote parent", + }, + } + : entry, + ), + markers: [ + { + kind: "subagent_task", + id: NodeId.make("remote-task"), + threadId: ThreadId.make("root"), + runId: null, + at: at(22, 13), + completedAt: null, + status: "running", + title: "Build", + remoteChild: { + environmentId: EnvironmentId.make("destination"), + threadId: ThreadId.make("root"), + label: "Build server", + }, + }, + ], +}); + +it("opens remote child and parent links with their own environment even when thread ids collide", async () => { + using screen = setup(withRemoteTasks); + await screen.press("Agent task on Build server: Build (running)"); + await screen.press("Open parent thread Remote parent on origin"); + expect(state.actions).toEqual([ + { name: "Thread", params: { environmentId: "destination", threadId: "root" } }, + { name: "Thread", params: { environmentId: "origin", threadId: "root" } }, + ]); +}); + +it("keeps remote links visible but cannot navigate to environments this client has not connected", () => { + using screen = setup(withRemoteTasks, ["remote"]); + const buttons = [...screen.container.querySelectorAll("button")]; + expect( + buttons.find( + (button) => + button.getAttribute("aria-label") === "Agent task on Build server: Build (running)", + )?.disabled, + ).toBe(true); + expect( + buttons.find( + (button) => + button.getAttribute("aria-label") === "Open parent thread Remote parent on origin", + )?.disabled, + ).toBe(true); + expect(screen.container.textContent).toContain("Connect to Build server to open this thread."); + expect(screen.container.textContent).toContain("Connect to origin to open this parent."); + expect(state.actions).toEqual([]); +}); diff --git a/apps/mobile/src/features/loom/ProjectLoomRouteScreen.tsx b/apps/mobile/src/features/loom/ProjectLoomRouteScreen.tsx index a2d483907d55c..1094df6763ee4 100644 --- a/apps/mobile/src/features/loom/ProjectLoomRouteScreen.tsx +++ b/apps/mobile/src/features/loom/ProjectLoomRouteScreen.tsx @@ -1,9 +1,11 @@ import { useAtomValue } from "@effect/atom-react"; import { LegendList } from "@legendapp/list/react-native"; import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { loomCoverageNotes, loomMarkerLabel, + loomMarkerThreadRef, loomTruncationNotes, } from "@t3tools/client-runtime/projectLoom"; import { @@ -12,7 +14,7 @@ import { type LoomRunStatusFilter, type LoomWindowInput, type ProviderInstanceId, - type ThreadId, + type ScopedThreadRef, } from "@t3tools/contracts"; import { useMemo, useState } from "react"; import { Platform, Pressable, ScrollView, View } from "react-native"; @@ -27,6 +29,7 @@ import { useProject } from "../../state/entities"; import { projectLoom } from "../../state/projectLoom"; import { useEnvironmentQuery } from "../../state/query"; import { serverEnvironment } from "../../state/server"; +import { useEnvironments } from "../../state/environments"; import { ListFilterMenu } from "../workspace/ListFilterMenu"; import { useSelectWorkspaceDetail } from "../workspace/useSelectWorkspaceDetail"; import { buildLoomActivityList, type LoomActivityListItem } from "./loomActivityList"; @@ -67,6 +70,7 @@ function ProjectLoomScreen(props: { }) { const navigation = useNavigation(); const selectDetail = useSelectWorkspaceDetail(); + const { presentationById } = useEnvironments(); const config = useAtomValue(serverEnvironment.configValueAtom(props.environmentId)); const supported = config?.environment.capabilities.projectLoom === true; const project = useProject(props); @@ -112,8 +116,10 @@ function ProjectLoomScreen(props: { return next; }); }; - const selectThread = (threadId: ThreadId) => - selectDetail({ name: "Thread", params: { environmentId: props.environmentId, threadId } }); + const selectThread = (target: ScopedThreadRef) => + selectDetail({ name: "Thread", params: target }); + const canOpenThread = (target: ScopedThreadRef) => + target.environmentId === props.environmentId || presentationById.has(target.environmentId); const renderItem = ({ item }: { item: LoomActivityListItem }) => { if (item.kind === "date") { return ( @@ -129,6 +135,8 @@ function ProjectLoomScreen(props: { } if (item.kind === "thread") { const { row } = item; + const delegatedFrom = row.delegatedFrom; + const parentAvailable = delegatedFrom === undefined || canOpenThread(delegatedFrom); return ( selectThread(row.threadId)} + onPress={() => selectThread(scopeThreadRef(props.environmentId, row.threadId))} className="min-h-11 min-w-0 flex-1 justify-center gap-1" > @@ -170,6 +178,24 @@ function ProjectLoomScreen(props: { .join(" · ")} + {delegatedFrom === undefined ? null : ( + + selectThread(scopeThreadRef(delegatedFrom.environmentId, delegatedFrom.threadId)) + } + className="min-h-11 max-w-36 justify-center" + > + + {parentAvailable + ? `Parent on ${delegatedFrom.environmentId}` + : `Connect to ${delegatedFrom.environmentId} to open this parent.`} + + + )} ); } @@ -178,16 +204,27 @@ function ProjectLoomScreen(props: { item.kind === "run" ? `Run ${item.run.ordinal} · ${item.run.status}` : loomMarkerLabel(item.marker); + const target = + item.kind === "run" + ? scopeThreadRef(props.environmentId, item.run.threadId) + : loomMarkerThreadRef(props.environmentId, item.marker); + const available = canOpenThread(target); + const remoteLabel = + item.kind === "marker" && item.marker.kind === "subagent_task" + ? item.marker.remoteChild?.label + : undefined; return ( { if (item.kind === "marker" && item.marker.kind === "pull_request") { void tryOpenExternalUrl(item.marker.url, "pull-request"); - } else selectThread(item.kind === "run" ? item.run.threadId : item.marker.threadId); + } else selectThread(target); }} className="min-h-11 gap-1 border-b border-separator bg-grouped-card px-4 py-3" style={{ marginLeft: Math.min(item.depth, 5) * 16 + 16 }} @@ -203,6 +240,11 @@ function ProjectLoomScreen(props: { } · ${item.run.completedAt === null ? "End not recorded" : `Ended ${time(item.run.completedAt)}`}` : ""} + {available ? null : ( + + Connect to {remoteLabel ?? target.environmentId} to open this thread. + + )} ); }; diff --git a/apps/server/scripts/cliExecutableTarget.test.ts b/apps/server/scripts/cliExecutableTarget.test.ts new file mode 100644 index 0000000000000..0d50b37ec45ca --- /dev/null +++ b/apps/server/scripts/cliExecutableTarget.test.ts @@ -0,0 +1,68 @@ +import { assert, it } from "@effect/vitest"; + +import rootPackageJson from "../../../package.json" with { type: "json" }; +import { resolveCliExecutableTarget } from "./cliExecutableTarget.ts"; + +const nodeVersion = rootPackageJson.engines.node; +const windowsHost = { + enabled: true, + target: "win-x64", + nodeVersion, + hostNodeVersion: nodeVersion, + hostPlatform: "win32" as const, + hostArch: "x64" as const, +}; + +it("reuses pinned host Node for an explicit native Windows target without archive extraction", () => { + // Fork releases pass --target win-x64 on Windows. Setting tsdown.targets would download the + // same Node again and invoke PATH's tar on a C: archive path, which GNU tar treats as remote. + const config = resolveCliExecutableTarget(windowsHost); + assert.isUndefined(config.targets); + // tsdown appends .exe on Windows; keep the filename archive packaging already expects. + assert.equal(config.fileName, "t3-win-x64"); +}); + +it("requires the pinned version for explicit as well as implicit native builds", () => { + for (const target of [undefined, "win-x64"]) { + assert.throws( + () => resolveCliExecutableTarget({ ...windowsHost, target, hostNodeVersion: "25.7.0" }), + `Native CLI executables must be built with Node ${nodeVersion}`, + ); + } +}); + +it("keeps pinned downloads for a foreign platform or architecture", () => { + for (const host of [ + { hostPlatform: "linux" as const, hostArch: "x64" as const }, + { hostPlatform: "win32" as const, hostArch: "arm64" as const }, + ]) { + const config = resolveCliExecutableTarget({ + ...windowsHost, + ...host, + hostNodeVersion: "25.7.0", + }); + assert.deepEqual(config, { + fileName: "t3", + targets: [{ platform: "win", arch: "x64", nodeVersion }], + }); + } +}); + +it("preserves default native names and the Linux ARM64 injection path", () => { + assert.deepEqual(resolveCliExecutableTarget({ ...windowsHost, target: undefined }), { + fileName: "t3", + }); + assert.deepEqual( + resolveCliExecutableTarget({ + ...windowsHost, + target: undefined, + hostPlatform: "linux", + hostArch: "arm64", + }), + { fileName: "t3" }, + ); + assert.throws( + () => resolveCliExecutableTarget({ ...windowsHost, target: "linux-arm64" }), + "T3CODE_PACK_EXE_TARGET must be one of", + ); +}); diff --git a/apps/server/scripts/cliExecutableTarget.ts b/apps/server/scripts/cliExecutableTarget.ts new file mode 100644 index 0000000000000..063e1f5673916 --- /dev/null +++ b/apps/server/scripts/cliExecutableTarget.ts @@ -0,0 +1,44 @@ +// TEMPORARY one-time Node fix. Remove when fixed: https://github.com/nodejs/node/issues/67048 +// No linux-arm64: --build-sea output cannot load on 16/64 KiB page kernels, so it builds natively. +const SEA_TARGETS = { + "darwin-arm64": { platform: "darwin", arch: "arm64" }, + "darwin-x64": { platform: "darwin", arch: "x64" }, + "linux-x64": { platform: "linux", arch: "x64" }, + "win-arm64": { platform: "win", arch: "arm64" }, + "win-x64": { platform: "win", arch: "x64" }, +} as const; + +/** Select the Node executable embedded by tsdown, retaining the release's exact version pin. */ +export function resolveCliExecutableTarget(input: { + readonly enabled: boolean; + readonly target: string | undefined; + readonly nodeVersion: string; + readonly hostNodeVersion: string; + readonly hostPlatform: NodeJS.Platform; + readonly hostArch: NodeJS.Architecture; +}) { + const target = input.target?.trim(); + if (target && !Object.hasOwn(SEA_TARGETS, target)) { + throw new Error( + `T3CODE_PACK_EXE_TARGET must be one of ${Object.keys(SEA_TARGETS).join(", ")}, got "${target}".`, + ); + } + const selected = target ? SEA_TARGETS[target as keyof typeof SEA_TARGETS] : undefined; + const hostPlatform = input.hostPlatform === "win32" ? "win" : input.hostPlatform; + const native = + selected === undefined || + (selected.platform === hostPlatform && selected.arch === input.hostArch); + if (input.enabled && native && input.hostNodeVersion !== input.nodeVersion) { + throw new Error( + `Native CLI executables must be built with Node ${input.nodeVersion}; running ${input.hostNodeVersion}.`, + ); + } + // tsdown downloads every explicit target, including the host. Native builds can embed the + // pinned running Node directly, avoiding redundant downloads and Windows PATH tar selection. + // Preserve the explicit target's output name because archive assembly already recognizes it. + if (native) return { fileName: target ? `t3-${target}` : "t3" }; + return { + fileName: "t3", + targets: [{ ...selected!, nodeVersion: input.nodeVersion }], + }; +} diff --git a/apps/server/src/mcp/McpToolAccess.ts b/apps/server/src/mcp/McpToolAccess.ts index b82d9bb5ae59c..9cc34bf1440fa 100644 --- a/apps/server/src/mcp/McpToolAccess.ts +++ b/apps/server/src/mcp/McpToolAccess.ts @@ -124,6 +124,22 @@ export const reads = (handle: (params: P) => Effect.Effect) () => Effect.void, ); +/** Checks here before forwarding, then again there: a peer credential cannot add caller access. */ +export const readsWithCapability = ( + access: { readonly capability: McpInvocationContext.McpCapability; readonly denied: string }, + handle: (params: P) => Effect.Effect, +) => { + const check = McpInvocationContext.requireMcpCapability(access.capability).pipe( + Effect.mapError( + () => new OrchestratorMcpFailure({ code: "capability_denied", message: access.denied }), + ), + ); + return declare( + (params: P) => check.pipe(Effect.flatMap(() => handle(params))), + () => check, + ); +}; + export const readsAsCaller = (handle: (params: P) => Effect.Effect) => declare((params: P) => requireThreadCaller.pipe(Effect.flatMap(() => handle(params))), undefined); diff --git a/apps/server/src/mcp/toolkits/conversationSummaries/tools.ts b/apps/server/src/mcp/toolkits/conversationSummaries/tools.ts index d7246eabfb10d..d7559e093e8ce 100644 --- a/apps/server/src/mcp/toolkits/conversationSummaries/tools.ts +++ b/apps/server/src/mcp/toolkits/conversationSummaries/tools.ts @@ -5,9 +5,11 @@ import { ConversationSummaryList, ConversationSummaryListInput, ConversationSummaryRead, + OrchestratorMcpEnvironmentTarget, OrchestratorMcpFailure, } from "@t3tools/contracts"; import { Tool, Toolkit } from "effect/ai"; +import * as Schema from "effect/Schema"; import * as ThreadManagement from "../../../orchestration-v2/ThreadManagementService.ts"; import * as ConversationSummaries from "../../../project/conversationSummaries/ConversationSummaries.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; @@ -27,7 +29,10 @@ export const ConversationSummariesToolkit = Toolkit.make( ...shared, description: "List the generated summaries of a thread's older messages, oldest first, with whether summarizing is on for the project and what the latest background pass did. Use when a handoff mentions a conversation summary or you need the gist of a long thread before reading it with t3_thread_read. A summary whose source messages changed or were removed is stale and returns no text. Paginate with cursor=nextCursor. Does not generate summaries.", - parameters: ConversationSummaryListInput, + parameters: Schema.Struct({ + ...ConversationSummaryListInput.fields, + environmentId: OrchestratorMcpEnvironmentTarget, + }), success: ConversationSummaryList, }) .annotate(Tool.Readonly, true) @@ -36,7 +41,10 @@ export const ConversationSummariesToolkit = Toolkit.make( ...shared, description: "Read one conversation summary: its statements, the message ids each statement cites, and its exact source messages (ids and digests). The text is a model-written summary, not the conversation; check anything you rely on with t3_conversation_summary_expand. Stale summaries return no text.", - parameters: ConversationSummaryDescribeInput, + parameters: Schema.Struct({ + ...ConversationSummaryDescribeInput.fields, + environmentId: OrchestratorMcpEnvironmentTarget, + }), success: ConversationSummaryRead, }) .annotate(Tool.Readonly, true) @@ -45,7 +53,10 @@ export const ConversationSummariesToolkit = Toolkit.make( ...shared, description: "Return the exact original messages a conversation summary was written from, in order, as they are now. A message that changed, was removed, or sits in a deleted or archived thread returns a typed unavailable reason instead of text. Paginate with offset=nextOffset.", - parameters: ConversationSummaryExpandInput, + parameters: Schema.Struct({ + ...ConversationSummaryExpandInput.fields, + environmentId: OrchestratorMcpEnvironmentTarget, + }), success: ConversationSummaryExpansion, }) .annotate(Tool.Readonly, true) diff --git a/apps/server/src/mcp/toolkits/intelligence.test.ts b/apps/server/src/mcp/toolkits/intelligence.test.ts new file mode 100644 index 0000000000000..ff3642594c1f3 --- /dev/null +++ b/apps/server/src/mcp/toolkits/intelligence.test.ts @@ -0,0 +1,239 @@ +import { expect, it } from "@effect/vitest"; +import { + EnvironmentId, + OrchestratorMcpFailure, + type ProjectKnowledgeContext, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { McpSchema, McpServer } from "effect/ai"; + +import * as ThreadManagement from "../../orchestration-v2/ThreadManagementService.ts"; +import * as PeerForwarding from "../../peer/PeerForwarding.ts"; +import * as ConversationSummaries from "../../project/conversationSummaries/ConversationSummaries.ts"; +import * as CodeIntelligence from "../../project/knowledge/CodeIntelligence.ts"; +import * as FileHistory from "../../project/knowledge/FileHistory.ts"; +import * as ProjectKnowledge from "../../project/knowledge/ProjectKnowledge.ts"; +import * as ProjectLoom from "../../project/loom/ProjectLoom.ts"; +import * as McpHttpServer from "../McpHttpServer.ts"; +import * as McpInvocationContext from "../McpInvocationContext.ts"; +import * as ConversationSummaryHandlers from "./conversationSummaries/handlers.ts"; +import { ConversationSummariesToolkit } from "./conversationSummaries/tools.ts"; +import * as KnowledgeHandlers from "./knowledge/handlers.ts"; +import { KnowledgeToolkit } from "./knowledge/tools.ts"; +import * as ProjectCodeHandlers from "./projectCode/handlers.ts"; +import { ProjectCodeToolkit } from "./projectCode/tools.ts"; +import * as ProjectLoomHandlers from "./projectLoom/handlers.ts"; +import { ProjectLoomToolkit } from "./projectLoom/tools.ts"; + +const here = EnvironmentId.make("here"); +const there = EnvironmentId.make("there"); +const caller = ( + access: McpInvocationContext.McpClientCaller["access"] = "full-access", +): McpInvocationContext.McpInvocationScope => ({ + environmentId: here, + requestNamespace: "client:intelligence-test", + thread: undefined, + client: { sessionId: "intelligence-test", label: "Agent", access }, + capabilities: new Set(["orchestration", "project-code"]), + issuedAt: 0, +}); +const mcpClient = McpSchema.McpServerClient.of({ + clientId: 1, + protocolVersion: "2025-06-18", + clientCapabilities: {}, + clientInfo: { name: "intelligence-test", version: "1" }, + initializePayload: { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "intelligence-test", version: "1" }, + }, + getClient: Effect.die("unused"), +}); + +interface Forwarded { + readonly tool: string; + readonly environmentId: EnvironmentId; + readonly input: unknown; +} +const registration = Layer.mergeAll( + McpHttpServer.toolkitRegistration(KnowledgeToolkit, KnowledgeHandlers.layer), + McpHttpServer.toolkitRegistration( + ConversationSummariesToolkit, + ConversationSummaryHandlers.layer, + ), + McpHttpServer.toolkitRegistration(ProjectCodeToolkit, ProjectCodeHandlers.layer), + McpHttpServer.toolkitRegistration(ProjectLoomToolkit, ProjectLoomHandlers.layer), +).pipe(Layer.provideMerge(McpServer.McpServer.layer)); + +const server = ( + forwarded: Array, + knowledge = Layer.mock(ProjectKnowledge.ProjectKnowledge)({}), +) => + registration.pipe( + Layer.provideMerge( + Layer.mergeAll( + knowledge, + Layer.mock(ConversationSummaries.ConversationSummaries)({}), + Layer.mock(CodeIntelligence.CodeIntelligence)({}), + Layer.mock(FileHistory.FileHistory)({}), + Layer.mock(ProjectLoom.ProjectLoom)({}), + Layer.mock(ThreadManagement.ThreadManagementService)({}), + ), + ), + Layer.provide( + Layer.mock(PeerForwarding.PeerForwarding)({ + call: (_scope, tool, environmentId, input) => { + forwarded.push({ tool: tool.name, environmentId, input }); + // No local domain service should execute for a remote request. + return Effect.fail( + new OrchestratorMcpFailure({ code: "invalid_request", message: "forwarded" }), + ); + }, + }), + ), + ); + +const call = (name: string, input: Record, scope = caller()) => + McpServer.McpServer.pipe( + Effect.flatMap((service) => service.callTool({ name, arguments: input })), + Effect.map(({ content }) => + content.map((part) => (part.type === "text" ? part.text : "")).join(""), + ), + Effect.provideService(McpInvocationContext.McpInvocationContext, scope), + Effect.provideService(McpSchema.McpServerClient, mcpClient), + ); + +const preparation = { + projectId: "project", + selections: [{ kind: "fact", factId: "fact", revision: 1 }], + maxBytes: 1000, +}; +const mutation = { + action: "feedback", + projectId: "project", + commandId: "feedback", + factId: "fact", + expectedRevision: 1, + helpful: true, +}; + +it.effect.each([ + ["t3_knowledge_search", { projectId: "project", requestId: "recall", query: "cache" }], + ["t3_knowledge_list", { projectId: "project" }], + ["t3_knowledge_read_fact", { projectId: "project", factId: "fact" }], + ["t3_knowledge_status", { projectId: "project" }], + ["t3_knowledge_read_source", { projectId: "project", threadId: "thread", messageId: "message" }], + ["t3_knowledge_prepare_context", preparation], + ["t3_knowledge_mutate", { mutation }], + ["t3_conversation_summary_list", { projectId: "project", threadId: "thread" }], + [ + "t3_conversation_summary_describe", + { projectId: "project", threadId: "thread", summaryId: `summary:${"a".repeat(64)}` }, + ], + [ + "t3_conversation_summary_expand", + { projectId: "project", threadId: "thread", summaryId: `summary:${"a".repeat(64)}` }, + ], + [ + "t3_project_code_query", + { projectId: "project", threadId: "thread", query: { operation: "status" } }, + ], + ["t3_project_file_history", { projectId: "project", path: "src/cache.ts" }], + ["t3_project_loom_read", { projectId: "project" }], +] as const)("routes %s to its selected environment before reading local ids", ([tool, input]) => + Effect.gen(function* () { + const forwarded: Array = []; + const args = { ...input, environmentId: there }; + const result = yield* call(tool, args).pipe(Effect.provide(server(forwarded))); + expect(result).toContain("forwarded"); + expect(forwarded).toEqual([{ tool, environmentId: there, input: args }]); + }), +); + +it.effect.each(["read-only", "approval-required"] as const)( + "does not forward a knowledge mutation from a %s caller", + (access) => + Effect.gen(function* () { + const forwarded: Array = []; + const result = yield* call( + "t3_knowledge_mutate", + { mutation, environmentId: there }, + caller(access), + ).pipe(Effect.provide(server(forwarded))); + expect(result).toContain("capability_denied"); + expect(forwarded).toEqual([]); + }), +); + +it.effect.each([undefined, here, there])( + "does not borrow a peer's filesystem access for a code query to %s", + (environmentId) => + Effect.gen(function* () { + const forwarded: Array = []; + const result = yield* call( + "t3_project_code_query", + { + projectId: "project", + query: { operation: "status" }, + ...(environmentId === undefined ? {} : { environmentId }), + }, + { ...caller(), capabilities: new Set(["orchestration"]) }, + ).pipe(Effect.provide(server(forwarded))); + expect(result).toContain("capability_denied"); + expect(result).toContain("filesystem:read"); + expect(forwarded).toEqual([]); + }), +); + +it.effect("rechecks a linked caller's mutation ceiling at the destination", () => + Effect.gen(function* () { + const forwarded: Array = []; + const scope = caller("full-access"); + const result = yield* call( + "t3_knowledge_mutate", + { mutation }, + { + ...scope, + client: { + ...scope.client!, + linked: true, + narrowedTo: { runtimeMode: "approval-required", interactionMode: "default" }, + }, + }, + ).pipe(Effect.provide(server(forwarded))); + expect(result).toContain("capability_denied"); + expect(forwarded).toEqual([]); + }), +); + +it.effect.each([undefined, here, there])( + "prepares context in the authenticated destination when the target is %s", + (environmentId) => + Effect.gen(function* () { + const forwarded: Array = []; + const prepared: Array = []; + const destination = environmentId ?? here; + const knowledge = Layer.mock(ProjectKnowledge.ProjectKnowledge)({ + prepareContext: (input) => { + prepared.push(input); + return Effect.succeed({ context: input, text: "context", byteLength: 7 }); + }, + }); + const result = yield* call( + "t3_knowledge_prepare_context", + { + ...preparation, + ...(environmentId === undefined ? {} : { environmentId }), + }, + { ...caller(), environmentId: destination }, + ).pipe(Effect.provide(server(forwarded, knowledge))); + expect(JSON.parse(result)).toEqual({ + context: { ...preparation, environmentId: destination }, + text: "context", + byteLength: 7, + }); + expect(prepared).toEqual([{ ...preparation, environmentId: destination }]); + expect(forwarded).toEqual([]); + }), +); diff --git a/apps/server/src/mcp/toolkits/knowledge/handlers.ts b/apps/server/src/mcp/toolkits/knowledge/handlers.ts index 1d6d9a27383aa..50c4b3eb8f120 100644 --- a/apps/server/src/mcp/toolkits/knowledge/handlers.ts +++ b/apps/server/src/mcp/toolkits/knowledge/handlers.ts @@ -38,7 +38,12 @@ export const layer = McpToolAccess.toLayer(KnowledgeToolkit, { ), ), t3_knowledge_prepare_context: McpToolAccess.reads((input) => - Effect.flatMap(access, (service) => service.prepareContext(input)).pipe( + Effect.gen(function* () { + const { scope } = yield* readCaller(); + const service = yield* ProjectKnowledge.ProjectKnowledge; + // The router consumes the target; attachments belong to the environment that serves them. + return yield* service.prepareContext({ ...input, environmentId: scope.environmentId }); + }).pipe( Effect.mapError((error) => (error._tag === "ProjectKnowledgeError" ? failure(error) : error)), ), ), diff --git a/apps/server/src/mcp/toolkits/knowledge/tools.ts b/apps/server/src/mcp/toolkits/knowledge/tools.ts index 2c8edfb9508e8..234b4bbebdf46 100644 --- a/apps/server/src/mcp/toolkits/knowledge/tools.ts +++ b/apps/server/src/mcp/toolkits/knowledge/tools.ts @@ -1,4 +1,5 @@ import { + OrchestratorMcpEnvironmentTarget, OrchestratorMcpFailure, ProjectKnowledgeFactDetail, ProjectKnowledgeMutation, @@ -34,8 +35,11 @@ export const KnowledgeToolkit = Toolkit.make( Tool.make("t3_knowledge_search", { ...shared, description: - "Use when a task depends on prior project decisions, conventions, constraints, or agent observations. Resolve projectId from this environment's registered projects with t3_project_list and t3_project_read. Recall is on demand; facts are not automatically added to the prompt. Read returned message citations with t3_knowledge_read_source before relying on them. Facts use holographic and lexical ranking. Unavailable ranking is explicit. Superseded facts are excluded unless includeSuperseded is set. Reuse requestId for retries so retrieval is counted once.", - parameters: ProjectKnowledgeSearchInput, + "Use when a task depends on prior project decisions, conventions, constraints, or agent observations. Resolve projectId from the selected environment's registered projects with t3_project_list and t3_project_read using the same environmentId. Recall is on demand; facts are not automatically added to the prompt. Read returned message citations with t3_knowledge_read_source in that same environment before relying on them. Facts use holographic and lexical ranking. Unavailable ranking is explicit. Superseded facts are excluded unless includeSuperseded is set. Reuse requestId for retries so retrieval is counted once.", + parameters: Schema.Struct({ + ...ProjectKnowledgeSearchInput.fields, + environmentId: OrchestratorMcpEnvironmentTarget, + }), success: ProjectKnowledgeSearchResult, }) .annotate(Tool.Readonly, true) @@ -44,7 +48,10 @@ export const KnowledgeToolkit = Toolkit.make( ...shared, description: "List project facts, including proposed or forgotten facts when requested. Superseded facts are excluded unless includeSuperseded is set; sourceState unavailable lists only facts whose citations no longer resolve. This works independently of the retrieval kernel.", - parameters: ProjectKnowledgeListInput, + parameters: Schema.Struct({ + ...ProjectKnowledgeListInput.fields, + environmentId: OrchestratorMcpEnvironmentTarget, + }), success: ProjectKnowledgeListResult, }) .annotate(Tool.Readonly, true) @@ -53,7 +60,10 @@ export const KnowledgeToolkit = Toolkit.make( ...shared, description: "Read one project fact with its recent revision, confirmation, feedback and supersession history, linked superseding facts, and possible duplicates or conflicts found by lexical similarity.", - parameters: ProjectKnowledgeReadFactInput, + parameters: Schema.Struct({ + ...ProjectKnowledgeReadFactInput.fields, + environmentId: OrchestratorMcpEnvironmentTarget, + }), success: ProjectKnowledgeFactDetail, }) .annotate(Tool.Readonly, true) @@ -62,7 +72,10 @@ export const KnowledgeToolkit = Toolkit.make( ...shared, description: "Summarize project memory health: fact counts by status, trust bands, facts below the default recall threshold, retrieval and feedback totals, and facts whose sources are unavailable.", - parameters: ProjectKnowledgeStatusInput, + parameters: Schema.Struct({ + ...ProjectKnowledgeStatusInput.fields, + environmentId: OrchestratorMcpEnvironmentTarget, + }), success: ProjectKnowledgeStatus, }) .annotate(Tool.Readonly, true) @@ -71,7 +84,10 @@ export const KnowledgeToolkit = Toolkit.make( ...shared, description: "Read the exact canonical message behind a citation. Deleted, changed and out-of-project sources return unavailable without old excerpts.", - parameters: ProjectKnowledgeReadSourceInput, + parameters: Schema.Struct({ + ...ProjectKnowledgeReadSourceInput.fields, + environmentId: OrchestratorMcpEnvironmentTarget, + }), success: ProjectKnowledgeSource, }) .annotate(Tool.Readonly, true) @@ -80,7 +96,10 @@ export const KnowledgeToolkit = Toolkit.make( ...shared, description: "Preview explicitly selected current project knowledge under a UTF-8 byte allowance. Returns references for the user to attach; it does not send a turn.", - parameters: ProjectKnowledgeContext, + parameters: Schema.Struct({ + ...ProjectKnowledgeContext.fields, + environmentId: OrchestratorMcpEnvironmentTarget, + }), success: ProjectKnowledgePreparedContext, }) .annotate(Tool.Readonly, true) @@ -89,7 +108,10 @@ export const KnowledgeToolkit = Toolkit.make( ...shared, description: "Propose or edit project knowledge, supersede a fact with a replacement, forget a fact, or give trust feedback. When a fact comes from a conversation message, attach that exact message citation. Leave sources empty only for an agent observation with no message source; its origin is recorded from this authenticated caller. Proposed facts can be recalled immediately, but are not automatically added to the prompt. Confirmation in the app is optional and remains a separate trust action. Agents cannot confirm facts or claim direct user assertions. Reuse commandId when retrying. Updates require the current revision. Supersede creates replacementFactId and keeps the old fact's content but removes it from recall; prefer it over forget when knowledge changed. Created, edited and replacement facts return hints naming possible duplicates or conflicts to review. Forgotten facts cannot be restored.", - parameters: Schema.Struct({ mutation: ProjectKnowledgeMutation }), + parameters: Schema.Struct({ + mutation: ProjectKnowledgeMutation, + environmentId: OrchestratorMcpEnvironmentTarget, + }), success: ProjectKnowledgeMutationResult, }).annotate(Tool.Destructive, true), ); diff --git a/apps/server/src/mcp/toolkits/projectCode/handlers.ts b/apps/server/src/mcp/toolkits/projectCode/handlers.ts index cacbe420ea225..283cfa57bd805 100644 --- a/apps/server/src/mcp/toolkits/projectCode/handlers.ts +++ b/apps/server/src/mcp/toolkits/projectCode/handlers.ts @@ -2,7 +2,6 @@ import { OrchestratorMcpFailure } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as CodeIntelligence from "../../../project/knowledge/CodeIntelligence.ts"; import * as FileHistory from "../../../project/knowledge/FileHistory.ts"; -import * as McpInvocationContext from "../../McpInvocationContext.ts"; import * as McpToolAccess from "../../McpToolAccess.ts"; import { readCaller, unavailable } from "../../threadAccess.ts"; import { ProjectCodeToolkit } from "./tools.ts"; @@ -40,43 +39,39 @@ const queryFailure = ( } }; -const denyProjectCode = () => - new OrchestratorMcpFailure({ - code: "capability_denied", - message: "This credential does not have filesystem:read access for project code queries.", - }); - export const layer = McpToolAccess.toLayer(ProjectCodeToolkit, { - t3_project_code_query: McpToolAccess.reads((input) => - Effect.gen(function* () { - const scope = yield* McpInvocationContext.requireMcpCapability("project-code").pipe( - Effect.mapError(() => denyProjectCode()), - ); - yield* readCaller(); - if ( - scope.thread !== undefined && - input.threadId !== undefined && - input.threadId !== scope.thread.threadId - ) { - return yield* new OrchestratorMcpFailure({ - code: "thread_not_found", - message: "The selected thread is not available to this credential.", - }); - } - const code = yield* CodeIntelligence.CodeIntelligence; - return yield* code - .query(input) - .pipe( - Effect.mapError((error) => - error._tag === "CodeIntelligenceProjectNotFound" || - error._tag === "CodeIntelligenceThreadMismatch" || - error._tag === "CodeIntelligencePathOutsideCheckout" || - error._tag === "CodeIntelligenceInspectionFailed" - ? queryFailure(error) - : unavailable(), - ), - ); - }), + t3_project_code_query: McpToolAccess.readsWithCapability( + { + capability: "project-code", + denied: "This credential does not have filesystem:read access for project code queries.", + }, + (input) => + Effect.gen(function* () { + const { scope } = yield* readCaller(); + if ( + scope.thread !== undefined && + input.threadId !== undefined && + input.threadId !== scope.thread.threadId + ) { + return yield* new OrchestratorMcpFailure({ + code: "thread_not_found", + message: "The selected thread is not available to this credential.", + }); + } + const code = yield* CodeIntelligence.CodeIntelligence; + return yield* code + .query(input) + .pipe( + Effect.mapError((error) => + error._tag === "CodeIntelligenceProjectNotFound" || + error._tag === "CodeIntelligenceThreadMismatch" || + error._tag === "CodeIntelligencePathOutsideCheckout" || + error._tag === "CodeIntelligenceInspectionFailed" + ? queryFailure(error) + : unavailable(), + ), + ); + }), ), t3_project_file_history: McpToolAccess.reads((input) => Effect.gen(function* () { diff --git a/apps/server/src/mcp/toolkits/projectCode/tools.ts b/apps/server/src/mcp/toolkits/projectCode/tools.ts index 9e692722e03ba..ce868fa667b7e 100644 --- a/apps/server/src/mcp/toolkits/projectCode/tools.ts +++ b/apps/server/src/mcp/toolkits/projectCode/tools.ts @@ -1,4 +1,5 @@ import { + OrchestratorMcpEnvironmentTarget, OrchestratorMcpFailure, ProjectCodeQueryInput, ProjectCodeQueryResult, @@ -6,6 +7,7 @@ import { ProjectFileHistoryResult, } from "@t3tools/contracts"; import { Tool, Toolkit } from "effect/ai"; +import * as Schema from "effect/Schema"; import * as CodeIntelligence from "../../../project/knowledge/CodeIntelligence.ts"; import * as FileHistory from "../../../project/knowledge/FileHistory.ts"; import * as ThreadManagement from "../../../orchestration-v2/ThreadManagementService.ts"; @@ -22,7 +24,10 @@ export const ProjectCodeToolkit = Toolkit.make( ], description: "Query the registered project's multilingual code graph for symbol, caller, callee, reference, dependency, or impact questions before broad repository-reading loops. Resolve projectId with t3_project_list and t3_project_read. Pass threadId when querying that thread's registered worktree. Supports bounded search, symbol lookup, callers, callees, references, dependencies, impact, tests (usages walked to test files), affected (changed declarations in a file with their callers and reaching tests), index status, and freshness (compares the existing index to the checkout without indexing). Results include source digests, coverage, unresolved references, and edge evidence grades. Relations may be static candidates rather than compiler-resolved facts; inspect source when coverage is partial or stale, a selection is ambiguous, or evidence is heuristic. Project and optional thread IDs select an enrolled project and its exact registered worktree.", - parameters: ProjectCodeQueryInput, + parameters: Schema.Struct({ + ...ProjectCodeQueryInput.fields, + environmentId: OrchestratorMcpEnvironmentTarget, + }), success: ProjectCodeQueryResult, }) .annotate(Tool.Readonly, true) @@ -37,7 +42,10 @@ export const ProjectCodeToolkit = Toolkit.make( ], description: "List threads whose turn checkpoints included a project-relative file path, newest first. A checkpoint records every file that changed in its workspace since the previous checkpoint, including changes made by people or other threads sharing that workspace, so an entry means the file was present at that thread's checkpoint, not that the thread authored the change.", - parameters: ProjectFileHistoryInput, + parameters: Schema.Struct({ + ...ProjectFileHistoryInput.fields, + environmentId: OrchestratorMcpEnvironmentTarget, + }), success: ProjectFileHistoryResult, }) .annotate(Tool.Readonly, true) diff --git a/apps/server/src/mcp/toolkits/projectLoom/tools.ts b/apps/server/src/mcp/toolkits/projectLoom/tools.ts index ae3eaa52ff40b..12de3f93439cd 100644 --- a/apps/server/src/mcp/toolkits/projectLoom/tools.ts +++ b/apps/server/src/mcp/toolkits/projectLoom/tools.ts @@ -1,5 +1,11 @@ -import { LoomWindow, LoomWindowInput, OrchestratorMcpFailure } from "@t3tools/contracts"; +import { + LoomWindow, + LoomWindowInput, + OrchestratorMcpEnvironmentTarget, + OrchestratorMcpFailure, +} from "@t3tools/contracts"; import { Tool, Toolkit } from "effect/ai"; +import * as Schema from "effect/Schema"; import * as ThreadManagement from "../../../orchestration-v2/ThreadManagementService.ts"; import * as ProjectLoom from "../../../project/loom/ProjectLoom.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; @@ -14,8 +20,11 @@ export const ProjectLoomToolkit = Toolkit.make( McpInvocationContext.McpInvocationContext, ], description: - "Read a project's activity timeline for a time window (default: the last 7 days): its threads as lanes with fork and subagent parents, runs with request, start and completion times, checkpoints with file counts, linked pull requests, commands that look like test runs, provider-native tasks, and fork or handoff transfers between threads. Use it to see what happened across a project recently or which thread did what when, before reading individual threads with t3_thread_read. Resolve projectId with t3_project_list. Activity ticks are omitted. A null time is unknown, not zero. A test command marker means a test command ran, not that tests passed. Limits apply per kind; check truncated, and page lanes with cursor=nextCursor using the returned from and to.", - parameters: LoomWindowInput, + "Read a project's activity timeline for a time window (default: the last 7 days): its threads as lanes with fork and subagent parents, runs with request, start and completion times, checkpoints with file counts, linked pull requests, commands that look like test runs, provider-native and remote tasks, and fork or handoff transfers between threads. Use it to see what happened across a project recently or which thread did what when, before reading individual threads with t3_thread_read. Resolve projectId with t3_project_list in the same environment. Follow a task's remoteChild or a lane's delegatedFrom with both its environmentId and threadId; their activity lives in that environment, not in a local lane with the same id. Activity ticks are omitted. A null time is unknown, not zero. A test command marker means a test command ran, not that tests passed. Limits apply per kind; check truncated, and page lanes with cursor=nextCursor using the returned from and to.", + parameters: Schema.Struct({ + ...LoomWindowInput.fields, + environmentId: OrchestratorMcpEnvironmentTarget, + }), success: LoomWindow, }) .annotate(Tool.Readonly, true) diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts index 37350b4925cf9..7266e877c75f7 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts @@ -2416,6 +2416,8 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { yield* runtime.startTurn(turnInput(thread)); assert.equal((yield* Fiber.join(terminal))?.status, "completed"); } + // A transient registration failure changes availability, not the adapter's supported features. + assert.isTrue(runtime.providerSession.capabilities.tools.supportsMcpTools); }).pipe(Effect.scoped), ); diff --git a/apps/server/src/orchestration-v2/ProjectionControlReads.test.ts b/apps/server/src/orchestration-v2/ProjectionControlReads.test.ts index c7913cc1f2c2d..ad09635869fe4 100644 --- a/apps/server/src/orchestration-v2/ProjectionControlReads.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionControlReads.test.ts @@ -1,4 +1,5 @@ import * as ServerSettings from "../serverSettings.ts"; +import * as ProjectKnowledge from "../project/knowledge/ProjectKnowledge.ts"; import { disabledLayer } from "../pstack/PstackPack.testkit.ts"; import { assert, it } from "@effect/vitest"; import { @@ -348,6 +349,7 @@ it.effect.each(storageCases)( }).pipe( Effect.provide( Layer.merge(ProviderTurnControlService.layer, RuntimeRequestService.layer).pipe( + Layer.provide(Layer.mock(ProjectKnowledge.ProjectKnowledge)({})), Layer.provide(disabledLayer), Layer.provide(ServerSettings.layerTest()), Layer.provide(layerSessions), diff --git a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts index 969cbcde17816..5745149623987 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts @@ -1,6 +1,14 @@ import * as FileSystem from "effect/FileSystem"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { ComposerContextId, MessageId, ServerSettingsError } from "@t3tools/contracts"; +import { + ComposerContextId, + EnvironmentId, + MessageId, + ProjectFactId, + ProjectKnowledgeError, + ServerSettingsError, + type ProjectKnowledgeDelivery, +} from "@t3tools/contracts"; import { formatComposerContextReference } from "@t3tools/shared/composerContextReferences"; import { claudeProvider, disabledLayer, makeFixture } from "../pstack/PstackPack.testkit.ts"; import * as ServerSettings from "../serverSettings.ts"; @@ -21,8 +29,10 @@ import { ThreadId, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; @@ -30,7 +40,8 @@ import * as Stream from "effect/Stream"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; -import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; +import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; +import * as ProjectKnowledge from "../project/knowledge/ProjectKnowledge.ts"; import * as ProviderSessionManager from "./ProviderSessionManager.ts"; import * as ProviderTurnControlService from "./ProviderTurnControlService.ts"; @@ -290,6 +301,7 @@ it.effect( }), ); const layerControl = ProviderTurnControlService.layer.pipe( + Layer.provide(Layer.mock(ProjectKnowledge.ProjectKnowledge)({})), Layer.provide(disabledLayer), Layer.provide(ServerSettings.layerTest()), Layer.provide(Layer.merge(layerProjection, layerSessionManager)), @@ -433,6 +445,7 @@ it.effect.each([ const received: string[] = []; let threadReads = 0; const layers = ProviderTurnControlService.layer.pipe( + Layer.provide(Layer.mock(ProjectKnowledge.ProjectKnowledge)({})), Layer.provide( Layer.mergeAll( fixture.layer, @@ -571,3 +584,290 @@ it.effect.each([ } }).pipe(Effect.provide(NodeServices.layer)), ); + +it.effect.each([ + "sent", + "stale", + "budget", + "budget-null-turn-window", + "uncertain", + "turn-ended", + "sent-replaced", + "uncertain-replaced", + "uncertain-replaced-legacy", + "stale-during-history", +] as const)( + "delivers selected knowledge through active steering with %s receipt semantics", + (mode) => + Effect.gen(function* () { + const now = yield* DateTime.now; + const threadId = ThreadId.make("knowledge-steer-thread"); + const projectId = ProjectId.make("knowledge-steer-project"); + const providerSessionId = ProviderSessionId.make("knowledge-steer-session"); + const providerThreadId = ProviderThreadId.make("knowledge-steer-provider-thread"); + const providerTurnId = ProviderTurnId.make("knowledge-steer-turn"); + const attemptId = RunAttemptId.make("knowledge-steer-attempt"); + const messageId = MessageId.make("knowledge-steer-message"); + const runId = RunId.make("knowledge-steer-run"); + const providerThread: OrchestrationV2ProviderThread = { + id: providerThreadId, + driver, + providerInstanceId, + providerSessionId, + appThreadId: threadId, + ownerNodeId: null, + nativeThreadRef: { driver, nativeId: "knowledge-native-thread", strength: "strong" }, + nativeConversationHeadRef: null, + status: "active", + firstRunOrdinal: 1, + lastRunOrdinal: 1, + handoffIds: [], + forkedFrom: null, + createdAt: now, + updatedAt: now, + ...(mode.startsWith("budget") + ? { contextUsage: { usedTokens: 128_000, maxTokens: 128_000 } } + : {}), + }; + const projection = makeProjection({ + now, + threadId, + providerThread, + providerTurnId, + attemptId, + }); + const record = { + version: 1 as const, + kind: "project-knowledge" as const, + contextId: ComposerContextId.make("knowledge-steer-context"), + label: "Testing convention", + knowledge: { + environmentId: EnvironmentId.make("knowledge-steer-environment"), + projectId, + selections: [ + { kind: "fact" as const, factId: ProjectFactId.make("test-runner"), revision: 1 }, + ], + maxBytes: 48000, + }, + }; + const receipts: ProjectKnowledgeDelivery[] = []; + const received: string[] = []; + let resolved = 0; + let completed = false; + let replaced = false; + let sourceChanged = false; + const historyStarted = yield* Deferred.make(); + const releaseHistory = yield* Deferred.make(); + const uncertain = mode.startsWith("uncertain"); + const layers = ProviderTurnControlService.layer.pipe( + Layer.provide( + Layer.mergeAll( + disabledLayer, + ServerSettings.layerTest(), + Layer.mock(ProjectKnowledge.ProjectKnowledge)({ + resolveForTurn: (context) => + Effect.suspend(() => { + resolved += 1; + if ((resolved > 1 && mode === "stale") || sourceChanged) { + return Effect.fail( + new ProjectKnowledgeError({ reason: "stale_context", projectId }), + ); + } + if (resolved > 1 && mode === "turn-ended") completed = true; + return Effect.succeed({ + context, + text: "Use the project-selected test runner.", + byteLength: 37, + }); + }), + readDeliveryState: (target) => + Effect.sync(() => { + const matching = receipts.filter( + (receipt) => + receipt.messageId === target.messageId && + receipt.providerInstanceId === target.providerInstanceId && + (target.scope === "attempt" + ? receipt.attemptId === target.attemptId && + (receipt.providerThreadId === undefined || + receipt.providerThreadId === target.providerThreadId) + : receipt.providerThreadId === target.providerThreadId && + receipt.nativeThreadId === target.nativeThreadId && + (receipt.nativeThreadId != null || + receipt.attemptId === target.attemptId)), + ); + return { + sent: matching.some((receipt) => receipt.status === "sent"), + sentThisAttempt: matching.some( + (receipt) => + receipt.status === "sent" && receipt.attemptId === target.attemptId, + ), + uncertain: matching.some( + (receipt) => receipt.status === "prepared" || receipt.status === "uncertain", + ), + }; + }), + recordDelivery: (receipt) => + Effect.sync(() => { + const index = receipts.findIndex( + (existing) => + existing.attemptId === receipt.attemptId && + existing.messageId === receipt.messageId, + ); + if (index < 0) receipts.push(receipt); + else receipts[index] = receipt; + }), + }), + Layer.mock(ProjectionStore.ProjectionStoreV2)({ + getThread: () => Effect.succeed({ ...projection.thread, projectId }), + getTurnStartHistory: () => + mode === "stale-during-history" + ? Deferred.succeed(historyStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseHistory)), + Effect.as([]), + ) + : Effect.succeed([]), + getProviderControlContext: (_threadId, input) => + Effect.succeed({ + providerThread: replaced + ? { + ...providerThread, + nativeThreadRef: { + driver, + nativeId: "replacement-native-thread", + strength: "strong", + }, + } + : providerThread, + providerTurn: { + ...projection.providerTurns[0]!, + status: completed ? "completed" : "running", + ...(mode === "budget-null-turn-window" + ? { + tokenUsage: { + usedTokens: 128_000, + maxTokens: null, + updatedAt: DateTime.formatIso(now), + }, + } + : {}), + }, + attempt: + input.attemptId === undefined + ? undefined + : { + ...projection.attempts[0]!, + id: attemptId, + runId, + ...(mode === "uncertain-replaced-legacy" + ? {} + : { nativeThreadId: "knowledge-native-thread" }), + }, + run: replaced + ? undefined + : { + ...projection.runs[0]!, + id: runId, + activeAttemptId: attemptId, + providerInstanceId, + modelSelection, + }, + message: { + id: input.messageId!, + threadId, + runId, + nodeId: null, + role: "user", + streaming: false, + createdAt: now, + updatedAt: now, + text: `Follow this decision: ${formatComposerContextReference(record)}`, + context: { version: 1, records: [record] }, + attachments: [], + createdBy: "user", + creationSource: "web", + } satisfies OrchestrationV2ThreadProjection["messages"][number], + }), + }), + Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({ + get: () => + Effect.succeedSome({ + driver, + instanceId: providerInstanceId, + providerSessionId, + steerTurn: (input: ProviderAdapter.ProviderAdapterV2SteerInput) => + Effect.suspend(() => { + received.push(input.message.text); + if (uncertain) completed = true; + return uncertain + ? Effect.fail( + new ProviderAdapter.ProviderAdapterSteerRunError({ + driver, + providerThreadId, + providerTurnId, + }), + ) + : Effect.void; + }), + } as ProviderAdapter.ProviderAdapterV2SessionRuntime), + }), + ), + ), + ); + yield* Effect.gen(function* () { + const control = yield* ProviderTurnControlService.ProviderTurnControlServiceV2; + const input = { threadId, providerSessionId, providerThreadId, providerTurnId, messageId }; + const first = yield* mode === "stale-during-history" + ? Effect.gen(function* () { + const sending = yield* control.steer(input).pipe(Effect.result, Effect.forkChild); + yield* Deferred.await(historyStarted); + sourceChanged = true; + yield* Deferred.succeed(releaseHistory, undefined); + return yield* Fiber.join(sending); + }) + : control.steer(input).pipe(Effect.result); + if (mode.includes("replaced")) { + assert.equal(first._tag, uncertain ? "Failure" : "Success"); + completed = true; + replaced = true; + if (mode === "uncertain-replaced-legacy") { + const { providerThreadId: _destination, ...legacyReceipt } = receipts[0]!; + receipts[0] = { ...legacyReceipt, nativeThreadId: null }; + } + const retried = yield* control.steer(input).pipe(Effect.result); + if (uncertain) { + assert.equal(retried._tag, "Failure"); + if (retried._tag === "Failure") assert.notEqual(retried.failure.turnCompleted, true); + } else { + assert.equal(retried._tag, "Success"); + } + assert.lengthOf(received, 1); + return; + } + if (mode === "sent") { + assert.equal(first._tag, "Success"); + completed = true; + yield* control.steer(input); + completed = false; + yield* control.steer({ ...input, messageId: MessageId.make("knowledge-steer-followup") }); + assert.lengthOf(received, 2); + assert.lengthOf(receipts, 2); + assert.isTrue(receipts.every((receipt) => receipt.status === "sent")); + assert.isTrue( + received.every((text) => text.includes("Use the project-selected test runner.")), + ); + assert.isTrue( + received.every((text) => !text.includes("Selected project knowledge is unavailable")), + ); + } else if (mode === "uncertain") { + assert.equal(first._tag, "Failure"); + assert.equal((yield* control.steer(input).pipe(Effect.result))._tag, "Failure"); + assert.lengthOf(received, 1); + assert.equal(receipts[0]?.status, "uncertain"); + } else { + assert.equal(first._tag, "Failure"); + assert.lengthOf(received, 0); + assert.equal(receipts[0]?.status, "failed"); + } + }).pipe(Effect.provide(layers)); + }), +); diff --git a/apps/server/src/orchestration-v2/ProviderTurnControlService.ts b/apps/server/src/orchestration-v2/ProviderTurnControlService.ts index 4f242b01db61e..0dc188c1f6f70 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnControlService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnControlService.ts @@ -1,7 +1,22 @@ import * as ServerSettings from "../serverSettings.ts"; import { hasPstackComposerInvocation } from "../pstack/composer.ts"; import * as PstackPack from "../pstack/PstackPack.ts"; -import { projectComposerContextForProvider } from "@t3tools/shared/composerContextReferences"; +import { + collectComposerContextReferences, + projectComposerContextForProvider, +} from "@t3tools/shared/composerContextReferences"; +import * as ProjectKnowledge from "../project/knowledge/ProjectKnowledge.ts"; +import { + prepareKnowledgeDelivery, + readKnowledgeDeliveryState, +} from "../project/knowledge/delivery.ts"; +import { + attachmentTokenAllowance, + DEFAULT_HANDOFF_TOKEN_CAP, + handoffBudgetAssumptions, + handoffTokenCapConfig, + historicalMessage, +} from "@t3tools/provider-core/server/handoffBudget"; import { MessageId, type NodeId, @@ -90,6 +105,7 @@ export const layer: Layer.Layer< | ProjectionStore.ProjectionStoreV2 | ProviderSessionManager.ProviderSessionManagerV2 | PstackPack.PstackPack + | ProjectKnowledge.ProjectKnowledge | ServerSettings.ServerSettingsService > = Layer.effect( ProviderTurnControlServiceV2, @@ -98,6 +114,7 @@ export const layer: Layer.Layer< const settings = yield* ServerSettings.ServerSettingsService; const projections = yield* ProjectionStore.ProjectionStoreV2; const sessions = yield* ProviderSessionManager.ProviderSessionManagerV2; + const knowledge = yield* ProjectKnowledge.ProjectKnowledge; const load = Effect.fn("ProviderTurnControlService.load")(function* (input: { readonly threadId: ThreadId; @@ -330,6 +347,52 @@ export const layer: Layer.Layer< steer: Effect.fn("ProviderTurnControlService.steer")( function* (input) { const context = yield* projections.getProviderControlContext(input.threadId, input); + const hasKnowledge = + context.message?.context?.records.some( + (record) => record.kind === "project-knowledge", + ) === true || + collectComposerContextReferences(context.message?.text ?? "").some( + (reference) => reference.kind === "project-knowledge", + ); + const targetAttemptId = context.providerTurn?.runAttemptId; + let originalAttempt = context.attempt; + // Check receipts before loading a live turn: completion must not turn an uncertain send into a new prompt. + if ( + hasKnowledge && + context.message !== undefined && + context.providerThread !== undefined && + targetAttemptId != null + ) { + const thread = yield* projections.getThread(input.threadId); + originalAttempt ??= (yield* projections.getProviderControlContext(input.threadId, { + ...input, + attemptId: targetAttemptId, + })).attempt; + const state = yield* readKnowledgeDeliveryState( + { + projectId: thread.projectId, + threadId: input.threadId, + messageId: input.messageId, + attemptId: targetAttemptId, + providerInstanceId: + originalAttempt?.providerInstanceId ?? context.providerThread.providerInstanceId, + providerThreadId: originalAttempt?.providerThreadId ?? context.providerThread.id, + nativeThreadId: + originalAttempt?.nativeThreadId ?? + context.providerThread.nativeThreadRef?.nativeId ?? + null, + }, + "attempt", + ).pipe(Effect.provideService(ProjectKnowledge.ProjectKnowledge, knowledge)); + if (state.sentThisAttempt) return; + if (state.uncertain) + return yield* new ProviderTurnControlError({ + threadId: input.threadId, + operation: "steer", + providerTurnId: input.providerTurnId, + cause: "The provider may already have received this steering message.", + }); + } const ownership = context.message?.delegatedCompletion; if (ownership !== undefined) { const projection = yield* projections.getThreadRecords(input.threadId, ["runs"], { @@ -372,6 +435,128 @@ export const layer: Layer.Layer< message, ); } + let text = projectComposerContextForProvider(composer); + let knowledgeDelivery: + | Effect.Success> + | undefined; + if (hasKnowledge) { + const thread = yield* projections.getThread(input.threadId); + const attemptId = loaded.providerTurn.runAttemptId; + if (attemptId === null) + return yield* new ProviderTurnControlError({ + threadId: input.threadId, + operation: "steer", + providerTurnId: input.providerTurnId, + cause: "The steering target has no recorded run attempt.", + }); + knowledgeDelivery = yield* prepareKnowledgeDelivery({ + target: { + attemptId, + runId: run.id, + threadId: input.threadId, + messageId: message.id, + projectId: thread.projectId, + providerInstanceId: run.providerInstanceId, + providerThreadId: loaded.providerThread.id, + nativeThreadId: loaded.providerThread.nativeThreadRef?.nativeId ?? null, + }, + ...composer, + }).pipe(Effect.provideService(ProjectKnowledge.ProjectKnowledge, knowledge)); + const current = yield* load({ ...input, operation: "steer" }); + if ( + current.providerTurn.runAttemptId !== attemptId || + current.context.run?.activeAttemptId !== attemptId + ) { + return yield* new ProviderTurnControlError({ + threadId: input.threadId, + operation: "steer", + providerTurnId: input.providerTurnId, + cause: "The steering run attempt changed while resolving project knowledge.", + }); + } + const providerThread = { + ...current.providerThread, + ...(current.providerTurn.tokenUsage === undefined + ? {} + : { + contextUsage: { + ...current.providerThread.contextUsage, + usedTokens: current.providerTurn.tokenUsage.usedTokens, + ...(current.providerTurn.tokenUsage.maxTokens != null && + current.providerTurn.tokenUsage.maxTokens > 0 + ? { maxTokens: current.providerTurn.tokenUsage.maxTokens } + : {}), + }, + }), + }; + const tokenCap = yield* handoffTokenCapConfig.pipe( + Effect.orElseSucceed(() => DEFAULT_HANDOFF_TOKEN_CAP), + ); + const nativeContextEstimate = + providerThread.contextUsage?.usedTokens === undefined + ? (yield* projections.getTurnStartHistory(input.threadId)).reduce((size, item) => { + if (item.providerThreadId !== providerThread.id) return size; + const historical = historicalMessage(item); + return ( + size + + (historical === null ? 0 : Buffer.byteLength(historical.text)) + + (item.type === "user_message" + ? attachmentTokenAllowance(item.attachments) + : 0) + ); + }, 0) + : 0; + yield* knowledgeDelivery.checkBudget( + handoffBudgetAssumptions({ + tokenCap, + userText: knowledgeDelivery.composer.baseText, + attachments: message.attachments, + providerThread, + nativeContextEstimate, + modelContextWindow: loaded.session.value.getModelContextWindow?.( + run.modelSelection, + thread.worktreePath, + ), + }), + ); + const state = yield* knowledgeDelivery.readState; + if (state.sentThisAttempt) return; + if (state.uncertain) + return yield* new ProviderTurnControlError({ + threadId: input.threadId, + operation: "steer", + providerTurnId: input.providerTurnId, + cause: "The provider may already have received this steering message.", + }); + text = state.sent + ? knowledgeDelivery.composer.baseText + : knowledgeDelivery.composer.text; + yield* knowledgeDelivery.record("prepared"); + // Long budget/history reads must finish before the last source check. + // Revalidation records a failed receipt itself when the source is stale. + yield* knowledgeDelivery.revalidate; + const refusedBeforeSend = knowledgeDelivery.settle("failed"); + const final = yield* load({ ...input, operation: "steer" }).pipe( + Effect.tapError(() => refusedBeforeSend), + ); + const nativeThreadId = + originalAttempt?.nativeThreadId ?? + loaded.providerThread.nativeThreadRef?.nativeId ?? + null; + if ( + final.providerTurn.runAttemptId !== attemptId || + final.context.run?.activeAttemptId !== attemptId || + (final.providerThread.nativeThreadRef?.nativeId ?? null) !== nativeThreadId + ) { + yield* refusedBeforeSend; + return yield* new ProviderTurnControlError({ + threadId: input.threadId, + operation: "steer", + providerTurnId: input.providerTurnId, + cause: "The steering destination changed before the message was delivered.", + }); + } + } yield* loaded.session.value .steerTurn({ threadId: input.threadId, @@ -380,7 +565,7 @@ export const layer: Layer.Layer< providerTurnId: loaded.providerTurn.id, message: { messageId: message.id, - text: projectComposerContextForProvider(composer), + text, attachments: message.attachments, createdBy: message.createdBy, creationSource: message.creationSource, @@ -395,6 +580,7 @@ export const layer: Layer.Layer< .pipe( Effect.catch((cause) => Effect.gen(function* () { + yield* knowledgeDelivery?.settle("uncertain") ?? Effect.void; const current = yield* projections.getProviderControlContext( input.threadId, input, @@ -403,12 +589,15 @@ export const layer: Layer.Layer< threadId: input.threadId, operation: "steer", providerTurnId: input.providerTurnId, - turnCompleted: current.providerTurn?.status === "completed", + turnCompleted: + knowledgeDelivery === undefined && + current.providerTurn?.status === "completed", cause, }); }), ), ); + yield* knowledgeDelivery?.settle("sent") ?? Effect.void; }, (effect, input) => effect.pipe( diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts index 9c85350fd2d1c..a0d297db4e156 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts @@ -37,6 +37,7 @@ import { } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as DateTime from "effect/DateTime"; +import * as SqlClient from "effect/sql/SqlClient"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -729,6 +730,30 @@ function makeLocalCommandHarness(input: { }), ); const knowledgeDeliveries: Array = [...(input.initialDeliveries ?? [])]; + const matchingKnowledgeDeliveries = (target: KnowledgePersistence.DeliveryStateInput) => { + const latest = [ + ...new Map( + knowledgeDeliveries.map((receipt) => [ + JSON.stringify([receipt.attemptId, receipt.messageId]), + receipt, + ]), + ).values(), + ]; + return latest.filter( + (receipt) => + receipt.projectId === target.projectId && + receipt.threadId === target.threadId && + receipt.messageId === target.messageId && + receipt.providerInstanceId === target.providerInstanceId && + (target.scope === "attempt" + ? receipt.attemptId === target.attemptId && + (receipt.providerThreadId === undefined || + receipt.providerThreadId === target.providerThreadId) + : receipt.providerThreadId === target.providerThreadId && + receipt.nativeThreadId === target.nativeThreadId && + (receipt.nativeThreadId != null || receipt.attemptId === target.attemptId)), + ); + }; const layer = ProviderTurnStart.layer.pipe( Layer.provide( input.pstack ? Layer.succeed(PstackPack.PstackPack, input.pstack) : disabledLayer, @@ -738,6 +763,34 @@ function makeLocalCommandHarness(input: { Layer.mock(ProjectKnowledge.ProjectKnowledge)({ ...(input.resolveKnowledge ? { resolveForTurn: input.resolveKnowledge } : {}), ...(input.readSentDeliveries ? { readSentDeliveries: input.readSentDeliveries } : {}), + markDeliveryUncertain: (target) => + Effect.gen(function* () { + const updatedAt = DateTime.formatIso(yield* DateTime.now); + if (input.knowledgeStore) + return yield* input.knowledgeStore + .markDeliveryUncertain(target, updatedAt) + .pipe(Effect.orDie); + for (const receipt of matchingKnowledgeDeliveries(target)) { + if (receipt.status === "prepared") + knowledgeDeliveries.push({ ...receipt, status: "uncertain", updatedAt }); + } + }), + readDeliveryState: (target) => + input.knowledgeStore + ? input.knowledgeStore.readDeliveryState(target).pipe(Effect.orDie) + : Effect.sync(() => { + const matching = matchingKnowledgeDeliveries(target); + return { + sent: matching.some((receipt) => receipt.status === "sent"), + sentThisAttempt: matching.some( + (receipt) => + receipt.status === "sent" && receipt.attemptId === target.attemptId, + ), + uncertain: matching.some( + (receipt) => receipt.status === "prepared" || receipt.status === "uncertain", + ), + }; + }), readDeliveries: (request) => input.knowledgeStore ? input.knowledgeStore @@ -1849,6 +1902,57 @@ effectIt.effect.each(["prepared", "uncertain"] as const)( }), ); +effectIt.effect.each(["current", "legacy"] as const)( + "recovers a %s prepared native delivery omitted by the inspector receipt page", + (identity) => + Effect.gen(function* () { + const old = knowledgeRetryHarness(); + yield* old.start; + yield* dispatchKnowledge(old.startRootRun.mock.calls[0]![0]); + const original = old.knowledgeDeliveries.at(-1)!; + const { providerThreadId: _destination, ...legacy } = original; + const receipt = identity === "legacy" ? { ...legacy, nativeThreadId: null } : original; + const store = yield* KnowledgePersistence.makeStore; + for (let index = 0; index < 25; index++) + yield* store.recordDelivery({ + ...receipt, + attemptId: RunAttemptId.make(`older-delivery-${index}`), + status: "failed", + createdAt: "1900-01-01T00:00:00.000Z", + updatedAt: "1900-01-01T00:00:00.000Z", + }); + yield* store.recordDelivery({ ...receipt, status: "prepared" }); + const page = yield* store.deliveries(receipt.projectId, receipt.threadId, receipt.messageId); + expect(page).toHaveLength(20); + expect(page.every((row) => row.status === "failed")).toBe(true); + const recovery = knowledgeRetryHarness({ + knowledgeStore: store, + recoveryNativeId: "fresh-after-paged-receipts", + }); + yield* recovery.start; + expect(recovery.events.some((event) => event.type === "context-handoff.updated")).toBe(true); + const sql = yield* SqlClient.SqlClient; + const [recovered] = yield* sql<{ + status: string; + }>`SELECT status FROM project_knowledge_deliveries + WHERE attempt_id = ${receipt.attemptId} AND message_id = ${receipt.messageId}`; + expect(recovered?.status).toBe("uncertain"); + const start = recovery.startRootRun.mock.calls[0]![0]; + yield* dispatchKnowledge(start); + yield* dispatchKnowledge(start); + expect(recovery.nativeStart).toHaveBeenCalledOnce(); + expect( + (yield* store.readDeliveryState({ + ...original, + nativeThreadId: "fresh-after-paged-receipts", + scope: "native-conversation", + })).sentThisAttempt, + ).toBe(true); + }).pipe( + Effect.provide(SqlitePersistence.layerMemory.pipe(Layer.provideMerge(NodeServices.layer))), + ), +); + effectIt.effect( "SQLite restart mid-delivery recovers prepared knowledge onto a fresh native handoff exactly once", () => diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index 7d9b8beef8f26..f3fe9fe0abe53 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -1,15 +1,12 @@ import * as PstackPack from "../pstack/PstackPack.ts"; import { projectComposerContextForProvider } from "@t3tools/shared/composerContextReferences"; import { modelSelectionsEqual } from "@t3tools/shared/model"; -import { - deliveredKnowledgeForHistory, - resolveKnowledgeComposer, -} from "../project/knowledge/composer.ts"; +import { deliveredKnowledgeForHistory } from "../project/knowledge/composer.ts"; import * as ProjectKnowledge from "../project/knowledge/ProjectKnowledge.ts"; +import { prepareKnowledgeDelivery } from "../project/knowledge/delivery.ts"; import { ConversationSummaryRuntime } from "../project/conversationSummaries/conversationSummarySchedule.ts"; import { CommandId, - ProjectKnowledgeError, latestProviderTurnForAttempt, type OrchestrationV2DomainEvent, type OrchestrationV2ContextHandoff, @@ -18,7 +15,6 @@ import { type OrchestrationV2Run, type OrchestrationV2RunAttempt, type OrchestrationV2TurnItem, - type ProjectKnowledgeDeliveryStatus, type RunAttemptId, isCompactionCommandMessage, parseGoalCommand, @@ -1102,32 +1098,34 @@ export const layer: Layer.Layer< }), ); } - const knowledgeReceipts = message.context?.records.some( + const knowledgeTarget = { + projectId: projection.thread.projectId, + threadId: projection.thread.id, + messageId: message.id, + attemptId: attempt.id, + providerInstanceId: providerThread.providerInstanceId, + providerThreadId: providerThread.id, + nativeThreadId: providerThread.nativeThreadRef.nativeId, + }; + const uncertainKnowledge = message.context?.records.some( (record) => record.kind === "project-knowledge", ) - ? (yield* knowledge.readDeliveries({ - projectId: projection.thread.projectId, - threadId: projection.thread.id, - messageId: message.id, - })).deliveries - : []; - const uncertainKnowledge = knowledgeReceipts.filter( - (receipt) => - receipt.providerThreadId === providerThread.id && - receipt.nativeThreadId === providerThread.nativeThreadRef?.nativeId && - (receipt.status === "prepared" || receipt.status === "uncertain"), - ); + ? yield* Effect.gen(function* () { + // Legacy receipts can identify the attempt without a native destination. + // Quarantine that old attempt before a genuinely fresh conversation starts. + for (const scope of ["native-conversation", "attempt"] as const) { + const target = { ...knowledgeTarget, scope }; + if (!(yield* knowledge.readDeliveryState(target)).uncertain) continue; + yield* knowledge.markDeliveryUncertain(target); + return true; + } + return false; + }) + : false; // A crash after dispatch leaves prepared indistinguishable from an accepted prompt. // Like pending handoff injection, recover onto a fresh native conversation. - for (const receipt of uncertainKnowledge) { - yield* knowledge.recordDelivery({ - ...receipt, - status: "uncertain", - updatedAt: DateTime.formatIso(yield* DateTime.now), - }); - } const uncertainDelivery = - uncertainKnowledge.length > 0 || + uncertainKnowledge || projection.contextHandoffs.some( (handoff) => handoff.toProviderThreadId === providerThread.id && @@ -1422,44 +1420,6 @@ export const layer: Layer.Layer< const routableSubagents = projection.subagents.filter((subagent) => RunExecutionService.canRouteRelatedSubagent(subagent.status), ); - let knowledgeBudget: ReturnType | undefined; - const recordKnowledgeRefusal = Effect.fn("ProviderTurnStartService.recordKnowledgeRefusal")( - function* (error: ProjectKnowledgeError) { - const reason = - error.reason === "context_budget" - ? ("over_budget" as const) - : error.reason === "stale_context" - ? ("stale_revision" as const) - : error.reason === "wrong_project" || error.reason === "wrong_environment" - ? ("not_permitted" as const) - : error.reason === "storage" || error.reason === "scoring" - ? ("recall_failed" as const) - : ("source_unavailable" as const); - const omissions = (message.context?.records ?? []).flatMap((record) => - record.kind === "project-knowledge" - ? [{ contextId: record.contextId, label: record.label, reason }] - : [], - ); - if (omissions.length === 0) return; - const now = DateTime.formatIso(yield* DateTime.now); - yield* knowledge.recordDelivery({ - attemptId: attempt.id, - runId: run.id, - threadId: projection.thread.id, - messageId: message.id, - projectId: projection.thread.projectId, - providerInstanceId: run.providerInstanceId, - providerThreadId: runningProviderThread.id, - nativeThreadId: runningProviderThread.nativeThreadRef?.nativeId ?? null, - status: "failed", - items: [], - omissions, - ...(knowledgeBudget === undefined ? {} : { budget: knowledgeBudget }), - createdAt: now, - updatedAt: now, - }); - }, - ); const pstackComposer = yield* pstack.expandComposer( { projectId: projection.thread.projectId, @@ -1468,118 +1428,47 @@ export const layer: Layer.Layer< }, message, ); - const composer = yield* resolveKnowledgeComposer({ - projectId: projection.thread.projectId, + const knowledgeDelivery = yield* prepareKnowledgeDelivery({ + target: { + attemptId: attempt.id, + runId: run.id, + threadId: projection.thread.id, + messageId: message.id, + projectId: projection.thread.projectId, + providerInstanceId: run.providerInstanceId, + providerThreadId: runningProviderThread.id, + nativeThreadId: runningProviderThread.nativeThreadRef?.nativeId ?? null, + }, ...pstackComposer, - }).pipe( - Effect.provideService(ProjectKnowledge.ProjectKnowledge, knowledge), - Effect.tapError(recordKnowledgeRefusal), - ); + }).pipe(Effect.provideService(ProjectKnowledge.ProjectKnowledge, knowledge)); + const { composer } = knowledgeDelivery; if (composer.byteLength > 0 && !(yield* isCurrentAttemptInStatus("running"))) return; - const receipts = - composer.items.length === 0 && composer.omissions.length === 0 - ? [] - : (yield* knowledge.readDeliveries({ - projectId: projection.thread.projectId, - threadId: projection.thread.id, - messageId: message.id, - })).deliveries; - const alreadySent = receipts.some( - (receipt) => - receipt.status === "sent" && - receipt.providerInstanceId === run.providerInstanceId && - receipt.providerThreadId === runningProviderThread.id && - receipt.nativeThreadId != null && - receipt.nativeThreadId === runningProviderThread.nativeThreadRef?.nativeId, - ); - userText = alreadySent ? composer.baseText : composer.text; - // Keyed by attempt so a retried start updates the same record, while a replacement - // attempt (account failover) keeps its own account of what its provider received. - const recordKnowledgeDelivery = Effect.fn("ProviderTurnStartService.recordKnowledgeDelivery")( - function* (status: ProjectKnowledgeDeliveryStatus) { - // An omission-only attempt still gets a receipt so the inspector can say why nothing went. - if (composer.items.length === 0 && composer.omissions.length === 0) return; - const now = DateTime.formatIso(yield* DateTime.now); - yield* knowledge.recordDelivery({ - attemptId: attempt.id, - runId: run.id, - threadId: projection.thread.id, - messageId: message.id, - projectId: projection.thread.projectId, - providerInstanceId: run.providerInstanceId, - providerThreadId: runningProviderThread.id, - nativeThreadId: runningProviderThread.nativeThreadRef?.nativeId ?? null, - status, - ...(knowledgeBudget === undefined ? {} : { budget: knowledgeBudget }), - items: composer.items, - omissions: composer.omissions, - createdAt: now, - updatedAt: now, - }); - }, - ); - const settleKnowledgeDelivery = (status: ProjectKnowledgeDeliveryStatus) => - recordKnowledgeDelivery(status).pipe( - Effect.catchCause(() => - Effect.logWarning("Failed to record project knowledge delivery status", { - runId: run.id, - deliveryStatus: status, - }), - ), - ); + const initialDeliveryState = yield* knowledgeDelivery.readState; + userText = initialDeliveryState.sent ? composer.baseText : composer.text; { - knowledgeBudget = handoffBudgetAssumptions({ - tokenCap, - modelContextWindow, - userText: - restartNote === "" ? composer.baseText : `${restartNote}\n\n${composer.baseText}`, - attachments: message.attachments, - providerThread: budgetProviderThread, - nativeContextEstimate: - budgetProviderThread.contextUsage?.usedTokens === undefined - ? yield* nativeContextEstimate - : 0, - }); - if (composer.byteLength > knowledgeBudget.knowledgeAllowance) { - const error = new ProjectKnowledgeError({ - reason: "context_budget", - projectId: projection.thread.projectId, - byteLength: composer.byteLength, - }); - yield* recordKnowledgeRefusal(error); - return yield* error; - } + yield* knowledgeDelivery.checkBudget( + handoffBudgetAssumptions({ + tokenCap, + modelContextWindow, + userText: + restartNote === "" ? composer.baseText : `${restartNote}\n\n${composer.baseText}`, + attachments: message.attachments, + providerThread: budgetProviderThread, + nativeContextEstimate: + budgetProviderThread.contextUsage?.usedTokens === undefined + ? yield* nativeContextEstimate + : 0, + }), + ); if (!(yield* isCurrentAttemptInStatus("running"))) return; } const startWithHandoffs = Effect.fn("ProviderTurnStartService.startWithHandoffs")( function* (turnInput: Parameters[0], compact = false) { // The wrapper can be retried without rebuilding turn-start context. Read the // committed receipt here, before any native history or prompt injection. - const latestReceipts = - composer.items.length === 0 && composer.omissions.length === 0 - ? [] - : (yield* knowledge.readDeliveries({ - projectId: projection.thread.projectId, - threadId: projection.thread.id, - messageId: message.id, - })).deliveries; - const sameConversation = latestReceipts.filter( - (receipt) => - receipt.providerThreadId === runningProviderThread.id && - receipt.nativeThreadId != null && - receipt.nativeThreadId === runningProviderThread.nativeThreadRef?.nativeId, - ); - if ( - sameConversation.some( - (receipt) => receipt.attemptId === attempt.id && receipt.status === "sent", - ) - ) - return; - if ( - sameConversation.some( - (receipt) => receipt.status === "prepared" || receipt.status === "uncertain", - ) - ) { + const deliveryState = yield* knowledgeDelivery.readState; + if (deliveryState.sentThisAttempt) return; + if (deliveryState.uncertain) { return yield* new ProviderAdapter.ProviderAdapterTurnStartError({ driver: session.driver, threadId: projection.thread.id, @@ -1666,26 +1555,10 @@ export const layer: Layer.Layer< }); }), }); - if (composer.byteLength > 0) { - yield* resolveKnowledgeComposer({ - projectId: projection.thread.projectId, - text: message.text, - records: message.context?.records ?? [], - }).pipe( - Effect.provideService(ProjectKnowledge.ProjectKnowledge, knowledge), - Effect.tapError(recordKnowledgeRefusal), - Effect.tapError(() => delivery.unsent), - ); - } + yield* knowledgeDelivery.revalidate.pipe(Effect.tapError(() => delivery.unsent)); if (!(yield* isCurrentAttemptInStatus("running"))) return; - if ( - alreadySent && - receipts.some( - (receipt) => receipt.attemptId === attempt.id && receipt.status === "sent", - ) - ) - return; - yield* recordKnowledgeDelivery("prepared").pipe(Effect.tapError(() => delivery.unsent)); + if ((yield* knowledgeDelivery.readState).sentThisAttempt) return; + yield* knowledgeDelivery.record("prepared").pipe(Effect.tapError(() => delivery.unsent)); const start = compact ? session.compactThread! : session.startTurn; const context = [delivery.context, restartNote] .filter((part) => part !== "") @@ -1714,10 +1587,10 @@ export const layer: Layer.Layer< : Effect.void, ), Effect.tapError((error) => - settleKnowledgeDelivery(refusedBeforePrompt(error) ? "failed" : "uncertain"), + knowledgeDelivery.settle(refusedBeforePrompt(error) ? "failed" : "uncertain"), ), ); - yield* settleKnowledgeDelivery("sent"); + yield* knowledgeDelivery.settle("sent"); // The provider already accepted the turn. A stale pending marker // can force a fresh thread later, but must not stop live ingestion. yield* delivery.delivered.pipe( diff --git a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts index fae87d1553cb9..5649ac477ed16 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts @@ -386,6 +386,7 @@ export function layerWithRegistry( Layer.provide(layerProvidedRegistry), ); const layerProviderTurnControlServiceProvided = ProviderTurnControlService.layer.pipe( + Layer.provide(Layer.mock(ProjectKnowledge.ProjectKnowledge)({})), Layer.provide(layerServerSettings), Layer.provide(Layer.merge(layerStores, layerProviderSessionManagerProvided)), ); diff --git a/apps/server/src/persistence/Migrations.test.ts b/apps/server/src/persistence/Migrations.test.ts index 7a5f0712fdf1b..ef36eae074998 100644 --- a/apps/server/src/persistence/Migrations.test.ts +++ b/apps/server/src/persistence/Migrations.test.ts @@ -9,6 +9,38 @@ import { migrationManifest, runMigrations } from "./Migrations.ts"; const v2Migrations = migrationManifest.filter(([id]) => id >= 55); const newerMigrationId = Math.max(...migrationManifest.map(([id]) => id)) + 1; const consolidatedMigration = [80, "ProjectIntelligenceAndCrossEnvironment"] as const; +const messageDeliveryMigration = [81, "ProjectKnowledgeMessageDeliveries"] as const; + +it.effect("upgrades attempt receipts atomically and preserves distinct steering messages", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations({ toMigrationInclusive: 80 }); + yield* seedDelivery; + yield* sql`CREATE VIEW project_knowledge_deliveries_project_cursor AS SELECT 1`; + const before = yield* sql`SELECT * FROM project_knowledge_deliveries`; + const schema = yield* sql`SELECT name, sql FROM sqlite_master ORDER BY name`; + assert.isTrue(Exit.isFailure(yield* Effect.exit(runMigrations()))); + assert.deepStrictEqual(yield* sql`SELECT * FROM project_knowledge_deliveries`, before); + assert.deepStrictEqual(yield* sql`SELECT name, sql FROM sqlite_master ORDER BY name`, schema); + + yield* sql`DROP VIEW project_knowledge_deliveries_project_cursor`; + assert.deepStrictEqual(yield* runMigrations(), [messageDeliveryMigration]); + assert.deepStrictEqual(yield* sql`SELECT * FROM project_knowledge_deliveries`, before); + yield* sql`INSERT INTO project_knowledge_deliveries ( + attempt_id, run_id, thread_id, message_id, project_id, provider_instance_id, + status, items_json, omissions_json, created_at, updated_at + ) SELECT attempt_id, run_id, thread_id, 'followup', project_id, provider_instance_id, + 'prepared', '[]', '[]', created_at, updated_at FROM project_knowledge_deliveries`; + assert.deepStrictEqual( + yield* sql`SELECT message_id, status FROM project_knowledge_deliveries ORDER BY message_id`, + [ + { message_id: "followup", status: "prepared" }, + { message_id: "message", status: "sent" }, + ], + ); + assert.deepStrictEqual(yield* runMigrations(), []); + }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), +); const previewNativeColumns = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; @@ -228,6 +260,7 @@ describe("runMigrations", () => { [78, "ShellStopRequestIndex"], [79, "ThreadMessageSearch"], consolidatedMigration, + messageDeliveryMigration, ]); assert.deepStrictEqual((yield* history).slice(0, before.length), before); const indexes = yield* sql` @@ -291,7 +324,7 @@ describe("runMigrations", () => { ); assert.deepStrictEqual( (yield* history).filter(([id]) => id >= 80), - [consolidatedMigration], + [consolidatedMigration, messageDeliveryMigration], ); yield* assertConsolidatedSchema; assert.deepStrictEqual(yield* runMigrations(), []); @@ -314,15 +347,18 @@ describe("runMigrations", () => { }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), ); - it.effect("adds one migration to the current main schema", () => + it.effect("adds intelligence migrations to the upstream schema", () => Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; yield* runMigrations({ toMigrationInclusive: 79 }); assert.deepStrictEqual( migrationManifest.filter(([id]) => id >= 80), - [consolidatedMigration], + [consolidatedMigration, messageDeliveryMigration], ); - assert.deepStrictEqual(yield* runMigrations(), [consolidatedMigration]); + assert.deepStrictEqual(yield* runMigrations(), [ + consolidatedMigration, + messageDeliveryMigration, + ]); yield* assertConsolidatedSchema; assert.deepStrictEqual( yield* sql`SELECT name FROM sqlite_master WHERE name = 'cross_environment_launches'`, @@ -347,11 +383,11 @@ describe("runMigrations", () => { const beforeRoutes = yield* sql`SELECT * FROM cross_environment_launches`; const beforeDeliveries = yield* sql`SELECT * FROM project_knowledge_deliveries`; - assert.deepStrictEqual(yield* runMigrations(), []); + assert.deepStrictEqual(yield* runMigrations(), [messageDeliveryMigration]); yield* assertConsolidatedSchema; assert.deepStrictEqual( - yield* sql`SELECT * FROM effect_sql_migrations ORDER BY migration_id`, + yield* sql`SELECT * FROM effect_sql_migrations WHERE migration_id <= 80 ORDER BY migration_id`, beforeHistory, ); assert.deepStrictEqual(yield* sql`SELECT * FROM cross_environment_launches`, beforeRoutes); @@ -442,7 +478,7 @@ describe("runMigrations", () => { assert.deepStrictEqual(yield* sql`SELECT * FROM cross_environment_launches`, beforeRoutes); yield* sql`DROP VIEW orchestration_v2_projection_turn_items_waiting_link_idx`; - assert.deepStrictEqual(yield* runMigrations(), []); + assert.deepStrictEqual(yield* runMigrations(), [messageDeliveryMigration]); yield* assertConsolidatedSchema; assert.deepStrictEqual(yield* sql`SELECT secret_name, fenced FROM peer_environment_links`, [ { secret_name: "peer-secret-name", fenced: 0 }, @@ -485,7 +521,10 @@ describe("runMigrations", () => { `; } - assert.deepStrictEqual(yield* runMigrations(), [consolidatedMigration]); + assert.deepStrictEqual(yield* runMigrations(), [ + consolidatedMigration, + messageDeliveryMigration, + ]); assert.deepStrictEqual(yield* history, migrationManifest); assert.deepStrictEqual( yield* sql`SELECT provider_thread_id, native_thread_id, budget_json FROM project_knowledge_deliveries`, @@ -533,7 +572,10 @@ describe("runMigrations", () => { VALUES (83, 'CrossEnvironmentLaunches', '2026-10-25 01:00:00') `; const routes = yield* sql`SELECT * FROM cross_environment_launches`; - assert.deepStrictEqual(yield* runMigrations(), [consolidatedMigration]); + assert.deepStrictEqual(yield* runMigrations(), [ + consolidatedMigration, + messageDeliveryMigration, + ]); assert.deepStrictEqual(yield* history, migrationManifest); assert.deepStrictEqual(yield* sql`SELECT * FROM cross_environment_launches`, routes); yield* assertConsolidatedSchema; diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index a3d28cb2c9fa9..731b8dc052b43 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -84,6 +84,7 @@ import Migration0077 from "./Migrations/077_ProjectLoomBoundedReads.ts"; import Migration0078 from "./Migrations/078_ShellStopRequestIndex.ts"; import Migration0079 from "./Migrations/079_ThreadMessageSearch.ts"; import Migration0080 from "./Migrations/080_ProjectIntelligenceAndCrossEnvironment.ts"; +import Migration0081 from "./Migrations/081_ProjectKnowledgeMessageDeliveries.ts"; const migrationEntries = [ [1, "OrchestrationEvents", Migration0001], @@ -167,6 +168,7 @@ const migrationEntries = [ [78, "ShellStopRequestIndex", Migration0078], [79, "ThreadMessageSearch", Migration0079], [80, "ProjectIntelligenceAndCrossEnvironment", Migration0080], + [81, "ProjectKnowledgeMessageDeliveries", Migration0081], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/081_ProjectKnowledgeMessageDeliveries.ts b/apps/server/src/persistence/Migrations/081_ProjectKnowledgeMessageDeliveries.ts new file mode 100644 index 0000000000000..8940e1146afe9 --- /dev/null +++ b/apps/server/src/persistence/Migrations/081_ProjectKnowledgeMessageDeliveries.ts @@ -0,0 +1,38 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql` + CREATE TABLE project_knowledge_message_deliveries ( + attempt_id TEXT NOT NULL, + run_id TEXT NOT NULL, + thread_id TEXT NOT NULL, + message_id TEXT NOT NULL, + project_id TEXT NOT NULL, + provider_instance_id TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('prepared', 'sent', 'failed', 'uncertain')), + items_json TEXT NOT NULL, + omissions_json TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + provider_thread_id TEXT, + native_thread_id TEXT, + budget_json TEXT, + PRIMARY KEY (attempt_id, message_id) + ) WITHOUT ROWID + `; + yield* sql` + INSERT INTO project_knowledge_message_deliveries + SELECT attempt_id, run_id, thread_id, message_id, project_id, provider_instance_id, + status, items_json, omissions_json, created_at, updated_at, + provider_thread_id, native_thread_id, budget_json + FROM project_knowledge_deliveries + `; + yield* sql`DROP TABLE project_knowledge_deliveries`; + yield* sql`ALTER TABLE project_knowledge_message_deliveries RENAME TO project_knowledge_deliveries`; + yield* sql`CREATE INDEX project_knowledge_deliveries_message + ON project_knowledge_deliveries(project_id, thread_id, message_id, created_at)`; + yield* sql`CREATE INDEX project_knowledge_deliveries_project_cursor + ON project_knowledge_deliveries(project_id, attempt_id, message_id)`; +}); diff --git a/apps/server/src/project/conversationSummaries/ConversationSummaries.test.ts b/apps/server/src/project/conversationSummaries/ConversationSummaries.test.ts index f29537252f515..58f2a022b8b69 100644 --- a/apps/server/src/project/conversationSummaries/ConversationSummaries.test.ts +++ b/apps/server/src/project/conversationSummaries/ConversationSummaries.test.ts @@ -1,6 +1,7 @@ import { assert, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { + ConversationSummary, DEFAULT_SERVER_SETTINGS, EnvironmentId, EventId, @@ -21,8 +22,10 @@ import { } from "@t3tools/contracts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import { historyCost } from "@t3tools/provider-core/server/handoffBudget"; +import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as Hex from "effect/encoding/Hex"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import { McpSchema, McpServer } from "effect/ai"; @@ -62,6 +65,7 @@ const optedIn = { projectSettingsOverrides: { [projectId]: { enableConversationSummaries: true } }, }; const decodeSettings = Schema.decodeUnknownEffect(ServerSettingsSchema); +const encodeSummary = Schema.encodeEffect(Schema.fromJsonString(ConversationSummary)); // 8 exchanges: the first 10 messages are settled, the last 6 are the fresh tail. const MESSAGE_COUNT = 16; @@ -198,7 +202,7 @@ const threadPayload = { deletedAt: null, }; -const seed = Effect.gen(function* () { +const seedThread = Effect.fn("test.seedSummaryThread")(function* (messageCount: number) { const projects = yield* ProjectStore.ProjectStoreV2; yield* projects.apply({ sequence: 0, @@ -237,7 +241,7 @@ const seed = Effect.gen(function* () { occurredAt: at, payload: threadPayload, }); - for (let index = 0; index < MESSAGE_COUNT; index++) { + for (let index = 0; index < messageCount; index++) { yield* apply(messageEvent(index, rawText(index))); yield* apply({ id: EventId.make(`item-${index}`), @@ -249,6 +253,62 @@ const seed = Effect.gen(function* () { }); } }); +const seed = seedThread(MESSAGE_COUNT); + +const persistSummary = Effect.fn("test.persistSummary")(function* ( + index: number, + sourceIndexes: ReadonlyArray, + stale = false, +) { + const sql = yield* SqlClient.SqlClient; + const crypto = yield* Crypto.Crypto; + const sources = yield* Effect.forEach(sourceIndexes, (sourceIndex) => + crypto.digest("SHA-256", new TextEncoder().encode(rawText(sourceIndex))).pipe( + Effect.map((digest) => ({ + kind: "message" as const, + threadId, + messageId: messageId(sourceIndex), + digest: stale ? "0".repeat(64) : Hex.encode(digest), + })), + ), + ); + const summary: ConversationSummary = { + id: `summary:${index.toString(16).padStart(64, "0")}`, + ...scope, + sources, + statements: [{ text: `Summary ${index}`, citations: [sources[0]!.messageId] }], + modelSelection: { instanceId, model: "gpt-5" }, + createdAt: DateTime.formatIso(at), + }; + const payloadJson = yield* encodeSummary(summary); + yield* sql` + INSERT INTO conversation_summaries ( + summary_id, project_id, thread_id, first_source_at, created_at, payload_json + ) VALUES ( + ${summary.id}, ${projectId}, ${threadId}, + ${DateTime.formatIso(DateTime.add(at, { minutes: sourceIndexes[0]! }))}, + ${summary.createdAt}, ${payloadJson} + ) + `; + return summary; +}); + +const prepareHandoff = Effect.fn("test.prepareSummaryHandoff")(function* (messageCount: number) { + const handoffs = yield* ContextHandoffService.ContextHandoffServiceV2; + return yield* handoffs.prepareProviderHandoff({ + threadId, + targetRunId: RunId.make("summary-target-run"), + transferId: null, + fromProviderThreadIds: [], + toProviderThreadId: providerThread.id, + fromProviderInstanceId: instanceId, + toProviderInstanceId: instanceId, + coveredRunOrdinals: { from: 1, to: 1 }, + strategy: "full_thread_summary", + items: Array.from({ length: messageCount }, (_, index) => item(index)), + createdAt: at, + }); +}); const summarize = Effect.flatMap(ConversationSummaries.ConversationSummaries, (service) => service.summarizeThread({ threadId, runId }), @@ -345,6 +405,61 @@ it.effect("records an unavailable model as a failed pass without publishing a su }).pipe(Effect.provide(makeLayer(model.generate, optedIn))); }); +it.effect("continues coverage and handoff lookup beyond the first 500 summaries", () => { + const model = citing(); + return Effect.gen(function* () { + // Short closed stretches are valid leaves. Keep enough to cross the SQL page boundary, + // followed by ten uncovered settled messages and the six-message verbatim tail. + const summaryCount = 501; + const coveredMessages = summaryCount * 2; + const messageCount = coveredMessages + 10 + 6; + yield* seedThread(messageCount); + for (let index = 0; index < summaryCount; index++) + yield* persistSummary(index, [index * 2, index * 2 + 1]); + + yield* summarize; + yield* summarize; + assert.equal(model.calls.length, 1); + assert.equal(model.calls[0]!.messages[0]!.text, rawText(coveredMessages)); + assert.deepInclude((yield* list).lastPass, { generated: 0, waitingMessages: 0 }); + + const service = yield* ConversationSummaries.ConversationSummaries; + const handoff = yield* prepareHandoff(messageCount); + const offered = yield* service.forHandoff({ + projectId, + handoffs: [handoff], + itemIds: new Set([itemId(coveredMessages - 2), itemId(coveredMessages - 1)]), + }); + assert.deepEqual( + offered.map((summary) => summary.coveredItemIds), + [[itemId(coveredMessages - 2), itemId(coveredMessages - 1)]], + ); + assert.include(offered[0]!.text, "Summary 500"); + }).pipe(Effect.provide(makeLayer(model.generate, optedIn))); +}); + +it.effect("does not spend the handoff candidate limit on stale summaries", () => { + const model = citing(); + return Effect.gen(function* () { + yield* seed; + const sourceIndexes = Array.from({ length: 10 }, (_, index) => index); + for (let index = 0; index < 40; index++) yield* persistSummary(index, sourceIndexes, true); + const current = yield* persistSummary(40, sourceIndexes); + const service = yield* ConversationSummaries.ConversationSummaries; + const handoff = yield* prepareHandoff(MESSAGE_COUNT); + const offered = yield* service.forHandoff({ + projectId, + handoffs: [handoff], + itemIds: new Set(Array.from({ length: MESSAGE_COUNT }, (_, index) => itemId(index))), + }); + assert.deepEqual( + offered.map((summary) => summary.id), + [current.id], + ); + assert.equal(model.calls.length, 0); + }).pipe(Effect.provide(makeLayer(model.generate, optedIn))); +}); + it.effect( "withholds a summary whose source changed, expands the rest canonically, and resummarizes", () => { diff --git a/apps/server/src/project/conversationSummaries/ConversationSummaries.ts b/apps/server/src/project/conversationSummaries/ConversationSummaries.ts index 8ccc22fc1100a..9d2b3af52d6f6 100644 --- a/apps/server/src/project/conversationSummaries/ConversationSummaries.ts +++ b/apps/server/src/project/conversationSummaries/ConversationSummaries.ts @@ -51,8 +51,8 @@ const MAX_LEAVES_PER_PASS = 3; const MAX_STATEMENTS = 8; const MAX_STATEMENT_CHARS = 600; const MAX_CITATIONS = 8; -// Rows read when matching a thread's summaries against a handoff or computing coverage. -const THREAD_SUMMARY_LIMIT = 500; +// Bound each read, not the thread's total coverage. Long threads must keep making progress. +const THREAD_SUMMARY_PAGE_SIZE = 500; const HANDOFF_CANDIDATE_LIMIT = 40; const CLEANUP_LIMIT = 100; @@ -74,7 +74,11 @@ export class ConversationSummaries extends Context.Service< >()("t3/project/conversationSummaries/ConversationSummaries") {} const decodeSummary = Schema.decodeUnknownEffect(Schema.fromJsonString(ConversationSummary)); +const decodeSources = Schema.decodeUnknownEffect( + Schema.fromJsonString(ConversationSummary.fields.sources), +); const encodeSummary = Schema.encodeSync(Schema.fromJsonString(ConversationSummary)); +const encodeIds = Schema.encodeSync(Schema.fromJsonString(Schema.Array(Schema.String))); const decodePass = Schema.decodeUnknownEffect(Schema.fromJsonString(ConversationSummaryPass)); const encodePass = Schema.encodeSync(Schema.fromJsonString(ConversationSummaryPass)); const isConversationSummaryError = Schema.is(ConversationSummaryError); @@ -82,6 +86,7 @@ const isConversationSummaryError = Schema.is(ConversationSummaryError); type FinalizedMessage = OrchestrationV2ConversationMessage & { readonly role: "user" | "assistant"; }; +type SummarySources = Pick; const byConversationOrder = (left: FinalizedMessage, right: FinalizedMessage) => DateTime.toEpochMillis(left.createdAt) - DateTime.toEpochMillis(right.createdAt) || @@ -158,18 +163,56 @@ const make = Effect.gen(function* () { return project.value; }); - const threadSummaries = Effect.fn("ConversationSummaries.threadSummaries")(function* ( - projectId: ProjectId, - threadId: ThreadId, - ) { - const rows = yield* sql<{ readonly payload_json: string }>` - SELECT payload_json FROM conversation_summaries - WHERE project_id = ${projectId} AND thread_id = ${threadId} - ORDER BY first_source_at, summary_id - LIMIT ${THREAD_SUMMARY_LIMIT} - `; - return yield* Effect.forEach(rows, (row) => decodeSummary(row.payload_json)); - }); + const scanThreadSummarySources = Effect.fn("ConversationSummaries.scanThreadSummarySources")( + function* ( + projectId: ProjectId, + threadId: ThreadId, + // Return false when the caller has enough results; true continues through every page. + visit: (page: ReadonlyArray) => Effect.Effect, + messageIds?: ReadonlyArray, + ) { + const selectedMessages = messageIds === undefined ? undefined : encodeIds(messageIds); + let after: { readonly first_source_at: string; readonly summary_id: string } | undefined; + while (true) { + const rows = yield* sql<{ + readonly first_source_at: string; + readonly summary_id: string; + readonly sources_json: string; + }>` + SELECT first_source_at, summary_id, + json_extract(payload_json, '$.sources') AS sources_json + FROM conversation_summaries + WHERE project_id = ${projectId} AND thread_id = ${threadId} + ${ + after === undefined + ? sql`` + : sql`AND (first_source_at > ${after.first_source_at} + OR (first_source_at = ${after.first_source_at} AND summary_id > ${after.summary_id}))` + } + ${ + selectedMessages === undefined + ? sql`` + : sql`AND NOT EXISTS ( + SELECT 1 FROM json_each(payload_json, '$.sources') AS source + WHERE json_extract(source.value, '$.messageId') NOT IN ( + SELECT value FROM json_each(${selectedMessages}) + ) + )` + } + ORDER BY first_source_at, summary_id + LIMIT ${THREAD_SUMMARY_PAGE_SIZE} + `; + if (rows.length === 0) return; + const page = yield* Effect.forEach(rows, (row) => + decodeSources(row.sources_json).pipe( + Effect.map((sources) => ({ id: row.summary_id, sources })), + ), + ); + if (!(yield* visit(page)) || rows.length < THREAD_SUMMARY_PAGE_SIZE) return; + after = rows.at(-1)!; + } + }, + ); const findSummary = Effect.fn("ConversationSummaries.findSummary")(function* ( input: ConversationSummaryDescribeInput, @@ -350,51 +393,41 @@ const make = Effect.gen(function* () { const cleanupSuperseded = Effect.fn("ConversationSummaries.cleanupSuperseded")(function* ( projectId: ProjectId, threadId: ThreadId, + digests: ReadonlyMap, + covered: ReadonlySet, ) { - const summaries = yield* threadSummaries(projectId, threadId); - if (summaries.length === 0) return; - // One canonical read for the bounded thread range, rather than reloading the whole - // conversation once per summary. Different digests of one message remain distinct. - const refs = [ - ...new Map( - summaries.flatMap((summary) => - summary.sources.map( - (source) => [source.messageId + ":" + source.digest, source] as const, - ), - ), - ).values(), - ]; - const results = yield* knowledge.readSources({ projectId, threadId, sources: refs }); - const available = new Set( - refs.flatMap((ref, index) => - results[index]?.state === "available" ? [ref.messageId + ":" + ref.digest] : [], - ), - ); - const fresh = summaries.map((summary) => - summary.sources.every((source) => available.has(source.messageId + ":" + source.digest)), - ); - const freshCoverage = new Set( - summaries.flatMap((summary, index) => - fresh[index] ? summary.sources.map((source) => source.messageId) : [], - ), - ); - const replaced = summaries.filter( - (summary, index) => - !fresh[index] && summary.sources.every((source) => freshCoverage.has(source.messageId)), + // Reuse the pass's validated coverage. Re-reading every source here would hash the whole + // history again on every completed turn, even when no summary needs replacing. + if (covered.size === 0) return; + let deleted = 0; + yield* scanThreadSummarySources( + projectId, + threadId, + Effect.fnUntraced(function* (summaries) { + const replaced = summaries.filter( + (summary) => + summary.sources.every((source) => covered.has(source.messageId)) && + summary.sources.some((source) => digests.get(source.messageId) !== source.digest), + ); + if (replaced.length > 0) { + const removed = yield* sql<{ readonly summary_id: string }>` + DELETE FROM conversation_summaries + WHERE summary_id IN ( + SELECT summary_id FROM conversation_summaries + WHERE ${sql.in( + "summary_id", + replaced.map((summary) => summary.id), + )} + AND ${unreferenced} + ORDER BY summary_id LIMIT ${CLEANUP_LIMIT - deleted} + ) AND ${unreferenced} + RETURNING summary_id + `; + deleted += removed.length; + } + return deleted < CLEANUP_LIMIT; + }), ); - if (replaced.length === 0) return; - yield* sql` - DELETE FROM conversation_summaries - WHERE summary_id IN ( - SELECT summary_id FROM conversation_summaries - WHERE ${sql.in( - "summary_id", - replaced.map((summary) => summary.id), - )} - AND ${unreferenced} - ORDER BY summary_id LIMIT ${CLEANUP_LIMIT} - ) AND ${unreferenced} - `; }); const summarizeThread: ConversationSummaries["Service"]["summarizeThread"] = Effect.fn( @@ -407,13 +440,19 @@ const make = Effect.gen(function* () { .pipe(Effect.catchTags({ ProjectionStoreThreadNotFoundError: () => Effect.succeed(null) })); if (records === null) return; const projectId = records.thread.projectId; + const digests = new Map(); + const covered = new Set(); + let hasStaleSummaries = false; const finish = ( result: ConversationSummaryPass["result"], reason: string | null, generated: number, waitingMessages: number, ) => - cleanupSuperseded(projectId, threadId).pipe( + (hasStaleSummaries + ? cleanupSuperseded(projectId, threadId, digests, covered) + : Effect.void + ).pipe( Effect.andThen(DateTime.now), Effect.flatMap((now) => recordPass(projectId, threadId, { @@ -441,23 +480,41 @@ const make = Effect.gen(function* () { if (Option.isNone(project) || project.value.deletedAt !== null) return; const cwd = records.thread.worktreePath ?? project.value.workspaceRoot; + // Match canonical source eligibility without reloading and rehashing each summary's + // sources. Deleted/archived threads and non-final messages are excluded above/below. + const rolledBack = yield* sql<{ readonly message_id: string }>` + SELECT message.message_id + FROM orchestration_v2_projection_messages AS message + JOIN orchestration_v2_projection_runs AS run ON run.run_id = message.run_id + WHERE message.thread_id = ${threadId} AND run.status = 'rolled_back' + `; + const excluded = new Set(rolledBack.map((row) => row.message_id)); const finalized = records.messages .filter( (message): message is FinalizedMessage => (message.role === "user" || message.role === "assistant") && + !excluded.has(message.id) && !message.streaming && message.text.trim() !== "", ) .toSorted(byConversationOrder); - const digests = new Map(); for (const message of finalized) digests.set(message.id, yield* digest(message.text)); // Only summaries whose every source is unchanged count as coverage; a stale range is // summarized again from the messages as they are now. - const covered = new Set(); - for (const summary of yield* threadSummaries(projectId, threadId)) { - if (summary.sources.every((source) => digests.get(source.messageId) === source.digest)) - for (const source of summary.sources) covered.add(source.messageId); - } + yield* scanThreadSummarySources(projectId, threadId, (summaries) => + Effect.sync(() => { + for (const summary of summaries) { + if ( + summary.sources.every((source) => digests.get(source.messageId) === source.digest) + ) { + for (const source of summary.sources) covered.add(source.messageId); + } else { + hasStaleSummaries = true; + } + } + return true; + }), + ); const settled = finalized.slice(0, Math.max(0, finalized.length - FRESH_TAIL_MESSAGES)); const waiting = () => settled.filter((message) => !covered.has(message.id)).length; @@ -584,33 +641,48 @@ const make = Effect.gen(function* () { threadIds.add(handoff.threadId); for (const message of handoff.history?.messages ?? []) threadIds.add(message.threadId); } - const found: Array<{ - readonly summary: ConversationSummary; - readonly items: Array; - }> = []; - for (const threadId of threadIds) { - const summaries = yield* threadSummaries(projectId, threadId); - if (summaries.length === 0) continue; - const itemByMessage = new Map(); - for (const item of yield* projections.getTurnStartHistory(threadId)) { - if ( - (item.type === "user_message" || item.type === "assistant_message") && - itemIds.has(item.id) - ) - itemByMessage.set(item.messageId, item.id); - } - for (const summary of summaries) { - if (found.length >= HANDOFF_CANDIDATE_LIMIT) break; - const items = summary.sources.flatMap( - (source) => itemByMessage.get(source.messageId) ?? [], - ); - if (items.length === summary.sources.length) found.push({ summary, items }); - } - } const offered: Array = []; - for (const { summary, items } of found) { - if ((yield* read(summary)).state !== "available") continue; - offered.push({ id: summary.id, text: renderForHandoff(summary), coveredItemIds: items }); + const requestedItems = encodeIds([...itemIds]); + for (const threadId of threadIds) { + if (offered.length >= HANDOFF_CANDIDATE_LIMIT) break; + // Look up only the requested item identities, not every command and message body in + // the thread. json_each avoids SQLite's bind limit for a long omitted-history range. + const items = yield* sql<{ + readonly turn_item_id: TurnItemId; + readonly message_id: MessageId; + }>` + SELECT item.turn_item_id, json_extract(item.payload_json, '$.messageId') AS message_id + FROM json_each(${requestedItems}) AS requested + JOIN orchestration_v2_projection_turn_items AS item ON item.turn_item_id = requested.value + WHERE item.thread_id = ${threadId} AND item.type IN ('user_message', 'assistant_message') + `; + const itemByMessage = new Map(items.map((item) => [item.message_id, item.turn_item_id])); + if (itemByMessage.size === 0) continue; + yield* scanThreadSummarySources( + projectId, + threadId, + Effect.fnUntraced(function* (summaries) { + for (const metadata of summaries) { + const items = metadata.sources.flatMap( + (source) => itemByMessage.get(source.messageId) ?? [], + ); + if (items.length !== metadata.sources.length) continue; + // Decode statement bodies only for summaries covering the handoff's requested + // messages; source validation still runs immediately before offering any text. + const summary = yield* findSummary({ projectId, threadId, summaryId: metadata.id }); + // Stale rows must not consume the offer limit and hide later usable summaries. + if ((yield* read(summary)).state !== "available") continue; + offered.push({ + id: summary.id, + text: renderForHandoff(summary), + coveredItemIds: items, + }); + if (offered.length >= HANDOFF_CANDIDATE_LIMIT) return false; + } + return true; + }), + [...itemByMessage.keys()], + ); } return offered; }, diff --git a/apps/server/src/project/knowledge/ProjectKnowledge.test.ts b/apps/server/src/project/knowledge/ProjectKnowledge.test.ts index 4abe159f7d850..170c7193cb785 100644 --- a/apps/server/src/project/knowledge/ProjectKnowledge.test.ts +++ b/apps/server/src/project/knowledge/ProjectKnowledge.test.ts @@ -1,5 +1,6 @@ import * as HolographicMemory from "./HolographicMemory.ts"; import { deliveredKnowledgeForHistory, resolveKnowledgeComposer } from "./composer.ts"; +import { readKnowledgeDeliveryState } from "./delivery.ts"; import { formatComposerContextReference } from "@t3tools/shared/composerContextReferences"; import { assert, it } from "@effect/vitest"; import { @@ -1032,6 +1033,137 @@ const composeDelivery = Effect.fn("test.composeDelivery")(function* ( return { delivery, forget, excerpts }; }); +it.effect("keeps each steering message's receipt and forgets only its own evidence", () => + Effect.gen(function* () { + const knowledge = yield* ProjectKnowledge.ProjectKnowledge; + const projectId = ProjectId.make("steering-deliveries"); + yield* createProject(projectId); + const first = yield* composeDelivery(projectId, "first-steer"); + const second = yield* composeDelivery(projectId, "second-steer"); + const original = first.delivery("shared-attempt", "sent", 1); + const followup = { + ...second.delivery("shared-attempt", "prepared", 2), + threadId: original.threadId, + runId: original.runId, + }; + yield* knowledge.recordDelivery(original); + yield* knowledge.recordDelivery(followup); + yield* knowledge.recordDelivery({ ...followup, status: "sent" }); + yield* knowledge.recordDelivery({ + ...followup, + items: followup.items.map((item) => ({ ...item, text: "must not replace sent evidence" })), + }); + const readSecond = knowledge.readDeliveries(followup); + assert.equal((yield* readSecond).deliveries[0]?.items[0]?.text, followup.items[0]?.text); + assert.equal((yield* first.excerpts)[0]?.[1], "sent"); + + const evidence = yield* deliveredKnowledgeForHistory({ + projectId, + messages: [original, followup].map((receipt) => ({ + itemId: TurnItemId.make(`item:${receipt.messageId}`), + role: "user" as const, + kind: "user_message", + text: "Use the attached decision.", + threadId: receipt.threadId, + runId: receipt.runId, + providerThreadId: null, + status: "completed", + })), + userItemId: (id) => `item:${id}`, + }); + assert.equal(evidence.size, 2); + yield* first.forget; + assert.equal((yield* first.excerpts)[0]?.[2], ""); + assert.equal((yield* readSecond).deliveries[0]?.items[0]?.text, followup.items[0]?.text); + }).pipe(Effect.provide(layerTest)), +); + +it.effect("checks delivery state beyond the evidence inspector's twenty-receipt page", () => + Effect.gen(function* () { + const knowledge = yield* ProjectKnowledge.ProjectKnowledge; + const projectId = ProjectId.make("delivery-state-pagination"); + yield* createProject(projectId); + const receipt = { + projectId, + threadId: ThreadId.make("delivery-state-thread"), + messageId: MessageId.make("delivery-state-message"), + runId: RunId.make("delivery-state-run"), + attemptId: RunAttemptId.make("current-attempt"), + providerInstanceId, + providerThreadId: ProviderThreadId.make("delivery-state-provider-thread"), + nativeThreadId: "original-native", + status: "uncertain" as const, + items: [], + omissions: [], + createdAt: DateTime.formatIso(at(30)), + updatedAt: DateTime.formatIso(at(30)), + }; + for (let index = 0; index < 25; index++) + yield* knowledge.recordDelivery({ + ...receipt, + attemptId: RunAttemptId.make(`older-attempt-${index}`), + status: "failed", + createdAt: DateTime.formatIso(at(index)), + updatedAt: DateTime.formatIso(at(index)), + }); + yield* knowledge.recordDelivery(receipt); + assert.lengthOf((yield* knowledge.readDeliveries(receipt)).deliveries, 20); + assert.deepEqual(yield* readKnowledgeDeliveryState(receipt), { + sent: false, + sentThisAttempt: false, + uncertain: true, + }); + yield* knowledge.recordDelivery({ ...receipt, status: "sent" }); + // Receipt safety must not depend on decoding/revalidating evidence bodies. + const sql = yield* SqlClient.SqlClient; + yield* sql`UPDATE project_knowledge_deliveries SET items_json = 'unreadable evidence' + WHERE attempt_id = ${receipt.attemptId} AND message_id = ${receipt.messageId}`; + assert.deepEqual(yield* readKnowledgeDeliveryState(receipt), { + sent: true, + sentThisAttempt: true, + uncertain: false, + }); + const replacement = { ...receipt, nativeThreadId: "replacement-native" }; + assert.isFalse((yield* readKnowledgeDeliveryState(replacement)).sent); + assert.isTrue((yield* readKnowledgeDeliveryState(replacement, "attempt")).sentThisAttempt); + assert.isFalse( + (yield* readKnowledgeDeliveryState({ + ...receipt, + messageId: MessageId.make("another-message"), + })).sent, + ); + }).pipe(Effect.provide(layerTest)), +); + +it.effect("pages delivery scrubbing through multiple messages in the same attempt", () => + Effect.gen(function* () { + const knowledge = yield* ProjectKnowledge.ProjectKnowledge; + const projectId = ProjectId.make("steering-scrub-pages"); + yield* createProject(projectId); + const fixture = yield* composeDelivery(projectId, "scrub-steer"); + const receipt = fixture.delivery("one-attempt", "sent", 1); + yield* Effect.forEach( + Array.from({ length: 55 }, (_, index) => index), + (index) => + knowledge.recordDelivery({ ...receipt, messageId: MessageId.make(`steer-${index}`) }), + ); + const sql = yield* SqlClient.SqlClient; + // Simulate a removal already committed before the durable scrub continuation runs. + yield* sql`UPDATE project_knowledge_facts SET status = 'forgotten', content = NULL, + forgotten_at = '2026-09-27T00:02:00.000Z', entities_json = '[]', tags_json = '[]', sources_json = '[]' + WHERE project_id = ${projectId}`; + const afterId = yield* knowledge.scrubDeliveryPage({ projectId }); + assert.isNotNull(afterId); + assert.equal(yield* knowledge.scrubDeliveryPage({ projectId, afterId: afterId! }), null); + const [count] = yield* sql<{ total: number; retained: number }>` + SELECT COUNT(*) AS total, + SUM(CASE WHEN items_json LIKE '%Cache keys include tenant IDs.%' THEN 1 ELSE 0 END) AS retained + FROM project_knowledge_deliveries WHERE project_id = ${projectId}`; + assert.equal(count?.total, 55); + assert.equal(count?.retained, 0); + }).pipe(Effect.provide(layerTest)), +); + it.effect.each(["confirm", "feedback"] as const)( "scrubs a delivered %s revision using the citations that supplied its content", (action) => diff --git a/apps/server/src/project/knowledge/ProjectKnowledge.ts b/apps/server/src/project/knowledge/ProjectKnowledge.ts index e277cd6dfc538..83bc34bf06820 100644 --- a/apps/server/src/project/knowledge/ProjectKnowledge.ts +++ b/apps/server/src/project/knowledge/ProjectKnowledge.ts @@ -46,7 +46,13 @@ import * as ServerEnvironment from "../../environment/ServerEnvironment.ts"; import * as ProjectStore from "../../orchestration-v2/ProjectStore.ts"; import * as ProjectionStore from "../../orchestration-v2/ProjectionStore.ts"; import * as ThreadSearch from "../../orchestration-v2/ThreadSearch.ts"; -import { makeStore, type HistoryRecord, type SearchReceipt } from "./persistence.ts"; +import { + makeStore, + type DeliveryState, + type DeliveryStateInput, + type HistoryRecord, + type SearchReceipt, +} from "./persistence.ts"; import * as HolographicMemory from "./HolographicMemory.ts"; import { projectMemorySearchTokens } from "./holographicScoring.ts"; import { conflictHints } from "./knowledgeConflicts.ts"; @@ -100,6 +106,13 @@ export class ProjectKnowledge extends Context.Service< readonly readDeliveries: ( input: ProjectKnowledgeReadDeliveriesInput, ) => Effect.Effect; + /** Original delivery outcomes, independent of source availability and inspector pagination. */ + readonly readDeliveryState: ( + input: DeliveryStateInput, + ) => Effect.Effect; + readonly markDeliveryUncertain: ( + input: DeliveryStateInput, + ) => Effect.Effect; readonly scrubDeliveryPage: (input: { readonly projectId: ProjectId; readonly afterId?: string | undefined; @@ -1209,13 +1222,13 @@ const make = Effect.gen(function* () { )(function* (input) { // Each continuation releases the maintenance worker instead of walking a whole project. const limit = 50; - const rows = yield* store + const page = yield* store .deliveryPage(input.projectId, input.afterId, limit) .pipe(Effect.mapError(storageError(input.projectId))); - yield* Effect.forEach(rows, revalidateDelivery, { discard: true }).pipe( + yield* Effect.forEach(page.deliveries, revalidateDelivery, { discard: true }).pipe( Effect.mapError(storageError(input.projectId)), ); - return rows.length === limit ? rows[rows.length - 1]!.attemptId : null; + return page.afterId; }); const readSentDeliveries: ProjectKnowledge["Service"]["readSentDeliveries"] = Effect.fn( "ProjectKnowledge.readSentDeliveries", @@ -1227,6 +1240,16 @@ const make = Effect.gen(function* () { Effect.mapError(storageError(input.projectId)), ); }); + const readDeliveryState: ProjectKnowledge["Service"]["readDeliveryState"] = Effect.fn( + "ProjectKnowledge.readDeliveryState", + )((input) => store.readDeliveryState(input).pipe(Effect.mapError(storageError(input.projectId)))); + const markDeliveryUncertain: ProjectKnowledge["Service"]["markDeliveryUncertain"] = Effect.fn( + "ProjectKnowledge.markDeliveryUncertain", + )(function* (input) { + yield* store + .markDeliveryUncertain(input, DateTime.formatIso(yield* DateTime.now)) + .pipe(Effect.mapError(storageError(input.projectId))); + }); const readDeliveries: ProjectKnowledge["Service"]["readDeliveries"] = Effect.fn( "ProjectKnowledge.readDeliveries", )(function* (input) { @@ -1456,6 +1479,8 @@ const make = Effect.gen(function* () { resolveForTurn, recordDelivery, readDeliveries, + readDeliveryState, + markDeliveryUncertain, readSentDeliveries, scrubDeliveryPage, proposeAssisted, diff --git a/apps/server/src/project/knowledge/delivery.test.ts b/apps/server/src/project/knowledge/delivery.test.ts new file mode 100644 index 0000000000000..b4723ff9cf19b --- /dev/null +++ b/apps/server/src/project/knowledge/delivery.test.ts @@ -0,0 +1,50 @@ +import { expect, it } from "@effect/vitest"; +import { + MessageId, + ProjectId, + ProviderInstanceId, + ProviderThreadId, + RunAttemptId, + ThreadId, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { vi } from "vite-plus/test"; + +import * as ProjectKnowledge from "./ProjectKnowledge.ts"; +import { readKnowledgeDeliveryState } from "./delivery.ts"; + +it.effect( + "uses the status boundary for both delivery scopes without reading inspector evidence", + () => + Effect.gen(function* () { + const target = { + projectId: ProjectId.make("project"), + threadId: ThreadId.make("thread"), + messageId: MessageId.make("message"), + attemptId: RunAttemptId.make("attempt"), + providerInstanceId: ProviderInstanceId.make("codex"), + providerThreadId: ProviderThreadId.make("provider-thread"), + nativeThreadId: "original-native", + }; + const state = { sent: false, sentThisAttempt: false, uncertain: true }; + const readDeliveryState = vi.fn< + ProjectKnowledge.ProjectKnowledge["Service"]["readDeliveryState"] + >(() => Effect.succeed(state)); + yield* Effect.gen(function* () { + expect(yield* readKnowledgeDeliveryState(target)).toEqual(state); + expect(yield* readKnowledgeDeliveryState(target, "attempt")).toEqual(state); + }).pipe( + Effect.provide( + Layer.mock(ProjectKnowledge.ProjectKnowledge)({ + readDeliveryState, + readDeliveries: () => Effect.die("Inspector evidence is not part of dispatch safety"), + }), + ), + ); + expect(readDeliveryState.mock.calls).toEqual([ + [{ ...target, scope: "native-conversation" }], + [{ ...target, scope: "attempt" }], + ]); + }), +); diff --git a/apps/server/src/project/knowledge/delivery.ts b/apps/server/src/project/knowledge/delivery.ts new file mode 100644 index 0000000000000..8c880d16b4764 --- /dev/null +++ b/apps/server/src/project/knowledge/delivery.ts @@ -0,0 +1,137 @@ +import { + ProjectKnowledgeError, + type ComposerContextRecord, + type ProjectKnowledgeDelivery, + type ProjectKnowledgeDeliveryStatus, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; + +import * as ProjectKnowledge from "./ProjectKnowledge.ts"; +import { resolveKnowledgeComposer } from "./composer.ts"; + +type DeliveryTarget = Pick< + ProjectKnowledgeDelivery, + | "attemptId" + | "runId" + | "threadId" + | "messageId" + | "projectId" + | "providerInstanceId" + | "providerThreadId" + | "nativeThreadId" +>; + +export const readKnowledgeDeliveryState = Effect.fn("ProjectKnowledge.readDeliveryState")( + function* ( + target: Omit, + scope: "native-conversation" | "attempt" = "native-conversation", + ) { + const knowledge = yield* ProjectKnowledge.ProjectKnowledge; + return yield* knowledge.readDeliveryState({ ...target, scope }); + }, +); + +export const prepareKnowledgeDelivery = Effect.fn("ProjectKnowledge.prepareDelivery")( + function* (input: { + readonly target: DeliveryTarget; + readonly text: string; + readonly records: ReadonlyArray; + }) { + const knowledge = yield* ProjectKnowledge.ProjectKnowledge; + let budget: ProjectKnowledgeDelivery["budget"]; + const refuse = Effect.fn("ProjectKnowledge.refuseDelivery")(function* ( + error: ProjectKnowledgeError, + ) { + const reason = + error.reason === "context_budget" + ? ("over_budget" as const) + : error.reason === "stale_context" + ? ("stale_revision" as const) + : error.reason === "wrong_project" || error.reason === "wrong_environment" + ? ("not_permitted" as const) + : error.reason === "storage" || error.reason === "scoring" + ? ("recall_failed" as const) + : ("source_unavailable" as const); + const omissions = input.records.flatMap((record) => + record.kind === "project-knowledge" + ? [{ contextId: record.contextId, label: record.label, reason }] + : [], + ); + if (omissions.length === 0) return; + const now = DateTime.formatIso(yield* DateTime.now); + yield* knowledge.recordDelivery({ + ...input.target, + status: "failed", + items: [], + omissions, + ...(budget === undefined ? {} : { budget }), + createdAt: now, + updatedAt: now, + }); + }); + const resolve = resolveKnowledgeComposer({ + projectId: input.target.projectId, + text: input.text, + records: input.records, + }).pipe( + Effect.provideService(ProjectKnowledge.ProjectKnowledge, knowledge), + Effect.tapError(refuse), + ); + const composer = yield* resolve; + const readState = Effect.suspend(() => + composer.items.length > 0 || composer.omissions.length > 0 + ? readKnowledgeDeliveryState(input.target).pipe( + Effect.provideService(ProjectKnowledge.ProjectKnowledge, knowledge), + ) + : Effect.succeed({ sent: false, sentThisAttempt: false, uncertain: false }), + ); + const record = Effect.fn("ProjectKnowledge.recordMessageDelivery")(function* ( + status: ProjectKnowledgeDeliveryStatus, + ) { + if (composer.items.length === 0 && composer.omissions.length === 0) return; + const now = DateTime.formatIso(yield* DateTime.now); + yield* knowledge.recordDelivery({ + ...input.target, + status, + items: composer.items, + omissions: composer.omissions, + ...(budget === undefined ? {} : { budget }), + createdAt: now, + updatedAt: now, + }); + }); + const settle = (status: ProjectKnowledgeDeliveryStatus) => + record(status).pipe( + Effect.catchCause(() => + Effect.logWarning("Failed to record project knowledge delivery status", { + runId: input.target.runId, + messageId: input.target.messageId, + deliveryStatus: status, + }), + ), + ); + const checkBudget = Effect.fn("ProjectKnowledge.checkDeliveryBudget")(function* ( + allowance: NonNullable, + ) { + budget = allowance; + if (composer.byteLength <= allowance.knowledgeAllowance) return; + const error = new ProjectKnowledgeError({ + reason: "context_budget", + projectId: input.target.projectId, + byteLength: composer.byteLength, + }); + yield* refuse(error); + return yield* error; + }); + return { + composer, + readState, + record, + settle, + checkBudget, + // Revalidate immediately before transport dispatch after any asynchronous handoff work. + revalidate: composer.byteLength > 0 ? Effect.asVoid(resolve) : Effect.void, + }; + }, +); diff --git a/apps/server/src/project/knowledge/persistence.ts b/apps/server/src/project/knowledge/persistence.ts index ce5a184449a9f..7211d74f30a17 100644 --- a/apps/server/src/project/knowledge/persistence.ts +++ b/apps/server/src/project/knowledge/persistence.ts @@ -100,6 +100,29 @@ const DeliveryRow = Schema.Struct({ omissions: Schema.fromJsonString(Schema.Array(ProjectKnowledgeDeliveryOmission)), }); const decodeDeliveries = Schema.decodeUnknownEffect(Schema.Array(DeliveryRow)); +export type DeliveryStateInput = Pick< + ProjectKnowledgeDelivery, + | "projectId" + | "threadId" + | "messageId" + | "attemptId" + | "providerInstanceId" + | "providerThreadId" + | "nativeThreadId" +> & { readonly scope: "native-conversation" | "attempt" }; +export interface DeliveryState { + readonly sent: boolean; + readonly sentThisAttempt: boolean; + readonly uncertain: boolean; +} +const DeliveryCursor = Schema.fromJsonString( + Schema.Struct({ + attemptId: ProjectKnowledgeDelivery.fields.attemptId, + messageId: ProjectKnowledgeDelivery.fields.messageId, + }), +); +const decodeDeliveryCursor = Schema.decodeUnknownEffect(DeliveryCursor); +const encodeDeliveryCursor = Schema.encodeSync(DeliveryCursor); const decodeDeliveryItems = Schema.decodeUnknownEffect( Schema.fromJsonString(Schema.Array(ProjectKnowledgeDeliveryItem)), ); @@ -453,7 +476,7 @@ export const makeStore = Effect.gen(function* () { ${encodeDeliveryItems(items)}, ${encodeDeliveryOmissions(delivery.omissions)}, ${delivery.createdAt}, ${delivery.updatedAt}, ${delivery.providerThreadId ?? null}, ${delivery.nativeThreadId ?? null}, ${delivery.budget === undefined ? null : yield* encodeDeliveryBudget(delivery.budget)} - ) ON CONFLICT (attempt_id) DO UPDATE SET + ) ON CONFLICT (attempt_id, message_id) DO UPDATE SET status = excluded.status, updated_at = excluded.updated_at, provider_thread_id = excluded.provider_thread_id, native_thread_id = excluded.native_thread_id, budget_json = excluded.budget_json, omissions_json = excluded.omissions_json ${ @@ -474,8 +497,12 @@ export const makeStore = Effect.gen(function* () { projectId: ProjectId, factId: ProjectFactId, ) { - const rows = yield* sql<{ readonly attemptId: string; readonly items: string }>` - SELECT attempt_id AS "attemptId", items_json AS items FROM project_knowledge_deliveries d + const rows = yield* sql<{ + readonly attemptId: string; + readonly messageId: string; + readonly items: string; + }>` + SELECT attempt_id AS "attemptId", message_id AS "messageId", items_json AS items FROM project_knowledge_deliveries d WHERE project_id = ${projectId} AND EXISTS ( SELECT 1 FROM json_each(d.items_json) item, json_each(item.value, '$.context.selections') selection @@ -487,7 +514,7 @@ export const makeStore = Effect.gen(function* () { const scrubbed = withoutForgotten(items, new Set([factId])); if (scrubbed !== items) yield* sql`UPDATE project_knowledge_deliveries SET items_json = ${encodeDeliveryItems(scrubbed)} - WHERE attempt_id = ${row.attemptId}`; + WHERE attempt_id = ${row.attemptId} AND message_id = ${row.messageId}`; } }); const replaceDeliveryItems = Effect.fn("ProjectKnowledgeStore.replaceDeliveryItems")(function* ( @@ -496,9 +523,44 @@ export const makeStore = Effect.gen(function* () { ) { yield* sql`UPDATE project_knowledge_deliveries SET items_json = ${encodeDeliveryItems(delivery.items)}, omissions_json = ${encodeDeliveryOmissions(delivery.omissions)} - WHERE attempt_id = ${delivery.attemptId} AND project_id = ${delivery.projectId} + WHERE attempt_id = ${delivery.attemptId} AND message_id = ${delivery.messageId} + AND project_id = ${delivery.projectId} AND items_json = ${encodeDeliveryItems(original.items)}`; }); + // Dispatch safety reads status across every matching attempt, independently of + // the inspector's page limit and evidence decoding/source revalidation. + const deliveryTargetPredicate = (input: DeliveryStateInput) => sql` + project_id = ${input.projectId} AND thread_id = ${input.threadId} AND message_id = ${input.messageId} + AND provider_instance_id = ${input.providerInstanceId} + AND ${ + input.scope === "attempt" + ? sql`attempt_id = ${input.attemptId} AND (provider_thread_id IS NULL OR provider_thread_id IS ${input.providerThreadId ?? null})` + : sql`provider_thread_id IS ${input.providerThreadId ?? null} + AND native_thread_id IS ${input.nativeThreadId} + AND (${input.nativeThreadId} IS NOT NULL OR attempt_id = ${input.attemptId})` + }`; + const readDeliveryState = Effect.fn("ProjectKnowledgeStore.readDeliveryState")(function* ( + input: DeliveryStateInput, + ) { + const [row] = yield* sql<{ sent: number; sentThisAttempt: number; uncertain: number }>` + SELECT COALESCE(MAX(status = 'sent'), 0) AS sent, + COALESCE(MAX(status = 'sent' AND attempt_id = ${input.attemptId}), 0) AS "sentThisAttempt", + COALESCE(MAX(status IN ('prepared', 'uncertain')), 0) AS uncertain + FROM project_knowledge_deliveries + WHERE ${deliveryTargetPredicate(input)}`; + return { + sent: row?.sent === 1, + sentThisAttempt: row?.sentThisAttempt === 1, + uncertain: row?.uncertain === 1, + }; + }); + const markDeliveryUncertain = Effect.fn("ProjectKnowledgeStore.markDeliveryUncertain")(function* ( + input: DeliveryStateInput, + updatedAt: string, + ) { + yield* sql`UPDATE project_knowledge_deliveries SET status = 'uncertain', updated_at = ${updatedAt} + WHERE status = 'prepared' AND ${deliveryTargetPredicate(input)}`; + }); const deliveries = Effect.fn("ProjectKnowledgeStore.deliveries")(function* ( projectId: ProjectId, threadId: ThreadId, @@ -524,6 +586,10 @@ export const makeStore = Effect.gen(function* () { afterId: string | undefined, limit: number, ) { + // Durable continuations from older builds carry only an attempt id. + const cursor = afterId?.startsWith("message:") + ? yield* decodeDeliveryCursor(afterId.slice("message:".length)) + : undefined; const rows = yield* sql` SELECT attempt_id AS "attemptId", run_id AS "runId", thread_id AS "threadId", message_id AS "messageId", project_id AS "projectId", @@ -531,13 +597,27 @@ export const makeStore = Effect.gen(function* () { native_thread_id AS "nativeThreadId", status, items_json AS items, budget_json AS budget, omissions_json AS omissions, created_at AS "createdAt", updated_at AS "updatedAt" FROM project_knowledge_deliveries - WHERE project_id = ${projectId} AND attempt_id > ${afterId ?? ""} - ORDER BY attempt_id ASC LIMIT ${limit}`; - return (yield* decodeDeliveries(rows)).map(({ providerThreadId, budget, ...row }) => ({ - ...row, - ...(budget == null ? {} : { budget }), - ...(providerThreadId == null ? {} : { providerThreadId }), - })); + WHERE project_id = ${projectId} AND ${ + cursor === undefined + ? sql`attempt_id > ${afterId ?? ""}` + : sql`(attempt_id, message_id) > (${cursor.attemptId}, ${cursor.messageId})` + } + ORDER BY attempt_id ASC, message_id ASC LIMIT ${limit}`; + const deliveries = (yield* decodeDeliveries(rows)).map( + ({ providerThreadId, budget, ...row }) => ({ + ...row, + ...(budget == null ? {} : { budget }), + ...(providerThreadId == null ? {} : { providerThreadId }), + }), + ); + const last = deliveries.at(-1); + return { + deliveries, + afterId: + last !== undefined && deliveries.length === limit + ? `message:${encodeDeliveryCursor({ attemptId: last.attemptId, messageId: last.messageId })}` + : null, + }; }); const sentDeliveries = Effect.fn("ProjectKnowledgeStore.sentDeliveries")(function* ( projectId: ProjectId, @@ -615,6 +695,8 @@ export const makeStore = Effect.gen(function* () { citedFacts, recordRetrieval, recordDelivery, + readDeliveryState, + markDeliveryUncertain, scrubDeliveries, replaceDeliveryItems, deliveries, diff --git a/apps/server/src/project/loom/ProjectLoom.test.ts b/apps/server/src/project/loom/ProjectLoom.test.ts index 3be2a4e20b8eb..a8093ff405287 100644 --- a/apps/server/src/project/loom/ProjectLoom.test.ts +++ b/apps/server/src/project/loom/ProjectLoom.test.ts @@ -4,6 +4,7 @@ import { CheckpointId, CheckpointScopeId, ContextTransferId, + EnvironmentId, EventId, LOOM_LIMITS, MessageId, @@ -15,6 +16,8 @@ import { TurnItemId, type LoomWindowInput, type OrchestrationV2DomainEvent, + type OrchestrationV2DelegatedFrom, + type OrchestrationV2RemoteTaskChild, type OrchestrationV2RunStatus, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; @@ -91,6 +94,7 @@ interface ThreadSpec { readonly project?: ProjectId; readonly title?: string; readonly parent?: { readonly id: string; readonly relationship: "fork" | "subagent" }; + readonly delegatedFrom?: OrchestrationV2DelegatedFrom; readonly branch?: string; readonly deleted?: boolean; readonly pullRequestLinkedHours?: number; @@ -126,6 +130,7 @@ const thread = (spec: ThreadSpec) => { rootThreadId: parentThreadId ?? threadId, }, forkedFrom: null, + ...(spec.delegatedFrom === undefined ? {} : { delegatedFrom: spec.delegatedFrom }), ...(spec.pullRequestLinkedHours === undefined ? {} : { @@ -317,7 +322,12 @@ const transfer = (spec: { }, }); -const nativeTask = (threadId: string, id: string, startedHours: number | null) => +const nativeTask = ( + threadId: string, + id: string, + startedHours: number | null, + remoteChild?: OrchestrationV2RemoteTaskChild, +) => apply({ type: "subagent.updated", threadId: ThreadId.make(threadId), @@ -328,12 +338,13 @@ const nativeTask = (threadId: string, id: string, startedHours: number | null) = threadId: ThreadId.make(threadId), runId: null, parentNodeId: NodeId.make(`${threadId}-node`), - origin: "provider_native", + origin: remoteChild === undefined ? "provider_native" : "app_owned", createdBy: "agent", driver: "claudeAgent" as never, providerInstanceId: instanceId, providerThreadId: null, childThreadId: null, + ...(remoteChild === undefined ? {} : { remoteChild }), nativeTaskRef: null, prompt: "explore", title: "Explore the repo", @@ -381,6 +392,74 @@ const repeat = (count: number, make: (index: number) => Effect.Effect { + it.effect("preserves remote task destinations and parent origins without joining local ids", () => + Effect.gen(function* () { + yield* seedProjects; + const remoteEnvironment = EnvironmentId.make("another-environment"); + const remoteChild = { + environmentId: remoteEnvironment, + // A remote id may equal a local id; it must not become a local child lane. + threadId: ThreadId.make("parent"), + label: "Build server", + runId: RunId.make("remote-run"), + stoppedThere: true, + }; + const delegatedFrom = { + environmentId: remoteEnvironment, + threadId: ThreadId.make("parent"), + title: "Remote parent", + }; + yield* thread({ id: "parent", createdHours: 0 }); + yield* thread({ id: "remote-worker", createdHours: 0, delegatedFrom }); + yield* nativeTask("parent", "remote-task", 6, remoteChild); + yield* nativeTask("parent", "native-task", 7); + + const result = yield* read(window); + assert.deepInclude( + result.lanes.find((lane) => lane.threadId === "remote-worker"), + { + relationship: "subagent", + parentThreadId: null, + delegatedFrom, + }, + ); + assert.deepInclude( + result.markers.find((marker) => marker.id === "remote-task"), + { + kind: "subagent_task", + threadId: ThreadId.make("parent"), + remoteChild: { + environmentId: remoteEnvironment, + threadId: ThreadId.make("parent"), + label: "Build server", + }, + }, + ); + assert.notProperty( + result.markers.find((marker) => marker.id === "native-task"), + "remoteChild", + ); + assert.notInclude(JSON.stringify(result), "stoppedThere"); + assert.deepEqual(result.ancestors, []); + + // A paged-out remote child remains an ancestor with its origin intact. + yield* thread({ + id: "local-fork", + createdHours: 1, + parent: { id: "remote-worker", relationship: "fork" }, + }); + const filtered = yield* read({ ...window, filters: { title: "local-fork" } }); + assert.deepInclude( + filtered.ancestors.find((lane) => lane.threadId === "remote-worker"), + { + relationship: "subagent", + parentThreadId: null, + delegatedFrom, + }, + ); + }).pipe(Effect.provide(layer)), + ); + it.effect( "maps activity kinds and offsets oldest first without excluded or out-of-window items", () => diff --git a/apps/server/src/project/loom/ProjectLoom.ts b/apps/server/src/project/loom/ProjectLoom.ts index 18517c07d74b6..52442cd83bdae 100644 --- a/apps/server/src/project/loom/ProjectLoom.ts +++ b/apps/server/src/project/loom/ProjectLoom.ts @@ -130,18 +130,34 @@ interface LaneRow { readonly archived_at: string | null; readonly parent_thread_id: string | null; readonly relationship: string | null; + readonly delegated_from_environment_id: string | null; + readonly delegated_from_thread_id: string | null; + readonly delegated_from_title: string | null; readonly model: string | null; readonly branch: string | null; } -const relationshipOf = (row: Pick) => - row.relationship === "fork" || row.relationship === "subagent" ? row.relationship : "root"; +const relationshipOf = (row: LaneRow) => + row.relationship === "fork" || row.relationship === "subagent" + ? row.relationship + : row.delegated_from_environment_id !== null && row.delegated_from_thread_id !== null + ? "subagent" + : "root"; const laneIdentity = (row: LaneRow) => ({ threadId: row.thread_id, title: row.title, relationship: relationshipOf(row), parentThreadId: row.parent_thread_id, + ...(row.delegated_from_environment_id === null || row.delegated_from_thread_id === null + ? {} + : { + delegatedFrom: { + environmentId: row.delegated_from_environment_id, + threadId: row.delegated_from_thread_id, + title: row.delegated_from_title ?? "Untitled thread", + }, + }), }); const decodeWindow = Schema.decodeUnknownEffect(LoomWindow); @@ -156,6 +172,9 @@ const make = Effect.gen(function* () { t.default_provider, t.created_at, t.archived_at, json_extract(t.payload_json, '$.lineage.parentThreadId') AS parent_thread_id, json_extract(t.payload_json, '$.lineage.relationshipToParent') AS relationship, + json_extract(t.payload_json, '$.delegatedFrom.environmentId') AS delegated_from_environment_id, + json_extract(t.payload_json, '$.delegatedFrom.threadId') AS delegated_from_thread_id, + substr(json_extract(t.payload_json, '$.delegatedFrom.title'), 1, ${LABEL_CHARS}) AS delegated_from_title, substr(json_extract(t.payload_json, '$.modelSelection.model'), 1, ${LABEL_CHARS}) AS model, substr(json_extract(t.payload_json, '$.branch'), 1, ${LABEL_CHARS}) AS branch `; @@ -447,7 +466,7 @@ const make = Effect.gen(function* () { // Named, because SQLite otherwise reads child_thread_id IS NULL through the child thread // index, which holds every other project's native tasks too. - const readNativeTasks = (laneIds: ReadonlyArray, window: ResolvedWindow) => sql<{ + const readTaskMarkers = (laneIds: ReadonlyArray, window: ResolvedWindow) => sql<{ readonly subagent_id: string; readonly thread_id: string; readonly run_id: string | null; @@ -455,9 +474,15 @@ const make = Effect.gen(function* () { readonly started_at: string; readonly completed_at: string | null; readonly title: string | null; + readonly remote_environment_id: string | null; + readonly remote_thread_id: string | null; + readonly remote_label: string | null; }>` SELECT s.subagent_id, s.thread_id, s.run_id, s.status, s.started_at, s.completed_at, - substr(json_extract(s.payload_json, '$.title'), 1, ${LABEL_CHARS}) AS title + substr(json_extract(s.payload_json, '$.title'), 1, ${LABEL_CHARS}) AS title, + json_extract(s.payload_json, '$.remoteChild.environmentId') AS remote_environment_id, + json_extract(s.payload_json, '$.remoteChild.threadId') AS remote_thread_id, + substr(json_extract(s.payload_json, '$.remoteChild.label'), 1, ${LABEL_CHARS}) AS remote_label FROM orchestration_v2_projection_subagents s INDEXED BY orchestration_v2_projection_subagents_thread_idx WHERE s.thread_id IN ${laneSet(laneIds)} @@ -535,7 +560,7 @@ const make = Effect.gen(function* () { readPullRequests(laneIds, window), readCommands(runIds, window), readActivity(runIds, window), - readNativeTasks(laneIds, window), + readTaskMarkers(laneIds, window), countUntimedTasks(laneIds, window), readEdges(projectId, laneIds, window), ]); @@ -627,6 +652,15 @@ const make = Effect.gen(function* () { completedAt: task.completed_at, status: task.status, title: task.title, + ...(task.remote_environment_id === null || task.remote_thread_id === null + ? {} + : { + remoteChild: { + environmentId: task.remote_environment_id, + threadId: task.remote_thread_id, + label: task.remote_label ?? task.remote_environment_id.slice(0, LABEL_CHARS), + }, + }), })); const checkpointMarkers = rows.checkpoints.slice(0, LOOM_LIMITS.markers).map((checkpoint) => ({ kind: "checkpoint" as const, diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 6903df2158083..86836c4997a4f 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -374,6 +374,7 @@ export const ClaudeDriver: ProviderDriver< ); const textGeneration: ProviderInstance["textGeneration"] = auth ? { + supportedOperations: nativeGeneration.supportedOperations, generateCommitMessage: (input) => protect("generateCommitMessage", nativeGeneration.generateCommitMessage(input)), generatePrContent: (input) => diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 08e3e06363b7d..e1989fcb5cdfc 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -400,6 +400,7 @@ export const CodexDriver: ProviderDriver< ); const textGeneration: ProviderInstance["textGeneration"] = auth ? { + supportedOperations: nativeGeneration.supportedOperations, generateCommitMessage: (input) => protect("generateCommitMessage", nativeGeneration.generateCommitMessage(input)), generatePrContent: (input) => diff --git a/apps/server/src/provider/Drivers/CodexManagedProvider.ts b/apps/server/src/provider/Drivers/CodexManagedProvider.ts index efb5c5e8f7f7a..b9ae2f906ec58 100644 --- a/apps/server/src/provider/Drivers/CodexManagedProvider.ts +++ b/apps/server/src/provider/Drivers/CodexManagedProvider.ts @@ -278,6 +278,7 @@ export const makeManagedCodexProvider = Effect.fn("makeManagedCodexProvider")(fu ), ); const textGeneration: ProviderInstance["textGeneration"] = { + supportedOperations: nativeGeneration.supportedOperations, generateCommitMessage: (value) => protect("generateCommitMessage", nativeGeneration.generateCommitMessage(value)), generatePrContent: (value) => diff --git a/apps/server/src/provider/ProviderAccountTransfer.test.ts b/apps/server/src/provider/ProviderAccountTransfer.test.ts index c49fd8b106cbe..89de63dec2cd7 100644 --- a/apps/server/src/provider/ProviderAccountTransfer.test.ts +++ b/apps/server/src/provider/ProviderAccountTransfer.test.ts @@ -11,6 +11,7 @@ import { type ProviderAccountExport, } from "@t3tools/contracts"; import { cursorClient } from "@t3tools/provider-cursor/client"; +import { fxClient } from "@t3tools/provider-fx/client"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -44,6 +45,7 @@ const builtIn: ReadonlyArray = [ driver(codex, CodexSettings), driver(antigravity, AntigravitySettings), driver(cursor, cursorClient.settingsSchema), + driver(fxClient.driverKind, fxClient.settingsSchema), ]; interface EnvironmentInput { @@ -105,6 +107,39 @@ const failure = (effect: Effect.Effect) => ); describe("ProviderAccountTransfer", () => { + it.effect("transfers fx configuration while keeping binary paths local", () => + Effect.gen(function* () { + const instanceId = ProviderInstanceId.make("fx_work"); + const a = yield* makeEnvironment({ + settings: { + providerInstances: { + [instanceId]: { + driver: fxClient.driverKind, + enabled: true, + config: { + enabled: true, + binaryPath: "/a/bin/fx", + customModels: ["team-model"], + }, + }, + }, + }, + }); + const b = yield* makeEnvironment(); + const exported = yield* a.transfer.exportAccount(instanceId); + expect(exported.instance.enabled).toBe(true); + expect(exported.instance.config).toEqual({ customModels: ["team-model"] }); + expect(exported.omitted).toContainEqual({ + setting: { _tag: "ConfigField", field: "binaryPath" }, + reason: "machine-local", + }); + yield* b.transfer.importAccount(exported); + expect((yield* b.settings.getSettings).providerInstances[instanceId]?.config).toEqual( + exported.instance.config, + ); + }), + ); + it.effect("exports portable settings and secrets and leaves machine-local settings behind", () => Effect.gen(function* () { const a = yield* makeEnvironment({ settings: sourceSettings }); diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index 742be226ea463..631afe580d2ed 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -67,6 +67,7 @@ import * as ProviderInstanceRegistry from "./ProviderInstanceRegistry.ts"; import * as ProviderRegistry from "./ProviderRegistry.ts"; import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; import { makeManualOnlyProviderMaintenanceCapabilities } from "@t3tools/provider-core/server/maintenanceResolver"; +import { fromRunner } from "@t3tools/provider-core/server/textGenerationOperations"; vi.mock("@anthropic-ai/claude-agent-sdk", { spy: true }); const decodeServerProvider = Schema.decodeSync(ServerProvider); @@ -428,6 +429,79 @@ it.layer( }); describe("ProviderRegistry.layer", () => { + it.effect( + "publishes operation capabilities from the runtime instead of stale probe flags", + () => + Effect.gen(function* () { + const instanceId = ProviderInstanceId.make("writer"); + const driverKind = ProviderDriverKind.make("muse"); + const provider = decodeServerProvider({ + instanceId, + driver: driverKind, + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-06-10T00:00:00.000Z", + models: [], + supportsTextGeneration: false, + textGenerationOperations: ["generateReviewGuide"], + }); + const instance: ProviderInstance = { + instanceId, + driverKind, + displayName: undefined, + enabled: true, + continuationIdentity: { driverKind, continuationKey: "muse:instance:writer" }, + snapshot: { + getSnapshot: Effect.succeed(provider), + refresh: Effect.succeed(provider), + streamChanges: Stream.empty, + applyUsageLimits: () => Effect.void, + markLimited: () => Effect.void, + resolveMaintenance: () => + Effect.succeed( + makeManualOnlyProviderMaintenanceCapabilities({ + provider: driverKind, + packageName: null, + }), + ), + }, + orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], + textGeneration: { + ...fromRunner("Fixture", () => Effect.die("Unexpected generation operation")), + supportedOperations: ["generateConversationSummary"], + }, + }; + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry.ProviderRegistry; + const providers = yield* registry.refresh(); + expect(providers[0]?.supportsTextGeneration).toBe(true); + expect(providers[0]?.textGenerationOperations).toEqual(["generateConversationSummary"]); + }).pipe( + Effect.provide( + ProviderRegistry.layer.pipe( + Layer.provide( + Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({ + getInstance: () => Effect.succeed(instance), + listInstances: Effect.succeed([instance]), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.empty, + subscribeChanges: Effect.flatMap(PubSub.unbounded(), PubSub.subscribe), + }), + ), + Layer.provide( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-provider-operation-capabilities-", + }), + ), + ), + ), + ); + }), + ); + it("stores workspace skills, commands and options without changing machine metadata", () => { const provider = { instanceId: ProviderInstanceId.make("codex"), diff --git a/apps/server/src/provider/ProviderRegistry.ts b/apps/server/src/provider/ProviderRegistry.ts index 3cdbf6c67e954..e9e486e94f7f1 100644 --- a/apps/server/src/provider/ProviderRegistry.ts +++ b/apps/server/src/provider/ProviderRegistry.ts @@ -329,7 +329,15 @@ const correlateSnapshotWithSource = ( ), ); } - return Effect.succeed(snapshot); + return Effect.succeed({ + ...snapshot, + ...(source.textGenerationOperations === undefined + ? {} + : { + supportsTextGeneration: source.textGenerationOperations.length > 0, + textGenerationOperations: source.textGenerationOperations, + }), + }); }; const snapshotInstanceKey = (provider: ServerProvider): ProviderInstanceId => { @@ -340,6 +348,7 @@ const snapshotInstanceKey = (provider: ServerProvider): ProviderInstanceId => { const buildSnapshotSource = (instance: ProviderInstance): ProviderSnapshotSource => ({ instanceId: instance.instanceId, driverKind: instance.driverKind, + textGenerationOperations: instance.textGeneration.supportedOperations, getSnapshot: instance.snapshot.getSnapshot, refresh: instance.snapshot.refresh, streamChanges: instance.snapshot.streamChanges, diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index 03b86d9df8705..428e81ebe24be 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { - "claudeSystemAppendBytes": 6298, - "codexAdditionalContextBytes": 6848, - "mcpToolListJsonBytes": 61320 + "claudeSystemAppendBytes": 7802, + "codexAdditionalContextBytes": 8350, + "mcpToolListJsonBytes": 61985 } diff --git a/apps/server/src/provider/builtInProviderCatalog.ts b/apps/server/src/provider/builtInProviderCatalog.ts index 6359b8872c5f7..6f2373b129465 100644 --- a/apps/server/src/provider/builtInProviderCatalog.ts +++ b/apps/server/src/provider/builtInProviderCatalog.ts @@ -1,10 +1,16 @@ -import type { ProviderDriverKind, ProviderInstanceId, ServerProvider } from "@t3tools/contracts"; +import type { + ProviderDriverKind, + ProviderInstanceId, + ServerProvider, + TextGenerationOperation, +} from "@t3tools/contracts"; import type * as Stream from "effect/Stream"; import type { ManagedServerProvider } from "@t3tools/provider-core/server/snapshot"; export type ProviderSnapshotSource = { readonly instanceId: ProviderInstanceId; readonly driverKind: ProviderDriverKind; + readonly textGenerationOperations: ReadonlyArray; readonly getSnapshot: ManagedServerProvider["getSnapshot"]; readonly refresh: ManagedServerProvider["refresh"]; readonly streamChanges: Stream.Stream; diff --git a/apps/server/src/textGeneration/AntigravityTextGeneration.ts b/apps/server/src/textGeneration/AntigravityTextGeneration.ts index 670c2eb28d513..8060d96ddaccc 100644 --- a/apps/server/src/textGeneration/AntigravityTextGeneration.ts +++ b/apps/server/src/textGeneration/AntigravityTextGeneration.ts @@ -301,8 +301,7 @@ export const makeAntigravityTextGeneration = Effect.fn("makeAntigravityTextGener ), ); - return TextGenerationOperations.fromRunner( - "AntigravityTextGeneration", - TextGenerationOperations.withoutInvestigation("Antigravity", runAntigravityJson), - ); + return TextGenerationOperations.fromRunner("AntigravityTextGeneration", runAntigravityJson, { + supportsInvestigation: false, + }); }); diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.ts index 9817f1de85883..d0187d42f5f92 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.ts @@ -321,5 +321,7 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu ); }); - return TextGenerationOperations.fromRunner("ClaudeTextGeneration", runClaudeJson); + return TextGenerationOperations.fromRunner("ClaudeTextGeneration", runClaudeJson, { + supportsInvestigation: true, + }); }); diff --git a/apps/server/src/textGeneration/CodexTextGeneration.ts b/apps/server/src/textGeneration/CodexTextGeneration.ts index 1c0519f87719d..c88b02bf1e2e6 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.ts @@ -395,5 +395,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func }).pipe(Effect.ensuring(cleanup)); }); - return TextGenerationOperations.fromRunner("CodexTextGeneration", runCodexJson); + return TextGenerationOperations.fromRunner("CodexTextGeneration", runCodexJson, { + supportsInvestigation: true, + }); }); diff --git a/apps/server/src/textGeneration/TextGeneration.test.ts b/apps/server/src/textGeneration/TextGeneration.test.ts new file mode 100644 index 0000000000000..4536ef81c84c7 --- /dev/null +++ b/apps/server/src/textGeneration/TextGeneration.test.ts @@ -0,0 +1,79 @@ +import { expect, it } from "@effect/vitest"; +import { ProviderInstanceId, TextGenerationError } from "@t3tools/contracts"; +import type { ProviderInstance } from "@t3tools/provider-core/server/driver"; +import { fromRunner } from "@t3tools/provider-core/server/textGenerationOperations"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { vi } from "vite-plus/test"; + +import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts"; +import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; +import * as TextGeneration from "./TextGeneration.ts"; + +const selected = ProviderInstanceId.make("selected"); +const input = { + cwd: "/project", + messages: [{ ref: "M1", role: "user" as const, text: "Keep the existing API." }], + modelSelection: { instanceId: selected, model: "chosen-model" }, +}; + +const make = (textGeneration: ProviderInstance["textGeneration"]) => { + const getInstance = vi.fn((instanceId: typeof selected) => + Effect.succeed( + instanceId === selected + ? ({ + instanceId, + displayName: "Selected provider", + textGeneration, + } as ProviderInstance) + : undefined, + ), + ); + return { + getInstance, + service: TextGeneration.make.pipe( + Effect.provide( + Layer.merge( + Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({ getInstance }), + Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({ + resolveLink: () => undefined, + }), + ), + ), + ), + }; +}; + +it.effect("rejects an unsupported selected operation before invoking a provider", () => + Effect.gen(function* () { + const generateConversationSummary = vi.fn(() => Effect.succeed({ statements: [] })); + const test = make({ + ...fromRunner("Fixture", () => Effect.die("Unexpected generation operation")), + supportedOperations: ["generateThreadTitle"], + generateConversationSummary, + }); + const service = yield* test.service; + const failure = yield* service.generateConversationSummary(input).pipe(Effect.flip); + expect(failure).toBeInstanceOf(TextGenerationError); + expect(failure.operation).toBe("generateConversationSummary"); + expect(failure.detail).toContain("does not support"); + expect(generateConversationSummary).not.toHaveBeenCalled(); + expect(test.getInstance.mock.calls).toEqual([[selected]]); + }), +); + +it.effect("uses the exact selected instance and model for a supported operation", () => + Effect.gen(function* () { + const result = { statements: [{ text: "Keep the existing API.", citations: ["M1"] }] }; + const generateConversationSummary = vi.fn(() => Effect.succeed(result)); + const test = make({ + ...fromRunner("Fixture", () => Effect.die("Unexpected generation operation")), + supportedOperations: ["generateConversationSummary"], + generateConversationSummary, + }); + const service = yield* test.service; + expect(yield* service.generateConversationSummary(input)).toEqual(result); + expect(generateConversationSummary).toHaveBeenCalledExactlyOnceWith(input); + expect(test.getInstance.mock.calls).toEqual([[selected]]); + }), +); diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index 48d70c8db7c18..0ae18783422d5 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -1,7 +1,7 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import type { ProviderInstanceId } from "@t3tools/contracts"; +import type { ProviderInstanceId, TextGenerationOperation } from "@t3tools/contracts"; import type { ProviderTextGeneration } from "@t3tools/provider-core/server/textGeneration"; import { TextGenerationError } from "@t3tools/contracts"; @@ -31,32 +31,26 @@ export class TextGeneration extends Context.Service => - registry.getInstance(instanceId).pipe( - Effect.flatMap((instance) => - instance - ? Effect.succeed(instance.textGeneration) - : Effect.fail( - new TextGenerationError({ - operation, - detail: `No provider instance registered for id '${instanceId}'.`, - }), - ), - ), - ); +): Effect.fn.Return { + const instance = yield* registry.getInstance(instanceId); + if (!instance) { + return yield* new TextGenerationError({ + operation, + detail: `No provider instance registered for id '${instanceId}'.`, + }); + } + if (!instance.textGeneration.supportedOperations.includes(operation)) { + return yield* new TextGenerationError({ + operation, + detail: `Provider '${instanceId}' does not support this text generation operation. Choose a model from a provider that supports it.`, + }); + } + return instance.textGeneration; +}); /** @public */ export const make = Effect.gen(function* () { diff --git a/apps/server/vite.config.ts b/apps/server/vite.config.ts index 6f0cc98b071fe..074e79fe590ee 100644 --- a/apps/server/vite.config.ts +++ b/apps/server/vite.config.ts @@ -6,6 +6,7 @@ import { loadRepoEnv } from "../../scripts/lib/public-config.ts"; import rootPackageJson from "../../package.json" with { type: "json" }; import packageJson from "./package.json" with { type: "json" }; import { WeightedShardSequencer } from "./src/testUtils/weightedShardSequencer.ts"; +import { resolveCliExecutableTarget } from "./scripts/cliExecutableTarget.ts"; // NSIS install time tracks file count, not bytes. import { @@ -34,35 +35,16 @@ const packCodeIntelligenceWorker = process.env.T3CODE_PACK_CODE_INTELLIGENCE_WOR // "1" builds dist-exe/t3 here; "bundle" stops at bin.mjs for scripts/cli.ts to inject (linux-arm64). const packMode = process.env.T3CODE_PACK_EXE; const packExecutable = packMode === "1" || packMode === "bundle"; -// TEMPORARY one-time Node fix. Remove when fixed: https://github.com/nodejs/node/issues/67048 -// No linux-arm64: --build-sea output cannot load on 16/64 KiB page kernels, so it builds natively. -const SEA_TARGETS = { - "darwin-arm64": { platform: "darwin", arch: "arm64" }, - "darwin-x64": { platform: "darwin", arch: "x64" }, - "linux-x64": { platform: "linux", arch: "x64" }, - "win-arm64": { platform: "win", arch: "arm64" }, - "win-x64": { platform: "win", arch: "x64" }, -} as const; -const packExecutableTarget = process.env.T3CODE_PACK_EXE_TARGET?.trim(); -const cliNodeVersion = rootPackageJson.engines.node; -if (packExecutable && !packExecutableTarget && process.versions.node !== cliNodeVersion) { - throw new Error( - `Native CLI executables must be built with Node ${cliNodeVersion}; running ${process.versions.node}.`, - ); -} -if (packExecutableTarget && !Object.hasOwn(SEA_TARGETS, packExecutableTarget)) { - throw new Error( - `T3CODE_PACK_EXE_TARGET must be one of ${Object.keys(SEA_TARGETS).join(", ")}, got "${packExecutableTarget}".`, - ); -} -const packExecutableTargets = packExecutableTarget - ? [ - { - ...SEA_TARGETS[packExecutableTarget as keyof typeof SEA_TARGETS], - nodeVersion: cliNodeVersion, - }, - ] - : undefined; +const packExecutableTarget = resolveCliExecutableTarget({ + enabled: packExecutable, + target: process.env.T3CODE_PACK_EXE_TARGET, + nodeVersion: rootPackageJson.engines.node, + hostNodeVersion: process.versions.node, + // oxlint-disable-next-line t3code/no-global-process-runtime -- Build config selects the running Node binary, including its emulated architecture, before the Effect runtime exists. + hostPlatform: process.platform, + // oxlint-disable-next-line t3code/no-global-process-runtime -- Build config must match the Node binary's architecture rather than the machine's physical architecture. + hostArch: process.arch, +}); // The root config's `projects` route repo-root runs to package configs, // including this one. Inheriting them would make this config a container. @@ -105,9 +87,8 @@ export default mergeConfig( ...(!packCodeIntelligenceWorker && packMode === "1" ? { exe: { - fileName: "t3", + ...packExecutableTarget, outDir: "dist-exe", - ...(packExecutableTargets ? { targets: packExecutableTargets } : {}), // SEA code caching breaks import(); liftoff-only prevents tree-sitter WASM Zone OOM. // The executable cannot accept Node flags, so set them here at process startup. seaConfig: { useCodeCache: false, execArgv: ["--liftoff-only"] }, diff --git a/apps/web/src/components/loom/LoomEventPanel.tsx b/apps/web/src/components/loom/LoomEventPanel.tsx index 0c1457326e8ac..348d84d41d887 100644 --- a/apps/web/src/components/loom/LoomEventPanel.tsx +++ b/apps/web/src/components/loom/LoomEventPanel.tsx @@ -1,16 +1,29 @@ -import { loomEdgeLabel, loomMarkerLabel } from "@t3tools/client-runtime/projectLoom"; -import type { LoomWindow, RunId, ThreadId } from "@t3tools/contracts"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { + loomEdgeLabel, + loomMarkerLabel, + loomMarkerThreadRef, +} from "@t3tools/client-runtime/projectLoom"; +import type { + EnvironmentId, + LoomWindow, + RunId, + ScopedThreadRef, + ThreadId, +} from "@t3tools/contracts"; import { XIcon } from "lucide-react"; import { useState, type MouseEvent, type ReactNode } from "react"; import { cn } from "~/lib/utils"; import { Button } from "../ui/button"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { formatLoomTime } from "./loomScale"; import type { LoomSelection } from "./loomSearch"; import { loomMarkerKey, loomSelectionThread } from "./loomWeave"; export interface LoomActions { - readonly openThread: (threadId: ThreadId) => void; + readonly canOpenThread: (target: ScopedThreadRef) => boolean; + readonly openThread: (target: ScopedThreadRef) => void; readonly openDiff: (threadId: ThreadId, runId: RunId) => void; readonly openPullRequest: ( event: MouseEvent, @@ -26,11 +39,16 @@ interface LoomEvent { readonly label: string; readonly open: (event: MouseEvent) => void; readonly openLabel: string; + readonly unavailable?: string; } const PAGE = 200; -function loomEvents(data: LoomWindow, actions: LoomActions): ReadonlyArray { +function loomEvents( + data: LoomWindow, + environmentId: EnvironmentId, + actions: LoomActions, +): ReadonlyArray { const titleOf = new Map( [...data.lanes, ...data.ancestors].map((lane) => [lane.threadId as string, lane.title]), ); @@ -39,7 +57,7 @@ function loomEvents(data: LoomWindow, actions: LoomActions): ReadonlyArray actions.openThread(run.threadId), + open: () => actions.openThread(scopeThreadRef(environmentId, run.threadId)), openLabel: "Open thread", })); const markers = data.markers.map((marker): LoomEvent => { @@ -64,7 +82,18 @@ function loomEvents(data: LoomWindow, actions: LoomActions): ReadonlyArray actions.openThread(marker.threadId), openLabel: "Open thread" }; + const target = loomMarkerThreadRef(environmentId, marker); + const remoteChild = marker.kind === "subagent_task" ? marker.remoteChild : undefined; + return { + ...base, + open: () => actions.openThread(target), + openLabel: remoteChild !== undefined ? "Open remote task" : "Open thread", + ...(actions.canOpenThread(target) + ? {} + : { + unavailable: `Connect to ${remoteChild?.label ?? target.environmentId} to open this thread.`, + }), + }; }); const edges = data.edges.map((edge): LoomEvent => ({ selection: { kind: "edge", id: edge.id }, @@ -74,7 +103,7 @@ function loomEvents(data: LoomWindow, actions: LoomActions): ReadonlyArray actions.openThread(edge.targetThreadId), + open: () => actions.openThread(scopeThreadRef(environmentId, edge.targetThreadId)), openLabel: "Open thread", })); return [...runs, ...markers, ...edges].toSorted((left, right) => right.at.localeCompare(left.at)); @@ -89,6 +118,7 @@ const ASIDE_CLASS = * selected the panel shows `navigator` instead. */ export function LoomEventPanel(props: { + readonly environmentId: EnvironmentId; readonly data: LoomWindow; readonly selection: LoomSelection | null; readonly actions: LoomActions; @@ -104,10 +134,13 @@ export function LoomEventPanel(props: { ); } - const events = loomEvents(data, props.actions); + const events = loomEvents(data, props.environmentId, props.actions); const laneId = loomSelectionThread(data, selection) as ThreadId | null; const lane = laneId === null ? null : (data.lanes.find((entry) => entry.threadId === laneId) ?? null); + const identity = lane ?? data.ancestors.find((entry) => entry.threadId === laneId); + const delegatedFrom = identity?.delegatedFrom; + const parentAvailable = delegatedFrom === undefined || props.actions.canOpenThread(delegatedFrom); const laneTitles = new Map(data.lanes.map((entry) => [entry.threadId as string, entry.title])); const shown = laneId === null ? events : events.filter((event) => event.threadId === laneId); const selected = @@ -122,7 +155,7 @@ export function LoomEventPanel(props: {

- {lane?.title ?? (laneId === null ? "Selection" : "Lane outside this window")} + {identity?.title ?? (laneId === null ? "Selection" : "Lane outside this window")}

{lane === null ? null : (

@@ -135,14 +168,53 @@ export function LoomEventPanel(props: { · {formatLoomTime(selected.at)}

)} + {delegatedFrom === undefined ? null : ( +

+ Delegated from {delegatedFrom.title} on {delegatedFrom.environmentId} +

+ )} + {selected?.unavailable === undefined ? null : ( +

{selected.unavailable}

+ )} + {parentAvailable || delegatedFrom === undefined ? null : ( +

+ Connect to {delegatedFrom.environmentId} to open this parent. +

+ )}
{laneId === null ? null : ( - )} + {delegatedFrom === undefined ? null : ( + + )} {selected === null || selected.openLabel === "Open thread" ? null : ( - )} @@ -164,22 +236,35 @@ export function LoomEventPanel(props: { {shown.slice(0, limit).map((event) => { const isSelected = selection?.kind === event.selection.kind && selection.id === event.selection.id; + const button = ( + + ); return (
  • - + {event.unavailable === undefined ? ( + button + ) : ( + + }> + {button} + + {event.unavailable} + + )}
  • ); })} diff --git a/apps/web/src/components/loom/ProjectLoomPage.browser.test.tsx b/apps/web/src/components/loom/ProjectLoomPage.browser.test.tsx index d47d2115a9e35..49ce487be7158 100644 --- a/apps/web/src/components/loom/ProjectLoomPage.browser.test.tsx +++ b/apps/web/src/components/loom/ProjectLoomPage.browser.test.tsx @@ -4,6 +4,7 @@ import { CheckpointId, EnvironmentId, MessageId, + NodeId, ProjectId, ProviderInstanceId, RunId, @@ -23,6 +24,7 @@ const state = vi.hoisted(() => ({ config: null as unknown, windows: new Map(), respond: (_input: unknown): unknown => null, + environments: [] as string[], })); vi.mock( import("../../state/server"), @@ -42,6 +44,10 @@ vi.mock( }) as never, ); vi.mock(import("../../state/entities"), () => ({ useProject: () => null }) as never); +vi.mock( + import("../../state/environments"), + () => ({ useEnvironmentIds: () => state.environments }) as never, +); vi.mock( import("../../lib/openPullRequestLink"), () => ({ useOpenPrLink: () => () => false }) as never, @@ -130,6 +136,7 @@ const fullWindow: LoomWindow = { }; function setup() { + state.environments = [environmentId, "build-server", "origin-server"]; state.config = Atom.make({ environment: { capabilities: { projectLoom: true } } }); state.windows.clear(); state.respond = (input) => { @@ -218,6 +225,124 @@ async function renderLoom(registry: AtomRegistry.AtomRegistry, initialSearch?: L ); } +it("opens a remote task in its destination even when its id equals a local thread", async () => { + const registry = setup(); + const destination = EnvironmentId.make("build-server"); + state.respond = () => + Atom.make( + AsyncResult.success({ + ...fullWindow, + markers: [ + { + kind: "subagent_task", + id: NodeId.make("remote-task"), + threadId: rootId, + runId: null, + at: "2026-10-20T11:00:00.000Z", + completedAt: null, + status: "running", + title: "Build", + remoteChild: { environmentId: destination, threadId: rootId, label: "Build server" }, + }, + ], + } satisfies LoomWindow), + ); + await using screen = await renderLoom(registry, { + from, + to, + mode: "lanes", + sel: "marker:subagent_task:remote-task", + }); + await expect + .element( + page + .getByRole("list", { name: "Events" }) + .getByText("Agent task on Build server: Build (running)", { exact: true }), + ) + .toBeVisible(); + await userEvent.click(page.getByRole("button", { name: "Open remote task" })); + await vi.waitFor(() => + expect(screen.router?.state.location.pathname).toBe(`/${destination}/${rootId}`), + ); +}); + +it("opens the parent of a remotely delegated lane in the origin environment", async () => { + const registry = setup(); + const origin = EnvironmentId.make("origin-server"); + state.respond = () => + Atom.make( + AsyncResult.success({ + ...fullWindow, + lanes: fullWindow.lanes.map((entry) => + entry.threadId === rootId + ? { + ...entry, + relationship: "subagent", + delegatedFrom: { + environmentId: origin, + threadId: rootId, + title: "Remote parent", + }, + } + : entry, + ), + } satisfies LoomWindow), + ); + await using screen = await renderLoom(registry, { + from, + to, + mode: "lanes", + sel: `lane:${rootId}`, + }); + await userEvent.click( + page.getByRole("button", { name: "Open parent thread Remote parent on origin-server" }), + ); + await vi.waitFor(() => + expect(screen.router?.state.location.pathname).toBe(`/${origin}/${rootId}`), + ); +}); + +it("keeps a remote task visible and explains why an unconnected destination cannot open", async () => { + const registry = setup(); + state.environments = [environmentId]; + state.respond = () => + Atom.make( + AsyncResult.success({ + ...fullWindow, + markers: [ + { + kind: "subagent_task", + id: NodeId.make("remote-task"), + threadId: rootId, + runId: null, + at: "2026-10-20T11:00:00.000Z", + completedAt: null, + status: "running", + title: "Build", + remoteChild: { + environmentId: EnvironmentId.make("build-server"), + threadId: rootId, + label: "Build server", + }, + }, + ], + } satisfies LoomWindow), + ); + await using _screen = await renderLoom(registry, { + from, + to, + mode: "lanes", + sel: "marker:subagent_task:remote-task", + }); + await expect.element(page.getByRole("button", { name: "Open remote task" })).toBeDisabled(); + await expect + .element(page.getByRole("button", { name: "Open thread", exact: true })) + .toBeEnabled(); + await expect + .element(page.getByText("Connect to Build server to open this thread.")) + .toBeVisible(); +}); + it("pages older lanes of the default window within the bounds that produced the cursor", async () => { const registry = setup(); const requests: Array = []; diff --git a/apps/web/src/components/loom/ProjectLoomPage.tsx b/apps/web/src/components/loom/ProjectLoomPage.tsx index 52501fcb87c00..49f0411981e83 100644 --- a/apps/web/src/components/loom/ProjectLoomPage.tsx +++ b/apps/web/src/components/loom/ProjectLoomPage.tsx @@ -31,6 +31,7 @@ import { isElectron } from "~/env"; import { useOpenPrLink } from "~/lib/openPullRequestLink"; import { useRightPanelStore } from "~/rightPanelStore"; import { useProject } from "~/state/entities"; +import { useEnvironmentIds } from "~/state/environments"; import { projectLoom } from "~/state/projectLoom"; import { useEnvironmentQuery } from "~/state/query"; import { serverEnvironment } from "~/state/server"; @@ -138,6 +139,7 @@ interface LoomPage { function LoomWorkspace(props: ProjectLoomPageProps) { const { search, onSearchChange } = props; const navigate = useNavigate(); + const environmentIds = useEnvironmentIds(); const openPrLink = useOpenPrLink(); const filters = loomFiltersOf(search); // Lane pages are ephemeral: a cursor only belongs to the project, window and filters that made @@ -181,10 +183,12 @@ function LoomWorkspace(props: ProjectLoomPageProps) { ); const actions: LoomActions = { - openThread: (threadId: ThreadId) => + canOpenThread: (target) => + target.environmentId === props.environmentId || environmentIds.includes(target.environmentId), + openThread: (target) => void navigate({ to: "/$environmentId/$threadId", - params: buildThreadRouteParams(scopeThreadRef(props.environmentId, threadId)), + params: buildThreadRouteParams(target), }), openDiff: (threadId: ThreadId, runId: RunId) => { const threadRef = scopeThreadRef(props.environmentId, threadId); @@ -275,6 +279,7 @@ function LoomWorkspace(props: ProjectLoomPageProps) { ) : ( target.serverConfig?.environment.capabilities.threadAutoSettlementScope === true, ); - const textGenerationProviders = serverProviders.filter( - (provider) => provider.supportsTextGeneration !== false, + const textGenerationProviders = serverProviders.filter((provider) => + isProviderTextGenerationCapable(provider), ); const textGenerationModelSelection = resolveAppModelSelectionState( settings, @@ -1573,6 +1574,18 @@ export function ThreadsSettingsPanel() { const textGenerationModelDisabledReason = useScopedModelDisabledReason( settings, textGenerationModelInstanceEntries, + [ + "generateCommitMessage", + "generatePrContent", + "generateBranchName", + "generateThreadTitle", + "generateKnowledgeCandidates", + "generateConversationSummary", + ], + ); + const textGenerationSelectionIssue = textGenerationModelDisabledReason( + textGenInstanceId, + textGenModel, ); const mixedAddProjectBaseDirectory = useScopedSettingsMixed(["addProjectBaseDirectory"]); const mixedTextGenerationModel = useScopedSettingsMixed(["textGenerationModelSelection"]); @@ -1753,7 +1766,15 @@ export function ThreadsSettingsPanel() { serverScoped settingKeys={["textGenerationModelSelection"]} {...searchableSetting("text-generation-model")} - description="Used for thread titles and other generated text on connected devices with this provider. Source control can override it." + description={ + <> + Used for thread titles, project knowledge, conversation summaries, and other generated + text on connected devices with this provider. Source control can override it. + {textGenerationSelectionIssue && !mixedTextGenerationModel ? ( + {textGenerationSelectionIssue} + ) : null} + + } resetAction={ hasServerTargets && isTextGenerationModelDirty ? ( provider.supportsTextGeneration !== false, + const textGenerationProviders = serverProviders.filter((provider) => + isProviderTextGenerationCapable(provider), ); const instanceEntries = sortProviderInstanceEntries( applyProviderInstanceSettings( @@ -151,7 +152,14 @@ export function SourceControlWritingSettingsSection() { activeSelection.instanceId, activeSelection.model, ); - const writerModelDisabledReason = useScopedModelDisabledReason(settings, instanceEntries); + const writerModelDisabledReason = useScopedModelDisabledReason(settings, instanceEntries, [ + "generateCommitMessage", + "generatePrContent", + "generateBranchName", + ]); + const reviewGuideModelDisabledReason = useScopedModelDisabledReason(settings, instanceEntries, [ + "generateReviewGuide", + ]); const writerInstanceEntry = instanceEntries.find( (entry) => entry.instanceId === activeSelection.instanceId, ); @@ -176,6 +184,10 @@ export function SourceControlWritingSettingsSection() { const reviewGuideInstanceEntry = instanceEntries.find( (entry) => entry.instanceId === reviewGuideSelection.instanceId, ); + const reviewGuideSelectionIssue = reviewGuideModelDisabledReason( + reviewGuideSelection.instanceId, + reviewGuideSelection.model, + ); return ( @@ -318,7 +330,16 @@ export function SourceControlWritingSettingsSection() { serverScoped settingKeys={["reviewGuideModelSelection"]} {...searchableSetting("review-guide-model")} - description="Model that reads a pull request to write its review guide. It reads the change itself over many steps, so a stronger model helps. Off uses the source control writer model." + description={ + <> + Model that reads a pull request to write its review guide. It reads the change + itself over many steps, so a stronger model helps. Off uses the source control + writer model. + {reviewGuideSelectionIssue && !mixedReviewGuideModel ? ( + {reviewGuideSelectionIssue} + ) : null} + + } control={ !hasServerTargets ? ( @@ -337,9 +358,9 @@ export function SourceControlWritingSettingsSection() { triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME} triggerAriaLabel="Review guide model" {...(mixedReviewGuideModel ? { triggerLabel: "Mixed" } : {})} - getModelDisabledReason={writerModelDisabledReason} + getModelDisabledReason={reviewGuideModelDisabledReason} onInstanceModelChange={(instanceId, model) => { - const reason = writerModelDisabledReason(instanceId, model); + const reason = reviewGuideModelDisabledReason(instanceId, model); if (reason) { toastManager.add({ type: "error", diff --git a/apps/web/src/components/settings/useScopedModelAvailability.ts b/apps/web/src/components/settings/useScopedModelAvailability.ts index abf1e1e1afc03..c64df24146e4c 100644 --- a/apps/web/src/components/settings/useScopedModelAvailability.ts +++ b/apps/web/src/components/settings/useScopedModelAvailability.ts @@ -1,4 +1,9 @@ -import type { ProviderInstanceId, UnifiedSettings } from "@t3tools/contracts"; +import { + isProviderTextGenerationCapable, + type ProviderInstanceId, + type TextGenerationOperation, + type UnifiedSettings, +} from "@t3tools/contracts"; import { mergeEnvironmentSettings } from "../../hooks/useSettings"; import { getCustomModelOptionsByInstance } from "../../modelSelection"; @@ -10,9 +15,20 @@ import { import { useEnvironmentProviders, useEnvironments } from "../../state/environments"; import { useSettingsScope } from "./SettingsScopeContext"; +const OPERATION_LABELS: Record = { + generateCommitMessage: "commit messages", + generatePrContent: "pull request descriptions", + generateBranchName: "branch names", + generateThreadTitle: "thread titles", + generateReviewGuide: "review guides", + generateKnowledgeCandidates: "project knowledge", + generateConversationSummary: "conversation summaries", +}; + export function useScopedModelDisabledReason( settings: UnifiedSettings, entries: readonly ProviderInstanceEntry[], + requiredOperations: readonly TextGenerationOperation[] = [], ) { const { targets } = useSettingsScope(); const { environments } = useEnvironments(); @@ -42,6 +58,14 @@ export function useScopedModelDisabledReason( ) { return `This model is unavailable on ${environment?.label ?? "a selected environment"}. Select that environment to choose its model separately.`; } + const provider = providers.find((provider) => provider.instanceId === instanceId); + const unsupported = requiredOperations.find( + (operation) => + provider !== undefined && !isProviderTextGenerationCapable(provider, operation), + ); + if (unsupported) { + return `This provider cannot generate ${OPERATION_LABELS[unsupported]} on ${environment.label}. Choose another provider for this setting.`; + } } return null; }; diff --git a/apps/web/src/modelSelection.test.ts b/apps/web/src/modelSelection.test.ts index 05a7e76b6b25b..c00265dd6a5ef 100644 --- a/apps/web/src/modelSelection.test.ts +++ b/apps/web/src/modelSelection.test.ts @@ -629,6 +629,24 @@ describe("instance-scoped model selection", () => { ProviderInstanceId.make("codex"), ); }); + it("preserves a selected provider with partial operations so unsupported tasks need an explicit choice", () => { + const partial = { + ...provider({ + provider: ProviderDriverKind.make("muse"), + instanceId: "muse", + models: ["chosen"], + }), + textGenerationOperations: ["generateThreadTitle"] as const, + }; + const selected = createModelSelection(partial.instanceId, "chosen"); + const settings = { ...settingsWithProviderInstances(), textGenerationModelSelection: selected }; + expect( + resolveAppModelSelectionState(settings, [ + partial, + provider({ instanceId: "codex", models: ["gpt-5.6-luna"] }), + ]), + ).toEqual(selected); + }); it("falls back to the text generation default, not the chat default, for a removed model", () => { const codex = provider({ instanceId: "codex", models: ["gpt-6-astra", "gpt-6-luna"] }); const providers = [ diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index db52028c397e0..20c50853fb959 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -3,6 +3,7 @@ import { DEFAULT_TEXT_GENERATION_MODEL, DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER, defaultInstanceIdForDriver, + isProviderTextGenerationCapable, type ModelSelection, ProviderDriverKind, ProviderInstanceId, @@ -340,8 +341,8 @@ export function resolveAppModelSelectionState( instanceId: DEFAULT_TEXT_GENERATION_INSTANCE_ID, model: DEFAULT_TEXT_GENERATION_MODEL, }; - const supportedProviders = providers.filter( - (provider) => provider.supportsTextGeneration !== false, + const supportedProviders = providers.filter((provider) => + isProviderTextGenerationCapable(provider), ); const entries = deriveProviderInstanceEntries(supportedProviders); const selectedEntry = entries.find( diff --git a/docs/user/loom.md b/docs/user/loom.md index eb20af84ab003..1120634c697c9 100644 --- a/docs/user/loom.md +++ b/docs/user/loom.md @@ -33,7 +33,8 @@ italics. A lane whose parent was deleted shows a broken-link icon. - **Pull requests.** Squares, placed when the pull request was linked to the thread. - **Test commands.** Triangles for commands that look like test runs. Red means the command exited with an error. The Loom does not count passed or failed tests. -- **Agent tasks.** Circles for provider tasks that have no thread of their own. +- **Agent tasks.** Circles for provider tasks without a local thread, including tasks delegated + to another environment. Remote tasks name their destination. - **Transfers.** Curves connect forks, handoffs, and subagent results between threads. A provider handoff within one thread is a small chevron on its lane. @@ -47,6 +48,11 @@ shown without reloading. Select a mark to see it in the event list beside the ti list holds the same activity in time order; select an entry to open its thread, its checkpoint diff, or its pull request. +Open a remote task to read its thread in the destination environment. A remotely delegated +thread also links back to its parent in the origin environment. These links become available +when this client is connected to that environment. Each project's Loom shows the activity +stored in its own environment; open the remote thread to see the work performed there. + Very busy windows are limited in size. When that happens, a note says what was left out, such as older runs, and **Older lanes** shows the next set of threads. The **As of** time shows when the timeline was read; select refresh to update it. diff --git a/evals/mcp-tool-use/cases/project-intelligence.ndjson b/evals/mcp-tool-use/cases/project-intelligence.ndjson new file mode 100644 index 0000000000000..844ec813a3c42 --- /dev/null +++ b/evals/mcp-tool-use/cases/project-intelligence.ndjson @@ -0,0 +1,7 @@ +{"id":"intelligence-recall-with-citations","area":"project-intelligence","kind":"bad","pattern":"prior project decision needs memory and canonical evidence","experience":"The integration audit found no intelligence tools in the model replay catalog and no task-specific intelligence guidance in the shared provider instructions.","privacy":"commit-safe","setup":{"role":"top","mocks":{"t3_knowledge_search":{"messages":{"matches":[],"nextCursor":null},"facts":{"state":"available","coverage":"complete","nextCursor":null,"matches":[{"fact":{"id":"fact-cache-ttl","projectId":"project-app","content":"Keep the cache TTL at five minutes because downstream invalidations can arrive late.","entities":["cache"],"tags":["ttl"],"kind":"decision","sources":[{"kind":"message","threadId":"thread-cache-decision","messageId":"message-cache-decision","digest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}],"origin":{"kind":"agent-thread","threadId":"thread-cache-decision","providerInstanceId":"codex"},"revision":1,"trust":0.9,"retrievalCount":1,"createdAt":"2026-10-22T12:00:00.000Z","updatedAt":"2026-10-22T12:00:00.000Z","status":"confirmed","forgottenAt":null},"score":{"holographic":0.9,"lexical":0.9,"tokenOverlap":1,"temporalDecay":1,"reinforcement":1,"trust":0.9,"combined":0.9}}]}},"t3_knowledge_read_source":{"state":"available","source":{"kind":"message","threadId":"thread-cache-decision","messageId":"message-cache-decision","digest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},"text":"Keep the TTL at five minutes. Our downstream invalidation feed can arrive up to four minutes late; shortening it would break our stated cache consistency contract.","title":"Cache retention decision","role":"user","createdAt":"2026-10-22T12:00:00.000Z","archived":false}}},"prompt":"For the current machine's registered project project-app, why did we keep the cache TTL at five minutes? Find the prior decision and verify its original message.","expect":{"calls":[{"tool":"t3_knowledge_search","args":{"projectId":"project-app","query":"~cache"}},{"tool":"t3_knowledge_read_source","args":{"projectId":"project-app","threadId":"thread-cache-decision","messageId":"message-cache-decision","digest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}],"order":["t3_knowledge_search","t3_knowledge_read_source"],"notCalls":["t3_knowledge_mutate","t3_project_code_query","t3_project_loom_read"],"maxCalls":{"t3_knowledge_search":1}},"source":{"kind":"integration-audit","commit":"47021ecc04f525c260159bd82f9f16e748ce41c3","path":"evals/mcp-tool-use/run.ts","finding":"The agent replay catalog omitted the knowledge, conversation summary, project code, and Loom toolkits."}} +{"id":"intelligence-code-impact-worktree","area":"project-intelligence","kind":"bad","pattern":"symbol impact must use the registered worktree","experience":"The integration audit found no intelligence tools in the model replay catalog and no task-specific intelligence guidance in the shared provider instructions.","privacy":"commit-safe","setup":{"role":"top","mocks":{"t3_project_code_query":{"operation":"impact","checkoutPath":"/home/user/app-worktree","matches":[],"nodes":[{"id":"symbol-cache","name":"readCache","qualifiedName":"readCache","kind":"function","language":"typescript","path":"src/cache.ts","startLine":10,"endLine":14,"startColumn":0,"endColumn":1,"signature":null,"isExported":true},{"id":"symbol-loader","name":"loadUser","qualifiedName":"loadUser","kind":"function","language":"typescript","path":"src/loader.ts","startLine":20,"endLine":24,"startColumn":0,"endColumn":1,"signature":null,"isExported":true}],"edges":[{"source":"symbol-loader","target":"symbol-cache","kind":"call","confidence":0.8,"resolvedBy":"import","provenance":"tree-sitter","evidenceGrade":"static-candidate","line":22,"column":2,"referenceName":"readCache","candidateTargets":[]}],"files":[{"path":"src/cache.ts"},{"path":"src/loader.ts"}],"unresolvedReferences":[],"selection":{"state":"matched","candidates":[{"id":"symbol-cache","name":"readCache","qualifiedName":"readCache","kind":"function","language":"typescript","path":"src/cache.ts","startLine":10,"endLine":14,"startColumn":0,"endColumn":1,"signature":null,"isExported":true}]},"sourceDigests":[{"path":"src/cache.ts","digest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","verified":true}],"stats":null,"coverage":{"state":"complete","catalogRevision":"test","relationSemantics":"static-candidates","columnEncoding":"utf16-code-units","languages":["typescript"],"indexedLanguages":["typescript"],"filesListed":2,"filesIndexed":2,"filesSkipped":0,"filesUnsupported":0,"filesFailed":0,"pendingReferences":0,"resultLimitReached":false,"limitations":["Static candidates require source inspection before claiming runtime reachability."]}}},"world":{"files":{"src/cache.ts":"export function readCache(key: string) { return cache.get(key); }\n","src/loader.ts":"import { readCache } from './cache';\nexport function loadUser(id: string) { return readCache(id); }\n"}}},"prompt":"In registered project project-app, thread thread-feature uses a worktree. What could be affected by changing readCache in src/cache.ts there? Start with the code graph; no edits.","expect":{"calls":[{"tool":"t3_project_code_query","args":{"projectId":"project-app","threadId":"thread-feature","query.operation":"impact","query.symbol.name":"readCache","query.symbol.filePath":"src/cache.ts"}}],"notCalls":["t3_knowledge_mutate","t3_knowledge_search","t3_project_loom_read"]},"source":{"kind":"integration-audit","commit":"47021ecc04f525c260159bd82f9f16e748ce41c3","path":"evals/mcp-tool-use/run.ts","finding":"The agent replay catalog omitted the knowledge, conversation summary, project code, and Loom toolkits."}} +{"id":"intelligence-project-activity-evidence","area":"project-intelligence","kind":"bad","pattern":"project activity is not proof that tests passed","experience":"The integration audit found no intelligence tools in the model replay catalog and no task-specific intelligence guidance in the shared provider instructions.","privacy":"commit-safe","setup":{"role":"top","mocks":{"t3_project_loom_read":{"projectId":"project-app","asOf":"2026-10-22T12:00:00.000Z","from":"2026-10-21T00:00:00.000Z","to":"2026-10-22T12:00:00.000Z","lanes":[{"threadId":"thread-cache","title":"Cache safety","relationship":"root","parentThreadId":null,"providerInstanceId":"codex","model":"gpt-5","branch":"fix/cache","createdAt":"2026-10-21T09:00:00.000Z","archivedAt":null}],"ancestors":[],"runs":[],"markers":[{"kind":"test_command","threadId":"thread-cache","runId":null,"at":"2026-10-21T10:00:00.000Z","id":"item-test","command":"pnpm test cache","completedAt":null,"exitCode":null}],"edges":[],"activity":[],"truncated":{"lanes":false,"ancestors":false,"runs":false,"markers":false,"edges":false,"activity":false,"bytes":false},"coverage":{"untimedTasks":0,"untimedTasksTruncated":false,"commandsScanned":1,"commandsTruncated":false},"nextCursor":null},"t3_thread_read":{"thread":{"threadId":"thread-cache","title":"Cache safety","status":"completed"},"items":[{"position":1,"role":"assistant","text":"Cache tests were started, but the process lost its output; no exit status was recorded."}],"nextPosition":1,"hasMore":false}}},"prompt":"For registered project project-app, what thread ran cache tests from 2026-10-21T00:00:00.000Z to 2026-10-22T12:00:00.000Z, and did those tests pass? Read the project timeline and report only what the recorded evidence establishes.","expect":{"calls":[{"tool":"t3_project_loom_read","args":{"projectId":"project-app","from":"2026-10-21T00:00:00.000Z","to":"2026-10-22T12:00:00.000Z"}}],"textMust":["[Nn]ot (recorded|known|confirmed)|[Uu]nknown|[Cc]annot (confirm|determine)|[Nn]o (exit status|recorded outcome|proof)|[Dd]oes(n['\u2019]t| not) (prove|establish)"],"notCalls":["t3_knowledge_mutate","t3_project_code_query"]},"source":{"kind":"integration-audit","commit":"47021ecc04f525c260159bd82f9f16e748ce41c3","path":"evals/mcp-tool-use/run.ts","finding":"The agent replay catalog omitted the knowledge, conversation summary, project code, and Loom toolkits."}} +{"id":"intelligence-summary-exact-source","area":"project-intelligence","kind":"bad","pattern":"a model summary is not the original decision","experience":"The integration audit found no intelligence tools in the model replay catalog and no task-specific intelligence guidance in the shared provider instructions.","privacy":"commit-safe","setup":{"role":"top","mocks":{"t3_conversation_summary_expand":{"summary":{"state":"available","summary":{"id":"summary:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","projectId":"project-app","threadId":"thread-cache-decision","sources":[{"kind":"message","threadId":"thread-cache-decision","messageId":"message-cache-decision","digest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}],"statements":[{"text":"The team agreed to keep the TTL at five minutes.","citations":["message-cache-decision"]}],"modelSelection":{"instanceId":"codex","model":"gpt-5"},"createdAt":"2026-10-22T12:00:00.000Z"}},"sources":[{"ref":{"kind":"message","threadId":"thread-cache-decision","messageId":"message-cache-decision","digest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},"result":{"state":"available","source":{"kind":"message","threadId":"thread-cache-decision","messageId":"message-cache-decision","digest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},"text":"Keep the TTL at five minutes. Our downstream invalidation feed can arrive up to four minutes late; shortening it would break our stated cache consistency contract.","title":"Cache retention decision","role":"user","createdAt":"2026-10-22T12:00:00.000Z","archived":false}}],"nextOffset":null}}},"prompt":"The handoff for project project-app, thread thread-cache-decision, includes conversation summary summary:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb: 'The team agreed to keep the TTL at five minutes.' What did the user actually say? Verify the original message; do not treat the summary as a quotation.","expect":{"calls":[{"tool":"t3_conversation_summary_expand","args":{"projectId":"project-app","threadId":"thread-cache-decision","summaryId":"summary:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}}],"textMust":["four minutes|four-minute"],"notCalls":["t3_knowledge_mutate","t3_project_code_query","t3_project_loom_read"]},"source":{"kind":"integration-audit","commit":"47021ecc04f525c260159bd82f9f16e748ce41c3","path":"evals/mcp-tool-use/run.ts","finding":"The agent replay catalog omitted the knowledge, conversation summary, project code, and Loom toolkits."}} +{"id":"intelligence-remote-recall-scope","area":"project-intelligence","kind":"guard","pattern":"knowledge IDs and citations stay on the selected environment","experience":"The integration audit found no intelligence tools in the model replay catalog and no task-specific intelligence guidance in the shared provider instructions.","privacy":"commit-safe","setup":{"role":"top","mocks":{"t3_knowledge_search":{"messages":{"matches":[],"nextCursor":null},"facts":{"state":"available","coverage":"complete","nextCursor":null,"matches":[{"fact":{"id":"fact-cache-ttl","projectId":"project-build","content":"Keep the cache TTL at five minutes because downstream invalidations can arrive late.","entities":["cache"],"tags":["ttl"],"kind":"decision","sources":[{"kind":"message","threadId":"thread-cache-decision","messageId":"message-cache-decision","digest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}],"origin":{"kind":"agent-thread","threadId":"thread-cache-decision","providerInstanceId":"codex"},"revision":1,"trust":0.9,"retrievalCount":1,"createdAt":"2026-10-22T12:00:00.000Z","updatedAt":"2026-10-22T12:00:00.000Z","status":"confirmed","forgottenAt":null},"score":{"holographic":0.9,"lexical":0.9,"tokenOverlap":1,"temporalDecay":1,"reinforcement":1,"trust":0.9,"combined":0.9}}]}},"t3_knowledge_read_source":{"state":"available","source":{"kind":"message","threadId":"thread-cache-decision","messageId":"message-cache-decision","digest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},"text":"Keep the TTL at five minutes. Our downstream invalidation feed can arrive up to four minutes late; shortening it would break our stated cache consistency contract.","title":"Cache retention decision","role":"user","createdAt":"2026-10-22T12:00:00.000Z","archived":false}}},"prompt":"Use the linked build machine (environmentId build-box), registered project project-build. Why did we keep the cache TTL at five minutes there? Verify the original cited message on that same machine.","expect":{"calls":[{"tool":"t3_knowledge_search","args":{"environmentId":"build-box","projectId":"project-build","query":"~cache"}},{"tool":"t3_knowledge_read_source","args":{"environmentId":"build-box","projectId":"project-build","threadId":"thread-cache-decision","messageId":"message-cache-decision"}}],"order":["t3_knowledge_search","t3_knowledge_read_source"],"maxCalls":{"t3_knowledge_search":1,"t3_knowledge_read_source":1},"notCalls":["t3_knowledge_mutate"]},"source":{"kind":"integration-audit","commit":"47021ecc04f525c260159bd82f9f16e748ce41c3","path":"evals/mcp-tool-use/run.ts","finding":"The agent replay catalog omitted the knowledge, conversation summary, project code, and Loom toolkits."}} +{"id":"intelligence-return-to-local-scope","area":"project-intelligence","kind":"control","pattern":"a previous remote query does not change the current environment","experience":"The integration audit found no intelligence tools in the model replay catalog and no task-specific intelligence guidance in the shared provider instructions.","privacy":"commit-safe","setup":{"role":"top","priorTurns":[{"role":"user","text":"Inspect readCache on the linked build box."},{"role":"assistant","text":"The build box has one static caller.","toolCalls":[{"tool":"t3_project_code_query","input":{"environmentId":"build-box","projectId":"project-build","query":{"operation":"impact","symbol":{"name":"readCache","filePath":"src/cache.ts"}}},"result":{"operation":"impact","checkoutPath":"/home/user/app-worktree","matches":[],"nodes":[{"id":"symbol-cache","name":"readCache","qualifiedName":"readCache","kind":"function","language":"typescript","path":"src/cache.ts","startLine":10,"endLine":14,"startColumn":0,"endColumn":1,"signature":null,"isExported":true},{"id":"symbol-loader","name":"loadUser","qualifiedName":"loadUser","kind":"function","language":"typescript","path":"src/loader.ts","startLine":20,"endLine":24,"startColumn":0,"endColumn":1,"signature":null,"isExported":true}],"edges":[{"source":"symbol-loader","target":"symbol-cache","kind":"call","confidence":0.8,"resolvedBy":"import","provenance":"tree-sitter","evidenceGrade":"static-candidate","line":22,"column":2,"referenceName":"readCache","candidateTargets":[]}],"files":[{"path":"src/cache.ts"},{"path":"src/loader.ts"}],"unresolvedReferences":[],"selection":{"state":"matched","candidates":[{"id":"symbol-cache","name":"readCache","qualifiedName":"readCache","kind":"function","language":"typescript","path":"src/cache.ts","startLine":10,"endLine":14,"startColumn":0,"endColumn":1,"signature":null,"isExported":true}]},"sourceDigests":[{"path":"src/cache.ts","digest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","verified":true}],"stats":null,"coverage":{"state":"complete","catalogRevision":"test","relationSemantics":"static-candidates","columnEncoding":"utf16-code-units","languages":["typescript"],"indexedLanguages":["typescript"],"filesListed":2,"filesIndexed":2,"filesSkipped":0,"filesUnsupported":0,"filesFailed":0,"pendingReferences":0,"resultLimitReached":false,"limitations":["Static candidates require source inspection before claiming runtime reachability."]}}}]}],"mocks":{"t3_project_code_query":{"operation":"impact","checkoutPath":"/home/user/app-worktree","matches":[],"nodes":[{"id":"symbol-cache","name":"readCache","qualifiedName":"readCache","kind":"function","language":"typescript","path":"src/cache.ts","startLine":10,"endLine":14,"startColumn":0,"endColumn":1,"signature":null,"isExported":true},{"id":"symbol-loader","name":"loadUser","qualifiedName":"loadUser","kind":"function","language":"typescript","path":"src/loader.ts","startLine":20,"endLine":24,"startColumn":0,"endColumn":1,"signature":null,"isExported":true}],"edges":[{"source":"symbol-loader","target":"symbol-cache","kind":"call","confidence":0.8,"resolvedBy":"import","provenance":"tree-sitter","evidenceGrade":"static-candidate","line":22,"column":2,"referenceName":"readCache","candidateTargets":[]}],"files":[{"path":"src/cache.ts"},{"path":"src/loader.ts"}],"unresolvedReferences":[],"selection":{"state":"matched","candidates":[{"id":"symbol-cache","name":"readCache","qualifiedName":"readCache","kind":"function","language":"typescript","path":"src/cache.ts","startLine":10,"endLine":14,"startColumn":0,"endColumn":1,"signature":null,"isExported":true}]},"sourceDigests":[{"path":"src/cache.ts","digest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","verified":true}],"stats":null,"coverage":{"state":"complete","catalogRevision":"test","relationSemantics":"static-candidates","columnEncoding":"utf16-code-units","languages":["typescript"],"indexedLanguages":["typescript"],"filesListed":2,"filesIndexed":2,"filesSkipped":0,"filesUnsupported":0,"filesFailed":0,"pendingReferences":0,"resultLimitReached":false,"limitations":["Static candidates require source inspection before claiming runtime reachability."]}}}},"prompt":"Now check the current machine instead: project project-app, thread thread-feature. Use its code graph to find the impact of readCache in src/cache.ts.","expect":{"calls":[{"tool":"t3_project_code_query","args":{"projectId":"project-app","threadId":"thread-feature","query.operation":"impact","query.symbol.name":"readCache"}}],"callsMustNot":[{"tool":"t3_project_code_query","args":{"environmentId":"build-box"}}],"maxCalls":{"t3_project_code_query":1},"notCalls":["t3_environment_link","t3_environment_links","t3_knowledge_search","t3_project_loom_read"]},"source":{"kind":"integration-audit","commit":"47021ecc04f525c260159bd82f9f16e748ce41c3","path":"evals/mcp-tool-use/run.ts","finding":"The agent replay catalog omitted the knowledge, conversation summary, project code, and Loom toolkits."}} +{"id":"intelligence-ordinary-edit-control","area":"project-intelligence","kind":"control","pattern":"a self-contained edit needs no project intelligence calls","experience":"The integration audit found no intelligence tools in the model replay catalog and no task-specific intelligence guidance in the shared provider instructions.","privacy":"commit-safe","setup":{"role":"top","world":{"branch":"fix/save-label","files":{"src/labels.ts":"export const saveLabel = 'Save changes';\n"}}},"prompt":"In src/labels.ts change the exported saveLabel string from 'Save changes' to 'Save'. This is just a copy edit; do not change anything else.","expect":{"calls":[{"tool":"Edit|Write|Bash"}],"notCalls":["t3_knowledge_search","t3_knowledge_list","t3_knowledge_read_fact","t3_knowledge_status","t3_knowledge_read_source","t3_knowledge_prepare_context","t3_knowledge_mutate","t3_conversation_summary_list","t3_conversation_summary_describe","t3_conversation_summary_expand","t3_project_code_query","t3_project_file_history","t3_project_loom_read"]},"source":{"kind":"integration-audit","commit":"47021ecc04f525c260159bd82f9f16e748ce41c3","path":"evals/mcp-tool-use/run.ts","finding":"The agent replay catalog omitted the knowledge, conversation summary, project code, and Loom toolkits."}} diff --git a/evals/mcp-tool-use/run.ts b/evals/mcp-tool-use/run.ts index 4fd1bf4ebd995..be2924a532a9f 100644 --- a/evals/mcp-tool-use/run.ts +++ b/evals/mcp-tool-use/run.ts @@ -33,13 +33,17 @@ import { ORCHESTRATOR_MCP_RUNNING_TASK_NEXT } from "@t3tools/contracts"; import { formatThreadLink } from "@t3tools/shared/threadLinks"; import { buildCodexAdditionalContext } from "../../apps/server/src/provider/CodexDeveloperInstructions.ts"; import { AttachmentToolkit } from "../../apps/server/src/mcp/toolkits/attachment/tools.ts"; +import { ConversationSummariesToolkit } from "../../apps/server/src/mcp/toolkits/conversationSummaries/tools.ts"; import { DeviceToolkit } from "../../apps/server/src/mcp/toolkits/device/tools.ts"; import { EnvironmentToolkit } from "../../apps/server/src/mcp/toolkits/environment/tools.ts"; import { HtmlToolkit } from "../../apps/server/src/mcp/toolkits/html/tools.ts"; +import { KnowledgeToolkit } from "../../apps/server/src/mcp/toolkits/knowledge/tools.ts"; import { OrchestratorToolkit } from "../../apps/server/src/mcp/toolkits/orchestrator/tools.ts"; import { PreviewToolkit } from "../../apps/server/src/mcp/toolkits/preview/tools.ts"; import { PreviewControlsToolkit } from "../../apps/server/src/mcp/toolkits/previewControls/tools.ts"; import { ProjectToolkit } from "../../apps/server/src/mcp/toolkits/project/tools.ts"; +import { ProjectCodeToolkit } from "../../apps/server/src/mcp/toolkits/projectCode/tools.ts"; +import { ProjectLoomToolkit } from "../../apps/server/src/mcp/toolkits/projectLoom/tools.ts"; import { CHILD_PULL_REQUEST_REPORT_NEXT, PullRequestsToolkit, @@ -294,6 +298,10 @@ const t3SourceTools: ReadonlyArray = [ ThreadToolkit, AttachmentToolkit, ProjectToolkit, + KnowledgeToolkit, + ConversationSummariesToolkit, + ProjectCodeToolkit, + ProjectLoomToolkit, EnvironmentToolkit, PreviewControlsToolkit, WorktreeToolkit, diff --git a/packages/client-runtime/src/projectLoom/index.test.ts b/packages/client-runtime/src/projectLoom/index.test.ts index 74c8a4fe3b0e9..88724a5de939b 100644 --- a/packages/client-runtime/src/projectLoom/index.test.ts +++ b/packages/client-runtime/src/projectLoom/index.test.ts @@ -1,7 +1,14 @@ -import { ProviderInstanceId, ThreadId, type LoomLane } from "@t3tools/contracts"; +import { + EnvironmentId, + NodeId, + ProviderInstanceId, + ThreadId, + type LoomLane, + type LoomMarker, +} from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { buildLoomRows, loomCoverageNotes } from "./index.ts"; +import { buildLoomRows, loomCoverageNotes, loomMarkerThreadRef } from "./index.ts"; const lane = (id: string, parent: string | null, createdAt: string): LoomLane => ({ threadId: ThreadId.make(id), @@ -28,6 +35,35 @@ const window = { const ids = (rows: ReturnType) => rows.map((row) => row.threadId); describe("buildLoomRows", () => { + it("keeps a remote parent's identity on ancestor rows without nesting under a colliding local id", () => { + const delegatedFrom = { + environmentId: EnvironmentId.make("another-environment"), + threadId: ThreadId.make("root"), + title: "Remote parent", + }; + const rows = buildLoomRows( + { + lanes: [window.lanes[0]!, lane("fork", "remote-worker", "2026-10-20T02:00:00.000Z")], + ancestors: [ + { + threadId: ThreadId.make("remote-worker"), + title: "Worker", + relationship: "subagent", + parentThreadId: null, + delegatedFrom, + }, + ], + }, + new Set(), + ); + expect(rows.map((row) => [row.threadId, row.depth])).toEqual([ + ["root", 0], + ["remote-worker", 0], + ["fork", 1], + ]); + expect(rows[1]).toMatchObject({ delegatedFrom, lane: null, parentMissing: false }); + }); + it("nests children oldest first and marks a parent that is not loaded", () => { const rows = buildLoomRows(window, new Set()); expect(ids(rows)).toEqual(["root", "early-child", "grandchild", "late-child", "orphan"]); @@ -47,6 +83,31 @@ describe("buildLoomRows", () => { }); }); +it("opens remote tasks in their owning environment while native tasks stay on their lane", () => { + const environmentId = EnvironmentId.make("here"); + const marker: LoomMarker = { + kind: "subagent_task", + id: NodeId.make("task"), + threadId: ThreadId.make("thread"), + runId: null, + at: "2026-10-20T02:00:00.000Z", + completedAt: null, + status: "running", + title: "Task", + }; + expect(loomMarkerThreadRef(environmentId, marker)).toEqual({ environmentId, threadId: "thread" }); + expect( + loomMarkerThreadRef(environmentId, { + ...marker, + remoteChild: { + environmentId: EnvironmentId.make("there"), + threadId: ThreadId.make("thread"), + label: "Build server", + }, + }), + ).toEqual({ environmentId: "there", threadId: "thread" }); +}); + describe("loomCoverageNotes", () => { it("names a capped command scan and says when the untimed count is only a bound", () => { expect( diff --git a/packages/client-runtime/src/projectLoom/index.ts b/packages/client-runtime/src/projectLoom/index.ts index 289811f61cd2d..3d350a671ad9f 100644 --- a/packages/client-runtime/src/projectLoom/index.ts +++ b/packages/client-runtime/src/projectLoom/index.ts @@ -1,5 +1,6 @@ import { WS_METHODS, + type EnvironmentId, type LoomAncestor, type LoomEdge, type LoomLane, @@ -8,10 +9,12 @@ import { type LoomRun, type LoomRunActivity, type LoomWindow, + type ScopedThreadRef, type ThreadId, } from "@t3tools/contracts"; import type { Atom } from "effect/reactivity"; import type { EnvironmentRegistry } from "../connection/registry.ts"; +import { scopeThreadRef } from "../environment/scoped.ts"; import { createEnvironmentRpcQueryAtomFamily } from "../state/runtime.ts"; export function createProjectLoomAtoms( @@ -31,6 +34,8 @@ export interface LoomRow { readonly threadId: ThreadId; readonly title: string; readonly relationship: LoomLaneRelationship; + /** Kept on ancestor stubs too; this parent lives outside the local lane tree. */ + readonly delegatedFrom?: LoomLane["delegatedFrom"]; readonly depth: number; /** Null for an ancestor that is not on this page and carries no activity. */ readonly lane: LoomLane | null; @@ -118,6 +123,9 @@ export function buildLoomRows( threadId, title: node.identity.title, relationship: node.identity.relationship, + ...(node.identity.delegatedFrom === undefined + ? {} + : { delegatedFrom: node.identity.delegatedFrom }), depth, lane: node.lane, childCount: node.children.length, @@ -166,6 +174,16 @@ const MARKER_LABELS: Record = { subagent_task: "Agent task", }; +/** Remote tasks open their child there; native tasks and other markers open their local lane. */ +export function loomMarkerThreadRef( + environmentId: EnvironmentId, + marker: LoomMarker, +): ScopedThreadRef { + return marker.kind === "subagent_task" && marker.remoteChild !== undefined + ? scopeThreadRef(marker.remoteChild.environmentId, marker.remoteChild.threadId) + : scopeThreadRef(environmentId, marker.threadId); +} + export function loomMarkerLabel(marker: LoomMarker): string { switch (marker.kind) { case "checkpoint": @@ -175,7 +193,7 @@ export function loomMarkerLabel(marker: LoomMarker): string { case "test_command": return `${MARKER_LABELS.test_command}${marker.exitCode === null ? "" : ` (exit ${marker.exitCode})`}: ${marker.command}`; case "subagent_task": - return `${MARKER_LABELS.subagent_task}: ${marker.title ?? "untitled"} (${marker.status})`; + return `${MARKER_LABELS.subagent_task}${marker.remoteChild === undefined ? "" : ` on ${marker.remoteChild.label}`}: ${marker.title ?? "untitled"} (${marker.status})`; } } diff --git a/packages/contracts/src/projectKnowledge.ts b/packages/contracts/src/projectKnowledge.ts index 6db9c982a2811..7803127cc348d 100644 --- a/packages/contracts/src/projectKnowledge.ts +++ b/packages/contracts/src/projectKnowledge.ts @@ -353,10 +353,7 @@ const ProjectKnowledgeContextSchema = Schema.Struct({ maxBytes: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 48000 })), }); export type ProjectKnowledgeContext = typeof ProjectKnowledgeContextSchema.Type; -export const ProjectKnowledgeContext: Schema.Codec< - ProjectKnowledgeContext, - typeof ProjectKnowledgeContextSchema.Encoded -> = ProjectKnowledgeContextSchema; +export const ProjectKnowledgeContext = ProjectKnowledgeContextSchema; const ProjectKnowledgePreparedContextSchema = Schema.Struct({ context: ProjectKnowledgeContext, text: Schema.String, diff --git a/packages/contracts/src/projectLoom.ts b/packages/contracts/src/projectLoom.ts index fcaba017d6bb8..699dade576855 100644 --- a/packages/contracts/src/projectLoom.ts +++ b/packages/contracts/src/projectLoom.ts @@ -2,6 +2,7 @@ import * as Schema from "effect/Schema"; import { CheckpointId, ContextTransferId, + EnvironmentId, IsoDateTime, MessageId, NodeId, @@ -77,6 +78,14 @@ const LaneIdentity = { relationship: LoomLaneRelationship, /** Null for roots. A parent outside the project or deleted is reported here but never loaded. */ parentThreadId: Schema.NullOr(ThreadId), + /** A parent in another environment is an explicit link, never a local parentThreadId. */ + delegatedFrom: Schema.optionalKey( + Schema.Struct({ + environmentId: EnvironmentId, + threadId: ThreadId, + title: Schema.String, + }), + ), }; export const LoomLane = Schema.Struct({ @@ -152,7 +161,7 @@ export const LoomMarker = Schema.Union([ command: Schema.String.check(Schema.isMaxLength(LOOM_COMMAND_PREVIEW_CHARS)), exitCode: Schema.NullOr(Schema.Int), }), - /** A provider-native task with no child thread to give it a lane. */ + /** A task with no local child lane: provider-native, or delegated to another environment. */ Schema.Struct({ ...MarkerBase, kind: Schema.Literal("subagent_task"), @@ -160,6 +169,14 @@ export const LoomMarker = Schema.Union([ completedAt: Schema.NullOr(IsoDateTime), status: OrchestrationV2Subagent.fields.status, title: Schema.NullOr(Schema.String), + /** The remote thread owns the task's activity; threadId above still names the local lane. */ + remoteChild: Schema.optionalKey( + Schema.Struct({ + environmentId: EnvironmentId, + threadId: ThreadId, + label: Schema.String, + }), + ), }), ]); export type LoomMarker = typeof LoomMarker.Type; diff --git a/packages/contracts/src/server.test.ts b/packages/contracts/src/server.test.ts index a79c499afa3ac..23907fa1b8fde 100644 --- a/packages/contracts/src/server.test.ts +++ b/packages/contracts/src/server.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "vite-plus/test"; import { ExecutionEnvironmentDescriptor } from "./environment.ts"; import { OtlpEndpointCheckInput, + isProviderTextGenerationCapable, resolveEnvironmentMachineKind, ServerObservability, ServerProvider, @@ -27,6 +28,30 @@ const baseProviderSnapshot = { }; describe("ServerProvider", () => { + it("honors operation capabilities without breaking older server snapshots", () => { + const legacy = decodeServerProvider(baseProviderSnapshot); + expect(isProviderTextGenerationCapable(legacy, "generateConversationSummary")).toBe(true); + const limited = decodeServerProvider({ + ...baseProviderSnapshot, + textGenerationOperations: ["generateThreadTitle", "futureOperation"], + }); + expect(limited.textGenerationOperations).toEqual(["generateThreadTitle"]); + expect(isProviderTextGenerationCapable(limited)).toBe(true); + expect(isProviderTextGenerationCapable(limited, "generateThreadTitle")).toBe(true); + expect(isProviderTextGenerationCapable(limited, "generateConversationSummary")).toBe(false); + expect( + isProviderTextGenerationCapable( + decodeServerProvider({ ...baseProviderSnapshot, textGenerationOperations: [] }), + ), + ).toBe(false); + expect( + isProviderTextGenerationCapable( + decodeServerProvider({ ...baseProviderSnapshot, supportsTextGeneration: false }), + "generateKnowledgeCandidates", + ), + ).toBe(false); + }); + it("preserves installed-version presence through the provider wire codec", () => { const encode = Schema.encodeSync(ServerProvider); for (const installation of [{}, { installedVersion: null }, { installedVersion: "1.2.3" }]) { diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index bc0e34f1c2f5f..93b54ccff8213 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -204,6 +204,17 @@ export const ServerProviderUpdateState = Schema.Struct({ }); export type ServerProviderUpdateState = typeof ServerProviderUpdateState.Type; +export const TextGenerationOperation = Schema.Literals([ + "generateCommitMessage", + "generatePrContent", + "generateBranchName", + "generateThreadTitle", + "generateReviewGuide", + "generateKnowledgeCandidates", + "generateConversationSummary", +]); +export type TextGenerationOperation = typeof TextGenerationOperation.Type; + export const ServerProvider = Schema.Struct({ // Only instanceId is stable enough for provider routing. instanceId: ProviderInstanceId, @@ -220,6 +231,7 @@ export const ServerProvider = Schema.Struct({ requiresNewThreadForModelChange: Schema.optional(Schema.Boolean), supportsConversationRollback: Schema.optional(Schema.Boolean), supportsTextGeneration: Schema.optional(Schema.Boolean), + textGenerationOperations: Schema.optional(ForwardCompatibleArray(TextGenerationOperation)), setup: Schema.optional( Schema.Struct({ canAuthenticate: Schema.Boolean, @@ -280,9 +292,16 @@ export type ServerProviders = typeof ServerProviders.Type; export const isProviderAvailable = (snapshot: ServerProvider): boolean => snapshot.availability !== "unavailable"; -/** Legacy producers omit this field and support text generation. */ -export const isProviderTextGenerationCapable = (snapshot: ServerProvider): boolean => - snapshot.supportsTextGeneration !== false; +/** Older servers only advertise the generic flag; they remain eligible and validate on execution. */ +export const isProviderTextGenerationCapable = ( + snapshot: ServerProvider, + operation?: TextGenerationOperation, +): boolean => + snapshot.supportsTextGeneration !== false && + (snapshot.textGenerationOperations === undefined || + (operation === undefined + ? snapshot.textGenerationOperations.length > 0 + : snapshot.textGenerationOperations.includes(operation))); export const OtlpEndpointSource = Schema.Literals(["environment", "bootstrap", "settings"]); export type OtlpEndpointSource = typeof OtlpEndpointSource.Type; diff --git a/packages/provider-acp-registry/src/server/driver.ts b/packages/provider-acp-registry/src/server/driver.ts index 9ac31478cfa99..2fbc428750e7b 100644 --- a/packages/provider-acp-registry/src/server/driver.ts +++ b/packages/provider-acp-registry/src/server/driver.ts @@ -23,7 +23,7 @@ import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import { AcpRegistryAdapterV2Driver, type AcpRegistryAdapterV2DriverEnv } from "./adapter.ts"; -import type { ProviderTextGeneration } from "@t3tools/provider-core/server/textGeneration"; +import type { ProviderTextGenerationRuntime } from "@t3tools/provider-core/server/textGeneration"; import { ProviderDriverError } from "@t3tools/provider-core/server/errors"; import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; @@ -67,7 +67,7 @@ const MAINTENANCE = makeManualOnlyProviderMaintenanceCapabilities({ packageName: null, }); -const makeUnsupportedTextGeneration = (): ProviderTextGeneration => { +const makeUnsupportedTextGeneration = (): ProviderTextGenerationRuntime => { const unsupported = (operation: string) => Effect.fail( new TextGenerationError({ @@ -76,6 +76,7 @@ const makeUnsupportedTextGeneration = (): ProviderTextGeneration => { }), ); return { + supportedOperations: [], generateCommitMessage: () => unsupported("generateCommitMessage"), generatePrContent: () => unsupported("generatePrContent"), generateBranchName: () => unsupported("generateBranchName"), diff --git a/packages/provider-core/src/server/driver.ts b/packages/provider-core/src/server/driver.ts index d236a98fdcba2..4b1031048c1d7 100644 --- a/packages/provider-core/src/server/driver.ts +++ b/packages/provider-core/src/server/driver.ts @@ -19,7 +19,7 @@ import type * as ProviderAdapter from "./ProviderAdapter.ts"; import type { ProviderAuthController } from "./auth.ts"; import type { ProviderDriverError } from "./errors.ts"; import type { ManagedServerProvider } from "./snapshot.ts"; -import type { ProviderTextGeneration } from "./textGeneration.ts"; +import type { ProviderTextGenerationRuntime } from "./textGeneration.ts"; import type { ProviderUsageReader } from "./usage.ts"; export interface ProviderDriverMetadata { @@ -50,7 +50,7 @@ export interface ProviderInstance { ProviderDriverError >; readonly orchestrationAdapter: ProviderAdapter.ProviderAdapterV2["Service"]; - readonly textGeneration: ProviderTextGeneration; + readonly textGeneration: ProviderTextGenerationRuntime; readonly auth?: ProviderAuthController; readonly acpSessionManagement?: { readonly listSessions: (input: { diff --git a/packages/provider-core/src/server/orchestrationInstructions.ts b/packages/provider-core/src/server/orchestrationInstructions.ts index 3705dafd0dcb0..0d94c0c623cfa 100644 --- a/packages/provider-core/src/server/orchestrationInstructions.ts +++ b/packages/provider-core/src/server/orchestrationInstructions.ts @@ -16,6 +16,14 @@ Ordinary coding tasks need no T3 tools. - In your reply, a thread's first mention is its \`link\` verbatim. No \`link\`: \`[title](t3-thread://v1/)\`. Never a plain title or bare id. - A secret: call \`request_secret\` yourself. Pass its \`secretRef\` to a T3 tool, or use \`delivery: "file"\` for a shell command. Never ask in chat or invent one. When asked only to schedule, \`schedule_task\` without running its prompt. - T3 tools may load late. Try the name once before you call it absent.`, + `### Project intelligence + +- Use intelligence when the task needs project history or relationships; do not prefetch it for a routine edit. Resolve an unknown project with \`t3_project_list\` and \`t3_project_read\`. +- Prior decisions, conventions, and constraints: \`t3_knowledge_search\`, then verify message citations with \`t3_knowledge_read_source\`. Recall is on demand. When asked to remember project knowledge, use \`t3_knowledge_mutate\` with exact message citations when available; an agent proposal is not a user confirmation. +- Symbols, callers, dependencies, affected code, or reaching tests: \`t3_project_code_query\`. Pass the thread id for its registered worktree. Check coverage, freshness, and edge evidence; static candidates are not proof of runtime behavior. +- Work across threads: \`t3_project_loom_read\`. For a file's checkpoint history, \`t3_project_file_history\`. A checkpoint does not establish authorship, and a test command marker does not establish that tests passed. +- A handoff summary is model-written. Use \`t3_conversation_summary_expand\` for the original messages; list or describe summaries to locate one. If a source is unavailable, report that rather than quoting a stale excerpt. +- \`t3_knowledge_prepare_context\` previews selected facts or messages for attachment; it does not send them. These tools use the same \`environmentId\` routing as other T3 tools. Keep the project, thread, fact, summary, and message ids on their owning environment; omit \`environmentId\` for this machine.`, `### Linked environments - \`t3_environment_links\` lists linked machines. Pass \`environmentId\` to work there; project, thread, run, and model IDs then belong to that environment. Pass \`threadId\` explicitly. Work stays within your modes and the link's access. diff --git a/packages/provider-core/src/server/textGeneration.ts b/packages/provider-core/src/server/textGeneration.ts index cd9a03622b5d9..f6ccea0889db8 100644 --- a/packages/provider-core/src/server/textGeneration.ts +++ b/packages/provider-core/src/server/textGeneration.ts @@ -4,6 +4,7 @@ import type { ModelSelection, PullRequestReviewGuide, TextGenerationError, + TextGenerationOperation, } from "@t3tools/contracts"; import type * as Effect from "effect/Effect"; @@ -165,3 +166,8 @@ export interface ProviderTextGeneration { input: ConversationSummaryGenerationInput, ) => Effect.Effect; } + +/** A provider declares the operations it implements; the application facade validates each call. */ +export interface ProviderTextGenerationRuntime extends ProviderTextGeneration { + readonly supportedOperations: ReadonlyArray; +} diff --git a/packages/provider-core/src/server/textGenerationOperations.test.ts b/packages/provider-core/src/server/textGenerationOperations.test.ts new file mode 100644 index 0000000000000..48ac0534ec6b0 --- /dev/null +++ b/packages/provider-core/src/server/textGenerationOperations.test.ts @@ -0,0 +1,49 @@ +import { expect, it } from "@effect/vitest"; +import { ProviderInstanceId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; + +import { decodeJsonReply, fromRunner, type Operation } from "./textGenerationOperations.ts"; + +const modelSelection = { instanceId: ProviderInstanceId.make("writer"), model: "chosen" }; + +it.effect("keeps structured intelligence available without allowing checkout investigation", () => + Effect.gen(function* () { + const calls: Operation[] = []; + const runtime = fromRunner( + "Fixture", + (request) => { + calls.push(request.operation); + return decodeJsonReply( + request, + "Fixture", + '{"statements":[{"text":"Keep the API.","citations":["M1"]}]}', + ); + }, + { supportsInvestigation: false }, + ); + expect( + yield* runtime.generateConversationSummary({ + cwd: "/project", + modelSelection, + messages: [{ ref: "M1", role: "user", text: "Keep the API." }], + }), + ).toEqual({ statements: [{ text: "Keep the API.", citations: ["M1"] }] }); + const error = yield* runtime + .generateReviewGuide({ + cwd: "/project", + modelSelection, + number: 1, + title: "API", + description: "", + baseBranch: "main", + baseCommit: "base", + settledReview: "review", + }) + .pipe(Effect.flip); + expect(error.operation).toBe("generateReviewGuide"); + expect(calls).toEqual(["generateConversationSummary"]); + expect(runtime.supportedOperations).toContain("generateConversationSummary"); + expect(runtime.supportedOperations).toContain("generateKnowledgeCandidates"); + expect(runtime.supportedOperations).not.toContain("generateReviewGuide"); + }), +); diff --git a/packages/provider-core/src/server/textGenerationOperations.ts b/packages/provider-core/src/server/textGenerationOperations.ts index d8219ff6433f9..4e50ff588c32f 100644 --- a/packages/provider-core/src/server/textGenerationOperations.ts +++ b/packages/provider-core/src/server/textGenerationOperations.ts @@ -1,11 +1,16 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; -import { type ChatAttachment, type ModelSelection, TextGenerationError } from "@t3tools/contracts"; +import { + type ChatAttachment, + type ModelSelection, + TextGenerationError, + TextGenerationOperation, +} from "@t3tools/contracts"; import { formatGeneratedBranchName, sanitizeFeatureBranchName } from "@t3tools/shared/git"; import { extractJsonObject } from "@t3tools/shared/schemaJson"; -import type { ProviderTextGeneration } from "./textGeneration.ts"; +import type { ProviderTextGeneration, ProviderTextGenerationRuntime } from "./textGeneration.ts"; import { buildBranchNamePrompt, buildCommitMessagePrompt, @@ -21,7 +26,7 @@ import { sanitizeThreadTitle, } from "./textGenerationUtils.ts"; -export type Operation = keyof ProviderTextGeneration; +export type Operation = TextGenerationOperation; export interface Request { readonly operation: Operation; @@ -36,7 +41,7 @@ export interface Request { const INVESTIGATION_TIMEOUT_MS = 15 * 60_000; -export const investigationUnsupported = (operation: Operation, provider: string) => +const investigationUnsupported = (operation: Operation, provider: string) => new TextGenerationError({ operation, detail: `${provider} cannot investigate a checkout read-only. Choose another review guide model, or source control writer model when none is set.`, @@ -46,13 +51,6 @@ export type Runner = ( request: Request, ) => Effect.Effect; -export const withoutInvestigation = - (provider: string, run: Runner): Runner => - (request) => - request.investigate - ? Effect.fail(investigationUnsupported(request.operation, provider)) - : run(request); - export const decodeJsonReply = ( request: Pick, "operation" | "outputSchema">, provider: string, @@ -71,7 +69,11 @@ export const decodeJsonReply = ( ); }; -export function fromRunner(name: string, run: Runner): ProviderTextGeneration { +export function fromRunner( + name: string, + run: Runner, + options: { readonly supportsInvestigation: boolean } = { supportsInvestigation: false }, +): ProviderTextGenerationRuntime { const generateCommitMessage: ProviderTextGeneration["generateCommitMessage"] = Effect.fn( `${name}.generateCommitMessage`, )(function* (input) { @@ -160,6 +162,9 @@ export function fromRunner(name: string, run: Runner): ProviderTextGeneration { const generateReviewGuide: ProviderTextGeneration["generateReviewGuide"] = Effect.fn( `${name}.generateReviewGuide`, )(function* (input) { + if (!options.supportsInvestigation) { + return yield* investigationUnsupported("generateReviewGuide", name); + } return yield* run({ operation: "generateReviewGuide", cwd: input.cwd, @@ -190,6 +195,9 @@ export function fromRunner(name: string, run: Runner): ProviderTextGeneration { }); return { + supportedOperations: TextGenerationOperation.literals.filter( + (operation) => operation !== "generateReviewGuide" || options.supportsInvestigation, + ), generateCommitMessage, generatePrContent, generateBranchName, @@ -197,5 +205,5 @@ export function fromRunner(name: string, run: Runner): ProviderTextGeneration { generateReviewGuide, generateKnowledgeCandidates, generateConversationSummary, - } satisfies ProviderTextGeneration; + } satisfies ProviderTextGenerationRuntime; } diff --git a/packages/provider-cursor/src/server/textGeneration.ts b/packages/provider-cursor/src/server/textGeneration.ts index 278f35f0f91a6..e56cef14a7428 100644 --- a/packages/provider-cursor/src/server/textGeneration.ts +++ b/packages/provider-cursor/src/server/textGeneration.ts @@ -203,8 +203,7 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu }, ); - return TextGenerationOperations.fromRunner( - "CursorTextGeneration", - TextGenerationOperations.withoutInvestigation("Cursor", runCursorJson), - ); + return TextGenerationOperations.fromRunner("CursorTextGeneration", runCursorJson, { + supportsInvestigation: false, + }); }); diff --git a/packages/provider-fx/src/server/driver.ts b/packages/provider-fx/src/server/driver.ts index ca9a04d6ed729..3d52e86d789de 100644 --- a/packages/provider-fx/src/server/driver.ts +++ b/packages/provider-fx/src/server/driver.ts @@ -31,7 +31,7 @@ import { makeProviderSnapshotSettingsSource, type ProviderSnapshotSettings, } from "@t3tools/provider-core/server/snapshotSettings"; -import type { ProviderTextGeneration } from "@t3tools/provider-core/server/textGeneration"; +import type { ProviderTextGenerationRuntime } from "@t3tools/provider-core/server/textGeneration"; import { FX_DRIVER_KIND } from "./acpSupport.ts"; const decodeFxSettings = Schema.decodeSync(FxSettings); @@ -43,7 +43,7 @@ const MAINTENANCE = makeManualOnlyProviderMaintenanceCapabilities({ }); // See `supportsTextGeneration` in status.ts. -const unsupportedTextGeneration: ProviderTextGeneration = (() => { +const unsupportedTextGeneration: ProviderTextGenerationRuntime = (() => { const unsupported = (operation: string) => Effect.fail( new TextGenerationError({ @@ -52,6 +52,7 @@ const unsupportedTextGeneration: ProviderTextGeneration = (() => { }), ); return { + supportedOperations: [], generateCommitMessage: () => unsupported("generateCommitMessage"), generatePrContent: () => unsupported("generatePrContent"), generateBranchName: () => unsupported("generateBranchName"), diff --git a/packages/provider-grok/src/server/textGeneration.ts b/packages/provider-grok/src/server/textGeneration.ts index 9907ad1441357..b3ead17d24f98 100644 --- a/packages/provider-grok/src/server/textGeneration.ts +++ b/packages/provider-grok/src/server/textGeneration.ts @@ -134,8 +134,7 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi }, ); - return TextGenerationOperations.fromRunner( - "GrokTextGeneration", - TextGenerationOperations.withoutInvestigation("Grok", runGrokJson), - ); + return TextGenerationOperations.fromRunner("GrokTextGeneration", runGrokJson, { + supportsInvestigation: false, + }); }); diff --git a/packages/provider-muse/src/server/textGeneration.test.ts b/packages/provider-muse/src/server/textGeneration.test.ts index 7876cac59a506..3a0ae93b48c14 100644 --- a/packages/provider-muse/src/server/textGeneration.test.ts +++ b/packages/provider-muse/src/server/textGeneration.test.ts @@ -110,6 +110,57 @@ const finish = }; it.layer(NodeServices.layer)("Muse text generation", (it) => { + it.effect("extracts cited knowledge through the isolated structured runner", () => + Effect.gen(function* () { + const expected = { + candidates: [ + { + kind: "convention", + content: "Use Effect services for server behavior.", + entities: ["Effect"], + citations: ["M1"], + }, + ], + }; + const test = fixture(finish(JSON.stringify(expected))); + const service = yield* test.make; + expect( + yield* service.generateKnowledgeCandidates({ + cwd: titleInput.cwd, + modelSelection, + runOutcome: "completed", + messages: [{ ref: "M1", role: "user", text: "Use Effect services for server behavior." }], + }), + ).toEqual(expected); + expect(test.createHost).toHaveBeenCalledWith(expect.objectContaining({ readOnly: true })); + expect(test.createHost).not.toHaveBeenCalledWith( + expect.objectContaining({ cwd: titleInput.cwd }), + ); + expect(test.host.close).toHaveBeenCalledOnce(); + }), + ); + + it.effect("summarizes cited conversation messages through the structured runner", () => + Effect.gen(function* () { + const expected = { + statements: [{ text: "The user requested an auth fix.", citations: ["M1"] }], + }; + const test = fixture(finish(JSON.stringify(expected))); + const service = yield* test.make; + expect( + yield* service.generateConversationSummary({ + cwd: titleInput.cwd, + modelSelection, + messages: [{ ref: "M1", role: "user", text: "Fix the login form." }], + }), + ).toEqual(expected); + expect(service.supportedOperations).toContain("generateConversationSummary"); + expect(service.supportedOperations).toContain("generateKnowledgeCandidates"); + expect(service.supportedOperations).not.toContain("generateReviewGuide"); + expect(test.host.close).toHaveBeenCalledOnce(); + }), + ); + it.effect("validates the final item once and closes an isolated restrictive host", () => Effect.gen(function* () { const test = fixture(finish('{"title":"Fix login form"}')); diff --git a/packages/provider-muse/src/server/textGeneration.ts b/packages/provider-muse/src/server/textGeneration.ts index fef88ac2a67ad..7de01d7dbafb9 100644 --- a/packages/provider-muse/src/server/textGeneration.ts +++ b/packages/provider-muse/src/server/textGeneration.ts @@ -3,10 +3,10 @@ import { MUSE_DEFAULT_MODEL, type ModelSelection, type ServerProviderModel, + type TextGenerationOperation, TextGenerationError, } from "@t3tools/contracts"; import type { MuseSettings } from "../settings.ts"; -import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import { extractJsonObject } from "@t3tools/shared/schemaJson"; import * as Effect from "effect/Effect"; @@ -16,20 +16,8 @@ import * as Schema from "effect/Schema"; import { createMuseSdkHost, createMuseSdkHostEffect, type MuseSdkHost } from "./sdk.ts"; import { museModelCapabilities, resolveMuseReasoningEffort } from "./modelCatalog.ts"; -import type { ProviderTextGeneration } from "@t3tools/provider-core/server/textGeneration"; -import { investigationUnsupported } from "@t3tools/provider-core/server/textGenerationOperations"; -import { - buildBranchNamePrompt, - buildCommitMessagePrompt, - buildPrContentPrompt, - buildThreadTitlePrompt, -} from "@t3tools/provider-core/server/textGenerationPrompts"; -import { - sanitizeCommitSubject, - sanitizePrTitle, - sanitizeThreadTitle, - toJsonSchemaObject, -} from "@t3tools/provider-core/server/textGenerationUtils"; +import * as TextGenerationOperations from "@t3tools/provider-core/server/textGenerationOperations"; +import { toJsonSchemaObject } from "@t3tools/provider-core/server/textGenerationUtils"; const SessionStarted = Schema.Struct({ session: Schema.Struct({ sessionId: Schema.String }) }); const ItemNotification = Schema.Struct({ @@ -70,8 +58,6 @@ const decodeUserInput = Schema.decodeUnknownSync(UserInputRequested); const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); -type Operation = keyof ProviderTextGeneration; - async function generateMuseText( host: MuseSdkHost, workspaceRoot: string, @@ -205,7 +191,7 @@ export const makeMuseTextGeneration = Effect.fn("makeMuseTextGeneration")(functi const { environment, modelCatalog = Effect.succeed([]), createHost } = options; const fileSystem = yield* FileSystem.FileSystem; const runMuseJson = Effect.fn("runMuseJson")(function* (input: { - operation: Operation; + operation: TextGenerationOperation; prompt: string; outputSchema: S; modelSelection: ModelSelection; @@ -294,75 +280,7 @@ export const makeMuseTextGeneration = Effect.fn("makeMuseTextGeneration")(functi ); }); - const generateCommitMessage: ProviderTextGeneration["generateCommitMessage"] = Effect.fn( - "MuseTextGeneration.generateCommitMessage", - )(function* (input) { - const generated = yield* runMuseJson({ - operation: "generateCommitMessage", - ...buildCommitMessagePrompt({ ...input, includeBranch: input.includeBranch === true }), - modelSelection: input.modelSelection, - }); - return { - subject: sanitizeCommitSubject(generated.subject), - body: generated.body.trim(), - ...("branch" in generated && typeof generated.branch === "string" - ? { branch: sanitizeFeatureBranchName(generated.branch) } - : {}), - }; - }); - const generatePrContent: ProviderTextGeneration["generatePrContent"] = Effect.fn( - "MuseTextGeneration.generatePrContent", - )(function* (input) { - const generated = yield* runMuseJson({ - operation: "generatePrContent", - ...buildPrContentPrompt(input), - modelSelection: input.modelSelection, - }); - return { title: sanitizePrTitle(generated.title), body: generated.body.trim() }; - }); - const generateBranchName: ProviderTextGeneration["generateBranchName"] = Effect.fn( - "MuseTextGeneration.generateBranchName", - )(function* (input) { - const generated = yield* runMuseJson({ - operation: "generateBranchName", - ...buildBranchNamePrompt(input), - modelSelection: input.modelSelection, - }); - return { branch: sanitizeBranchFragment(generated.branch) }; + return TextGenerationOperations.fromRunner("MuseTextGeneration", runMuseJson, { + supportsInvestigation: false, }); - const generateThreadTitle: ProviderTextGeneration["generateThreadTitle"] = Effect.fn( - "MuseTextGeneration.generateThreadTitle", - )(function* (input) { - const generated = yield* runMuseJson({ - operation: "generateThreadTitle", - ...buildThreadTitlePrompt(input), - modelSelection: input.modelSelection, - }); - return { title: sanitizeThreadTitle(generated.title) }; - }); - const generateReviewGuide: ProviderTextGeneration["generateReviewGuide"] = () => - Effect.fail(investigationUnsupported("generateReviewGuide", "Muse Code")); - const generateKnowledgeCandidates: ProviderTextGeneration["generateKnowledgeCandidates"] = () => - Effect.fail( - new TextGenerationError({ - operation: "generateKnowledgeCandidates", - detail: "Muse Code cannot extract project knowledge. Choose another text generation model.", - }), - ); - const generateConversationSummary: ProviderTextGeneration["generateConversationSummary"] = () => - Effect.fail( - new TextGenerationError({ - operation: "generateConversationSummary", - detail: "Muse Code cannot summarize conversations. Choose another text generation model.", - }), - ); - return { - generateCommitMessage, - generatePrContent, - generateBranchName, - generateThreadTitle, - generateReviewGuide, - generateKnowledgeCandidates, - generateConversationSummary, - } satisfies ProviderTextGeneration; }); diff --git a/packages/provider-opencode/src/server/driver.ts b/packages/provider-opencode/src/server/driver.ts index f1752e06f6907..b971f23c7aba9 100644 --- a/packages/provider-opencode/src/server/driver.ts +++ b/packages/provider-opencode/src/server/driver.ts @@ -18,7 +18,7 @@ import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import * as OpenCodeAdapterV2 from "./adapter.ts"; import * as OpenCode2AdapterV2 from "./v2/adapter.ts"; import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; -import type { ProviderTextGeneration } from "@t3tools/provider-core/server/textGeneration"; +import type { ProviderTextGenerationRuntime } from "@t3tools/provider-core/server/textGeneration"; import { ProviderDriverError } from "@t3tools/provider-core/server/errors"; import { openCodeUsageReader, type OpenCodeUsageReaderEnv } from "./usage.ts"; import { readOpenCodeGoUsageLimits } from "./usageLimits.ts"; @@ -122,10 +122,13 @@ function selectOpenCodeRuntimeAdapter(input: { function selectOpenCodeRuntimeTextGeneration( probe: OpenCodeRuntimeProbe, - v1: ProviderTextGeneration, - v2: ProviderTextGeneration, -): ProviderTextGeneration { + v1: ProviderTextGenerationRuntime, + v2: ProviderTextGenerationRuntime, +): ProviderTextGenerationRuntime { return { + supportedOperations: v1.supportedOperations.filter((operation) => + v2.supportedOperations.includes(operation), + ), generateCommitMessage: (input) => byOpenCodeRuntime(probe.get, { v1: v1.generateCommitMessage(input), diff --git a/packages/provider-opencode/src/server/textGeneration.ts b/packages/provider-opencode/src/server/textGeneration.ts index d122493f9abb1..6ae60e04033ad 100644 --- a/packages/provider-opencode/src/server/textGeneration.ts +++ b/packages/provider-opencode/src/server/textGeneration.ts @@ -346,8 +346,7 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" return yield* TextGenerationOperations.decodeJsonReply(input, "OpenCode", rawOutput); }); - return TextGenerationOperations.fromRunner( - "OpenCodeTextGeneration", - TextGenerationOperations.withoutInvestigation("OpenCode", runOpenCodeJson), - ); + return TextGenerationOperations.fromRunner("OpenCodeTextGeneration", runOpenCodeJson, { + supportsInvestigation: false, + }); }); diff --git a/packages/provider-opencode/src/server/v2/adapter.ts b/packages/provider-opencode/src/server/v2/adapter.ts index 9c4ec57c34776..798c1407e8bdf 100644 --- a/packages/provider-opencode/src/server/v2/adapter.ts +++ b/packages/provider-opencode/src/server/v2/adapter.ts @@ -127,7 +127,7 @@ const OpenCode2ProviderCapabilities = { emitsToolStarted: true, emitsToolCompleted: true, emitsToolOutput: true, - supportsMcpTools: false, + supportsMcpTools: true, supportsDynamicToolCallbacks: false, }, approvals: { diff --git a/packages/provider-opencode/src/server/v2/textGeneration.ts b/packages/provider-opencode/src/server/v2/textGeneration.ts index ff47491e14fe1..b70d520155ac6 100644 --- a/packages/provider-opencode/src/server/v2/textGeneration.ts +++ b/packages/provider-opencode/src/server/v2/textGeneration.ts @@ -186,8 +186,7 @@ export const make = Effect.fn("OpenCode2TextGeneration.make")(function* () { ), Effect.flatMap((raw) => TextGenerationOperations.decodeJsonReply(input, "OpenCode", raw)), ); - return TextGenerationOperations.fromRunner( - "OpenCode2TextGeneration", - TextGenerationOperations.withoutInvestigation("OpenCode", run), - ); + return TextGenerationOperations.fromRunner("OpenCode2TextGeneration", run, { + supportsInvestigation: false, + }); }); diff --git a/packages/provider-pi/src/server/textGeneration.ts b/packages/provider-pi/src/server/textGeneration.ts index 218a6300a35e1..9580b0806847b 100644 --- a/packages/provider-pi/src/server/textGeneration.ts +++ b/packages/provider-pi/src/server/textGeneration.ts @@ -125,8 +125,7 @@ export const makePiTextGeneration = Effect.fn("makePiTextGeneration")(function* }, ); - return TextGenerationOperations.fromRunner( - "PiTextGeneration", - TextGenerationOperations.withoutInvestigation("Pi", runPiJson), - ); + return TextGenerationOperations.fromRunner("PiTextGeneration", runPiJson, { + supportsInvestigation: false, + }); }); From 0e2f80996a585639ace6a18805400c2e051c2244 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:52:35 -0700 Subject: [PATCH 2/4] fix: remove Loom from agent tools Keep Loom available through the app while removing its MCP registration, provider allowlist entry, shared agent guidance, and replay scenario. Record the smaller measured agent context. --- apps/server/src/mcp/McpHttpServer.ts | 7 ---- .../src/mcp/toolkits/intelligence.test.ts | 6 ---- .../src/mcp/toolkits/projectLoom/handlers.ts | 22 ------------- .../src/mcp/toolkits/projectLoom/tools.ts | 32 ------------------- .../Adapters/ClaudeAdapterV2.test.ts | 2 -- .../Adapters/ClaudeAdapterV2.ts | 1 - .../src/provider/AgentContextBudget.test.ts | 2 -- .../src/provider/agentContextBudget.json | 6 ++-- .../cases/project-intelligence.ndjson | 13 ++++---- evals/mcp-tool-use/run.ts | 2 -- .../src/server/orchestrationInstructions.ts | 2 +- 11 files changed, 10 insertions(+), 85 deletions(-) delete mode 100644 apps/server/src/mcp/toolkits/projectLoom/handlers.ts delete mode 100644 apps/server/src/mcp/toolkits/projectLoom/tools.ts diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index ae5013120bdbe..abce1b23dc56b 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -1,7 +1,5 @@ import { ConversationSummariesToolkit } from "./toolkits/conversationSummaries/tools.ts"; import * as ConversationSummariesHandlers from "./toolkits/conversationSummaries/handlers.ts"; -import { ProjectLoomToolkit } from "./toolkits/projectLoom/tools.ts"; -import * as ProjectLoomHandlers from "./toolkits/projectLoom/handlers.ts"; import { KnowledgeToolkit } from "./toolkits/knowledge/tools.ts"; import * as KnowledgeHandlers from "./toolkits/knowledge/handlers.ts"; import { ProjectCodeToolkit } from "./toolkits/projectCode/tools.ts"; @@ -1117,10 +1115,6 @@ const layerConversationSummariesRegistration = toolkitRegistration( ConversationSummariesToolkit, ConversationSummariesHandlers.layer, ); -const layerProjectLoomRegistration = toolkitRegistration( - ProjectLoomToolkit, - ProjectLoomHandlers.layer, -); export const layerProjectCodeToolkit = toolkitRegistration( ProjectCodeToolkit, ProjectCodeHandlers.layer, @@ -1170,7 +1164,6 @@ export const layerTools = Layer.mergeAll( layerProjectRegistration, layerKnowledgeRegistration, layerConversationSummariesRegistration, - layerProjectLoomRegistration, layerProjectCodeToolkit, layerEnvironmentToolkit, layerPreviewControlsRegistration, diff --git a/apps/server/src/mcp/toolkits/intelligence.test.ts b/apps/server/src/mcp/toolkits/intelligence.test.ts index ff3642594c1f3..c469085b449ee 100644 --- a/apps/server/src/mcp/toolkits/intelligence.test.ts +++ b/apps/server/src/mcp/toolkits/intelligence.test.ts @@ -14,7 +14,6 @@ import * as ConversationSummaries from "../../project/conversationSummaries/Conv import * as CodeIntelligence from "../../project/knowledge/CodeIntelligence.ts"; import * as FileHistory from "../../project/knowledge/FileHistory.ts"; import * as ProjectKnowledge from "../../project/knowledge/ProjectKnowledge.ts"; -import * as ProjectLoom from "../../project/loom/ProjectLoom.ts"; import * as McpHttpServer from "../McpHttpServer.ts"; import * as McpInvocationContext from "../McpInvocationContext.ts"; import * as ConversationSummaryHandlers from "./conversationSummaries/handlers.ts"; @@ -23,8 +22,6 @@ import * as KnowledgeHandlers from "./knowledge/handlers.ts"; import { KnowledgeToolkit } from "./knowledge/tools.ts"; import * as ProjectCodeHandlers from "./projectCode/handlers.ts"; import { ProjectCodeToolkit } from "./projectCode/tools.ts"; -import * as ProjectLoomHandlers from "./projectLoom/handlers.ts"; -import { ProjectLoomToolkit } from "./projectLoom/tools.ts"; const here = EnvironmentId.make("here"); const there = EnvironmentId.make("there"); @@ -63,7 +60,6 @@ const registration = Layer.mergeAll( ConversationSummaryHandlers.layer, ), McpHttpServer.toolkitRegistration(ProjectCodeToolkit, ProjectCodeHandlers.layer), - McpHttpServer.toolkitRegistration(ProjectLoomToolkit, ProjectLoomHandlers.layer), ).pipe(Layer.provideMerge(McpServer.McpServer.layer)); const server = ( @@ -77,7 +73,6 @@ const server = ( Layer.mock(ConversationSummaries.ConversationSummaries)({}), Layer.mock(CodeIntelligence.CodeIntelligence)({}), Layer.mock(FileHistory.FileHistory)({}), - Layer.mock(ProjectLoom.ProjectLoom)({}), Layer.mock(ThreadManagement.ThreadManagementService)({}), ), ), @@ -140,7 +135,6 @@ it.effect.each([ { projectId: "project", threadId: "thread", query: { operation: "status" } }, ], ["t3_project_file_history", { projectId: "project", path: "src/cache.ts" }], - ["t3_project_loom_read", { projectId: "project" }], ] as const)("routes %s to its selected environment before reading local ids", ([tool, input]) => Effect.gen(function* () { const forwarded: Array = []; diff --git a/apps/server/src/mcp/toolkits/projectLoom/handlers.ts b/apps/server/src/mcp/toolkits/projectLoom/handlers.ts deleted file mode 100644 index 9845695c250fc..0000000000000 --- a/apps/server/src/mcp/toolkits/projectLoom/handlers.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { OrchestratorMcpFailure, type ProjectLoomError } from "@t3tools/contracts"; -import * as Effect from "effect/Effect"; -import * as ProjectLoom from "../../../project/loom/ProjectLoom.ts"; -import * as McpToolAccess from "../../McpToolAccess.ts"; -import { readCaller } from "../../threadAccess.ts"; -import { ProjectLoomToolkit } from "./tools.ts"; - -const failure = (error: ProjectLoomError) => - new OrchestratorMcpFailure({ - code: error.reason === "storage" ? "orchestration_error" : "invalid_request", - message: error.message, - }); - -export const layer = McpToolAccess.toLayer(ProjectLoomToolkit, { - t3_project_loom_read: McpToolAccess.reads((input) => - readCaller().pipe( - Effect.andThen(ProjectLoom.ProjectLoom), - Effect.flatMap((loom) => loom.readWindow(input).pipe(Effect.mapError(failure))), - Effect.map((window) => ({ ...window, activity: [] })), - ), - ), -}); diff --git a/apps/server/src/mcp/toolkits/projectLoom/tools.ts b/apps/server/src/mcp/toolkits/projectLoom/tools.ts deleted file mode 100644 index 12de3f93439cd..0000000000000 --- a/apps/server/src/mcp/toolkits/projectLoom/tools.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { - LoomWindow, - LoomWindowInput, - OrchestratorMcpEnvironmentTarget, - OrchestratorMcpFailure, -} from "@t3tools/contracts"; -import { Tool, Toolkit } from "effect/ai"; -import * as Schema from "effect/Schema"; -import * as ThreadManagement from "../../../orchestration-v2/ThreadManagementService.ts"; -import * as ProjectLoom from "../../../project/loom/ProjectLoom.ts"; -import * as McpInvocationContext from "../../McpInvocationContext.ts"; - -export const ProjectLoomToolkit = Toolkit.make( - Tool.make("t3_project_loom_read", { - failure: OrchestratorMcpFailure, - failureMode: "return" as const, - dependencies: [ - ProjectLoom.ProjectLoom, - ThreadManagement.ThreadManagementService, - McpInvocationContext.McpInvocationContext, - ], - description: - "Read a project's activity timeline for a time window (default: the last 7 days): its threads as lanes with fork and subagent parents, runs with request, start and completion times, checkpoints with file counts, linked pull requests, commands that look like test runs, provider-native and remote tasks, and fork or handoff transfers between threads. Use it to see what happened across a project recently or which thread did what when, before reading individual threads with t3_thread_read. Resolve projectId with t3_project_list in the same environment. Follow a task's remoteChild or a lane's delegatedFrom with both its environmentId and threadId; their activity lives in that environment, not in a local lane with the same id. Activity ticks are omitted. A null time is unknown, not zero. A test command marker means a test command ran, not that tests passed. Limits apply per kind; check truncated, and page lanes with cursor=nextCursor using the returned from and to.", - parameters: Schema.Struct({ - ...LoomWindowInput.fields, - environmentId: OrchestratorMcpEnvironmentTarget, - }), - success: LoomWindow, - }) - .annotate(Tool.Readonly, true) - .annotate(Tool.Destructive, false), -); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 7dc1b4d432563..29a8c805e3554 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -84,7 +84,6 @@ import { HtmlToolkit } from "../../mcp/toolkits/html/tools.ts"; import { EnvironmentToolkit } from "../../mcp/toolkits/environment/tools.ts"; import { KnowledgeToolkit } from "../../mcp/toolkits/knowledge/tools.ts"; import { ConversationSummariesToolkit } from "../../mcp/toolkits/conversationSummaries/tools.ts"; -import { ProjectLoomToolkit } from "../../mcp/toolkits/projectLoom/tools.ts"; import { ProjectCodeToolkit } from "../../mcp/toolkits/projectCode/tools.ts"; import { ProjectToolkit } from "../../mcp/toolkits/project/tools.ts"; import { WorktreeToolkit } from "../../mcp/toolkits/worktree/tools.ts"; @@ -559,7 +558,6 @@ describe("ClaudeAdapterV2 MCP query overrides", () => { ...Object.values(ProjectToolkit.tools), ...Object.values(KnowledgeToolkit.tools), ...Object.values(ConversationSummariesToolkit.tools), - ...Object.values(ProjectLoomToolkit.tools), ...Object.values(ProjectCodeToolkit.tools), ...Object.values(EnvironmentToolkit.tools), ...Object.values(PreviewControlsToolkit.tools), diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 1185f7af08187..2a953b42003ef 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -1581,7 +1581,6 @@ export const CLAUDE_READ_ONLY_T3_MCP_ALLOWED_TOOLS: ReadonlyArray = [ "mcp__t3-code__t3_conversation_summary_list", "mcp__t3-code__t3_conversation_summary_describe", "mcp__t3-code__t3_conversation_summary_expand", - "mcp__t3-code__t3_project_loom_read", "mcp__t3-code__t3_project_code_query", "mcp__t3-code__t3_project_file_history", "mcp__t3-code__t3_preview_list", diff --git a/apps/server/src/provider/AgentContextBudget.test.ts b/apps/server/src/provider/AgentContextBudget.test.ts index 6723e4d9b741c..4bb2be4175605 100644 --- a/apps/server/src/provider/AgentContextBudget.test.ts +++ b/apps/server/src/provider/AgentContextBudget.test.ts @@ -31,7 +31,6 @@ import { PreviewBrowser } from "../preview/PreviewBrowser.ts"; import { CodeIntelligence } from "../project/knowledge/CodeIntelligence.ts"; import { FileHistory } from "../project/knowledge/FileHistory.ts"; import { ProjectKnowledge } from "../project/knowledge/ProjectKnowledge.ts"; -import { ProjectLoom } from "../project/loom/ProjectLoom.ts"; import { ConversationSummaries } from "../project/conversationSummaries/ConversationSummaries.ts"; import { ManagedProjectFolders } from "../project/ManagedProjectFolders.ts"; import { ProjectService } from "../project/ProjectService.ts"; @@ -53,7 +52,6 @@ const toolHandlerServices = Layer.mergeAll( Layer.mock(CodeIntelligence)({}), Layer.mock(FileHistory)({}), Layer.mock(ProjectKnowledge)({}), - Layer.mock(ProjectLoom)({}), Layer.mock(ConversationSummaries)({}), Layer.mock(PeerForwarding)({}), Layer.mock(PeerLinkRequests)({}), diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index 428e81ebe24be..24a367e62c4bf 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { - "claudeSystemAppendBytes": 7802, - "codexAdditionalContextBytes": 8350, - "mcpToolListJsonBytes": 61985 + "claudeSystemAppendBytes": 7697, + "codexAdditionalContextBytes": 8245, + "mcpToolListJsonBytes": 60335 } diff --git a/evals/mcp-tool-use/cases/project-intelligence.ndjson b/evals/mcp-tool-use/cases/project-intelligence.ndjson index 844ec813a3c42..8c21621b0b7f0 100644 --- a/evals/mcp-tool-use/cases/project-intelligence.ndjson +++ b/evals/mcp-tool-use/cases/project-intelligence.ndjson @@ -1,7 +1,6 @@ -{"id":"intelligence-recall-with-citations","area":"project-intelligence","kind":"bad","pattern":"prior project decision needs memory and canonical evidence","experience":"The integration audit found no intelligence tools in the model replay catalog and no task-specific intelligence guidance in the shared provider instructions.","privacy":"commit-safe","setup":{"role":"top","mocks":{"t3_knowledge_search":{"messages":{"matches":[],"nextCursor":null},"facts":{"state":"available","coverage":"complete","nextCursor":null,"matches":[{"fact":{"id":"fact-cache-ttl","projectId":"project-app","content":"Keep the cache TTL at five minutes because downstream invalidations can arrive late.","entities":["cache"],"tags":["ttl"],"kind":"decision","sources":[{"kind":"message","threadId":"thread-cache-decision","messageId":"message-cache-decision","digest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}],"origin":{"kind":"agent-thread","threadId":"thread-cache-decision","providerInstanceId":"codex"},"revision":1,"trust":0.9,"retrievalCount":1,"createdAt":"2026-10-22T12:00:00.000Z","updatedAt":"2026-10-22T12:00:00.000Z","status":"confirmed","forgottenAt":null},"score":{"holographic":0.9,"lexical":0.9,"tokenOverlap":1,"temporalDecay":1,"reinforcement":1,"trust":0.9,"combined":0.9}}]}},"t3_knowledge_read_source":{"state":"available","source":{"kind":"message","threadId":"thread-cache-decision","messageId":"message-cache-decision","digest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},"text":"Keep the TTL at five minutes. Our downstream invalidation feed can arrive up to four minutes late; shortening it would break our stated cache consistency contract.","title":"Cache retention decision","role":"user","createdAt":"2026-10-22T12:00:00.000Z","archived":false}}},"prompt":"For the current machine's registered project project-app, why did we keep the cache TTL at five minutes? Find the prior decision and verify its original message.","expect":{"calls":[{"tool":"t3_knowledge_search","args":{"projectId":"project-app","query":"~cache"}},{"tool":"t3_knowledge_read_source","args":{"projectId":"project-app","threadId":"thread-cache-decision","messageId":"message-cache-decision","digest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}],"order":["t3_knowledge_search","t3_knowledge_read_source"],"notCalls":["t3_knowledge_mutate","t3_project_code_query","t3_project_loom_read"],"maxCalls":{"t3_knowledge_search":1}},"source":{"kind":"integration-audit","commit":"47021ecc04f525c260159bd82f9f16e748ce41c3","path":"evals/mcp-tool-use/run.ts","finding":"The agent replay catalog omitted the knowledge, conversation summary, project code, and Loom toolkits."}} -{"id":"intelligence-code-impact-worktree","area":"project-intelligence","kind":"bad","pattern":"symbol impact must use the registered worktree","experience":"The integration audit found no intelligence tools in the model replay catalog and no task-specific intelligence guidance in the shared provider instructions.","privacy":"commit-safe","setup":{"role":"top","mocks":{"t3_project_code_query":{"operation":"impact","checkoutPath":"/home/user/app-worktree","matches":[],"nodes":[{"id":"symbol-cache","name":"readCache","qualifiedName":"readCache","kind":"function","language":"typescript","path":"src/cache.ts","startLine":10,"endLine":14,"startColumn":0,"endColumn":1,"signature":null,"isExported":true},{"id":"symbol-loader","name":"loadUser","qualifiedName":"loadUser","kind":"function","language":"typescript","path":"src/loader.ts","startLine":20,"endLine":24,"startColumn":0,"endColumn":1,"signature":null,"isExported":true}],"edges":[{"source":"symbol-loader","target":"symbol-cache","kind":"call","confidence":0.8,"resolvedBy":"import","provenance":"tree-sitter","evidenceGrade":"static-candidate","line":22,"column":2,"referenceName":"readCache","candidateTargets":[]}],"files":[{"path":"src/cache.ts"},{"path":"src/loader.ts"}],"unresolvedReferences":[],"selection":{"state":"matched","candidates":[{"id":"symbol-cache","name":"readCache","qualifiedName":"readCache","kind":"function","language":"typescript","path":"src/cache.ts","startLine":10,"endLine":14,"startColumn":0,"endColumn":1,"signature":null,"isExported":true}]},"sourceDigests":[{"path":"src/cache.ts","digest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","verified":true}],"stats":null,"coverage":{"state":"complete","catalogRevision":"test","relationSemantics":"static-candidates","columnEncoding":"utf16-code-units","languages":["typescript"],"indexedLanguages":["typescript"],"filesListed":2,"filesIndexed":2,"filesSkipped":0,"filesUnsupported":0,"filesFailed":0,"pendingReferences":0,"resultLimitReached":false,"limitations":["Static candidates require source inspection before claiming runtime reachability."]}}},"world":{"files":{"src/cache.ts":"export function readCache(key: string) { return cache.get(key); }\n","src/loader.ts":"import { readCache } from './cache';\nexport function loadUser(id: string) { return readCache(id); }\n"}}},"prompt":"In registered project project-app, thread thread-feature uses a worktree. What could be affected by changing readCache in src/cache.ts there? Start with the code graph; no edits.","expect":{"calls":[{"tool":"t3_project_code_query","args":{"projectId":"project-app","threadId":"thread-feature","query.operation":"impact","query.symbol.name":"readCache","query.symbol.filePath":"src/cache.ts"}}],"notCalls":["t3_knowledge_mutate","t3_knowledge_search","t3_project_loom_read"]},"source":{"kind":"integration-audit","commit":"47021ecc04f525c260159bd82f9f16e748ce41c3","path":"evals/mcp-tool-use/run.ts","finding":"The agent replay catalog omitted the knowledge, conversation summary, project code, and Loom toolkits."}} -{"id":"intelligence-project-activity-evidence","area":"project-intelligence","kind":"bad","pattern":"project activity is not proof that tests passed","experience":"The integration audit found no intelligence tools in the model replay catalog and no task-specific intelligence guidance in the shared provider instructions.","privacy":"commit-safe","setup":{"role":"top","mocks":{"t3_project_loom_read":{"projectId":"project-app","asOf":"2026-10-22T12:00:00.000Z","from":"2026-10-21T00:00:00.000Z","to":"2026-10-22T12:00:00.000Z","lanes":[{"threadId":"thread-cache","title":"Cache safety","relationship":"root","parentThreadId":null,"providerInstanceId":"codex","model":"gpt-5","branch":"fix/cache","createdAt":"2026-10-21T09:00:00.000Z","archivedAt":null}],"ancestors":[],"runs":[],"markers":[{"kind":"test_command","threadId":"thread-cache","runId":null,"at":"2026-10-21T10:00:00.000Z","id":"item-test","command":"pnpm test cache","completedAt":null,"exitCode":null}],"edges":[],"activity":[],"truncated":{"lanes":false,"ancestors":false,"runs":false,"markers":false,"edges":false,"activity":false,"bytes":false},"coverage":{"untimedTasks":0,"untimedTasksTruncated":false,"commandsScanned":1,"commandsTruncated":false},"nextCursor":null},"t3_thread_read":{"thread":{"threadId":"thread-cache","title":"Cache safety","status":"completed"},"items":[{"position":1,"role":"assistant","text":"Cache tests were started, but the process lost its output; no exit status was recorded."}],"nextPosition":1,"hasMore":false}}},"prompt":"For registered project project-app, what thread ran cache tests from 2026-10-21T00:00:00.000Z to 2026-10-22T12:00:00.000Z, and did those tests pass? Read the project timeline and report only what the recorded evidence establishes.","expect":{"calls":[{"tool":"t3_project_loom_read","args":{"projectId":"project-app","from":"2026-10-21T00:00:00.000Z","to":"2026-10-22T12:00:00.000Z"}}],"textMust":["[Nn]ot (recorded|known|confirmed)|[Uu]nknown|[Cc]annot (confirm|determine)|[Nn]o (exit status|recorded outcome|proof)|[Dd]oes(n['\u2019]t| not) (prove|establish)"],"notCalls":["t3_knowledge_mutate","t3_project_code_query"]},"source":{"kind":"integration-audit","commit":"47021ecc04f525c260159bd82f9f16e748ce41c3","path":"evals/mcp-tool-use/run.ts","finding":"The agent replay catalog omitted the knowledge, conversation summary, project code, and Loom toolkits."}} -{"id":"intelligence-summary-exact-source","area":"project-intelligence","kind":"bad","pattern":"a model summary is not the original decision","experience":"The integration audit found no intelligence tools in the model replay catalog and no task-specific intelligence guidance in the shared provider instructions.","privacy":"commit-safe","setup":{"role":"top","mocks":{"t3_conversation_summary_expand":{"summary":{"state":"available","summary":{"id":"summary:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","projectId":"project-app","threadId":"thread-cache-decision","sources":[{"kind":"message","threadId":"thread-cache-decision","messageId":"message-cache-decision","digest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}],"statements":[{"text":"The team agreed to keep the TTL at five minutes.","citations":["message-cache-decision"]}],"modelSelection":{"instanceId":"codex","model":"gpt-5"},"createdAt":"2026-10-22T12:00:00.000Z"}},"sources":[{"ref":{"kind":"message","threadId":"thread-cache-decision","messageId":"message-cache-decision","digest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},"result":{"state":"available","source":{"kind":"message","threadId":"thread-cache-decision","messageId":"message-cache-decision","digest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},"text":"Keep the TTL at five minutes. Our downstream invalidation feed can arrive up to four minutes late; shortening it would break our stated cache consistency contract.","title":"Cache retention decision","role":"user","createdAt":"2026-10-22T12:00:00.000Z","archived":false}}],"nextOffset":null}}},"prompt":"The handoff for project project-app, thread thread-cache-decision, includes conversation summary summary:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb: 'The team agreed to keep the TTL at five minutes.' What did the user actually say? Verify the original message; do not treat the summary as a quotation.","expect":{"calls":[{"tool":"t3_conversation_summary_expand","args":{"projectId":"project-app","threadId":"thread-cache-decision","summaryId":"summary:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}}],"textMust":["four minutes|four-minute"],"notCalls":["t3_knowledge_mutate","t3_project_code_query","t3_project_loom_read"]},"source":{"kind":"integration-audit","commit":"47021ecc04f525c260159bd82f9f16e748ce41c3","path":"evals/mcp-tool-use/run.ts","finding":"The agent replay catalog omitted the knowledge, conversation summary, project code, and Loom toolkits."}} -{"id":"intelligence-remote-recall-scope","area":"project-intelligence","kind":"guard","pattern":"knowledge IDs and citations stay on the selected environment","experience":"The integration audit found no intelligence tools in the model replay catalog and no task-specific intelligence guidance in the shared provider instructions.","privacy":"commit-safe","setup":{"role":"top","mocks":{"t3_knowledge_search":{"messages":{"matches":[],"nextCursor":null},"facts":{"state":"available","coverage":"complete","nextCursor":null,"matches":[{"fact":{"id":"fact-cache-ttl","projectId":"project-build","content":"Keep the cache TTL at five minutes because downstream invalidations can arrive late.","entities":["cache"],"tags":["ttl"],"kind":"decision","sources":[{"kind":"message","threadId":"thread-cache-decision","messageId":"message-cache-decision","digest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}],"origin":{"kind":"agent-thread","threadId":"thread-cache-decision","providerInstanceId":"codex"},"revision":1,"trust":0.9,"retrievalCount":1,"createdAt":"2026-10-22T12:00:00.000Z","updatedAt":"2026-10-22T12:00:00.000Z","status":"confirmed","forgottenAt":null},"score":{"holographic":0.9,"lexical":0.9,"tokenOverlap":1,"temporalDecay":1,"reinforcement":1,"trust":0.9,"combined":0.9}}]}},"t3_knowledge_read_source":{"state":"available","source":{"kind":"message","threadId":"thread-cache-decision","messageId":"message-cache-decision","digest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},"text":"Keep the TTL at five minutes. Our downstream invalidation feed can arrive up to four minutes late; shortening it would break our stated cache consistency contract.","title":"Cache retention decision","role":"user","createdAt":"2026-10-22T12:00:00.000Z","archived":false}}},"prompt":"Use the linked build machine (environmentId build-box), registered project project-build. Why did we keep the cache TTL at five minutes there? Verify the original cited message on that same machine.","expect":{"calls":[{"tool":"t3_knowledge_search","args":{"environmentId":"build-box","projectId":"project-build","query":"~cache"}},{"tool":"t3_knowledge_read_source","args":{"environmentId":"build-box","projectId":"project-build","threadId":"thread-cache-decision","messageId":"message-cache-decision"}}],"order":["t3_knowledge_search","t3_knowledge_read_source"],"maxCalls":{"t3_knowledge_search":1,"t3_knowledge_read_source":1},"notCalls":["t3_knowledge_mutate"]},"source":{"kind":"integration-audit","commit":"47021ecc04f525c260159bd82f9f16e748ce41c3","path":"evals/mcp-tool-use/run.ts","finding":"The agent replay catalog omitted the knowledge, conversation summary, project code, and Loom toolkits."}} -{"id":"intelligence-return-to-local-scope","area":"project-intelligence","kind":"control","pattern":"a previous remote query does not change the current environment","experience":"The integration audit found no intelligence tools in the model replay catalog and no task-specific intelligence guidance in the shared provider instructions.","privacy":"commit-safe","setup":{"role":"top","priorTurns":[{"role":"user","text":"Inspect readCache on the linked build box."},{"role":"assistant","text":"The build box has one static caller.","toolCalls":[{"tool":"t3_project_code_query","input":{"environmentId":"build-box","projectId":"project-build","query":{"operation":"impact","symbol":{"name":"readCache","filePath":"src/cache.ts"}}},"result":{"operation":"impact","checkoutPath":"/home/user/app-worktree","matches":[],"nodes":[{"id":"symbol-cache","name":"readCache","qualifiedName":"readCache","kind":"function","language":"typescript","path":"src/cache.ts","startLine":10,"endLine":14,"startColumn":0,"endColumn":1,"signature":null,"isExported":true},{"id":"symbol-loader","name":"loadUser","qualifiedName":"loadUser","kind":"function","language":"typescript","path":"src/loader.ts","startLine":20,"endLine":24,"startColumn":0,"endColumn":1,"signature":null,"isExported":true}],"edges":[{"source":"symbol-loader","target":"symbol-cache","kind":"call","confidence":0.8,"resolvedBy":"import","provenance":"tree-sitter","evidenceGrade":"static-candidate","line":22,"column":2,"referenceName":"readCache","candidateTargets":[]}],"files":[{"path":"src/cache.ts"},{"path":"src/loader.ts"}],"unresolvedReferences":[],"selection":{"state":"matched","candidates":[{"id":"symbol-cache","name":"readCache","qualifiedName":"readCache","kind":"function","language":"typescript","path":"src/cache.ts","startLine":10,"endLine":14,"startColumn":0,"endColumn":1,"signature":null,"isExported":true}]},"sourceDigests":[{"path":"src/cache.ts","digest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","verified":true}],"stats":null,"coverage":{"state":"complete","catalogRevision":"test","relationSemantics":"static-candidates","columnEncoding":"utf16-code-units","languages":["typescript"],"indexedLanguages":["typescript"],"filesListed":2,"filesIndexed":2,"filesSkipped":0,"filesUnsupported":0,"filesFailed":0,"pendingReferences":0,"resultLimitReached":false,"limitations":["Static candidates require source inspection before claiming runtime reachability."]}}}]}],"mocks":{"t3_project_code_query":{"operation":"impact","checkoutPath":"/home/user/app-worktree","matches":[],"nodes":[{"id":"symbol-cache","name":"readCache","qualifiedName":"readCache","kind":"function","language":"typescript","path":"src/cache.ts","startLine":10,"endLine":14,"startColumn":0,"endColumn":1,"signature":null,"isExported":true},{"id":"symbol-loader","name":"loadUser","qualifiedName":"loadUser","kind":"function","language":"typescript","path":"src/loader.ts","startLine":20,"endLine":24,"startColumn":0,"endColumn":1,"signature":null,"isExported":true}],"edges":[{"source":"symbol-loader","target":"symbol-cache","kind":"call","confidence":0.8,"resolvedBy":"import","provenance":"tree-sitter","evidenceGrade":"static-candidate","line":22,"column":2,"referenceName":"readCache","candidateTargets":[]}],"files":[{"path":"src/cache.ts"},{"path":"src/loader.ts"}],"unresolvedReferences":[],"selection":{"state":"matched","candidates":[{"id":"symbol-cache","name":"readCache","qualifiedName":"readCache","kind":"function","language":"typescript","path":"src/cache.ts","startLine":10,"endLine":14,"startColumn":0,"endColumn":1,"signature":null,"isExported":true}]},"sourceDigests":[{"path":"src/cache.ts","digest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","verified":true}],"stats":null,"coverage":{"state":"complete","catalogRevision":"test","relationSemantics":"static-candidates","columnEncoding":"utf16-code-units","languages":["typescript"],"indexedLanguages":["typescript"],"filesListed":2,"filesIndexed":2,"filesSkipped":0,"filesUnsupported":0,"filesFailed":0,"pendingReferences":0,"resultLimitReached":false,"limitations":["Static candidates require source inspection before claiming runtime reachability."]}}}},"prompt":"Now check the current machine instead: project project-app, thread thread-feature. Use its code graph to find the impact of readCache in src/cache.ts.","expect":{"calls":[{"tool":"t3_project_code_query","args":{"projectId":"project-app","threadId":"thread-feature","query.operation":"impact","query.symbol.name":"readCache"}}],"callsMustNot":[{"tool":"t3_project_code_query","args":{"environmentId":"build-box"}}],"maxCalls":{"t3_project_code_query":1},"notCalls":["t3_environment_link","t3_environment_links","t3_knowledge_search","t3_project_loom_read"]},"source":{"kind":"integration-audit","commit":"47021ecc04f525c260159bd82f9f16e748ce41c3","path":"evals/mcp-tool-use/run.ts","finding":"The agent replay catalog omitted the knowledge, conversation summary, project code, and Loom toolkits."}} -{"id":"intelligence-ordinary-edit-control","area":"project-intelligence","kind":"control","pattern":"a self-contained edit needs no project intelligence calls","experience":"The integration audit found no intelligence tools in the model replay catalog and no task-specific intelligence guidance in the shared provider instructions.","privacy":"commit-safe","setup":{"role":"top","world":{"branch":"fix/save-label","files":{"src/labels.ts":"export const saveLabel = 'Save changes';\n"}}},"prompt":"In src/labels.ts change the exported saveLabel string from 'Save changes' to 'Save'. This is just a copy edit; do not change anything else.","expect":{"calls":[{"tool":"Edit|Write|Bash"}],"notCalls":["t3_knowledge_search","t3_knowledge_list","t3_knowledge_read_fact","t3_knowledge_status","t3_knowledge_read_source","t3_knowledge_prepare_context","t3_knowledge_mutate","t3_conversation_summary_list","t3_conversation_summary_describe","t3_conversation_summary_expand","t3_project_code_query","t3_project_file_history","t3_project_loom_read"]},"source":{"kind":"integration-audit","commit":"47021ecc04f525c260159bd82f9f16e748ce41c3","path":"evals/mcp-tool-use/run.ts","finding":"The agent replay catalog omitted the knowledge, conversation summary, project code, and Loom toolkits."}} +{"id":"intelligence-recall-with-citations","area":"project-intelligence","kind":"bad","pattern":"prior project decision needs memory and canonical evidence","experience":"The integration audit found no intelligence tools in the model replay catalog and no task-specific intelligence guidance in the shared provider instructions.","privacy":"commit-safe","setup":{"role":"top","mocks":{"t3_knowledge_search":{"messages":{"matches":[],"nextCursor":null},"facts":{"state":"available","coverage":"complete","nextCursor":null,"matches":[{"fact":{"id":"fact-cache-ttl","projectId":"project-app","content":"Keep the cache TTL at five minutes because downstream invalidations can arrive late.","entities":["cache"],"tags":["ttl"],"kind":"decision","sources":[{"kind":"message","threadId":"thread-cache-decision","messageId":"message-cache-decision","digest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}],"origin":{"kind":"agent-thread","threadId":"thread-cache-decision","providerInstanceId":"codex"},"revision":1,"trust":0.9,"retrievalCount":1,"createdAt":"2026-10-22T12:00:00.000Z","updatedAt":"2026-10-22T12:00:00.000Z","status":"confirmed","forgottenAt":null},"score":{"holographic":0.9,"lexical":0.9,"tokenOverlap":1,"temporalDecay":1,"reinforcement":1,"trust":0.9,"combined":0.9}}]}},"t3_knowledge_read_source":{"state":"available","source":{"kind":"message","threadId":"thread-cache-decision","messageId":"message-cache-decision","digest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},"text":"Keep the TTL at five minutes. Our downstream invalidation feed can arrive up to four minutes late; shortening it would break our stated cache consistency contract.","title":"Cache retention decision","role":"user","createdAt":"2026-10-22T12:00:00.000Z","archived":false}}},"prompt":"For the current machine's registered project project-app, why did we keep the cache TTL at five minutes? Find the prior decision and verify its original message.","expect":{"calls":[{"tool":"t3_knowledge_search","args":{"projectId":"project-app","query":"~cache"}},{"tool":"t3_knowledge_read_source","args":{"projectId":"project-app","threadId":"thread-cache-decision","messageId":"message-cache-decision","digest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}],"order":["t3_knowledge_search","t3_knowledge_read_source"],"notCalls":["t3_knowledge_mutate","t3_project_code_query"],"maxCalls":{"t3_knowledge_search":1}},"source":{"kind":"integration-audit","commit":"47021ecc04f525c260159bd82f9f16e748ce41c3","path":"evals/mcp-tool-use/run.ts","finding":"The agent replay catalog omitted the knowledge, conversation summary, and project code toolkits."}} +{"id":"intelligence-code-impact-worktree","area":"project-intelligence","kind":"bad","pattern":"symbol impact must use the registered worktree","experience":"The integration audit found no intelligence tools in the model replay catalog and no task-specific intelligence guidance in the shared provider instructions.","privacy":"commit-safe","setup":{"role":"top","mocks":{"t3_project_code_query":{"operation":"impact","checkoutPath":"/home/user/app-worktree","matches":[],"nodes":[{"id":"symbol-cache","name":"readCache","qualifiedName":"readCache","kind":"function","language":"typescript","path":"src/cache.ts","startLine":10,"endLine":14,"startColumn":0,"endColumn":1,"signature":null,"isExported":true},{"id":"symbol-loader","name":"loadUser","qualifiedName":"loadUser","kind":"function","language":"typescript","path":"src/loader.ts","startLine":20,"endLine":24,"startColumn":0,"endColumn":1,"signature":null,"isExported":true}],"edges":[{"source":"symbol-loader","target":"symbol-cache","kind":"call","confidence":0.8,"resolvedBy":"import","provenance":"tree-sitter","evidenceGrade":"static-candidate","line":22,"column":2,"referenceName":"readCache","candidateTargets":[]}],"files":[{"path":"src/cache.ts"},{"path":"src/loader.ts"}],"unresolvedReferences":[],"selection":{"state":"matched","candidates":[{"id":"symbol-cache","name":"readCache","qualifiedName":"readCache","kind":"function","language":"typescript","path":"src/cache.ts","startLine":10,"endLine":14,"startColumn":0,"endColumn":1,"signature":null,"isExported":true}]},"sourceDigests":[{"path":"src/cache.ts","digest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","verified":true}],"stats":null,"coverage":{"state":"complete","catalogRevision":"test","relationSemantics":"static-candidates","columnEncoding":"utf16-code-units","languages":["typescript"],"indexedLanguages":["typescript"],"filesListed":2,"filesIndexed":2,"filesSkipped":0,"filesUnsupported":0,"filesFailed":0,"pendingReferences":0,"resultLimitReached":false,"limitations":["Static candidates require source inspection before claiming runtime reachability."]}}},"world":{"files":{"src/cache.ts":"export function readCache(key: string) { return cache.get(key); }\n","src/loader.ts":"import { readCache } from './cache';\nexport function loadUser(id: string) { return readCache(id); }\n"}}},"prompt":"In registered project project-app, thread thread-feature uses a worktree. What could be affected by changing readCache in src/cache.ts there? Start with the code graph; no edits.","expect":{"calls":[{"tool":"t3_project_code_query","args":{"projectId":"project-app","threadId":"thread-feature","query.operation":"impact","query.symbol.name":"readCache","query.symbol.filePath":"src/cache.ts"}}],"notCalls":["t3_knowledge_mutate","t3_knowledge_search"]},"source":{"kind":"integration-audit","commit":"47021ecc04f525c260159bd82f9f16e748ce41c3","path":"evals/mcp-tool-use/run.ts","finding":"The agent replay catalog omitted the knowledge, conversation summary, and project code toolkits."}} +{"id":"intelligence-summary-exact-source","area":"project-intelligence","kind":"bad","pattern":"a model summary is not the original decision","experience":"The integration audit found no intelligence tools in the model replay catalog and no task-specific intelligence guidance in the shared provider instructions.","privacy":"commit-safe","setup":{"role":"top","mocks":{"t3_conversation_summary_expand":{"summary":{"state":"available","summary":{"id":"summary:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","projectId":"project-app","threadId":"thread-cache-decision","sources":[{"kind":"message","threadId":"thread-cache-decision","messageId":"message-cache-decision","digest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}],"statements":[{"text":"The team agreed to keep the TTL at five minutes.","citations":["message-cache-decision"]}],"modelSelection":{"instanceId":"codex","model":"gpt-5"},"createdAt":"2026-10-22T12:00:00.000Z"}},"sources":[{"ref":{"kind":"message","threadId":"thread-cache-decision","messageId":"message-cache-decision","digest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},"result":{"state":"available","source":{"kind":"message","threadId":"thread-cache-decision","messageId":"message-cache-decision","digest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},"text":"Keep the TTL at five minutes. Our downstream invalidation feed can arrive up to four minutes late; shortening it would break our stated cache consistency contract.","title":"Cache retention decision","role":"user","createdAt":"2026-10-22T12:00:00.000Z","archived":false}}],"nextOffset":null}}},"prompt":"The handoff for project project-app, thread thread-cache-decision, includes conversation summary summary:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb: 'The team agreed to keep the TTL at five minutes.' What did the user actually say? Verify the original message; do not treat the summary as a quotation.","expect":{"calls":[{"tool":"t3_conversation_summary_expand","args":{"projectId":"project-app","threadId":"thread-cache-decision","summaryId":"summary:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}}],"textMust":["four minutes|four-minute"],"notCalls":["t3_knowledge_mutate","t3_project_code_query"]},"source":{"kind":"integration-audit","commit":"47021ecc04f525c260159bd82f9f16e748ce41c3","path":"evals/mcp-tool-use/run.ts","finding":"The agent replay catalog omitted the knowledge, conversation summary, and project code toolkits."}} +{"id":"intelligence-remote-recall-scope","area":"project-intelligence","kind":"guard","pattern":"knowledge IDs and citations stay on the selected environment","experience":"The integration audit found no intelligence tools in the model replay catalog and no task-specific intelligence guidance in the shared provider instructions.","privacy":"commit-safe","setup":{"role":"top","mocks":{"t3_knowledge_search":{"messages":{"matches":[],"nextCursor":null},"facts":{"state":"available","coverage":"complete","nextCursor":null,"matches":[{"fact":{"id":"fact-cache-ttl","projectId":"project-build","content":"Keep the cache TTL at five minutes because downstream invalidations can arrive late.","entities":["cache"],"tags":["ttl"],"kind":"decision","sources":[{"kind":"message","threadId":"thread-cache-decision","messageId":"message-cache-decision","digest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}],"origin":{"kind":"agent-thread","threadId":"thread-cache-decision","providerInstanceId":"codex"},"revision":1,"trust":0.9,"retrievalCount":1,"createdAt":"2026-10-22T12:00:00.000Z","updatedAt":"2026-10-22T12:00:00.000Z","status":"confirmed","forgottenAt":null},"score":{"holographic":0.9,"lexical":0.9,"tokenOverlap":1,"temporalDecay":1,"reinforcement":1,"trust":0.9,"combined":0.9}}]}},"t3_knowledge_read_source":{"state":"available","source":{"kind":"message","threadId":"thread-cache-decision","messageId":"message-cache-decision","digest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},"text":"Keep the TTL at five minutes. Our downstream invalidation feed can arrive up to four minutes late; shortening it would break our stated cache consistency contract.","title":"Cache retention decision","role":"user","createdAt":"2026-10-22T12:00:00.000Z","archived":false}}},"prompt":"Use the linked build machine (environmentId build-box), registered project project-build. Why did we keep the cache TTL at five minutes there? Verify the original cited message on that same machine.","expect":{"calls":[{"tool":"t3_knowledge_search","args":{"environmentId":"build-box","projectId":"project-build","query":"~cache"}},{"tool":"t3_knowledge_read_source","args":{"environmentId":"build-box","projectId":"project-build","threadId":"thread-cache-decision","messageId":"message-cache-decision"}}],"order":["t3_knowledge_search","t3_knowledge_read_source"],"maxCalls":{"t3_knowledge_search":1,"t3_knowledge_read_source":1},"notCalls":["t3_knowledge_mutate"]},"source":{"kind":"integration-audit","commit":"47021ecc04f525c260159bd82f9f16e748ce41c3","path":"evals/mcp-tool-use/run.ts","finding":"The agent replay catalog omitted the knowledge, conversation summary, and project code toolkits."}} +{"id":"intelligence-return-to-local-scope","area":"project-intelligence","kind":"control","pattern":"a previous remote query does not change the current environment","experience":"The integration audit found no intelligence tools in the model replay catalog and no task-specific intelligence guidance in the shared provider instructions.","privacy":"commit-safe","setup":{"role":"top","priorTurns":[{"role":"user","text":"Inspect readCache on the linked build box."},{"role":"assistant","text":"The build box has one static caller.","toolCalls":[{"tool":"t3_project_code_query","input":{"environmentId":"build-box","projectId":"project-build","query":{"operation":"impact","symbol":{"name":"readCache","filePath":"src/cache.ts"}}},"result":{"operation":"impact","checkoutPath":"/home/user/app-worktree","matches":[],"nodes":[{"id":"symbol-cache","name":"readCache","qualifiedName":"readCache","kind":"function","language":"typescript","path":"src/cache.ts","startLine":10,"endLine":14,"startColumn":0,"endColumn":1,"signature":null,"isExported":true},{"id":"symbol-loader","name":"loadUser","qualifiedName":"loadUser","kind":"function","language":"typescript","path":"src/loader.ts","startLine":20,"endLine":24,"startColumn":0,"endColumn":1,"signature":null,"isExported":true}],"edges":[{"source":"symbol-loader","target":"symbol-cache","kind":"call","confidence":0.8,"resolvedBy":"import","provenance":"tree-sitter","evidenceGrade":"static-candidate","line":22,"column":2,"referenceName":"readCache","candidateTargets":[]}],"files":[{"path":"src/cache.ts"},{"path":"src/loader.ts"}],"unresolvedReferences":[],"selection":{"state":"matched","candidates":[{"id":"symbol-cache","name":"readCache","qualifiedName":"readCache","kind":"function","language":"typescript","path":"src/cache.ts","startLine":10,"endLine":14,"startColumn":0,"endColumn":1,"signature":null,"isExported":true}]},"sourceDigests":[{"path":"src/cache.ts","digest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","verified":true}],"stats":null,"coverage":{"state":"complete","catalogRevision":"test","relationSemantics":"static-candidates","columnEncoding":"utf16-code-units","languages":["typescript"],"indexedLanguages":["typescript"],"filesListed":2,"filesIndexed":2,"filesSkipped":0,"filesUnsupported":0,"filesFailed":0,"pendingReferences":0,"resultLimitReached":false,"limitations":["Static candidates require source inspection before claiming runtime reachability."]}}}]}],"mocks":{"t3_project_code_query":{"operation":"impact","checkoutPath":"/home/user/app-worktree","matches":[],"nodes":[{"id":"symbol-cache","name":"readCache","qualifiedName":"readCache","kind":"function","language":"typescript","path":"src/cache.ts","startLine":10,"endLine":14,"startColumn":0,"endColumn":1,"signature":null,"isExported":true},{"id":"symbol-loader","name":"loadUser","qualifiedName":"loadUser","kind":"function","language":"typescript","path":"src/loader.ts","startLine":20,"endLine":24,"startColumn":0,"endColumn":1,"signature":null,"isExported":true}],"edges":[{"source":"symbol-loader","target":"symbol-cache","kind":"call","confidence":0.8,"resolvedBy":"import","provenance":"tree-sitter","evidenceGrade":"static-candidate","line":22,"column":2,"referenceName":"readCache","candidateTargets":[]}],"files":[{"path":"src/cache.ts"},{"path":"src/loader.ts"}],"unresolvedReferences":[],"selection":{"state":"matched","candidates":[{"id":"symbol-cache","name":"readCache","qualifiedName":"readCache","kind":"function","language":"typescript","path":"src/cache.ts","startLine":10,"endLine":14,"startColumn":0,"endColumn":1,"signature":null,"isExported":true}]},"sourceDigests":[{"path":"src/cache.ts","digest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","verified":true}],"stats":null,"coverage":{"state":"complete","catalogRevision":"test","relationSemantics":"static-candidates","columnEncoding":"utf16-code-units","languages":["typescript"],"indexedLanguages":["typescript"],"filesListed":2,"filesIndexed":2,"filesSkipped":0,"filesUnsupported":0,"filesFailed":0,"pendingReferences":0,"resultLimitReached":false,"limitations":["Static candidates require source inspection before claiming runtime reachability."]}}}},"prompt":"Now check the current machine instead: project project-app, thread thread-feature. Use its code graph to find the impact of readCache in src/cache.ts.","expect":{"calls":[{"tool":"t3_project_code_query","args":{"projectId":"project-app","threadId":"thread-feature","query.operation":"impact","query.symbol.name":"readCache"}}],"callsMustNot":[{"tool":"t3_project_code_query","args":{"environmentId":"build-box"}}],"maxCalls":{"t3_project_code_query":1},"notCalls":["t3_environment_link","t3_environment_links","t3_knowledge_search"]},"source":{"kind":"integration-audit","commit":"47021ecc04f525c260159bd82f9f16e748ce41c3","path":"evals/mcp-tool-use/run.ts","finding":"The agent replay catalog omitted the knowledge, conversation summary, and project code toolkits."}} +{"id":"intelligence-ordinary-edit-control","area":"project-intelligence","kind":"control","pattern":"a self-contained edit needs no project intelligence calls","experience":"The integration audit found no intelligence tools in the model replay catalog and no task-specific intelligence guidance in the shared provider instructions.","privacy":"commit-safe","setup":{"role":"top","world":{"branch":"fix/save-label","files":{"src/labels.ts":"export const saveLabel = 'Save changes';\n"}}},"prompt":"In src/labels.ts change the exported saveLabel string from 'Save changes' to 'Save'. This is just a copy edit; do not change anything else.","expect":{"calls":[{"tool":"Edit|Write|Bash"}],"notCalls":["t3_knowledge_search","t3_knowledge_list","t3_knowledge_read_fact","t3_knowledge_status","t3_knowledge_read_source","t3_knowledge_prepare_context","t3_knowledge_mutate","t3_conversation_summary_list","t3_conversation_summary_describe","t3_conversation_summary_expand","t3_project_code_query","t3_project_file_history"]},"source":{"kind":"integration-audit","commit":"47021ecc04f525c260159bd82f9f16e748ce41c3","path":"evals/mcp-tool-use/run.ts","finding":"The agent replay catalog omitted the knowledge, conversation summary, and project code toolkits."}} diff --git a/evals/mcp-tool-use/run.ts b/evals/mcp-tool-use/run.ts index be2924a532a9f..01b86588a7094 100644 --- a/evals/mcp-tool-use/run.ts +++ b/evals/mcp-tool-use/run.ts @@ -43,7 +43,6 @@ import { PreviewToolkit } from "../../apps/server/src/mcp/toolkits/preview/tools import { PreviewControlsToolkit } from "../../apps/server/src/mcp/toolkits/previewControls/tools.ts"; import { ProjectToolkit } from "../../apps/server/src/mcp/toolkits/project/tools.ts"; import { ProjectCodeToolkit } from "../../apps/server/src/mcp/toolkits/projectCode/tools.ts"; -import { ProjectLoomToolkit } from "../../apps/server/src/mcp/toolkits/projectLoom/tools.ts"; import { CHILD_PULL_REQUEST_REPORT_NEXT, PullRequestsToolkit, @@ -301,7 +300,6 @@ const t3SourceTools: ReadonlyArray = [ KnowledgeToolkit, ConversationSummariesToolkit, ProjectCodeToolkit, - ProjectLoomToolkit, EnvironmentToolkit, PreviewControlsToolkit, WorktreeToolkit, diff --git a/packages/provider-core/src/server/orchestrationInstructions.ts b/packages/provider-core/src/server/orchestrationInstructions.ts index 0d94c0c623cfa..784c4fdd0530c 100644 --- a/packages/provider-core/src/server/orchestrationInstructions.ts +++ b/packages/provider-core/src/server/orchestrationInstructions.ts @@ -21,7 +21,7 @@ Ordinary coding tasks need no T3 tools. - Use intelligence when the task needs project history or relationships; do not prefetch it for a routine edit. Resolve an unknown project with \`t3_project_list\` and \`t3_project_read\`. - Prior decisions, conventions, and constraints: \`t3_knowledge_search\`, then verify message citations with \`t3_knowledge_read_source\`. Recall is on demand. When asked to remember project knowledge, use \`t3_knowledge_mutate\` with exact message citations when available; an agent proposal is not a user confirmation. - Symbols, callers, dependencies, affected code, or reaching tests: \`t3_project_code_query\`. Pass the thread id for its registered worktree. Check coverage, freshness, and edge evidence; static candidates are not proof of runtime behavior. -- Work across threads: \`t3_project_loom_read\`. For a file's checkpoint history, \`t3_project_file_history\`. A checkpoint does not establish authorship, and a test command marker does not establish that tests passed. +- For a file's checkpoint history, use \`t3_project_file_history\`. A checkpoint does not establish authorship. - A handoff summary is model-written. Use \`t3_conversation_summary_expand\` for the original messages; list or describe summaries to locate one. If a source is unavailable, report that rather than quoting a stale excerpt. - \`t3_knowledge_prepare_context\` previews selected facts or messages for attachment; it does not send them. These tools use the same \`environmentId\` routing as other T3 tools. Keep the project, thread, fact, summary, and message ids on their owning environment; omit \`environmentId\` for this machine.`, `### Linked environments From 870c7197c73b83dea214fc5d73e414e5c7afcdcb Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Tue, 27 Oct 2026 18:02:55 -0700 Subject: [PATCH 3/4] test: include knowledge delivery migration in preview upgrades --- apps/server/src/persistence/reconcileV2PreviewMigration.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts index f0e8e1e5331f0..dd1fbbc7f57d0 100644 --- a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts +++ b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts @@ -60,6 +60,7 @@ describe("V2 preview upgrade", () => { [78, "ShellStopRequestIndex"], [79, "ThreadMessageSearch"], [80, "ProjectIntelligenceAndCrossEnvironment"], + [81, "ProjectKnowledgeMessageDeliveries"], ]); assert.deepStrictEqual(yield* runMigrations(), []); assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_legacy_imports`, imports); @@ -163,6 +164,7 @@ describe("V2 preview upgrade", () => { [78, "ShellStopRequestIndex"], [79, "ThreadMessageSearch"], [80, "ProjectIntelligenceAndCrossEnvironment"], + [81, "ProjectKnowledgeMessageDeliveries"], ]); }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), ); From e6c8ee4f1d630aeccddb6f39e54844a3a5121ada Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Wed, 28 Oct 2026 14:33:57 -0700 Subject: [PATCH 4/4] fix(server): a provider's runtime text-generation opt-out survives the registry The registry replaced each snapshot's supportsTextGeneration with the static operation list, so an Antigravity profile with global hooks, or an uninstalled or signed-out instance, still advertised text generation and writers stopped falling back to the default model. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/provider/ProviderRegistry.test.ts | 15 +++++++++------ apps/server/src/provider/ProviderRegistry.ts | 12 ++++++------ 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index f7a2063979256..11a30022b3536 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -427,9 +427,12 @@ it.layer( }); describe("ProviderRegistry.layer", () => { - it.effect( - "publishes operation capabilities from the runtime instead of stale probe flags", - () => + it.effect.each([ + ["replaces stale snapshot operations", true, ["generateConversationSummary"]], + ["keeps a runtime unavailable flag", false, []], + ] as const)( + "publishes operation capabilities from the runtime: %s", + ([, snapshotSupportsTextGeneration, expectedOperations]) => Effect.gen(function* () { const instanceId = ProviderInstanceId.make("writer"); const driverKind = ProviderDriverKind.make("muse"); @@ -443,7 +446,7 @@ it.layer( auth: { status: "authenticated" }, checkedAt: "2026-06-10T00:00:00.000Z", models: [], - supportsTextGeneration: false, + supportsTextGeneration: snapshotSupportsTextGeneration, textGenerationOperations: ["generateReviewGuide"], }); const instance: ProviderInstance = { @@ -475,8 +478,8 @@ it.layer( yield* Effect.gen(function* () { const registry = yield* ProviderRegistry.ProviderRegistry; const providers = yield* registry.refresh(); - expect(providers[0]?.supportsTextGeneration).toBe(true); - expect(providers[0]?.textGenerationOperations).toEqual(["generateConversationSummary"]); + expect(providers[0]?.supportsTextGeneration).toBe(expectedOperations.length > 0); + expect(providers[0]?.textGenerationOperations).toEqual(expectedOperations); }).pipe( Effect.provide( ProviderRegistry.layer.pipe( diff --git a/apps/server/src/provider/ProviderRegistry.ts b/apps/server/src/provider/ProviderRegistry.ts index e9e486e94f7f1..f38933da7f9d8 100644 --- a/apps/server/src/provider/ProviderRegistry.ts +++ b/apps/server/src/provider/ProviderRegistry.ts @@ -329,14 +329,14 @@ const correlateSnapshotWithSource = ( ), ); } + if (source.textGenerationOperations === undefined) return Effect.succeed(snapshot); + // A runtime `false` (Antigravity with global hooks, unauthenticated) still wins over the static list. + const operations = + snapshot.supportsTextGeneration === false ? [] : source.textGenerationOperations; return Effect.succeed({ ...snapshot, - ...(source.textGenerationOperations === undefined - ? {} - : { - supportsTextGeneration: source.textGenerationOperations.length > 0, - textGenerationOperations: source.textGenerationOperations, - }), + supportsTextGeneration: operations.length > 0, + textGenerationOperations: operations, }); };