Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
18 changes: 18 additions & 0 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1738,6 +1738,24 @@ function renderFeedEntry(
);
}

if (entry.type === "sent-message") {
return (
<View className="mb-1 px-1">
<MarkdownImageAvailableWidthContext value={props.markdownContentWidth}>
<AssistantMarkdownContent
markdown={entry.text}
environmentId={props.environmentId}
markdownStyles={markdownStyles.assistant}
linkHandlers={props.markdownLinkHandlers}
onUseArtifactTemplate={props.onUseArtifactTemplate}
renderImage={props.renderMarkdownImage}
skills={props.skills}
/>
</MarkdownImageAvailableWidthContext>
</View>
);
}

if (entry.type === "viewed-images") {
const thumbnailWidth = Math.floor((props.contentWidth - VIEWED_IMAGE_GAP) / 2);
return (
Expand Down
33 changes: 31 additions & 2 deletions apps/mobile/src/lib/threadActivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ import type { McpAppReference } from "@t3tools/shared/mcpApp";
import {
compactDynamicToolOutput,
htmlRenderFromToolItem,
sentMessageFromToolItem,
mcpAppFromToolItem,
} from "@t3tools/shared/toolOutput";
import * as DateTime from "effect/DateTime";
Expand Down Expand Up @@ -182,6 +183,13 @@ type RawThreadFeedEntry =
readonly runId: RunId | null;
readonly render: HtmlRenderReference;
}
| {
readonly type: "sent-message";
readonly id: string;
readonly createdAt: string;
readonly runId: RunId | null;
readonly text: string;
}
| {
readonly type: "mcp-app";
readonly id: string;
Expand All @@ -200,7 +208,7 @@ export type ThreadFeedEntry = ThreadFeedEntryContent & {
};

type ThreadFeedEntryContent =
| Extract<RawThreadFeedEntry, { type: "message" | "html-render" | "mcp-app" }>
| Extract<RawThreadFeedEntry, { type: "message" | "html-render" | "sent-message" | "mcp-app" }>
| {
readonly type: "activity-group";
readonly id: string;
Expand Down Expand Up @@ -1098,7 +1106,10 @@ function feedEntryFoldRunId(entry: ThreadFeedEntry, runlessKey: RunId | null): R
if (entry.type === "message") {
return entry.message.role === "assistant" ? (entry.message.runId ?? runlessKey) : null;
}
return entry.type === "activity-group" || entry.type === "html-render" || entry.type === "mcp-app"
return entry.type === "activity-group" ||
entry.type === "html-render" ||
entry.type === "sent-message" ||
entry.type === "mcp-app"
? (entry.runId ?? runlessKey)
: null;
}
Expand Down Expand Up @@ -1227,6 +1238,7 @@ function deriveThreadFeedRunFolds(
entry.id !== terminalAssistantId &&
!(entry.type === "message" && retainedAnswerIds.has(entry.message.id)) &&
entry.type !== "html-render" &&
entry.type !== "sent-message" &&
entry.type !== "mcp-app" &&
entry.id !== visibleCompactionId &&
!(
Expand Down Expand Up @@ -2429,6 +2441,23 @@ export function buildThreadFeed(
entryRows.push(index);
continue;
}
const sentMessage =
item.type === "dynamic_tool" && item.status === "completed"
? sentMessageFromToolItem(item)
: undefined;
if (sentMessage !== undefined) {
const entry: RawThreadFeedEntry = {
type: "sent-message",
id: `sent-message:${row.visibility}:${row.sourceThreadId}:${row.sourceItemId}`,
createdAt,
runId: item.runId,
text: sentMessage,
};
projectedEntriesCache.set(row, { attemptId, entries: [entry] });
entries.push(entry);
entryRows.push(index);
continue;
}
if (item.type === "user_message" || item.type === "assistant_message") {
const updatedAt = DateTime.formatIso(item.updatedAt);
const entry: RawThreadFeedEntry = {
Expand Down
5 changes: 5 additions & 0 deletions apps/server/src/mcp/McpHttpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ import {
DeviceStandardToolkit,
} from "./toolkits/device/tools.ts";
import * as HtmlHandlers from "./toolkits/html/handlers.ts";
import { MessageToolkit } from "./toolkits/message/tools.ts";
import * as MessageHandlers from "./toolkits/message/handlers.ts";
import { HtmlPreviewTool, HtmlPreviewToolkit, HtmlRenderToolkit } from "./toolkits/html/tools.ts";
import { requestUrl } from "../requestUrl.ts";

Expand Down Expand Up @@ -1239,6 +1241,8 @@ export const layerOrchestratorToolkit = toolkitRegistration(

export const layerThreadToolkit = toolkitRegistration(ThreadToolkit, ThreadHandlers.layer);

const layerMessageToolkit = toolkitRegistration(MessageToolkit, MessageHandlers.layer);

const layerWorktreeToolkitRegistration = toolkitRegistration(
WorktreeToolkit,
WorktreeHandlers.layer,
Expand Down Expand Up @@ -1324,6 +1328,7 @@ export const layerTools = Layer.mergeAll(
layerPullRequestsToolkit,
layerDeviceToolkit,
layerHtmlToolkit,
layerMessageToolkit,
);

const layerToolExposure = Layer.effect(
Expand Down
20 changes: 20 additions & 0 deletions apps/server/src/mcp/toolkits/message/handlers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import * as Effect from "effect/Effect";

import * as McpInvocationContext from "../../McpInvocationContext.ts";
import * as McpToolAccess from "../../McpToolAccess.ts";
import { MessageToolkit } from "./tools.ts";

const handlers = {
send_to_user: McpToolAccess.actsAsCaller((input) =>
Effect.gen(function* () {
const scope = yield* McpInvocationContext.McpInvocationContext;
yield* McpInvocationContext.requireThreadScope(scope, "send_to_user");
return {
sentMessage: input.message,
message: "Shown to the user. Don't repeat it.",
};
}),
),
} satisfies McpToolAccess.Handlers<typeof MessageToolkit.tools>;

export const layer = McpToolAccess.toLayer(MessageToolkit, handlers);
39 changes: 39 additions & 0 deletions apps/server/src/mcp/toolkits/message/tools.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { OrchestratorMcpFailure } from "@t3tools/contracts";
import { SEND_TO_USER_MAX_LENGTH, SEND_TO_USER_TOOL_NAME } from "@t3tools/shared/toolOutput";
import * as Schema from "effect/Schema";
import { Tool, Toolkit } from "effect/ai";

import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts";
import * as McpInvocationContext from "../../McpInvocationContext.ts";

// Claude 5.1+ returns text written before a tool call only as a short summary; tool input arrives intact.
// It affects only the caller's thread, so read-only sandboxes and plan mode may use it.
const SendToUserTool = Tool.make(SEND_TO_USER_TOOL_NAME, {
description:
"Show the user a Markdown message in this thread, exactly as written, before your turn ends. Use it for content the user must read in full mid-turn, such as a draft, a code block, or what a question you are about to ask refers to. Don't repeat it in your reply.",
parameters: Schema.Struct({
message: Schema.String.check(
Schema.isMinLength(1),
Schema.isMaxLength(SEND_TO_USER_MAX_LENGTH),
).annotate({ description: "Markdown, shown verbatim." }),
}),
success: Schema.Struct({
sentMessage: Schema.String,
message: Schema.String,
}),
failure: OrchestratorMcpFailure,
failureMode: "return",
dependencies: [
McpInvocationContext.McpInvocationContext,
ThreadManagementService.ThreadManagementService,
],
})
.annotate(Tool.Title, "Send a message to the user")
.annotate(Tool.Readonly, true)
.annotate(Tool.Destructive, false)
.annotate(Tool.Idempotent, false)
.annotate(Tool.OpenWorld, false)
// Claude Code defers MCP tools behind ToolSearch; Anthropic says to declare this from the first request.
.annotate(Tool.Meta, { "anthropic/alwaysLoad": true });

export const MessageToolkit = Toolkit.make(SendToUserTool);
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ import { T3_CODE_DEVICE_TOOL_INSTRUCTIONS_WITH_TOOL_SEARCH } from "@t3tools/prov
import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions";
import { PreviewControlsToolkit } from "../../mcp/toolkits/previewControls/tools.ts";
import { HtmlToolkit } from "../../mcp/toolkits/html/tools.ts";
import { MessageToolkit } from "../../mcp/toolkits/message/tools.ts";
import { EnvironmentToolkit } from "../../mcp/toolkits/environment/tools.ts";
import { ProjectToolkit } from "../../mcp/toolkits/project/tools.ts";
import { WorktreeToolkit } from "../../mcp/toolkits/worktree/tools.ts";
Expand Down Expand Up @@ -663,6 +664,7 @@ describe("ClaudeAdapterV2 MCP query overrides", () => {
...Object.values(EnvironmentToolkit.tools),
...Object.values(PreviewControlsToolkit.tools),
...Object.values(HtmlToolkit.tools),
...Object.values(MessageToolkit.tools),
]
.filter((tool) => Context.get(tool.annotations, Tool.Readonly))
.map((tool) => `mcp__t3-code__${tool.name}`)
Expand Down
10 changes: 10 additions & 0 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1954,6 +1954,14 @@ const CLAUDE_BACKGROUND_WAIT_INSTRUCTIONS = `
To wait for a background command or Monitor you started (tests, a build), call \`t3_background_wait\` with its task ID before you end your turn. Do not mark work you leave running, or PRs. T3 MCP tools are deferred: load one with ToolSearch before you call it.
`;

// Claude 5.1+ returns text written before a tool call as a progress update, which the API
// returns only as a summary.
const CLAUDE_SEND_TO_USER_INSTRUCTIONS = `
### Text the user must read mid-turn

Text you write before a tool call may reach the user only as a one-line summary. When the user must read something in full before your turn ends, such as a draft, a code block, or what a question you're about to ask refers to, send it with \`send_to_user\`. Put everything else in your final reply.
`;

// Native subagents start from their task, not this session's system prompt.
const subagentDeviceInstructions: HookCallback = () =>
Promise.resolve({
Expand Down Expand Up @@ -2132,6 +2140,7 @@ export function makeClaudeQueryOptions(input: {
? ""
: T3_CODE_ORCHESTRATION_INSTRUCTIONS +
CLAUDE_BACKGROUND_WAIT_INSTRUCTIONS +
CLAUDE_SEND_TO_USER_INSTRUCTIONS +
(deviceInstructions
? `\n${T3_CODE_DEVICE_TOOL_INSTRUCTIONS_WITH_TOOL_SEARCH}\n`
: "")),
Expand Down Expand Up @@ -2252,6 +2261,7 @@ export const CLAUDE_READ_ONLY_T3_MCP_ALLOWED_TOOLS: ReadonlyArray<string> = [
"mcp__t3-code__t3_environment_links",
"mcp__t3-code__html_preview",
"mcp__t3-code__html_render",
"mcp__t3-code__send_to_user",
];

// T3 waits can block for an hour; exceed Claude's default 60 s timeout.
Expand Down
6 changes: 5 additions & 1 deletion apps/server/src/orchestration-v2/ProjectionStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4333,7 +4333,11 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => {
workCursor = page.nextCursor;
}
assert.strictEqual(workIds.size, 999);
for (const toolName of ["t3-code.delegate_task", "mcp__t3_code__delegate_task"]) {
for (const toolName of [
"t3-code.delegate_task",
"mcp__t3_code__delegate_task",
"mcp__t3-code__send_to_user",
]) {
yield* sql`UPDATE orchestration_v2_projection_turn_items SET type = 'dynamic_tool',
payload_json = json_set(payload_json, '$.type', 'dynamic_tool', '$.toolName', ${toolName})
WHERE thread_id = ${threadId} AND ordinal = 2`;
Expand Down
3 changes: 2 additions & 1 deletion apps/server/src/orchestration-v2/ProjectionStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2128,7 +2128,8 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
const inlineWorkOutput = sql`item.type = 'dynamic_tool' AND (
item.payload_json LIKE '%htmlRender%' OR item.payload_json LIKE ${`%${MCP_APP_OUTPUT_KEY}%`}
OR item.payload_json LIKE '%"viewedImagePath"%'
OR item.payload_json LIKE '%"toolName":"%delegate_task"%')`;
OR item.payload_json LIKE '%"toolName":"%delegate_task"%'
OR item.payload_json LIKE '%"toolName":"%send_to_user"%')`;
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Resolved

// SQLite uses the partial conversation index of migration 082 only for this exact predicate.
const CONVERSATION_INDEX_EXCLUDED_TYPES = sql.literal(
Expand Down
7 changes: 5 additions & 2 deletions apps/server/src/provider/AgentContextBudget.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,13 +116,16 @@ const layerAdvertisedTools = McpHttpServer.layerTools.pipe(
);

// Claude Code loads these tools up front instead of behind ToolSearch, outside this budget.
it.effect("only request_secret asks Claude Code to load it up front", () =>
it.effect("only request_secret and send_to_user ask Claude Code to load them up front", () =>
Effect.gen(function* () {
const mcp = yield* McpServer.McpServer;
const alwaysLoad = mcp.tools
.filter(({ tool }) => tool._meta?.["anthropic/alwaysLoad"] !== undefined)
.map(({ tool }) => [tool.name, tool._meta]);
expect(alwaysLoad).toEqual([["request_secret", { "anthropic/alwaysLoad": true }]]);
expect(alwaysLoad).toEqual([
["request_secret", { "anthropic/alwaysLoad": true }],
["send_to_user", { "anthropic/alwaysLoad": true }],
]);
}).pipe(Effect.provide(layerAdvertisedTools)),
);

Expand Down
4 changes: 2 additions & 2 deletions apps/server/src/provider/agentContextBudget.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"claudeSystemAppendBytes": 5594,
"claudeSystemAppendBytes": 5930,
"codexAdditionalContextBytes": 7324,
"mcpToolListJsonBytes": 48094
"mcpToolListJsonBytes": 48566
}
26 changes: 25 additions & 1 deletion apps/web/src/components/chat/MessagesTimeline.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,9 @@ export function timelineEntryRunId(entry: TimelineEntry): RunId | null {
if (entry.kind === "proposed-plan") {
return entry.proposedPlan.runId;
}
if (entry.kind === "html-render" || entry.kind === "mcp-app") return entry.runId;
if (entry.kind === "html-render" || entry.kind === "mcp-app" || entry.kind === "sent-message") {
return entry.runId;
}
return entry.kind === "work" ? (entry.entry.runId ?? null) : null;
}

Expand Down Expand Up @@ -634,6 +636,12 @@ type MessagesTimelineRowContent =
createdAt: string;
htmlRender: HtmlRenderReference;
}
| {
kind: "sent-message";
id: string;
createdAt: string;
text: string;
}
| {
kind: "mcp-app";
id: string;
Expand Down Expand Up @@ -817,6 +825,7 @@ function deriveSupersededAttemptFolds(
(entry.kind === "event" && entry.projectedItem.item.type === "handoff") ||
entry.kind === "html-render" ||
entry.kind === "mcp-app" ||
entry.kind === "sent-message" ||
timelineEntryIsPersistentResourceCard(entry) ||
liveSubagentEntryIds.has(entry.id) ||
(entry.kind === "work" && entry.entry.itemType === "system_notice")
Expand Down Expand Up @@ -2126,6 +2135,16 @@ export function deriveMessagesTimelineRows(input: {
continue;
}

if (timelineEntry.kind === "sent-message") {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
nextRows.push({
kind: "sent-message",
id: timelineEntry.id,
createdAt: timelineEntry.createdAt,
text: timelineEntry.text,
});
continue;
}

if (timelineEntry.kind === "mcp-app") {
nextRows.push({
kind: "mcp-app",
Expand Down Expand Up @@ -2720,6 +2739,11 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean
return a.createdAt === bh.createdAt && htmlRenderReferencesEqual(a.htmlRender, bh.htmlRender);
}

case "sent-message": {
const bs = b as typeof a;
return a.createdAt === bs.createdAt && a.text === bs.text;
}

case "mcp-app": {
const bm = b as typeof a;
return (
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ import {
AssistantMetaTimelineRow,
ProposedPlanTimelineRow,
HtmlRenderTimelineRow,
SentMessageTimelineRow,
McpAppTimelineRow,
} from "./TimelineAssistantRows";
import { V2EventTimelineRow } from "./TimelineV2EventRow";
Expand Down Expand Up @@ -1626,6 +1627,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
row.kind === "event" ||
row.kind === "attempt-fold" ||
row.kind === "html-render" ||
row.kind === "sent-message" ||
row.kind === "mcp-app" ||
row.kind === "viewed-images"
? "pb-2"
Expand Down Expand Up @@ -1676,6 +1678,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
{row.kind === "assistant-meta" ? <AssistantMetaTimelineRow row={row} /> : null}
{row.kind === "proposed-plan" ? <ProposedPlanTimelineRow row={row} /> : null}
{row.kind === "html-render" ? <HtmlRenderTimelineRow row={row} /> : null}
{row.kind === "sent-message" ? <SentMessageTimelineRow row={row} /> : null}
{row.kind === "viewed-images" ? <ViewedImagesTimelineRow row={row} /> : null}
{row.kind === "mcp-app" ? <McpAppTimelineRow row={row} /> : null}
{row.kind === "working" ? <WorkingTimelineRow row={row} /> : null}
Expand Down
Loading
Loading