Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions apps/server/src/mcp/McpHttpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
{
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -630,6 +636,7 @@ const McpTransportLive = McpServer.layerHttp({

export const layer = Layer.mergeAll(
PreviewToolkitRegistrationLive,
ThreadsToolkitRegistrationLive,
PullRequestsToolkitRegistrationLive,
DeviceToolkitRegistrationLive,
).pipe(Layer.provideMerge(McpTransportLive));
105 changes: 105 additions & 0 deletions apps/server/src/mcp/toolkits/threads/handlers.ts
Original file line number Diff line number Diff line change
@@ -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<ThreadsSurfaceError>({ _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,
});
52 changes: 52 additions & 0 deletions apps/server/src/mcp/toolkits/threads/tools.ts
Original file line number Diff line number Diff line change
@@ -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);
72 changes: 72 additions & 0 deletions apps/server/src/orchestration/ActivityPayloadProjection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>).data as Record<string, unknown>;
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<string, unknown>).data as Record<string, unknown>;
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<string, unknown>).data as Record<string, unknown>;
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",
Expand Down
48 changes: 48 additions & 0 deletions apps/server/src/orchestration/ActivityPayloadProjection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> | null {
return value !== null && typeof value === "object" && !Array.isArray(value)
Expand Down Expand Up @@ -306,6 +307,46 @@ function projectPreviewToolMetadata(data: Record<string, unknown>, status: unkno
}
}

/** True when the tool belongs to the server's own threads surface toolkit. */
function isThreadsSurfaceTool(data: Record<string, unknown>, item: Record<string, unknown> | 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<string, unknown>,
item: Record<string, unknown> | null,
): Record<string, unknown> | 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<string, unknown>)
: 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
Expand All @@ -330,6 +371,13 @@ function projectMcpToolCallData(data: Record<string, unknown>): Record<string, u
projectedData.item = projectedItem;
}

if (isThreadsSurfaceTool(data, item)) {
const structuredResult = extractThreadsSurfaceStructuredResult(data, item);
if (structuredResult) {
projectedData.structuredResult = structuredResult;
}
}

if ("toolName" in data) {
projectedData.toolName = data.toolName;
}
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/orchestration/decider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
interactionMode: command.interactionMode,
branch: command.branch,
worktreePath: command.worktreePath,
...(command.source === undefined ? {} : { source: command.source }),
createdAt: command.createdAt,
updatedAt: command.createdAt,
},
Expand Down
38 changes: 38 additions & 0 deletions apps/web/src/agentCreatedThreadToast.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
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<string>();

export function notifyAgentCreatedThreads(input: {
environmentId: EnvironmentId;
threads: ReadonlyArray<AgentCreatedThread>;
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));
},
},
}),
);
}
}
Loading
Loading