From 9174b0639edfa2d9670f217b7145d12f5060fa63 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 17:14:11 +1000 Subject: [PATCH 01/12] feat(server): expose threads_list and threads_create over the t3-code MCP toolkit --- apps/server/src/mcp/McpHttpServer.ts | 7 ++ .../src/mcp/toolkits/threads/handlers.ts | 105 ++++++++++++++++++ apps/server/src/mcp/toolkits/threads/tools.ts | 52 +++++++++ .../ActivityPayloadProjection.test.ts | 72 ++++++++++++ .../ActivityPayloadProjection.ts | 48 ++++++++ apps/server/src/orchestration/decider.ts | 1 + packages/contracts/src/index.ts | 1 + packages/contracts/src/orchestration.ts | 5 + packages/contracts/src/threadsSurface.ts | 68 ++++++++++++ 9 files changed, 359 insertions(+) create mode 100644 apps/server/src/mcp/toolkits/threads/handlers.ts create mode 100644 apps/server/src/mcp/toolkits/threads/tools.ts create mode 100644 packages/contracts/src/threadsSurface.ts diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 5a8cb573ad88..307c2ed6ef48 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -40,6 +40,8 @@ import { DeviceScreenshotToolkit, DeviceStandardToolkit, } from "./toolkits/device/tools.ts"; +import { ThreadsToolkitHandlersLive } from "./toolkits/threads/handlers.ts"; +import { ThreadsToolkit } from "./toolkits/threads/tools.ts"; const unauthorized = HttpServerResponse.jsonUnsafe( { @@ -599,6 +601,10 @@ const PreviewSnapshotRegistrationLive = Layer.effectDiscard(registerPreviewSnaps Layer.provide(PreviewSnapshotToolkitHandlersLive), ); +const ThreadsToolkitRegistrationLive = McpServer.toolkit(ThreadsToolkit).pipe( + Layer.provide(ThreadsToolkitHandlersLive), +); + export const PreviewToolkitRegistrationLive = Layer.mergeAll( PreviewStandardToolkitRegistrationLive, PreviewSnapshotRegistrationLive, @@ -630,6 +636,7 @@ const McpTransportLive = McpServer.layerHttp({ export const layer = Layer.mergeAll( PreviewToolkitRegistrationLive, + ThreadsToolkitRegistrationLive, PullRequestsToolkitRegistrationLive, DeviceToolkitRegistrationLive, ).pipe(Layer.provideMerge(McpTransportLive)); diff --git a/apps/server/src/mcp/toolkits/threads/handlers.ts b/apps/server/src/mcp/toolkits/threads/handlers.ts new file mode 100644 index 000000000000..d751294d669f --- /dev/null +++ b/apps/server/src/mcp/toolkits/threads/handlers.ts @@ -0,0 +1,105 @@ +import { + CommandId, + ThreadId, + type OrchestrationReadModel, + type ThreadsCreateInput, + type ThreadsCreateResult, + type ThreadsListInput, + type ThreadsListResult, + type ThreadsSurfaceError, + type ThreadsListItem, + THREADS_SURFACE_LIST_DEFAULT_LIMIT, + THREADS_SURFACE_LIST_MAX_LIMIT, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; + +import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import * as OrchestrationEngine from "../../../orchestration/Services/OrchestrationEngine.ts"; +import * as ProjectionSnapshotQuery from "../../../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { ThreadsToolkit } from "./tools.ts"; + +const fail = (detail: string) => + Effect.fail({ _tag: "ThreadsSurfaceError", detail }); + +const failFrom = (error: { readonly message: string }) => fail(error.message); + +const isThreadSettled = (thread: OrchestrationReadModel["threads"][number]): boolean => + thread.settledOverride === "settled" || + (thread.settledOverride === null && thread.settledAt !== null); + +const liveThreads = (readModel: OrchestrationReadModel) => + readModel.threads.filter((thread) => thread.deletedAt === null && thread.archivedAt === null); + +const threadsList = (input: ThreadsListInput) => + Effect.gen(function* () { + const query = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const readModel = yield* query.getCommandReadModel().pipe(Effect.catch(failFrom)); + + const threads = liveThreads(readModel) + .filter((thread) => input.projectId === undefined || thread.projectId === input.projectId) + .filter((thread) => { + if (input.filter === "settled") return isThreadSettled(thread); + if (input.filter === "active") return !isThreadSettled(thread); + return true; + }) + .sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : a.updatedAt > b.updatedAt ? -1 : 0)) + .slice( + 0, + Math.min(input.limit ?? THREADS_SURFACE_LIST_DEFAULT_LIMIT, THREADS_SURFACE_LIST_MAX_LIMIT), + ); + + const items: ThreadsListItem[] = threads.map((thread) => ({ + threadId: thread.id, + projectId: thread.projectId, + title: thread.title, + settled: isThreadSettled(thread), + updatedAt: thread.updatedAt, + })); + return { threads: items } satisfies ThreadsListResult; + }); + +const threadsCreate = (input: ThreadsCreateInput) => + Effect.gen(function* () { + const scope = yield* McpInvocationContext.McpInvocationContext; + const query = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const readModel = yield* query.getCommandReadModel().pipe(Effect.catch(failFrom)); + + const callingThread = readModel.threads.find((thread) => thread.id === scope.threadId); + if (!callingThread) { + return yield* fail("Calling thread no longer exists; cannot derive the target project."); + } + const projectId = input.projectId ?? callingThread.projectId; + if (!readModel.projects.some((project) => project.id === projectId)) { + return yield* fail(`Project ${projectId} does not exist in this environment.`); + } + + const crypto = yield* Crypto.Crypto; + const uuid = yield* crypto.randomUUIDv4.pipe(Effect.orDie); + const createdAt = DateTime.formatIso(yield* DateTime.now); + const engine = yield* OrchestrationEngine.OrchestrationEngineService; + yield* engine + .dispatch({ + type: "thread.create", + commandId: CommandId.make(`threads-create:${uuid}`), + threadId: ThreadId.make(uuid), + projectId, + title: input.title, + modelSelection: callingThread.modelSelection, + runtimeMode: callingThread.runtimeMode, + interactionMode: callingThread.interactionMode, + branch: null, + worktreePath: null, + source: "agent", + createdAt, + }) + .pipe(Effect.catch(failFrom)); + + return { threadId: ThreadId.make(uuid), title: input.title } satisfies ThreadsCreateResult; + }); + +export const ThreadsToolkitHandlersLive = ThreadsToolkit.toLayer({ + threads_list: threadsList, + threads_create: threadsCreate, +}); diff --git a/apps/server/src/mcp/toolkits/threads/tools.ts b/apps/server/src/mcp/toolkits/threads/tools.ts new file mode 100644 index 000000000000..836ece1c4599 --- /dev/null +++ b/apps/server/src/mcp/toolkits/threads/tools.ts @@ -0,0 +1,52 @@ +import { + ThreadsCreateInput, + ThreadsCreateResult, + ThreadsListInput, + ThreadsListResult, + ThreadsSurfaceError, +} from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import { Tool, Toolkit } from "effect/unstable/ai"; + +import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import * as OrchestrationEngine from "../../../orchestration/Services/OrchestrationEngine.ts"; +import * as ProjectionSnapshotQuery from "../../../orchestration/Services/ProjectionSnapshotQuery.ts"; + +const listDependencies = [ + McpInvocationContext.McpInvocationContext, + ProjectionSnapshotQuery.ProjectionSnapshotQuery, +]; + +const createDependencies = [ + McpInvocationContext.McpInvocationContext, + ProjectionSnapshotQuery.ProjectionSnapshotQuery, + OrchestrationEngine.OrchestrationEngineService, + Crypto.Crypto, +]; + +export const ThreadsListTool = Tool.make("threads_list", { + description: + "List threads in this environment. Returns thread ids and titles only — one row per thread with its id, project, title, settled state, and last-updated time. Use filter:'settled' for finished work, 'active' for in-flight threads, or 'recent' (default) for the most recently updated. Use this when the user asks to see threads rather than describe them from memory.", + parameters: ThreadsListInput, + success: ThreadsListResult, + failure: ThreadsSurfaceError, + dependencies: listDependencies, +}) + .annotate(Tool.Title, "List threads") + .annotate(Tool.Readonly, true) + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, true); + +export const ThreadsCreateTool = Tool.make("threads_create", { + description: + "Create a new, empty thread in this environment (in the current project unless projectId is given) and return its id. The thread starts with no conversation; the user can open it and start a turn. Creating a thread shows the user a notification with a link to it, so prefer this over describing where things live.", + parameters: ThreadsCreateInput, + success: ThreadsCreateResult, + failure: ThreadsSurfaceError, + dependencies: createDependencies, +}) + .annotate(Tool.Title, "Create thread") + .annotate(Tool.Readonly, false) + .annotate(Tool.Destructive, false); + +export const ThreadsToolkit = Toolkit.make(ThreadsListTool, ThreadsCreateTool); diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.test.ts b/apps/server/src/orchestration/ActivityPayloadProjection.test.ts index e6468ff8f789..b10f69c0af74 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.test.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.test.ts @@ -327,6 +327,78 @@ describe("projectActivityPayload", () => { ).not.toHaveProperty("toolIcon"); }); + it("keeps threads-surface results verbatim as structuredResult (Codex shape)", () => { + const result = { + threads: [ + { + threadId: "thrd_1", + projectId: "prj_1", + title: "Fix login redirect", + settled: true, + updatedAt: "2026-09-01T10:00:00.000Z", + }, + ], + }; + const projected = projectActivityPayload( + activity({ + itemType: "mcp_tool_call", + data: { + item: { + type: "mcpToolCall", + id: "item-2", + tool: "threads_list", + server: "t3-code", + status: "completed", + arguments: { filter: "settled" }, + result: { content: [{ type: "text", text: JSON.stringify(result) }] }, + }, + }, + }), + ); + const data = (projected.payload as Record).data as Record; + expect(data.structuredResult).toEqual(result); + }); + + it("keeps threads-surface results verbatim as structuredResult (Claude shape)", () => { + const result = { threadId: "thrd_2", title: "Explore canvas" }; + const projected = projectActivityPayload( + activity({ + itemType: "mcp_tool_call", + data: { + toolName: "threads_create", + input: { title: "Explore canvas" }, + result: { + type: "tool_result", + tool_use_id: "toolu_2", + content: [{ type: "text", text: JSON.stringify(result) }], + }, + }, + }), + ); + const data = (projected.payload as Record).data as Record; + expect(data.structuredResult).toEqual(result); + }); + + it("does not fabricate structuredResult when a threads-surface result is unparseable", () => { + const projected = projectActivityPayload( + activity({ + itemType: "mcp_tool_call", + data: { + toolName: "threads_list", + input: {}, + result: { + type: "tool_result", + tool_use_id: "toolu_3", + content: [{ type: "text", text: "not json" }], + }, + }, + }), + ); + const data = (projected.payload as Record).data as Record; + expect(data.structuredResult).toBeUndefined(); + expect(data.result).toEqual({ content: "not json" }); + }); + it("passes task lifecycle payloads (no data field) through untouched", () => { const source = activity({ taskId: "task-9", diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.ts b/apps/server/src/orchestration/ActivityPayloadProjection.ts index 0525aae7b72b..86b4f4c429e6 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.ts @@ -6,6 +6,7 @@ import type { } from "@t3tools/contracts"; import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; import { extractJsonObject } from "@t3tools/shared/schemaJson"; +import { THREADS_SURFACE_TOOL_NAMES } from "@t3tools/contracts"; function asRecord(value: unknown): Record | null { return value !== null && typeof value === "object" && !Array.isArray(value) @@ -306,6 +307,46 @@ function projectPreviewToolMetadata(data: Record, status: unkno } } +/** True when the tool belongs to the server's own threads surface toolkit. */ +function isThreadsSurfaceTool(data: Record, item: Record | null) { + const candidates = [data.toolName, item?.tool]; + return candidates.some( + (candidate) => + typeof candidate === "string" && + (THREADS_SURFACE_TOOL_NAMES as readonly string[]).includes(candidate), + ); +} + +/** + * Threads-surface tool results are small, structured, and needed verbatim by + * the clients (the item-list card renders thread ids from them), so they are + * preserved as `structuredResult` instead of the one-line summary every other + * MCP result gets. Results arrive wrapped per adapter (Codex `item.result`, + * Claude/OpenCode `data.result`); the JSON the toolkit encoded is extracted + * from the text content. + */ +function extractThreadsSurfaceStructuredResult( + data: Record, + item: Record | null, +): Record | undefined { + const rawResult = item?.result ?? data.result; + if (rawResult === undefined || rawResult === null) { + return undefined; + } + const text = extractMcpResultText(rawResult); + if (!text) { + return undefined; + } + try { + const parsed: unknown = JSON.parse(text); + return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record) + : undefined; + } catch { + return undefined; + } +} + /** * MCP tool calls carry full tool results (`data.item.result` on Codex, * `data.result` on Claude/OpenCode) that used to bypass slimming entirely to @@ -330,6 +371,13 @@ function projectMcpToolCallData(data: Record): Record Date: Tue, 15 Sep 2026 17:14:16 +1000 Subject: [PATCH 02/12] feat(web): render agent thread lists as clickable cards with create toasts --- apps/web/src/agentCreatedThreadToast.ts | 42 +++++ apps/web/src/components/ChatView.tsx | 21 +++ .../src/components/chat/MessagesTimeline.tsx | 65 ++++++- apps/web/src/session-logic.test.ts | 171 ++++++++++++++++++ apps/web/src/session-logic.ts | 94 +++++++++- pnpm-lock.yaml | 4 +- 6 files changed, 392 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/agentCreatedThreadToast.ts diff --git a/apps/web/src/agentCreatedThreadToast.ts b/apps/web/src/agentCreatedThreadToast.ts new file mode 100644 index 000000000000..f1c79f938bb8 --- /dev/null +++ b/apps/web/src/agentCreatedThreadToast.ts @@ -0,0 +1,42 @@ +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import type { EnvironmentId, ScopedThreadRef, ThreadsCreateResult } from "@t3tools/contracts"; + +import { stackedThreadToast, toastManager } from "./components/ui/toast"; + +export type AgentCreatedThread = ThreadsCreateResult; + +export type ThreadRouteNavigator = (threadRef: ScopedThreadRef) => void; + +// Module-level so a replayed event batch or a re-derived work log cannot +// double-toast the same thread. +const recentAgentThreadIds = new Set(); + +export function notifyAgentCreatedThreads(input: { + environmentId: EnvironmentId; + threads: ReadonlyArray; + navigate: ThreadRouteNavigator; +}): void { + for (const thread of input.threads) { + if (recentAgentThreadIds.has(thread.threadId)) { + continue; + } + recentAgentThreadIds.add(thread.threadId); + toastManager.add( + stackedThreadToast({ + type: "success", + title: "New thread created", + description: thread.title, + actionProps: { + children: "Open", + onClick: () => { + input.navigate(scopeThreadRef(input.environmentId, thread.threadId)); + }, + }, + }), + ); + } +} + +export function resetAgentCreatedThreadToastsForTests(): void { + recentAgentThreadIds.clear(); +} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 38f773cf037e..a63ca35675ab 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -125,6 +125,7 @@ import { deriveTimelineEntriesWithState, deriveActiveWorkStartedAt, deriveActivePlanState, + deriveAgentCreatedThreads, findLatestProposedPlan, deriveWorkLogEntries, hasActionableProposedPlan, @@ -132,6 +133,7 @@ import { selectHandoffImageResources, type TimelineEntriesProjection, } from "../session-logic"; +import { notifyAgentCreatedThreads } from "../agentCreatedThreadToast"; import { type LegendListRef } from "@legendapp/list/react"; import { CHAT_TIMELINE_ANCHOR_OFFSET, @@ -2864,6 +2866,25 @@ export default function ChatView(props: ChatViewProps) { [threadActivities], ); const workLogEntries = useMemo(() => deriveWorkLogEntries(threadActivities), [threadActivities]); + const agentCreatedThreads = useMemo( + () => deriveAgentCreatedThreads(workLogEntries), + [workLogEntries], + ); + useEffect(() => { + if (agentCreatedThreads.length === 0) { + return; + } + notifyAgentCreatedThreads({ + environmentId, + threads: agentCreatedThreads, + navigate: (threadRef) => { + void navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(threadRef), + }); + }, + }); + }, [agentCreatedThreads, environmentId, navigate]); // Native subagent fold: memoized by activity-list identity, shared by the // Agents surface, live strip, and workflow cards. v2Projection is null // until orchestration-v2 lands (source precedence lives in the derive). diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 307a893342f3..f5f50a058873 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -18,11 +18,12 @@ import { type MessageId, type ScopedThreadRef, type ServerProviderSkill, + type ThreadsListResult, type ToolActivityIcon, type TurnId, type WorktreeSetupSnapshot, } from "@t3tools/contracts"; -import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; +import { parseScopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences"; import type { CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates"; import { @@ -112,6 +113,7 @@ import { CircleAlertIcon, DownloadIcon, EyeIcon, + MinusIcon, GitPullRequestIcon, GlobeIcon, HammerIcon, @@ -237,7 +239,13 @@ import { useMediaQuery } from "~/hooks/useMediaQuery"; import { cn } from "~/lib/utils"; import { useUiStateStore } from "~/uiStateStore"; import { type TimestampFormat } from "@t3tools/contracts/settings"; -import { formatChatTimestampTooltip, formatDayAwareTimestamp } from "../../timestampFormat"; +import { + formatChatTimestampTooltip, + formatDayAwareTimestamp, + formatRelativeTimeLabel, +} from "../../timestampFormat"; +import { buildThreadRouteParams } from "../../threadRoutes"; +import { useNavigate } from "@tanstack/react-router"; import { SkillInlineText } from "./SkillInlineText"; import { deriveAgentSpawnSummary } from "./agentSpawnSummary"; @@ -4150,6 +4158,9 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { /> ); } + if (workEntry.threadsList) { + return ; + } return ( +

+ {threads.length} thread{threads.length === 1 ? "" : "s"} +

+
+ {threads.map((thread) => ( + + ))} +
+ + ); +}); + const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { workEntry: TimelineWorkEntry; workspaceRoot: string | undefined; diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 2dbcaeeb7929..3714141d0066 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -13,6 +13,7 @@ import { createMessageAttachmentPreviewProjector, deriveActiveWorkStartedAt, deriveActivePlanState, + deriveAgentCreatedThreads, deriveTimelineEntries, deriveTimelineEntriesWithState, deriveWorkLogEntries, @@ -1072,6 +1073,176 @@ describe("deriveWorkLogEntries", () => { expect(deriveWorkLogEntries(activities)).toHaveLength(2); }); + it("parses a threads_list structured result into the item-list card data", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "threads-list-done", + kind: "tool.completed", + summary: "t3-code · threads_list", + payload: { + itemType: "mcp_tool_call", + data: { + toolName: "threads_list", + structuredResult: { + threads: [ + { + threadId: "thread-1", + projectId: "project-1", + title: "Fix the bug", + settled: true, + updatedAt: "2026-02-23T00:00:00.000Z", + }, + { + threadId: "thread-2", + projectId: "project-1", + title: "Active work", + settled: false, + updatedAt: "2026-02-23T00:01:00.000Z", + }, + ], + }, + }, + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities); + expect(entry?.threadsList).toEqual({ + threads: [ + { + threadId: "thread-1", + projectId: "project-1", + title: "Fix the bug", + settled: true, + updatedAt: "2026-02-23T00:00:00.000Z", + }, + { + threadId: "thread-2", + projectId: "project-1", + title: "Active work", + settled: false, + updatedAt: "2026-02-23T00:01:00.000Z", + }, + ], + }); + expect(entry?.threadsCreated).toBeUndefined(); + }); + + it("detects the threads_list tool name on Codex-shaped item payloads", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "threads-list-codex", + kind: "tool.completed", + summary: "t3-code · threads_list", + payload: { + itemType: "mcp_tool_call", + data: { + item: { + type: "mcpToolCall", + tool: "threads_list", + result: { content: [{ type: "text", text: "{}" }] }, + }, + structuredResult: { threads: [] }, + }, + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities); + expect(entry?.threadsList).toEqual({ threads: [] }); + }); + + it("leaves threadsList undefined when the structured result shape is wrong", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "threads-list-bad", + kind: "tool.completed", + summary: "t3-code · threads_list", + payload: { + itemType: "mcp_tool_call", + data: { + toolName: "threads_list", + structuredResult: { threads: [{ threadId: "thread-1" }] }, + }, + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities); + expect(entry?.threadsList).toBeUndefined(); + }); + + it("captures the threads_create structured result for the new-thread toast", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "threads-create-done", + kind: "tool.completed", + summary: "t3-code · threads_create", + payload: { + itemType: "mcp_tool_call", + data: { + toolName: "threads_create", + structuredResult: { threadId: "thread-new", title: "Fresh thread" }, + }, + }, + }), + ]; + + const entries = deriveWorkLogEntries(activities); + expect(entries[0]?.threadsCreated).toEqual({ threadId: "thread-new", title: "Fresh thread" }); + expect(deriveAgentCreatedThreads(entries)).toEqual([ + { threadId: "thread-new", title: "Fresh thread" }, + ]); + }); + + it("derives one agent-created thread per id and skips failed calls", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "threads-create-progress", + kind: "tool.updated", + summary: "t3-code · threads_create", + payload: { + itemType: "mcp_tool_call", + status: "inProgress", + data: { + toolName: "threads_create", + structuredResult: { threadId: "thread-new", title: "Fresh thread" }, + }, + }, + }), + makeActivity({ + id: "threads-create-done", + kind: "tool.completed", + summary: "t3-code · threads_create", + payload: { + itemType: "mcp_tool_call", + data: { + toolName: "threads_create", + structuredResult: { threadId: "thread-new", title: "Fresh thread" }, + }, + }, + }), + makeActivity({ + id: "threads-create-failed", + kind: "tool.completed", + summary: "t3-code · threads_create", + payload: { + itemType: "mcp_tool_call", + status: "failed", + data: { + toolName: "threads_create", + structuredResult: { threadId: "thread-gone", title: "Doomed thread" }, + }, + }, + }), + ]; + + const entries = deriveWorkLogEntries(activities); + expect(deriveAgentCreatedThreads(entries)).toEqual([ + { threadId: "thread-new", title: "Fresh thread" }, + ]); + }); + it("unwraps PowerShell command wrappers for displayed command text", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 20a5671da850..d48572d032e2 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -26,8 +26,11 @@ import { type OrchestrationLatestTurn, type OrchestrationThreadActivity, type OrchestrationProposedPlanId, + type ThreadsCreateResult, + type ThreadsListResult, type ToolLifecycleItemType, - type ThreadId, + ProjectId, + ThreadId, type TurnId, } from "@t3tools/contracts"; @@ -72,6 +75,10 @@ export interface WorkLogEntry { toolIcon?: import("@t3tools/contracts").ToolActivityIcon; toolSource?: import("@t3tools/contracts").ToolActivitySource; toolData?: unknown; + /** Parsed `threads_list` structured result for the item-list card; absent when the payload shape is off. */ + threadsList?: ThreadsListResult; + /** Parsed `threads_create` structured result, used for the "new thread" toast. */ + threadsCreated?: ThreadsCreateResult; itemType?: ToolLifecycleItemType; requestKind?: PendingApproval["requestKind"]; /** From runtime item / task payload `status` when present (e.g. tool.updated). */ @@ -525,6 +532,31 @@ function isNoContentRuntimeWarning(activity: OrchestrationThreadActivity): boole ); } +/** + * Threads the agent created via `threads_create`, one per thread id, in work-log + * order. Failed or declined calls are excluded. + */ +export function deriveAgentCreatedThreads( + entries: ReadonlyArray, +): ThreadsCreateResult[] { + const byThreadId = new Map(); + for (const entry of entries) { + const created = entry.threadsCreated; + if ( + !created || + entry.toolLifecycleStatus === "failed" || + entry.toolLifecycleStatus === "declined" || + entry.toolLifecycleStatus === "stopped" + ) { + continue; + } + if (!byThreadId.has(created.threadId)) { + byThreadId.set(created.threadId, created); + } + } + return [...byThreadId.values()]; +} + function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): boolean { if (activity.kind !== "tool.updated" && activity.kind !== "tool.completed") { return false; @@ -638,6 +670,22 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo if (toolData !== undefined) { entry.toolData = toolData; } + // Tool-name detection mirrors the server's ActivityPayloadProjection: + // Claude/OpenCode put it at `data.toolName`, Codex at `data.item.tool`. + const item = asRecord(data?.item); + const toolName = asTrimmedString(data?.toolName) ?? (item ? asTrimmedString(item.tool) : null); + if (toolName === "threads_list") { + const threadsList = extractThreadsListResult(data?.structuredResult); + if (threadsList) { + entry.threadsList = threadsList; + } + } + if (toolName === "threads_create") { + const threadsCreated = extractThreadsCreateResult(data?.structuredResult); + if (threadsCreated) { + entry.threadsCreated = threadsCreated; + } + } } if (itemType) { entry.itemType = itemType; @@ -942,6 +990,50 @@ function asRecord(value: unknown): Record | null { return value && typeof value === "object" ? (value as Record) : null; } +/** + * Decodes a `threads_list` structured result defensively: any shape mismatch + * yields undefined so the timeline renders the plain tool row instead. + */ +function extractThreadsListResult(value: unknown): ThreadsListResult | undefined { + const record = asRecord(value); + if (!record || !Array.isArray(record.threads)) { + return undefined; + } + const threads: Array = []; + for (const item of record.threads) { + const entry = asRecord(item); + if (!entry) { + return undefined; + } + const threadId = asTrimmedString(entry.threadId); + const projectId = asTrimmedString(entry.projectId); + const title = asTrimmedString(entry.title); + const updatedAt = asTrimmedString(entry.updatedAt); + if (!threadId || !projectId || !title || !updatedAt || typeof entry.settled !== "boolean") { + return undefined; + } + // The payload arrived over the wire; the checks above are the validation. + threads.push({ + threadId: ThreadId.make(threadId), + projectId: ProjectId.make(projectId), + title, + settled: entry.settled, + updatedAt, + }); + } + return { threads }; +} + +function extractThreadsCreateResult(value: unknown): ThreadsCreateResult | undefined { + const record = asRecord(value); + if (!record) { + return undefined; + } + const threadId = asTrimmedString(record.threadId); + const title = asTrimmedString(record.title); + return threadId && title ? { threadId: ThreadId.make(threadId), title } : undefined; +} + function asTrimmedString(value: unknown): string | null { if (typeof value !== "string") { return null; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e970b9ee61b5..9446bf4e4dd5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -777,7 +777,7 @@ importers: version: link:../../packages/shared alchemy: specifier: 2.0.0-beta.76 - version: 2.0.0-beta.76(b3825b36417e56a486ccb5f22a7f3d7e) + version: 2.0.0-beta.76(197b68e0d20fdf20cf352009eca17c9f) drizzle-orm: specifier: 1.0.0-rc.5-ab785fc version: 1.0.0-rc.5-ab785fc(8ab70e2706da13c78d64d8a92fef1884) @@ -16493,7 +16493,7 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - alchemy@2.0.0-beta.76(b3825b36417e56a486ccb5f22a7f3d7e): + alchemy@2.0.0-beta.76(197b68e0d20fdf20cf352009eca17c9f): dependencies: '@alchemy.run/cloudflare-runtime': 2.0.0-beta.76(@distilled.cloud/cloudflare@1.0.0-rc.8(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2)))(@effect/platform-bun@4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))(redis@6.2.1)(utf-8-validate@6.0.6))(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(yaml@2.9.0))(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))(rolldown@1.2.5)(typescript@7.0.2) '@alchemy.run/floci': 2.0.0-beta.76(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2)) From d6fb15a823ec4d5aae106c2d3c48fd73c384e14b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 17:26:21 +1000 Subject: [PATCH 03/12] fix(server): keep threads toolkit tool definitions module-local --- apps/server/src/mcp/toolkits/threads/tools.ts | 4 ++-- apps/web/src/agentCreatedThreadToast.ts | 4 ---- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/apps/server/src/mcp/toolkits/threads/tools.ts b/apps/server/src/mcp/toolkits/threads/tools.ts index 836ece1c4599..7eff8a934ec1 100644 --- a/apps/server/src/mcp/toolkits/threads/tools.ts +++ b/apps/server/src/mcp/toolkits/threads/tools.ts @@ -24,7 +24,7 @@ const createDependencies = [ Crypto.Crypto, ]; -export const ThreadsListTool = Tool.make("threads_list", { +const ThreadsListTool = Tool.make("threads_list", { description: "List threads in this environment. Returns thread ids and titles only — one row per thread with its id, project, title, settled state, and last-updated time. Use filter:'settled' for finished work, 'active' for in-flight threads, or 'recent' (default) for the most recently updated. Use this when the user asks to see threads rather than describe them from memory.", parameters: ThreadsListInput, @@ -37,7 +37,7 @@ export const ThreadsListTool = Tool.make("threads_list", { .annotate(Tool.Destructive, false) .annotate(Tool.Idempotent, true); -export const ThreadsCreateTool = Tool.make("threads_create", { +const ThreadsCreateTool = Tool.make("threads_create", { description: "Create a new, empty thread in this environment (in the current project unless projectId is given) and return its id. The thread starts with no conversation; the user can open it and start a turn. Creating a thread shows the user a notification with a link to it, so prefer this over describing where things live.", parameters: ThreadsCreateInput, diff --git a/apps/web/src/agentCreatedThreadToast.ts b/apps/web/src/agentCreatedThreadToast.ts index f1c79f938bb8..5ccd436df736 100644 --- a/apps/web/src/agentCreatedThreadToast.ts +++ b/apps/web/src/agentCreatedThreadToast.ts @@ -36,7 +36,3 @@ export function notifyAgentCreatedThreads(input: { ); } } - -export function resetAgentCreatedThreadToastsForTests(): void { - recentAgentThreadIds.clear(); -} From fc95e18f1b139199be17df1b100b72796d169628 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Wed, 16 Sep 2026 14:35:16 +1000 Subject: [PATCH 04/12] fix(web): skip new-thread toasts for historical agent creates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening or reloading a thread replayed a "New thread created" toast for every persisted threads_create result, and the module-level dedup set reset on each page load. The toast effect now waits until the thread is live, baselines everything already in the work log, and only notifies for creates whose entry is newer than the observed tail — so replay, reload, and "load earlier turns" backfill all stay silent. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- apps/web/src/agentCreatedThreadToast.ts | 5 +- apps/web/src/components/ChatView.tsx | 27 ++++- apps/web/src/session-logic.test.ts | 138 +++++++++++++++++++++++- apps/web/src/session-logic.ts | 51 ++++++++- pnpm-lock.yaml | 4 +- 5 files changed, 212 insertions(+), 13 deletions(-) diff --git a/apps/web/src/agentCreatedThreadToast.ts b/apps/web/src/agentCreatedThreadToast.ts index 5ccd436df736..c6a468e758b4 100644 --- a/apps/web/src/agentCreatedThreadToast.ts +++ b/apps/web/src/agentCreatedThreadToast.ts @@ -1,9 +1,8 @@ import { scopeThreadRef } from "@t3tools/client-runtime/environment"; -import type { EnvironmentId, ScopedThreadRef, ThreadsCreateResult } from "@t3tools/contracts"; +import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; import { stackedThreadToast, toastManager } from "./components/ui/toast"; - -export type AgentCreatedThread = ThreadsCreateResult; +import type { AgentCreatedThread } from "./session-logic"; export type ThreadRouteNavigator = (threadRef: ScopedThreadRef) => void; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index a63ca35675ab..9198399f42e6 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -130,7 +130,9 @@ import { deriveWorkLogEntries, hasActionableProposedPlan, isLatestTurnSettled, + observeAgentCreatedThreads, selectHandoffImageResources, + type AgentCreatedThreadsBaseline, type TimelineEntriesProjection, } from "../session-logic"; import { notifyAgentCreatedThreads } from "../agentCreatedThreadToast"; @@ -2870,13 +2872,25 @@ export default function ChatView(props: ChatViewProps) { () => deriveAgentCreatedThreads(workLogEntries), [workLogEntries], ); + const agentCreatedBaselinesRef = useRef(new Map()); useEffect(() => { - if (agentCreatedThreads.length === 0) { + // Baseline once the thread is live: creates already in the work log — + // including anything replayed while it synced — are history, not toasts. + if (threadSyncPhase !== null) { + return; + } + const { baseline, fresh } = observeAgentCreatedThreads({ + baseline: agentCreatedBaselinesRef.current.get(routeThreadKey), + entries: workLogEntries, + threads: agentCreatedThreads, + }); + agentCreatedBaselinesRef.current.set(routeThreadKey, baseline); + if (fresh.length === 0) { return; } notifyAgentCreatedThreads({ environmentId, - threads: agentCreatedThreads, + threads: fresh, navigate: (threadRef) => { void navigate({ to: "/$environmentId/$threadId", @@ -2884,7 +2898,14 @@ export default function ChatView(props: ChatViewProps) { }); }, }); - }, [agentCreatedThreads, environmentId, navigate]); + }, [ + agentCreatedThreads, + environmentId, + navigate, + routeThreadKey, + threadSyncPhase, + workLogEntries, + ]); // Native subagent fold: memoized by activity-list identity, shared by the // Agents surface, live strip, and workflow cards. v2Projection is null // until orchestration-v2 lands (source precedence lives in the derive). diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 3714141d0066..48188c63c0cf 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -20,6 +20,7 @@ import { findLatestProposedPlan, hasActionableProposedPlan, isLatestTurnSettled, + observeAgentCreatedThreads, selectHandoffImageResources, selectMessageImageResources, workEntryIndicatesToolNeutralStatus, @@ -1191,7 +1192,7 @@ describe("deriveWorkLogEntries", () => { const entries = deriveWorkLogEntries(activities); expect(entries[0]?.threadsCreated).toEqual({ threadId: "thread-new", title: "Fresh thread" }); expect(deriveAgentCreatedThreads(entries)).toEqual([ - { threadId: "thread-new", title: "Fresh thread" }, + { threadId: "thread-new", title: "Fresh thread", createdAt: "2026-02-23T00:00:00.000Z" }, ]); }); @@ -1239,10 +1240,143 @@ describe("deriveWorkLogEntries", () => { const entries = deriveWorkLogEntries(activities); expect(deriveAgentCreatedThreads(entries)).toEqual([ - { threadId: "thread-new", title: "Fresh thread" }, + { threadId: "thread-new", title: "Fresh thread", createdAt: "2026-02-23T00:00:00.000Z" }, ]); }); + it("baselines historical creates and only surfaces ones that arrive later", () => { + const historical = deriveWorkLogEntries([ + makeActivity({ + id: "threads-create-old", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "tool.completed", + summary: "t3-code · threads_create", + payload: { + itemType: "mcp_tool_call", + data: { + toolName: "threads_create", + structuredResult: { threadId: "thread-old", title: "Old thread" }, + }, + }, + }), + ]); + + // First observation is history only: nothing is fresh. + const first = observeAgentCreatedThreads({ + baseline: undefined, + entries: historical, + threads: deriveAgentCreatedThreads(historical), + }); + expect(first.fresh).toEqual([]); + + // A create landing while the thread is open is fresh. + const withLive = deriveWorkLogEntries([ + makeActivity({ + id: "threads-create-old", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "tool.completed", + summary: "t3-code · threads_create", + payload: { + itemType: "mcp_tool_call", + data: { + toolName: "threads_create", + structuredResult: { threadId: "thread-old", title: "Old thread" }, + }, + }, + }), + makeActivity({ + id: "threads-create-live", + createdAt: "2026-02-23T00:00:09.000Z", + kind: "tool.completed", + summary: "t3-code · threads_create", + payload: { + itemType: "mcp_tool_call", + data: { + toolName: "threads_create", + structuredResult: { threadId: "thread-live", title: "Live thread" }, + }, + }, + }), + ]); + const second = observeAgentCreatedThreads({ + baseline: first.baseline, + entries: withLive, + threads: deriveAgentCreatedThreads(withLive), + }); + expect(second.fresh).toEqual([ + { threadId: "thread-live", title: "Live thread", createdAt: "2026-02-23T00:00:09.000Z" }, + ]); + + // The same list re-derived does not repeat the toast. + const third = observeAgentCreatedThreads({ + baseline: second.baseline, + entries: withLive, + threads: deriveAgentCreatedThreads(withLive), + }); + expect(third.fresh).toEqual([]); + }); + + it("keeps backfilled older creates silent after the baseline exists", () => { + const recent = deriveWorkLogEntries([ + makeActivity({ + id: "threads-create-recent", + createdAt: "2026-02-23T00:00:09.000Z", + kind: "tool.completed", + summary: "t3-code · threads_create", + payload: { + itemType: "mcp_tool_call", + data: { + toolName: "threads_create", + structuredResult: { threadId: "thread-recent", title: "Recent thread" }, + }, + }, + }), + ]); + const first = observeAgentCreatedThreads({ + baseline: undefined, + entries: recent, + threads: deriveAgentCreatedThreads(recent), + }); + expect(first.fresh).toEqual([]); + + // "Load earlier turns" prepends an older create: it predates the baseline + // watermark, so it must not toast even though its id is new. + const backfilled = deriveWorkLogEntries([ + makeActivity({ + id: "threads-create-ancient", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "tool.completed", + summary: "t3-code · threads_create", + payload: { + itemType: "mcp_tool_call", + data: { + toolName: "threads_create", + structuredResult: { threadId: "thread-ancient", title: "Ancient thread" }, + }, + }, + }), + makeActivity({ + id: "threads-create-recent", + createdAt: "2026-02-23T00:00:09.000Z", + kind: "tool.completed", + summary: "t3-code · threads_create", + payload: { + itemType: "mcp_tool_call", + data: { + toolName: "threads_create", + structuredResult: { threadId: "thread-recent", title: "Recent thread" }, + }, + }, + }), + ]); + const second = observeAgentCreatedThreads({ + baseline: first.baseline, + entries: backfilled, + threads: deriveAgentCreatedThreads(backfilled), + }); + expect(second.fresh).toEqual([]); + }); + it("unwraps PowerShell command wrappers for displayed command text", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index d48572d032e2..3a7a924f1674 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -532,14 +532,19 @@ function isNoContentRuntimeWarning(activity: OrchestrationThreadActivity): boole ); } +export interface AgentCreatedThread extends ThreadsCreateResult { + /** Time of the work-log entry that carried the create result. */ + createdAt: string; +} + /** * Threads the agent created via `threads_create`, one per thread id, in work-log * order. Failed or declined calls are excluded. */ export function deriveAgentCreatedThreads( entries: ReadonlyArray, -): ThreadsCreateResult[] { - const byThreadId = new Map(); +): AgentCreatedThread[] { + const byThreadId = new Map(); for (const entry of entries) { const created = entry.threadsCreated; if ( @@ -551,12 +556,52 @@ export function deriveAgentCreatedThreads( continue; } if (!byThreadId.has(created.threadId)) { - byThreadId.set(created.threadId, created); + byThreadId.set(created.threadId, { ...created, createdAt: entry.createdAt }); } } return [...byThreadId.values()]; } +/** + * Per-view baseline for the "new thread" toast: the first observation of a + * thread's work log is history — every create already persisted or replayed + * while the thread synced. `watermark` is the newest entry timestamp seen, so + * creates backfilled by "load earlier turns" stay silent too. + */ +export interface AgentCreatedThreadsBaseline { + readonly ids: ReadonlySet; + readonly watermark: string; +} + +export function observeAgentCreatedThreads(input: { + baseline: AgentCreatedThreadsBaseline | undefined; + entries: ReadonlyArray; + threads: ReadonlyArray; +}): { baseline: AgentCreatedThreadsBaseline; fresh: AgentCreatedThread[] } { + const watermark = input.entries.reduce( + (latest, entry) => (entry.createdAt > latest ? entry.createdAt : latest), + input.baseline?.watermark ?? "", + ); + const baseline = input.baseline; + if (baseline === undefined) { + // First live observation: everything already in the work log is history. + return { + baseline: { ids: new Set(input.threads.map((thread) => thread.threadId)), watermark }, + fresh: [], + }; + } + const fresh = input.threads.filter( + (thread) => !baseline.ids.has(thread.threadId) && thread.createdAt >= baseline.watermark, + ); + return { + baseline: { + ids: new Set([...baseline.ids, ...fresh.map((thread) => thread.threadId)]), + watermark, + }, + fresh, + }; +} + function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): boolean { if (activity.kind !== "tool.updated" && activity.kind !== "tool.completed") { return false; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9446bf4e4dd5..e970b9ee61b5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -777,7 +777,7 @@ importers: version: link:../../packages/shared alchemy: specifier: 2.0.0-beta.76 - version: 2.0.0-beta.76(197b68e0d20fdf20cf352009eca17c9f) + version: 2.0.0-beta.76(b3825b36417e56a486ccb5f22a7f3d7e) drizzle-orm: specifier: 1.0.0-rc.5-ab785fc version: 1.0.0-rc.5-ab785fc(8ab70e2706da13c78d64d8a92fef1884) @@ -16493,7 +16493,7 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - alchemy@2.0.0-beta.76(197b68e0d20fdf20cf352009eca17c9f): + alchemy@2.0.0-beta.76(b3825b36417e56a486ccb5f22a7f3d7e): dependencies: '@alchemy.run/cloudflare-runtime': 2.0.0-beta.76(@distilled.cloud/cloudflare@1.0.0-rc.8(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2)))(@effect/platform-bun@4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))(redis@6.2.1)(utf-8-validate@6.0.6))(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(yaml@2.9.0))(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))(rolldown@1.2.5)(typescript@7.0.2) '@alchemy.run/floci': 2.0.0-beta.76(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2)) From ba0da2f9e1e8da4989722da720d0bd93782dd96e Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Wed, 16 Sep 2026 15:52:16 +1000 Subject: [PATCH 05/12] fix(threads): match qualified mcp__t3-code__ tool names for threads surface Claude/OpenCode report toolkit calls as mcp__t3-code__threads_list in data.toolName, so the bare-name check never matched and neither the structured result projection nor the card/toast derivation fired. Route both sides through a shared matcher that accepts bare and qualified names for the t3-code server. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../ActivityPayloadProjection.test.ts | 4 ++-- .../ActivityPayloadProjection.ts | 9 +++------ apps/web/src/session-logic.test.ts | 20 +++++++++++++++++++ apps/web/src/session-logic.ts | 4 +++- packages/contracts/src/threadsSurface.ts | 16 +++++++++++++++ 5 files changed, 44 insertions(+), 9 deletions(-) diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.test.ts b/apps/server/src/orchestration/ActivityPayloadProjection.test.ts index b10f69c0af74..79fda1f47344 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.test.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.test.ts @@ -365,12 +365,12 @@ describe("projectActivityPayload", () => { activity({ itemType: "mcp_tool_call", data: { - toolName: "threads_create", + toolName: "mcp__t3-code__threads_create", input: { title: "Explore canvas" }, result: { type: "tool_result", tool_use_id: "toolu_2", - content: [{ type: "text", text: JSON.stringify(result) }], + content: JSON.stringify(result), }, }, }), diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.ts b/apps/server/src/orchestration/ActivityPayloadProjection.ts index 86b4f4c429e6..d36ea1084eef 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.ts @@ -1,4 +1,5 @@ import { projectQuestionToolInput } from "@t3tools/shared/toolActivity"; +import { matchThreadsSurfaceToolName } from "@t3tools/contracts"; import type { OrchestrationEvent, OrchestrationThreadActivity, @@ -6,7 +7,6 @@ import type { } from "@t3tools/contracts"; import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; import { extractJsonObject } from "@t3tools/shared/schemaJson"; -import { THREADS_SURFACE_TOOL_NAMES } from "@t3tools/contracts"; function asRecord(value: unknown): Record | null { return value !== null && typeof value === "object" && !Array.isArray(value) @@ -309,11 +309,8 @@ function projectPreviewToolMetadata(data: Record, status: unkno /** True when the tool belongs to the server's own threads surface toolkit. */ function isThreadsSurfaceTool(data: Record, item: Record | null) { - const candidates = [data.toolName, item?.tool]; - return candidates.some( - (candidate) => - typeof candidate === "string" && - (THREADS_SURFACE_TOOL_NAMES as readonly string[]).includes(candidate), + return [data.toolName, item?.tool].some( + (candidate) => matchThreadsSurfaceToolName(candidate) !== undefined, ); } diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 48188c63c0cf..d937b5ee2f28 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -1153,6 +1153,26 @@ describe("deriveWorkLogEntries", () => { expect(entry?.threadsList).toEqual({ threads: [] }); }); + it("detects the threads_list tool name on Claude-shaped qualified payloads", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "threads-list-claude", + kind: "tool.completed", + summary: "t3-code · threads_list", + payload: { + itemType: "mcp_tool_call", + data: { + toolName: "mcp__t3-code__threads_list", + structuredResult: { threads: [] }, + }, + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities); + expect(entry?.threadsList).toEqual({ threads: [] }); + }); + it("leaves threadsList undefined when the structured result shape is wrong", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 3a7a924f1674..10a6ecc2d0c4 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -31,6 +31,7 @@ import { type ToolLifecycleItemType, ProjectId, ThreadId, + matchThreadsSurfaceToolName, type TurnId, } from "@t3tools/contracts"; @@ -718,7 +719,8 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo // Tool-name detection mirrors the server's ActivityPayloadProjection: // Claude/OpenCode put it at `data.toolName`, Codex at `data.item.tool`. const item = asRecord(data?.item); - const toolName = asTrimmedString(data?.toolName) ?? (item ? asTrimmedString(item.tool) : null); + const toolName = + matchThreadsSurfaceToolName(data?.toolName) ?? matchThreadsSurfaceToolName(item?.tool); if (toolName === "threads_list") { const threadsList = extractThreadsListResult(data?.structuredResult); if (threadsList) { diff --git a/packages/contracts/src/threadsSurface.ts b/packages/contracts/src/threadsSurface.ts index 108021c0108c..5713d6cc00cf 100644 --- a/packages/contracts/src/threadsSurface.ts +++ b/packages/contracts/src/threadsSurface.ts @@ -20,6 +20,22 @@ import { export const THREADS_SURFACE_TOOL_NAMES = ["threads_list", "threads_create"] as const; export type ThreadsSurfaceToolName = (typeof THREADS_SURFACE_TOOL_NAMES)[number]; +/** + * Adapters surface toolkit tools under different names: Codex keeps the bare + * tool name (`item.tool`), while Claude/OpenCode wrap it as + * `mcp____`. Matches both forms for the `t3-code` server only, + * using the same server-name aliases as the preview tool matcher. + */ +const THREADS_SURFACE_QUALIFIED_NAME = + /^(?:(?:mcp__)?t3[-_]?code_{1,2})?(threads_list|threads_create)$/; + +export function matchThreadsSurfaceToolName(name: unknown): ThreadsSurfaceToolName | undefined { + if (typeof name !== "string") { + return undefined; + } + return THREADS_SURFACE_QUALIFIED_NAME.exec(name)?.[1] as ThreadsSurfaceToolName | undefined; +} + export const THREADS_SURFACE_LIST_DEFAULT_LIMIT = 8; export const THREADS_SURFACE_LIST_MAX_LIMIT = 25; From 132b496acaba39d5cae332d9b9f01acfb302cc3c Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Wed, 16 Sep 2026 16:02:55 +1000 Subject: [PATCH 06/12] fix(threads): surface threads_create results on non-mcp item types Some adapters classify the create call as a generic change item instead of mcp_tool_call, so the structured result was dropped before clients could see it and the new-thread toast could never fire. Project the result on any item type and match the tool name outside the mcp_tool_call gate on the web side. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../ActivityPayloadProjection.test.ts | 20 +++++++++++ .../ActivityPayloadProjection.ts | 9 +++++ apps/web/src/session-logic.test.ts | 20 +++++++++++ apps/web/src/session-logic.ts | 33 ++++++++++--------- 4 files changed, 67 insertions(+), 15 deletions(-) diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.test.ts b/apps/server/src/orchestration/ActivityPayloadProjection.test.ts index 79fda1f47344..40787f4c9a2e 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.test.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.test.ts @@ -379,6 +379,26 @@ describe("projectActivityPayload", () => { expect(data.structuredResult).toEqual(result); }); + it("keeps threads-surface results when the adapter files the call as a change item", () => { + const result = { threadId: "thrd_3", title: "Second demo thread" }; + const projected = projectActivityPayload( + activity({ + itemType: "file_change", + data: { + toolName: "mcp__t3-code__threads_create", + input: { title: "Second demo thread" }, + result: { + type: "tool_result", + tool_use_id: "call_1", + content: JSON.stringify(result), + }, + }, + }), + ); + const data = (projected.payload as Record).data as Record; + expect(data.structuredResult).toEqual(result); + }); + it("does not fabricate structuredResult when a threads-surface result is unparseable", () => { const projected = projectActivityPayload( activity({ diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.ts b/apps/server/src/orchestration/ActivityPayloadProjection.ts index d36ea1084eef..c02fb018701d 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.ts @@ -528,6 +528,15 @@ export function projectActivityPayload( projectedData.toolName = data.toolName; } + // Some adapters classify toolkit calls as generic change items rather than + // mcp_tool_call; the structured result matters the same either way. + if (isThreadsSurfaceTool(data, asRecord(data.item))) { + const structuredResult = extractThreadsSurfaceStructuredResult(data, asRecord(data.item)); + if (structuredResult) { + projectedData.structuredResult = structuredResult; + } + } + const rawOutput = projectRawOutput(data.rawOutput) ?? projectAcpContent(data.content) ?? diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index d937b5ee2f28..940dbd9e6e66 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -1173,6 +1173,26 @@ describe("deriveWorkLogEntries", () => { expect(entry?.threadsList).toEqual({ threads: [] }); }); + it("detects threads_create when the adapter files the call as a change item", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "threads-create-filechange", + kind: "tool.completed", + summary: "t3-code · threads_create", + payload: { + itemType: "file_change", + data: { + toolName: "mcp__t3-code__threads_create", + structuredResult: { threadId: "thread-new", title: "Fresh thread" }, + }, + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities); + expect(entry?.threadsCreated).toEqual({ threadId: "thread-new", title: "Fresh thread" }); + }); + it("leaves threadsList undefined when the structured result shape is wrong", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 10a6ecc2d0c4..b1461a3d0245 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -716,22 +716,25 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo if (toolData !== undefined) { entry.toolData = toolData; } - // Tool-name detection mirrors the server's ActivityPayloadProjection: - // Claude/OpenCode put it at `data.toolName`, Codex at `data.item.tool`. - const item = asRecord(data?.item); - const toolName = - matchThreadsSurfaceToolName(data?.toolName) ?? matchThreadsSurfaceToolName(item?.tool); - if (toolName === "threads_list") { - const threadsList = extractThreadsListResult(data?.structuredResult); - if (threadsList) { - entry.threadsList = threadsList; - } + } + // Threads-surface detection mirrors the server's ActivityPayloadProjection + // and is not gated on itemType: Claude/OpenCode name the tool + // `mcp__t3-code__*` at `data.toolName`, Codex keeps `item.tool`, and some + // adapters classify the create call as a generic change item. + const threadsToolData = asRecord(payload?.data); + const threadsToolName = + matchThreadsSurfaceToolName(threadsToolData?.toolName) ?? + matchThreadsSurfaceToolName(asRecord(threadsToolData?.item)?.tool); + if (threadsToolName === "threads_list") { + const threadsList = extractThreadsListResult(threadsToolData?.structuredResult); + if (threadsList) { + entry.threadsList = threadsList; } - if (toolName === "threads_create") { - const threadsCreated = extractThreadsCreateResult(data?.structuredResult); - if (threadsCreated) { - entry.threadsCreated = threadsCreated; - } + } + if (threadsToolName === "threads_create") { + const threadsCreated = extractThreadsCreateResult(threadsToolData?.structuredResult); + if (threadsCreated) { + entry.threadsCreated = threadsCreated; } } if (itemType) { From 2fcb052bd9e5c74339a890bd302d0a26068f26bc Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Wed, 16 Sep 2026 16:32:08 +1000 Subject: [PATCH 07/12] fix(server): keep threads surface results across double projection Snapshot reads run projectActivityPayload twice: once during hydration and again in projectThreadDetailSnapshot after superseded tool.updated rows are dropped. The first pass replaces data.result with a text summary, so the second pass could not re-parse the JSON and lost structuredResult - leaving the client without the threads payload on refresh or pagination. Carry an already-projected structuredResult through verbatim so re-projection is idempotent. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../ActivityPayloadProjection.test.ts | 16 ++++++++++++++++ .../orchestration/ActivityPayloadProjection.ts | 8 ++++++++ 2 files changed, 24 insertions(+) diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.test.ts b/apps/server/src/orchestration/ActivityPayloadProjection.test.ts index 40787f4c9a2e..fdf2dd7f10f2 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.test.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.test.ts @@ -399,6 +399,22 @@ describe("projectActivityPayload", () => { expect(data.structuredResult).toEqual(result); }); + it("keeps structuredResult when an already-projected activity is projected again", () => { + const result = { threadId: "thrd_4", title: "Reprojected" }; + const once = projectActivityPayload( + activity({ + itemType: "mcp_tool_call", + data: { + toolName: "mcp__t3-code__threads_create", + result: { content: JSON.stringify(result) }, + }, + }), + ); + const twice = projectActivityPayload(once); + const data = (twice.payload as Record).data as Record; + expect(data.structuredResult).toEqual(result); + }); + it("does not fabricate structuredResult when a threads-surface result is unparseable", () => { const projected = projectActivityPayload( activity({ diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.ts b/apps/server/src/orchestration/ActivityPayloadProjection.ts index c02fb018701d..1de3f06df2ac 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.ts @@ -326,6 +326,14 @@ function extractThreadsSurfaceStructuredResult( data: Record, item: Record | null, ): Record | undefined { + // Projection runs twice on snapshot reads (hydration, then + // projectThreadDetailSnapshot): the first pass replaces `data.result` with a + // summary that no longer parses, so an already-projected value carries + // through verbatim. + const existing = asRecord(data.structuredResult); + if (existing) { + return existing; + } const rawResult = item?.result ?? data.result; if (rawResult === undefined || rawResult === null) { return undefined; From c217c91201f6ea27c872b9a863655f3dd9cbfc6c Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Wed, 16 Sep 2026 16:42:28 +1000 Subject: [PATCH 08/12] fix(threads): bound surface errors and verify MCP server identity - ThreadsSurfaceError is now a Schema.TaggedError with an operation tag and optional retained cause, matching the preview toolkit's failure shape; arbitrary upstream error messages no longer reach the model as detail. - matchThreadsSurfaceToolName accepts the item's server field and rejects explicit foreign servers, so another MCP server exposing a bare threads_list/threads_create tool cannot produce thread cards. - threadsSurface.ts uses effect/* namespace imports like the rest of contracts. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../src/mcp/toolkits/threads/handlers.ts | 29 ++++++++++----- .../ActivityPayloadProjection.test.ts | 20 +++++++++++ .../ActivityPayloadProjection.ts | 5 +-- apps/web/src/session-logic.ts | 5 +-- packages/contracts/src/threadsSurface.ts | 36 ++++++++++++++----- 5 files changed, 73 insertions(+), 22 deletions(-) diff --git a/apps/server/src/mcp/toolkits/threads/handlers.ts b/apps/server/src/mcp/toolkits/threads/handlers.ts index d751294d669f..3f94db2a18e2 100644 --- a/apps/server/src/mcp/toolkits/threads/handlers.ts +++ b/apps/server/src/mcp/toolkits/threads/handlers.ts @@ -6,7 +6,7 @@ import { type ThreadsCreateResult, type ThreadsListInput, type ThreadsListResult, - type ThreadsSurfaceError, + ThreadsSurfaceError, type ThreadsListItem, THREADS_SURFACE_LIST_DEFAULT_LIMIT, THREADS_SURFACE_LIST_MAX_LIMIT, @@ -20,10 +20,11 @@ import * as OrchestrationEngine from "../../../orchestration/Services/Orchestrat import * as ProjectionSnapshotQuery from "../../../orchestration/Services/ProjectionSnapshotQuery.ts"; import { ThreadsToolkit } from "./tools.ts"; -const fail = (detail: string) => - Effect.fail({ _tag: "ThreadsSurfaceError", detail }); +const fail = (operation: ThreadsSurfaceError["operation"], detail: string) => + Effect.fail(new ThreadsSurfaceError({ operation, detail })); -const failFrom = (error: { readonly message: string }) => fail(error.message); +const failFrom = (operation: ThreadsSurfaceError["operation"], detail: string) => + Effect.mapError((cause: unknown) => new ThreadsSurfaceError({ operation, detail, cause })); const isThreadSettled = (thread: OrchestrationReadModel["threads"][number]): boolean => thread.settledOverride === "settled" || @@ -35,7 +36,9 @@ const liveThreads = (readModel: OrchestrationReadModel) => const threadsList = (input: ThreadsListInput) => Effect.gen(function* () { const query = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; - const readModel = yield* query.getCommandReadModel().pipe(Effect.catch(failFrom)); + const readModel = yield* query + .getCommandReadModel() + .pipe(failFrom("threads_list", "Failed to load the environment's threads.")); const threads = liveThreads(readModel) .filter((thread) => input.projectId === undefined || thread.projectId === input.projectId) @@ -64,15 +67,23 @@ const threadsCreate = (input: ThreadsCreateInput) => Effect.gen(function* () { const scope = yield* McpInvocationContext.McpInvocationContext; const query = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; - const readModel = yield* query.getCommandReadModel().pipe(Effect.catch(failFrom)); + const readModel = yield* query + .getCommandReadModel() + .pipe(failFrom("threads_create", "Failed to load the environment's threads.")); const callingThread = readModel.threads.find((thread) => thread.id === scope.threadId); if (!callingThread) { - return yield* fail("Calling thread no longer exists; cannot derive the target project."); + return yield* fail( + "threads_create", + "Calling thread no longer exists; cannot derive the target project.", + ); } const projectId = input.projectId ?? callingThread.projectId; if (!readModel.projects.some((project) => project.id === projectId)) { - return yield* fail(`Project ${projectId} does not exist in this environment.`); + return yield* fail( + "threads_create", + `Project ${projectId} does not exist in this environment.`, + ); } const crypto = yield* Crypto.Crypto; @@ -94,7 +105,7 @@ const threadsCreate = (input: ThreadsCreateInput) => source: "agent", createdAt, }) - .pipe(Effect.catch(failFrom)); + .pipe(failFrom("threads_create", "Failed to create the thread.")); return { threadId: ThreadId.make(uuid), title: input.title } satisfies ThreadsCreateResult; }); diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.test.ts b/apps/server/src/orchestration/ActivityPayloadProjection.test.ts index fdf2dd7f10f2..28e3a639c6fc 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.test.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.test.ts @@ -399,6 +399,26 @@ describe("projectActivityPayload", () => { expect(data.structuredResult).toEqual(result); }); + it("does not treat a bare threads_list from a foreign MCP server as threads-surface", () => { + const projected = projectActivityPayload( + activity({ + itemType: "mcp_tool_call", + data: { + item: { + type: "mcpToolCall", + id: "item-3", + tool: "threads_list", + server: "github", + status: "completed", + result: { content: [{ type: "text", text: '{"threads":[]}' }] }, + }, + }, + }), + ); + const data = (projected.payload as Record).data as Record; + expect(data.structuredResult).toBeUndefined(); + }); + it("keeps structuredResult when an already-projected activity is projected again", () => { const result = { threadId: "thrd_4", title: "Reprojected" }; const once = projectActivityPayload( diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.ts b/apps/server/src/orchestration/ActivityPayloadProjection.ts index 1de3f06df2ac..3d7a9d97e766 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.ts @@ -309,8 +309,9 @@ function projectPreviewToolMetadata(data: Record, status: unkno /** True when the tool belongs to the server's own threads surface toolkit. */ function isThreadsSurfaceTool(data: Record, item: Record | null) { - return [data.toolName, item?.tool].some( - (candidate) => matchThreadsSurfaceToolName(candidate) !== undefined, + return ( + matchThreadsSurfaceToolName(data.toolName, data.server) !== undefined || + matchThreadsSurfaceToolName(item?.tool, item?.server) !== undefined ); } diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index b1461a3d0245..5b6019864f1e 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -722,9 +722,10 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo // `mcp__t3-code__*` at `data.toolName`, Codex keeps `item.tool`, and some // adapters classify the create call as a generic change item. const threadsToolData = asRecord(payload?.data); + const threadsToolItem = asRecord(threadsToolData?.item); const threadsToolName = - matchThreadsSurfaceToolName(threadsToolData?.toolName) ?? - matchThreadsSurfaceToolName(asRecord(threadsToolData?.item)?.tool); + matchThreadsSurfaceToolName(threadsToolData?.toolName, threadsToolData?.server) ?? + matchThreadsSurfaceToolName(threadsToolItem?.tool, threadsToolItem?.server); if (threadsToolName === "threads_list") { const threadsList = extractThreadsListResult(threadsToolData?.structuredResult); if (threadsList) { diff --git a/packages/contracts/src/threadsSurface.ts b/packages/contracts/src/threadsSurface.ts index 5713d6cc00cf..77e53a6ea302 100644 --- a/packages/contracts/src/threadsSurface.ts +++ b/packages/contracts/src/threadsSurface.ts @@ -8,7 +8,8 @@ * * @module ThreadsSurface */ -import { Effect, Schema } from "effect"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; import { IsoDateTime, PositiveInt, @@ -17,8 +18,7 @@ import { TrimmedNonEmptyString, } from "./baseSchemas.ts"; -export const THREADS_SURFACE_TOOL_NAMES = ["threads_list", "threads_create"] as const; -export type ThreadsSurfaceToolName = (typeof THREADS_SURFACE_TOOL_NAMES)[number]; +export type ThreadsSurfaceToolName = "threads_list" | "threads_create"; /** * Adapters surface toolkit tools under different names: Codex keeps the bare @@ -28,11 +28,22 @@ export type ThreadsSurfaceToolName = (typeof THREADS_SURFACE_TOOL_NAMES)[number] */ const THREADS_SURFACE_QUALIFIED_NAME = /^(?:(?:mcp__)?t3[-_]?code_{1,2})?(threads_list|threads_create)$/; +const THREADS_SURFACE_SERVER_NAME = /^t3[-_]?code$/; -export function matchThreadsSurfaceToolName(name: unknown): ThreadsSurfaceToolName | undefined { +export function matchThreadsSurfaceToolName( + name: unknown, + server?: unknown, +): ThreadsSurfaceToolName | undefined { if (typeof name !== "string") { return undefined; } + // Adapters that split server and tool into separate fields (Codex + // `item.server` + `item.tool`) carry bare tool names; an explicit foreign + // server must not match, while a missing server stays permissive for + // adapters that only report the tool name. + if (typeof server === "string" && !THREADS_SURFACE_SERVER_NAME.test(server)) { + return undefined; + } return THREADS_SURFACE_QUALIFIED_NAME.exec(name)?.[1] as ThreadsSurfaceToolName | undefined; } @@ -77,8 +88,15 @@ export const ThreadsCreateResult = Schema.Struct({ }); export type ThreadsCreateResult = typeof ThreadsCreateResult.Type; -export const ThreadsSurfaceError = Schema.Struct({ - _tag: Schema.Literals(["ThreadsSurfaceError"]), - detail: Schema.String, -}); -export type ThreadsSurfaceError = typeof ThreadsSurfaceError.Type; +export class ThreadsSurfaceError extends Schema.TaggedError()( + "ThreadsSurfaceError", + { + operation: Schema.Literals(["threads_list", "threads_create"]), + detail: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return this.detail; + } +} From eb466934f836cb036eed707ef814a765c698f361 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Wed, 16 Sep 2026 16:50:07 +1000 Subject: [PATCH 09/12] refactor(threads): construct surface errors at the validation branches Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../src/mcp/toolkits/threads/handlers.ts | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/apps/server/src/mcp/toolkits/threads/handlers.ts b/apps/server/src/mcp/toolkits/threads/handlers.ts index 3f94db2a18e2..9469327e3644 100644 --- a/apps/server/src/mcp/toolkits/threads/handlers.ts +++ b/apps/server/src/mcp/toolkits/threads/handlers.ts @@ -20,9 +20,6 @@ import * as OrchestrationEngine from "../../../orchestration/Services/Orchestrat import * as ProjectionSnapshotQuery from "../../../orchestration/Services/ProjectionSnapshotQuery.ts"; import { ThreadsToolkit } from "./tools.ts"; -const fail = (operation: ThreadsSurfaceError["operation"], detail: string) => - Effect.fail(new ThreadsSurfaceError({ operation, detail })); - const failFrom = (operation: ThreadsSurfaceError["operation"], detail: string) => Effect.mapError((cause: unknown) => new ThreadsSurfaceError({ operation, detail, cause })); @@ -73,16 +70,20 @@ const threadsCreate = (input: ThreadsCreateInput) => const callingThread = readModel.threads.find((thread) => thread.id === scope.threadId); if (!callingThread) { - return yield* fail( - "threads_create", - "Calling thread no longer exists; cannot derive the target project.", + return yield* Effect.fail( + new ThreadsSurfaceError({ + operation: "threads_create", + detail: "Calling thread no longer exists; cannot derive the target project.", + }), ); } const projectId = input.projectId ?? callingThread.projectId; if (!readModel.projects.some((project) => project.id === projectId)) { - return yield* fail( - "threads_create", - `Project ${projectId} does not exist in this environment.`, + return yield* Effect.fail( + new ThreadsSurfaceError({ + operation: "threads_create", + detail: `Project ${projectId} does not exist in this environment.`, + }), ); } From b8e2cd114eec28fe24b2a2eca4c691217ae09fe6 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Wed, 16 Sep 2026 17:12:06 +1000 Subject: [PATCH 10/12] fix(threads): reject deleted projects in threads_create A soft-deleted project id passed the existence-only check and produced a thread under a project the user can no longer open. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- apps/server/src/mcp/toolkits/threads/handlers.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/server/src/mcp/toolkits/threads/handlers.ts b/apps/server/src/mcp/toolkits/threads/handlers.ts index 9469327e3644..97435bf78220 100644 --- a/apps/server/src/mcp/toolkits/threads/handlers.ts +++ b/apps/server/src/mcp/toolkits/threads/handlers.ts @@ -78,7 +78,8 @@ const threadsCreate = (input: ThreadsCreateInput) => ); } const projectId = input.projectId ?? callingThread.projectId; - if (!readModel.projects.some((project) => project.id === projectId)) { + const targetProject = readModel.projects.find((project) => project.id === projectId); + if (!targetProject || targetProject.deletedAt !== null) { return yield* Effect.fail( new ThreadsSurfaceError({ operation: "threads_create", From 1829bfe4cdc5d6c3205a8a34061b82fe54ba2325 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Wed, 16 Sep 2026 17:18:37 +1000 Subject: [PATCH 11/12] fix(threads): sort threads_list by parsed instant, not ISO text Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- apps/server/src/mcp/toolkits/threads/handlers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/mcp/toolkits/threads/handlers.ts b/apps/server/src/mcp/toolkits/threads/handlers.ts index 97435bf78220..96649cf10e8c 100644 --- a/apps/server/src/mcp/toolkits/threads/handlers.ts +++ b/apps/server/src/mcp/toolkits/threads/handlers.ts @@ -44,7 +44,7 @@ const threadsList = (input: ThreadsListInput) => if (input.filter === "active") return !isThreadSettled(thread); return true; }) - .sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : a.updatedAt > b.updatedAt ? -1 : 0)) + .sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt)) .slice( 0, Math.min(input.limit ?? THREADS_SURFACE_LIST_DEFAULT_LIMIT, THREADS_SURFACE_LIST_MAX_LIMIT), From 9ad92fc9ad21808286b58039b6e2f920582cc456 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Wed, 16 Sep 2026 17:29:37 +1000 Subject: [PATCH 12/12] test(threads): cover threads_list ordering and liveness filtering Extracts the list shaping into threadsListItems so the instant-order sort and the deleted/archived exclusion have focused coverage. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../src/mcp/toolkits/threads/handlers.test.ts | 78 +++++++++++++++++++ .../src/mcp/toolkits/threads/handlers.ts | 53 +++++++------ 2 files changed, 108 insertions(+), 23 deletions(-) create mode 100644 apps/server/src/mcp/toolkits/threads/handlers.test.ts diff --git a/apps/server/src/mcp/toolkits/threads/handlers.test.ts b/apps/server/src/mcp/toolkits/threads/handlers.test.ts new file mode 100644 index 000000000000..6d41ec5c8387 --- /dev/null +++ b/apps/server/src/mcp/toolkits/threads/handlers.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vite-plus/test"; +import { + DEFAULT_PROVIDER_INTERACTION_MODE, + ProjectId, + ThreadId, + ProviderInstanceId, + type OrchestrationReadModel, +} from "@t3tools/contracts"; + +import { threadsListItems } from "./handlers.ts"; + +const projectId = ProjectId.make("project-a"); + +const makeThread = ( + id: string, + updatedAt: string, + overrides: Partial = {}, +): OrchestrationReadModel["threads"][number] => ({ + id: ThreadId.make(id), + projectId, + title: id, + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + pullRequests: [], + createdAt: updatedAt, + updatedAt, + archivedAt: null, + settledOverride: null, + settledAt: null, + latestTurn: null, + messages: [], + session: null, + activities: [], + proposedPlans: [], + checkpoints: [], + deletedAt: null, + ...overrides, +}); + +describe("threadsListItems", () => { + it("orders by parsed instant so offset-bearing timestamps sort correctly", () => { + // Lexically "10:00+02:00" > "09:30Z", but 09:30Z is the later instant + // (10:00+02:00 = 08:00Z). + const items = threadsListItems( + [ + makeThread("offset", "2026-08-24T10:00:00+02:00"), + makeThread("later", "2026-08-24T09:30:00.000Z"), + ], + { filter: "recent" }, + ); + expect(items.map((item) => item.threadId)).toEqual([ + ThreadId.make("later"), + ThreadId.make("offset"), + ]); + }); + + it("excludes deleted and archived threads", () => { + const items = threadsListItems( + [ + makeThread("live", "2026-08-24T10:00:00.000Z"), + makeThread("deleted", "2026-08-24T11:00:00.000Z", { + deletedAt: "2026-08-24T12:00:00.000Z", + }), + makeThread("archived", "2026-08-24T12:00:00.000Z", { + archivedAt: "2026-08-24T13:00:00.000Z", + }), + ], + { filter: "recent" }, + ); + expect(items.map((item) => item.threadId)).toEqual([ThreadId.make("live")]); + }); +}); diff --git a/apps/server/src/mcp/toolkits/threads/handlers.ts b/apps/server/src/mcp/toolkits/threads/handlers.ts index 96649cf10e8c..db243e31ac7b 100644 --- a/apps/server/src/mcp/toolkits/threads/handlers.ts +++ b/apps/server/src/mcp/toolkits/threads/handlers.ts @@ -27,8 +27,35 @@ const isThreadSettled = (thread: OrchestrationReadModel["threads"][number]): boo thread.settledOverride === "settled" || (thread.settledOverride === null && thread.settledAt !== null); -const liveThreads = (readModel: OrchestrationReadModel) => - readModel.threads.filter((thread) => thread.deletedAt === null && thread.archivedAt === null); +/** + * Shapes live read-model threads into the tool's result: filtered by project + * and settled state, newest first by instant (ISO strings can carry offsets, + * so they are parsed rather than compared as text), capped by the input limit. + */ +export const threadsListItems = ( + threads: ReadonlyArray, + input: ThreadsListInput, +): ThreadsListItem[] => + threads + .filter((thread) => thread.deletedAt === null && thread.archivedAt === null) + .filter((thread) => input.projectId === undefined || thread.projectId === input.projectId) + .filter((thread) => { + if (input.filter === "settled") return isThreadSettled(thread); + if (input.filter === "active") return !isThreadSettled(thread); + return true; + }) + .sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt)) + .slice( + 0, + Math.min(input.limit ?? THREADS_SURFACE_LIST_DEFAULT_LIMIT, THREADS_SURFACE_LIST_MAX_LIMIT), + ) + .map((thread) => ({ + threadId: thread.id, + projectId: thread.projectId, + title: thread.title, + settled: isThreadSettled(thread), + updatedAt: thread.updatedAt, + })); const threadsList = (input: ThreadsListInput) => Effect.gen(function* () { @@ -37,27 +64,7 @@ const threadsList = (input: ThreadsListInput) => .getCommandReadModel() .pipe(failFrom("threads_list", "Failed to load the environment's threads.")); - const threads = liveThreads(readModel) - .filter((thread) => input.projectId === undefined || thread.projectId === input.projectId) - .filter((thread) => { - if (input.filter === "settled") return isThreadSettled(thread); - if (input.filter === "active") return !isThreadSettled(thread); - return true; - }) - .sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt)) - .slice( - 0, - Math.min(input.limit ?? THREADS_SURFACE_LIST_DEFAULT_LIMIT, THREADS_SURFACE_LIST_MAX_LIMIT), - ); - - const items: ThreadsListItem[] = threads.map((thread) => ({ - threadId: thread.id, - projectId: thread.projectId, - title: thread.title, - settled: isThreadSettled(thread), - updatedAt: thread.updatedAt, - })); - return { threads: items } satisfies ThreadsListResult; + return { threads: threadsListItems(readModel.threads, input) } satisfies ThreadsListResult; }); const threadsCreate = (input: ThreadsCreateInput) =>