diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 5a8cb573ad8..307c2ed6ef4 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 00000000000..d751294d669 --- /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 00000000000..7eff8a934ec --- /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, +]; + +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); + +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 e6468ff8f78..b10f69c0af7 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 0525aae7b72..86b4f4c429e 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 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)); + }, + }, + }), + ); + } +} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index e43bd23e130..9154ac03d47 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, @@ -2863,6 +2865,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 61381ddb71f..e73c7a35814 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"; @@ -4140,6 +4148,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 2dbcaeeb792..3714141d006 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 20a5671da85..d48572d032e 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/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 978a0459e69..2898f020bef 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -40,6 +40,7 @@ export * from "./browserProfile.ts"; export * from "./device.ts"; export * from "./preview.ts"; export * from "./previewAutomation.ts"; +export * from "./threadsSurface.ts"; export * from "./resourceTelemetry.ts"; export * from "./usage.ts"; export * from "./rpc.ts"; diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index aa2dd54fecb..58edf0e4087 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -1048,6 +1048,10 @@ const ThreadCreateCommand = Schema.Struct({ ), branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), + // "agent" marks thread creation initiated by the agent itself (e.g. the + // threads MCP toolkit), so clients can surface those differently from + // user-created threads. Optional so pre-agent-create payloads decode. + source: Schema.optional(Schema.Literal("agent")), createdAt: IsoDateTime, historyImport: Schema.optional(Schema.Literal(true)), }); @@ -1658,6 +1662,7 @@ export const ThreadCreatedPayload = Schema.Struct({ ), branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), + source: Schema.optional(Schema.Literal("agent")), createdAt: IsoDateTime, updatedAt: IsoDateTime, }); diff --git a/packages/contracts/src/threadsSurface.ts b/packages/contracts/src/threadsSurface.ts new file mode 100644 index 00000000000..108021c0108 --- /dev/null +++ b/packages/contracts/src/threadsSurface.ts @@ -0,0 +1,68 @@ +/** + * ThreadsSurface - Schemas for the agent-facing threads toolkit. + * + * Tools in this surface let a running agent list and create threads in the + * current environment. The payloads are deliberately small: thread items carry + * ids and titles only, never message content, so thread data stays out of the + * model. Clients resolve rendering (and navigation) locally from these ids. + * + * @module ThreadsSurface + */ +import { Effect, Schema } from "effect"; +import { + IsoDateTime, + PositiveInt, + ProjectId, + ThreadId, + 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 const THREADS_SURFACE_LIST_DEFAULT_LIMIT = 8; +export const THREADS_SURFACE_LIST_MAX_LIMIT = 25; + +export const ThreadsSurfaceListFilter = Schema.Literals(["recent", "settled", "active"]); +export type ThreadsSurfaceListFilter = typeof ThreadsSurfaceListFilter.Type; + +export const ThreadsListInput = Schema.Struct({ + filter: ThreadsSurfaceListFilter.pipe( + Schema.withDecodingDefault(Effect.succeed("recent" as const)), + ), + projectId: Schema.optional(ProjectId), + limit: Schema.optional(PositiveInt), +}); +export type ThreadsListInput = typeof ThreadsListInput.Type; + +export const ThreadsListItem = Schema.Struct({ + threadId: ThreadId, + projectId: ProjectId, + title: TrimmedNonEmptyString, + settled: Schema.Boolean, + updatedAt: IsoDateTime, +}); +export type ThreadsListItem = typeof ThreadsListItem.Type; + +export const ThreadsListResult = Schema.Struct({ + threads: Schema.Array(ThreadsListItem).check(Schema.isMaxLength(THREADS_SURFACE_LIST_MAX_LIMIT)), +}); +export type ThreadsListResult = typeof ThreadsListResult.Type; + +export const ThreadsCreateInput = Schema.Struct({ + title: TrimmedNonEmptyString, + projectId: Schema.optional(ProjectId), +}); +export type ThreadsCreateInput = typeof ThreadsCreateInput.Type; + +export const ThreadsCreateResult = Schema.Struct({ + threadId: ThreadId, + title: TrimmedNonEmptyString, +}); +export type ThreadsCreateResult = typeof ThreadsCreateResult.Type; + +export const ThreadsSurfaceError = Schema.Struct({ + _tag: Schema.Literals(["ThreadsSurfaceError"]), + detail: Schema.String, +}); +export type ThreadsSurfaceError = typeof ThreadsSurfaceError.Type; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 70ec9fe58a1..4bc463b5c24 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) @@ -16485,7 +16485,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))