From 430a441bf273b5866a9758c0e5659e8f875cebf1 Mon Sep 17 00:00:00 2001 From: Renfei Zhou Date: Sat, 14 Nov 2026 04:24:52 +0000 Subject: [PATCH] fix(fork): resolve upstream merge 37714a8de5 (2026-11-14) Resolves the 11 conflicts of the merge commit below and the breaks the merge left. - send_to_user: upstream added its own tool with the same name. The fork's tool stays the registered one, since display modes read its calls by name and `text`, and it now follows upstream's design in Normal display mode: up to 4000 UTF-16 code units, upstream's result with `sentMessage`, so web and mobile draw the text as the agent's words, and upstream's Claude instructions on mid-turn sends. A Claude conversation keeps its first system prompt, so those instructions come from a prompt hook while the thread is in Normal. Messages only, Messages + Dashboard and Chat + Replies keep the 1000-byte tool and get no such instructions. The tool carries upstream's alwaysLoad hint; labels follow upstream's; upstream's unregistered handler file is removed, and the eval lists the registered tools. - AppArmor: upstream's setup keeps a profile already on disk. The fork's full Chrome needs its path in the profile, so setup now also rewrites the exact profile an older T3 wrote, and keeps any other profile as upstream does. - Upstream's browser setup test host gets Xvfb, which the fork's setup asks for, and a case for the older profile. - The fork's parity test mocks upstream's new theme service. - The rest keeps both sides: GitHub-hosted macOS runner, delegated-completion wakes, preview mode restarts and start page, the reply-draft guard in the chat view. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 4 -- apps/server/src/cli/browser.test.ts | 9 +++ apps/server/src/cli/browser.ts | 12 ++-- ...DisplayModeCompactionNote.adapters.test.ts | 48 +++++++++++++ .../DisplayModeCompactionNote.test.ts | 63 ++++++++++++++++ .../DisplayModeCompactionNote.ts | 71 ++++++++++++++---- .../server/src/fork/generalRoleParity.test.ts | 2 + apps/server/src/fork/previewChrome.test.ts | 51 ++++++++++++- apps/server/src/fork/previewChrome.ts | 26 +++++++ .../fork/userMessageTools/SendToUser.test.ts | 72 +++++++++++++++---- .../src/fork/userMessageTools/SendToUser.ts | 51 +++++++++---- .../SendToUserSubThreads.test.ts | 24 +++++++ .../src/fork/userMessageTools/replyStage.ts | 2 +- apps/server/src/mcp/McpHttpServer.ts | 14 ++-- .../src/mcp/toolkits/message/handlers.ts | 15 ---- .../Adapters/ClaudeAdapterV2.ts | 12 +++- .../src/orchestration-v2/Orchestrator.ts | 6 +- .../src/orchestration-v2/ProjectionStore.ts | 4 -- apps/server/src/preview/PreviewBrowserHost.ts | 13 +--- apps/server/src/preview/ServerBrowser.ts | 4 +- .../src/preview/ServerBrowserContexts.ts | 5 +- .../src/provider/agentContextBudget.json | 10 +-- apps/web/src/components/ChatView.tsx | 10 +-- .../components/chat/TimelineAssistantRows.tsx | 1 + .../displayModeTimeline.browser.test.tsx | 70 +++++++++++++++++- .../fork/displayMode/displayModeTimeline.ts | 34 ++++++--- evals/mcp-tool-use/run.ts | 15 +++- packages/client-runtime/src/t3ToolSummary.ts | 6 +- .../fork/displayModes/displayModes.test.ts | 10 +++ .../src/fork/displayModes/displayModes.ts | 10 ++- packages/shared/src/t3McpToolPresentation.ts | 10 +-- 31 files changed, 529 insertions(+), 155 deletions(-) delete mode 100644 apps/server/src/mcp/toolkits/message/handlers.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 80549c16b4535..57b98d0f1904e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -591,11 +591,7 @@ jobs: name: Mobile Native Static Analysis needs: changes if: ${{ !cancelled() && needs.changes.outputs.mobile_native != 'false' }} -<<<<<<< origin/main runs-on: macos-26 -======= - runs-on: blacksmith-6vcpu-macos-27 ->>>>>>> upstream/main timeout-minutes: 10 steps: - name: Checkout diff --git a/apps/server/src/cli/browser.test.ts b/apps/server/src/cli/browser.test.ts index f652cb58b2104..ecebacecddf62 100644 --- a/apps/server/src/cli/browser.test.ts +++ b/apps/server/src/cli/browser.test.ts @@ -12,6 +12,7 @@ import { Command } from "effect/cli"; import { ChildProcessSpawner } from "effect/process"; import packageJson from "../../package.json" with { type: "json" }; +import { HEADLESS_SHELL_PROFILE } from "../fork/previewChrome.ts"; import * as PreviewBrowserHost from "../preview/PreviewBrowserHost.ts"; import { browserCommand } from "./browser.ts"; @@ -32,6 +33,8 @@ const runSetupAsRoot = (input: { readonly profile: string | undefined }) => { const files = new Map([ [USERNS_RESTRICTION, "1\n"], [BROWSER, ""], + // The fork's setup also asks for Xvfb, which full Chrome's headed mode runs in. + ["/usr/bin/Xvfb", ""], ]); if (input.profile !== undefined) { files.set(PreviewBrowserHost.APPARMOR_PROFILE_PATH, input.profile); @@ -122,6 +125,12 @@ it.effect.each([ written: PreviewBrowserHost.APPARMOR_PROFILE, said: "Wrote and loaded", }, + { + label: "rewrites the profile an older T3 wrote", + profile: HEADLESS_SHELL_PROFILE, + written: PreviewBrowserHost.APPARMOR_PROFILE, + said: "Wrote and loaded", + }, { label: "reloads a customized profile without rewriting it", profile: CUSTOMIZED_PROFILE, diff --git a/apps/server/src/cli/browser.ts b/apps/server/src/cli/browser.ts index d4c85ecadc7e1..c5910773efcf9 100644 --- a/apps/server/src/cli/browser.ts +++ b/apps/server/src/cli/browser.ts @@ -10,7 +10,7 @@ import * as ChildProcess from "effect/process/ChildProcess"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import { browserSetupRootWarning } from "../fork/browserSetupRootWarning.ts"; -import { FULL_CHROME_PRODUCT } from "../fork/previewChrome.ts"; +import { FULL_CHROME_PRODUCT, profileNeedsWrite } from "../fork/previewChrome.ts"; import { missingDisplayServer } from "../fork/previewDisplay.ts"; import * as PreviewBrowserHost from "../preview/PreviewBrowserHost.ts"; import { resolveBaseDir } from "../os-jank.ts"; @@ -175,11 +175,9 @@ const browserSetupCommand = Command.make("setup", { baseDir: baseDirFlag }).pipe } if (needsProfile) { - // An existing profile may carry an administrator's edits, so it is reloaded as is. - const profileOnDisk = yield* fs - .exists(PreviewBrowserHost.APPARMOR_PROFILE_PATH) - .pipe(Effect.orElseSucceed(() => false)); - if (!profileOnDisk) { + // Any profile but an older T3's may carry an administrator's edits, so it is reloaded as is. + const keepProfile = !(yield* profileNeedsWrite(PreviewBrowserHost.APPARMOR_PROFILE_PATH)); + if (!keepProfile) { yield* fs .writeFileString( PreviewBrowserHost.APPARMOR_PROFILE_PATH, @@ -200,7 +198,7 @@ const browserSetupCommand = Command.make("setup", { baseDir: baseDirFlag }).pipe PreviewBrowserHost.APPARMOR_PROFILE_PATH, ]); yield* Console.log( - profileOnDisk + keepProfile ? `Reloaded the AppArmor profile at ${PreviewBrowserHost.APPARMOR_PROFILE_PATH} that allows Chrome's sandbox for T3's browser.` : `Wrote and loaded the AppArmor profile at ${PreviewBrowserHost.APPARMOR_PROFILE_PATH} that allows Chrome's sandbox for T3's browser.`, ); diff --git a/apps/server/src/fork/displayModeWrap/DisplayModeCompactionNote.adapters.test.ts b/apps/server/src/fork/displayModeWrap/DisplayModeCompactionNote.adapters.test.ts index d867bfb8b1d61..fe8d24940364a 100644 --- a/apps/server/src/fork/displayModeWrap/DisplayModeCompactionNote.adapters.test.ts +++ b/apps/server/src/fork/displayModeWrap/DisplayModeCompactionNote.adapters.test.ts @@ -5,6 +5,7 @@ import { assert, it } from "@effect/vitest"; import { ClaudeSettings, CodexSettings, + EnvironmentId, MessageId, NodeId, ProjectId, @@ -221,6 +222,53 @@ it.effect("a Claude adapter built on the provider infrastructure adds the thread }).pipe(Effect.scoped, Effect.provide(infrastructureFor(threadId, "t3-compaction-note-claude-"))); }); +it.effect( + "Claude gets upstream's send_to_user section from the hook in Normal, not in its system prompt", + () => { + const normalId = ThreadId.make("thread:send-section:normal"); + const messagesOnlyId = ThreadId.make("thread:send-section:messages-only"); + const section = "### Text the user must read mid-turn"; + return Effect.gen(function* () { + const sessions = yield* McpProviderSessions.McpProviderSessions; + const submitted = Effect.fn("submitted")(function* (threadId: ThreadId) { + yield* sessions.set({ + environmentId: EnvironmentId.make("environment-send-section"), + threadId, + providerSessionId: "mcp-session-send-section", + providerInstanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, + endpoint: "http://127.0.0.1:43123/mcp", + authorizationHeader: "Bearer send-section", + browserToolsAvailable: false, + }); + const options = yield* startClaudeTurn(threadId, { text: "hi" }); + const append = (options.systemPrompt as { readonly append?: string }).append ?? ""; + assert.include(append, "call `t3_background_wait`"); + assert.notInclude(append, section); + const callback = options.hooks?.UserPromptSubmit?.[0]?.hooks[0]; + const output = yield* Effect.promise(() => + callback!( + { + hook_event_name: "UserPromptSubmit", + prompt: "hi", + session_id: "native-thread-compaction-note", + transcript_path: "/transcripts/native-thread-compaction-note.jsonl", + cwd: "/workspace", + } satisfies HookInput, + undefined, + { signal: new AbortController().signal }, + ), + ); + return "hookSpecificOutput" in output && + output.hookSpecificOutput?.hookEventName === "UserPromptSubmit" + ? output.hookSpecificOutput.additionalContext + : undefined; + }); + assert.include(yield* submitted(normalId), section); + assert.isUndefined(yield* submitted(messagesOnlyId)); + }).pipe(Effect.scoped, Effect.provide(infrastructureFor(messagesOnlyId, "t3-send-section-"))); + }, +); + it.effect("Claude gets a slash command's note only with that command's own prompt", () => { const threadId = ThreadId.make("thread:command-note:claude"); return Effect.gen(function* () { diff --git a/apps/server/src/fork/displayModeWrap/DisplayModeCompactionNote.test.ts b/apps/server/src/fork/displayModeWrap/DisplayModeCompactionNote.test.ts index 8a7bc61f92099..f06ec53f2c43b 100644 --- a/apps/server/src/fork/displayModeWrap/DisplayModeCompactionNote.test.ts +++ b/apps/server/src/fork/displayModeWrap/DisplayModeCompactionNote.test.ts @@ -49,6 +49,14 @@ it.layer(notes)("DisplayModeCompactionNote", (it) => { assert.deepEqual(yield* toldRows, before); }), ); + + it.effect("reads whether a thread is in Normal", () => + Effect.gen(function* () { + const { isNormal } = yield* DisplayModeCompactionNote.DisplayModeCompactionNote; + assert.isTrue(yield* isNormal(plainId)); + assert.isFalse(yield* isNormal(messagesOnlyId)); + }), + ); }); it("states the mode instead of announcing a switch", () => { @@ -74,6 +82,7 @@ it("adds the compaction note hook to Claude beside the subagent hook", async () notes: { noteAfterCompaction: (id) => Effect.succeed(id === threadId ? MESSAGES_ONLY_DISPLAY_MODE.stateNotice : undefined), + isNormal: () => Effect.succeed(true), }, threadId, activeTurn: Effect.undefined, @@ -101,3 +110,57 @@ it("adds the compaction note hook to Claude beside the subagent hook", async () }); assert.deepEqual(await callback({ ...input, agent_id: "subagent-1" }, undefined, { signal }), {}); }); + +it("gives upstream's send_to_user section once in Normal, again after another mode or a compaction", async () => { + const threadId = ThreadId.make("thread:compaction-note:section"); + let normal: boolean | undefined = true; + const hooks = DisplayModeCompactionNote.claudeDisplayModeHooks({ + notes: { + noteAfterCompaction: () => Effect.undefined, + isNormal: () => Effect.succeed(normal), + }, + threadId, + activeTurn: Effect.undefined, + runPromise: Effect.runPromise, + sendToUserSection: "SECTION", + }); + const prompt = hooks.UserPromptSubmit![0]!.hooks[0]!; + const compact = hooks.SessionStart![0]!.hooks[0]!; + const base = { + session_id: "native-thread-section", + transcript_path: "/transcripts/native-thread-section.jsonl", + cwd: "/workspace", + }; + const signal = new AbortController().signal; + const submit = async (extra: Partial = {}) => { + const output = await prompt( + { ...base, hook_event_name: "UserPromptSubmit", prompt: "Go on.", ...extra } as HookInput, + undefined, + { signal }, + ); + return "hookSpecificOutput" in output && + output.hookSpecificOutput?.hookEventName === "UserPromptSubmit" + ? output.hookSpecificOutput.additionalContext + : undefined; + }; + + assert.isUndefined(await submit({ source: "system" } as Partial)); + assert.isUndefined(await submit({ agent_id: "subagent-1" })); + assert.equal(await submit(), "SECTION"); + assert.isUndefined(await submit()); + normal = undefined; + assert.isUndefined(await submit()); + normal = false; + assert.isUndefined(await submit()); + normal = true; + assert.equal(await submit(), "SECTION"); + const compacted = await compact( + { ...base, hook_event_name: "SessionStart", source: "compact" } satisfies HookInput, + undefined, + { signal }, + ); + assert.deepEqual(compacted, { + hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: "SECTION" }, + }); + assert.isUndefined(await submit()); +}); diff --git a/apps/server/src/fork/displayModeWrap/DisplayModeCompactionNote.ts b/apps/server/src/fork/displayModeWrap/DisplayModeCompactionNote.ts index 6895f3aed5cf8..c8a18232f9f9d 100644 --- a/apps/server/src/fork/displayModeWrap/DisplayModeCompactionNote.ts +++ b/apps/server/src/fork/displayModeWrap/DisplayModeCompactionNote.ts @@ -17,6 +17,8 @@ export interface DisplayModeCompactionNoteShape { * and returns `undefined`. */ readonly noteAfterCompaction: (threadId: ThreadId) => Effect.Effect; + /** Whether the thread is in Normal, or `undefined` when the read fails, which it logs. */ + readonly isNormal: (threadId: ThreadId) => Effect.Effect; } export class DisplayModeCompactionNote extends Context.Service< @@ -69,7 +71,24 @@ export const layer: Layer.Layer< ); }); - return DisplayModeCompactionNote.of({ noteAfterCompaction }); + const isNormal: DisplayModeCompactionNoteShape["isNormal"] = Effect.fn( + "DisplayModeCompactionNote.isNormal", + )(function* (threadId) { + return yield* serverSettings.getSettings.pipe( + Effect.map( + (settings) => + resolveDisplayMode(settings.threadDisplayModes[threadId]) === NORMAL_DISPLAY_MODE, + ), + Effect.catchCause((cause) => + Effect.logWarning("Display mode read for the send_to_user section failed", { + threadId, + cause, + }).pipe(Effect.as(undefined)), + ), + ); + }); + + return DisplayModeCompactionNote.of({ noteAfterCompaction, isNormal }); }), ); @@ -84,8 +103,23 @@ export const claudeDisplayModeHooks = (input: { readonly threadId: ThreadId; readonly activeTurn: Effect.Effect; readonly runPromise: (effect: Effect.Effect) => Promise; + /** + * Upstream's send_to_user section of the system prompt, which a conversation keeps from its start. + * In each Claude process, the first user prompt in Normal gets it, a compaction in Normal gets it + * again, and so does the first Normal prompt after another mode's prompt. + */ + readonly sendToUserSection?: string; }): Partial> => { - const { notes, threadId, activeTurn, runPromise } = input; + const { notes, threadId, activeTurn, runPromise, sendToUserSection } = input; + let sectionOwed = true; + const section = async () => { + if (sendToUserSection === undefined || notes === undefined) return undefined; + const normal = await runPromise(notes.isNormal(threadId)); + if (normal === false) sectionOwed = true; + if (normal !== true || !sectionOwed) return undefined; + sectionOwed = false; + return sendToUserSection; + }; return notes === undefined ? {} : { @@ -95,20 +129,25 @@ export const claudeDisplayModeHooks = (input: { async (hookInput) => { if ( hookInput.hook_event_name !== "UserPromptSubmit" || - hookInput.source === "system" + hookInput.source === "system" || + hookInput.agent_id !== undefined ) return {}; const turn = await runPromise(activeTurn); - const note = turn?.commandNote; - if (note === undefined || hookInput.prompt.trim() !== turn?.message.text.trim()) - return {}; - await runPromise(note.delivered); - return { - hookSpecificOutput: { - hookEventName: "UserPromptSubmit", - additionalContext: note.text, - }, - }; + const note = + hookInput.prompt.trim() === turn?.message.text.trim() + ? turn.commandNote + : undefined; + if (note !== undefined) await runPromise(note.delivered); + const context = [note?.text, await section()].filter((text) => text !== undefined); + return context.length === 0 + ? {} + : { + hookSpecificOutput: { + hookEventName: "UserPromptSubmit", + additionalContext: context.join("\n\n"), + }, + }; }, ], }, @@ -119,13 +158,15 @@ export const claudeDisplayModeHooks = (input: { hooks: [ async (input) => { if (input.agent_id !== undefined) return {}; + sectionOwed = true; const note = await runPromise(notes.noteAfterCompaction(threadId)); - return note === undefined + const context = [note, await section()].filter((text) => text !== undefined); + return context.length === 0 ? {} : { hookSpecificOutput: { hookEventName: "SessionStart", - additionalContext: note, + additionalContext: context.join("\n\n"), }, }; }, diff --git a/apps/server/src/fork/generalRoleParity.test.ts b/apps/server/src/fork/generalRoleParity.test.ts index 5a8e2596a9b77..df4ca73da8a38 100644 --- a/apps/server/src/fork/generalRoleParity.test.ts +++ b/apps/server/src/fork/generalRoleParity.test.ts @@ -44,6 +44,7 @@ import { DeviceService } from "../device/DeviceService.ts"; import { DisplayModeWrap } from "./displayModeWrap/DisplayModeWrap.ts"; import { ThreadDashboard } from "./threadDashboard/ThreadDashboard.ts"; import { ServerEnvironment } from "../environment/ServerEnvironment.ts"; +import * as EnvironmentTheme from "../environmentTheme.ts"; import { GitWorkflowService } from "../git/GitWorkflowService.ts"; import * as McpHttpServer from "../mcp/McpHttpServer.ts"; import type { McpThreadInvocationScope } from "../mcp/McpInvocationContext.ts"; @@ -649,6 +650,7 @@ const toolHandlerServices = Layer.mergeAll( Layer.mock(ThreadDashboard)({}), Layer.mock(DisplayModeWrap)({ forgetNotesAfterCompaction: () => (ingest) => ingest }), Layer.mock(DocumentText)({}), + Layer.mock(EnvironmentTheme.EnvironmentThemeService)({}), Layer.mock(GitVcsDriver.GitVcsDriver)({}), Layer.mock(GitWorkflowService)({}), Layer.mock(ManagedProjectFolders)({ namedProjectsRoot: "" }), diff --git a/apps/server/src/fork/previewChrome.test.ts b/apps/server/src/fork/previewChrome.test.ts index bac90c7a410bb..4ebfc5c3ec191 100644 --- a/apps/server/src/fork/previewChrome.test.ts +++ b/apps/server/src/fork/previewChrome.test.ts @@ -13,7 +13,8 @@ import { HttpClient, HttpClientResponse } from "effect/http"; import * as NodeZlib from "node:zlib"; import * as PreviewBrowser from "../preview/PreviewBrowser.ts"; -import { chromeBrands, fullChromeRelease } from "./previewChrome.ts"; +import * as PreviewBrowserHost from "../preview/PreviewBrowserHost.ts"; +import { chromeBrands, fullChromeRelease, HEADLESS_SHELL_PROFILE } from "./previewChrome.ts"; const storedZip = (entries: ReadonlyArray<{ readonly name: string; readonly mode: number }>) => { const records: Array = []; @@ -64,6 +65,54 @@ it("pins full Chrome where its archive unpacks, and keeps the shell on macOS", ( expect(fullChromeRelease("mac-arm64")).toBeNull(); }); +it.effect.each([ + { label: "no profile", restricted: "1\n", profile: undefined, blocked: true }, + { + label: "the profile an older T3 wrote", + restricted: "1\n", + profile: HEADLESS_SHELL_PROFILE, + blocked: true, + }, + { + label: "the current profile", + restricted: "1\n", + profile: PreviewBrowserHost.APPARMOR_PROFILE, + blocked: false, + }, + { + label: "an administrator's profile", + restricted: "1\n", + profile: "profile edited {}\n", + blocked: false, + }, + { + label: "no userns restriction", + restricted: "0\n", + profile: HEADLESS_SHELL_PROFILE, + blocked: false, + }, +])("full Chrome's sandbox counts as blocked with $label", (input) => + Effect.gen(function* () { + const files = new Map([ + ["/proc/sys/kernel/apparmor_restrict_unprivileged_userns", input.restricted], + ]); + if (input.profile !== undefined) + files.set(PreviewBrowserHost.APPARMOR_PROFILE_PATH, input.profile); + const fileSystem = FileSystem.makeNoop({ + exists: (path) => Effect.succeed(files.has(path)), + readFileString: (path) => { + const content = files.get(path); + return content === undefined ? Effect.die(`no ${path}`) : Effect.succeed(content); + }, + }); + const blocked = yield* PreviewBrowserHost.sandboxBlocked.pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(HostProcess.Platform, "linux"), + ); + expect(blocked).toBe(input.blocked); + }), +); + it("builds the brand list real Chrome sends", () => { // Read from google-chrome 150.0.7871.114 with navigator.userAgentData.getHighEntropyValues. expect(chromeBrands(150, (version) => version)).toEqual([ diff --git a/apps/server/src/fork/previewChrome.ts b/apps/server/src/fork/previewChrome.ts index 85ec5ef154378..22522f33be54e 100644 --- a/apps/server/src/fork/previewChrome.ts +++ b/apps/server/src/fork/previewChrome.ts @@ -1,3 +1,7 @@ +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; + import type { PreviewBrowserRelease } from "../preview/PreviewBrowser.ts"; // Full Chrome for Testing, which the preview browser's "xvfb" mode runs (see previewBrowserMode.ts). @@ -84,3 +88,25 @@ export const FULL_CHROME_DEBIAN_PACKAGES: ReadonlyArray> = ["libcairo2"], ["xvfb"], ]; + +// What T3 wrote before full Chrome. Setup replaces it, and keeps any other profile as an administrator's. +export const HEADLESS_SHELL_PROFILE = `# Written by \`t3 browser setup\`: lets T3 Code's headless browser use Chrome's sandbox. +abi , +include + +profile t3-chrome-headless-shell /**/tools/chrome-headless-shell/*/*/chrome-headless-shell flags=(unconfined) { + userns, + + include if exists +} +`; + +/** Whether setup writes the AppArmor profile at `path`: none is there, or an older T3 wrote it. */ +export const profileNeedsWrite = Effect.fn("previewChrome.profileNeedsWrite")(function* ( + path: string, +) { + const fs = yield* FileSystem.FileSystem; + if (!(yield* fs.exists(path).pipe(Effect.orElseSucceed(() => false)))) return true; + const profile = yield* fs.readFileString(path).pipe(Effect.option); + return Option.getOrUndefined(profile) === HEADLESS_SHELL_PROFILE; +}); diff --git a/apps/server/src/fork/userMessageTools/SendToUser.test.ts b/apps/server/src/fork/userMessageTools/SendToUser.test.ts index dcab81964c4ad..0b94713cf6514 100644 --- a/apps/server/src/fork/userMessageTools/SendToUser.test.ts +++ b/apps/server/src/fork/userMessageTools/SendToUser.test.ts @@ -14,9 +14,11 @@ import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as TestClock from "effect/testing/TestClock"; -import { McpSchema, McpServer } from "effect/ai"; +import * as Context from "effect/Context"; +import { McpSchema, McpServer, Tool } from "effect/ai"; import * as McpHttpServer from "../../mcp/McpHttpServer.ts"; +import { MessageToolkit } from "../../mcp/toolkits/message/tools.ts"; import * as McpInvocationContext from "../../mcp/McpInvocationContext.ts"; import * as ServerSettings from "../../serverSettings.ts"; import { DisplayModeWrap } from "../displayModeWrap/DisplayModeWrap.ts"; @@ -160,6 +162,12 @@ const SHOWN = { ok: true, message: "Shown to the user. If you are done, end your turn with no text.", }; +/** Normal mode answers as upstream's send_to_user, and gives web and mobile the text to draw. */ +const shownInNormal = (text: string) => ({ + ok: true, + message: "Shown to the user. Don't repeat it.", + sentMessage: text, +}); const ACKED = { ok: true, message: "Shown to the user as an ack. Send your answer when it is ready.", @@ -179,13 +187,23 @@ it.effect("shows the message to the user without needing any capability", () => Effect.gen(function* () { const result = yield* send("The tests pass."); expect(result.isError).not.toBe(true); - expect(result.structuredContent).toEqual({ - ok: true, - message: "Shown to the user. If you are done, end your turn with no text.", - }); + expect(result.structuredContent).toEqual(shownInNormal("The tests pass.")); }).pipe(Effect.provide(layer)), ); +// The allowlist, ToolSearch and plan mode treat the registered send_to_user by upstream's annotations. +it("annotates send_to_user as upstream's send_to_user", () => { + const annotations = (tool: Tool.Any) => [ + ...[Tool.Readonly, Tool.Destructive, Tool.Idempotent, Tool.OpenWorld].map((key) => + Context.get(tool.annotations, key), + ), + Context.getOption(tool.annotations, Tool.Meta), + ]; + expect(annotations(SendToUser.SendToUserToolkit.tools.send_to_user)).toEqual( + annotations(MessageToolkit.tools.send_to_user), + ); +}); + it.effect("refuses a caller outside a thread", () => Effect.gen(function* () { const result = yield* send("Hello", { ...threadScope, thread: undefined }); @@ -250,12 +268,12 @@ describe("the 1000-byte limit", () => { }).pipe(Effect.provide(layer)), ); - it.effect("shows an ack of 200 bytes and refuses one byte more, in every display mode", () => + it.effect("shows an ack of 200 bytes and refuses one byte more, outside Normal", () => Effect.gen(function* () { - history = [typed("u1", "Fix the build.")]; const ack = (text: string, threadId: ThreadId) => call("send_to_user", { text, ack: true }, { threadId }); - for (const threadId of [normalThread, messagesThread]) { + for (const threadId of [messagesThread, dashboardThread]) { + history = [typed("u1", "Fix the build.")]; expect(refusalText(yield* ack("a".repeat(201), threadId))).toBe( "an ack is at most 200 bytes. Say only that you got the message and are on it.", ); @@ -263,13 +281,15 @@ describe("the 1000-byte limit", () => { } const answer = yield* call("send_to_user", { text: "a".repeat(1_000) }); expect(answer.structuredContent).toEqual(SHOWN); + const normal = yield* ack("a".repeat(201), normalThread); + expect(normal.structuredContent).toEqual(shownInNormal("a".repeat(201))); }).pipe(Effect.provide(layer)), ); - it.effect("gives the same answer in every display mode", () => + it.effect("refuses an oversize send in Messages only and Messages + Dashboard", () => Effect.gen(function* () { history = [typed("u1", "Fix the build.")]; - for (const threadId of [normalThread, messagesThread, dashboardThread]) { + for (const threadId of [messagesThread, dashboardThread]) { expect(seen(yield* call("send_to_user", { text: oversize }, { threadId }))).toEqual( REFUSED, ); @@ -277,6 +297,20 @@ describe("the 1000-byte limit", () => { }).pipe(Effect.provide(layer)), ); + it.effect.each([ + ["ASCII letters", "a".repeat(4_000)], + ["Chinese characters", "界".repeat(4_000)], + ["emoji", "😀".repeat(2_000)], + ])("shows 4000 UTF-16 code units of %s in Normal and refuses one more", ([_name, atLimit]) => + Effect.gen(function* () { + const normal = (text: string) => call("send_to_user", { text }, { threadId: normalThread }); + expect((yield* normal(atLimit)).structuredContent).toEqual(shownInNormal(atLimit)); + expect(refusalText(yield* normal(`${atLimit}a`))).toBe( + "a message is at most 4000 UTF-16 code units; send the rest in another message.", + ); + }).pipe(Effect.provide(layer)), + ); + it.effect("refuses an oversize new_text before the replace's other refusals", () => Effect.gen(function* () { history = [typed("u1", "Fix the build."), sent("s1", "On it.")]; @@ -329,9 +363,12 @@ describe("the 1000-byte limit", () => { ]) { expect(schema).toMatchObject({ minLength: 1 }); expect(schema).not.toHaveProperty("maxLength"); - expect(schema?.description).toContain("at most 1000 bytes"); + expect(schema?.description).toContain("1000 bytes of UTF-8 after JSON escaping"); expect(schema?.description).toContain("a longer text is refused and not shown"); } + expect(parameter("send_to_user", "text")?.description).toContain( + "In any other thread (the full view, Normal): at most 4000 UTF-16 code units", + ); const empty = yield* call("send_to_user", { text: "" }).pipe(Effect.exit); expect(Exit.isFailure(empty)).toBe(true); }).pipe(Effect.provide(layer)), @@ -372,6 +409,17 @@ describe("one send per typed message", () => { }).pipe(Effect.provide(layer)), ); + it.effect("counts a send made in Normal as an answer, though it passed ack", () => + Effect.gen(function* () { + const inNormal = { + ...acked("n1", "On it."), + output: shownInNormal("On it."), + } satisfies OrchestrationV2TurnItem; + history = [typed("u1", "Fix the build."), inNormal]; + expect(reasonOf(yield* call("send_to_user", { text: "Done." }))).toBe(SECOND_ANSWER); + }).pipe(Effect.provide(layer)), + ); + it.effect("takes one ack before the answer, and no send after the answer", () => Effect.gen(function* () { const named = (text: string, ack = false) => @@ -399,7 +447,7 @@ describe("one send per typed message", () => { ); expect(dashboard.isError).toBe(true); const normal = yield* call("send_to_user", { text: "Done." }, { threadId: normalThread }); - expect(normal.structuredContent).toEqual(SHOWN); + expect(normal.structuredContent).toEqual(shownInNormal("Done.")); }).pipe(Effect.provide(layer)), ); diff --git a/apps/server/src/fork/userMessageTools/SendToUser.ts b/apps/server/src/fork/userMessageTools/SendToUser.ts index d1c296e7fc736..9ded4bf52f049 100644 --- a/apps/server/src/fork/userMessageTools/SendToUser.ts +++ b/apps/server/src/fork/userMessageTools/SendToUser.ts @@ -24,6 +24,7 @@ import { WRAPPED_CORE_COUNT, } from "@t3tools/shared/displayModes"; import { TEXT_MATCH_MIN_RATIO, textSimilarity } from "@t3tools/shared/textMatch"; +import { SEND_TO_USER_MAX_LENGTH } from "@t3tools/shared/toolOutput"; import * as Clock from "effect/Clock"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; @@ -44,10 +45,15 @@ import { replyCallOf, } from "./replyStage.ts"; +const MAX_BYTES_TEXT = `Markdown, at most ${SEND_TO_USER_MAX_BYTES} bytes of UTF-8 after JSON escaping; a longer text is refused and not shown. Most CJK characters count as 3 bytes, a quote, a backslash, a tab, or a line break as 2, and most other control characters as 6.`; // The description goes before the checks: one set after a custom check is left out of the JSON schema. -const MessageText = Schema.String.annotate({ - description: `Markdown, at most ${SEND_TO_USER_MAX_BYTES} bytes of UTF-8 after JSON escaping; a longer text is refused and not shown. Most CJK characters count as 3 bytes, a quote, a backslash, a tab, or a line break as 2, and most other control characters as 6.`, -}).check(Schema.isMinLength(1)); +const messageText = (description: string) => + Schema.String.annotate({ description }).check(Schema.isMinLength(1)); +const MessageText = messageText(MAX_BYTES_TEXT); +// Normal mode takes upstream's send_to_user limit; the other modes keep the fork's. +const SendText = messageText( + `In a thread set to Messages only, Messages + Dashboard or Chat + Replies: ${MAX_BYTES_TEXT} In any other thread (the full view, Normal): at most ${SEND_TO_USER_MAX_LENGTH} UTF-16 code units, where most characters count once and an emoji or a rare CJK character twice or more.`, +); const jsonEscapedBytes = (text: string) => Buffer.byteLength(JSON.stringify(text), "utf8") - 2; @@ -83,6 +89,7 @@ const UNNAMED_SEND_HOLD_MS = 10_000; const MIN_PERCENT = Math.round(TEXT_MATCH_MIN_RATIO * 100); const DONE = "If you are done, end your turn with no text."; const SHOWN = { ok: true as const, message: `Shown to the user. ${DONE}` }; +const SHOWN_NORMAL = { ok: true as const, message: "Shown to the user. Don't repeat it." }; const ACKED = { ok: true as const, message: "Shown to the user as an ack. Send your answer when it is ready.", @@ -102,9 +109,9 @@ const dependencies = [ // The tools change only what the caller's thread shows, so read-only sandboxes and plan mode may use them. const SendToUserTool = Tool.make(SEND_TO_USER_TOOL_NAME, { description: - "Show the user a message in this thread. In a thread set to Messages only, this is the only output the user sees. In a thread set to Chat + Replies, also pass reply_to with user_message, or new_sub_thread.", + "Show the user a message in this thread. In a thread set to Messages only, this is the only output the user sees. In a thread set to Chat + Replies, also pass reply_to with user_message, or new_sub_thread. In the full view (Normal), use it for content the user must read in full before your turn ends, such as a draft or a code block, and don't repeat it in your reply.", parameters: Schema.Struct({ - text: MessageText, + text: SendText, reply_to: Pointer( "Chat + Replies only. The id of the user's message that this text answers, as the block before that message gave it.", ), @@ -126,7 +133,7 @@ const SendToUserTool = Tool.make(SEND_TO_USER_TOOL_NAME, { ack: Schema.optional( Schema.NullOr( Schema.Boolean.annotate({ - description: `True for a note of at most ${ACK_MAX_BYTES} bytes that you got the message and are on it, only when your answer will take a while. At most one, before the answer.`, + description: `In a thread set to Messages only, Messages + Dashboard or Chat + Replies: true for a note of at most ${ACK_MAX_BYTES} bytes that you got the message and are on it, only when your answer will take a while. At most one, before the answer.`, }), ), ), @@ -135,6 +142,8 @@ const SendToUserTool = Tool.make(SEND_TO_USER_TOOL_NAME, { ok: Schema.Literal(true), message: Schema.String, messageId: Schema.optional(Schema.String), + // Normal mode only: web and mobile draw this as the agent's words, as upstream's send_to_user. + sentMessage: Schema.optional(Schema.String), }), failure: OrchestratorMcpFailure, dependencies, @@ -143,7 +152,9 @@ const SendToUserTool = Tool.make(SEND_TO_USER_TOOL_NAME, { .annotate(Tool.Readonly, true) .annotate(Tool.Destructive, false) .annotate(Tool.Idempotent, false) - .annotate(Tool.OpenWorld, false); + .annotate(Tool.OpenWorld, false) + // Claude is told to use it from its first turn; Claude Code otherwise defers it behind ToolSearch. + .annotate(Tool.Meta, { "anthropic/alwaysLoad": true }); const ReplaceSendToUserTool = Tool.make(REPLACE_SEND_TO_USER_TOOL_NAME, { description: `In threads set to Messages only, Messages + Dashboard or Chat + Replies, you answer each message of the user once, after at most one ack. This replaces your latest message for it in place; an ack becomes the answer. Pass the user's last message and your current message as the recap shows them (their words only), each at least ${MIN_PERCENT}% alike, and the whole new text. In Chat + Replies, also pass reply_to or sub_thread.`, @@ -345,12 +356,15 @@ function answerCalls( const targetAt = items.findIndex( (item) => item.type === "user_message" && item.messageId === target, ); - return replyCallsAfter( - items, - targetAt, - named, - (item) => messagePointer(inputField(item, "reply_to")) === replyTo, - ); + return replyCallsAfter(items, targetAt, named, (item) => { + // A completed send counts where it shows; a Normal-mode send answers no message. + const event = displayEventFromTurnItem(item); + const pointer = + event?.type === "sent" || event?.type === "rewrite" + ? event.replyTo + : messagePointer(inputField(item, "reply_to")); + return pointer === replyTo; + }); } const orchestrationError = (error: { readonly message: string }) => @@ -673,12 +687,19 @@ export const layerHandlers = McpToolAccess.toLayer( return { [SEND_TO_USER_TOOL_NAME]: McpToolAccess.readsAsCaller((input) => Effect.gen(function* () { + const { threadId, mode } = yield* callerThread; + if (!mode.oneSendPerMessage) { + if (input.text.length > SEND_TO_USER_MAX_LENGTH) + return yield* new OrchestratorMcpFailure({ + code: "invalid_request", + message: `a message is at most ${SEND_TO_USER_MAX_LENGTH} UTF-16 code units; send the rest in another message.`, + }); + return { ...SHOWN_NORMAL, sentMessage: input.text }; + } const ack = input.ack === true; yield* checkMessageSize(input.text, ack); - const { threadId, mode } = yield* callerThread; if (mode.subThreads) return yield* sendInSubThread(threadId, input).pipe(lockFor(threadId).withPermits(1)); - if (!mode.oneSendPerMessage) return SHOWN; return yield* Effect.gen(function* () { const turn = yield* readThreadTurn(threadId); if (ack && turn.typed === undefined) { diff --git a/apps/server/src/fork/userMessageTools/SendToUserSubThreads.test.ts b/apps/server/src/fork/userMessageTools/SendToUserSubThreads.test.ts index d0a7764bf2099..e32dd0c51ea15 100644 --- a/apps/server/src/fork/userMessageTools/SendToUserSubThreads.test.ts +++ b/apps/server/src/fork/userMessageTools/SendToUserSubThreads.test.ts @@ -352,6 +352,30 @@ it.effect("refuses a second answer to one message, and points to the replace too }).pipe(Effect.provide(layer)), ); +it.effect("does not count a send made in Normal as an answer, though it passed reply_to", () => + Effect.gen(function* () { + setup([ + ...twoSubThreads(), + { + ...answer("a2", "Lint passes too.", "q2"), + output: { + ok: true, + message: "Shown to the user. Don't repeat it.", + sentMessage: "Lint passes too.", + }, + }, + ]); + const result = yield* call("send_to_user", { + text: "Also: types pass.", + reply_to: shortId("q2"), + user_message: "And the lint job?", + }); + expect(result.structuredContent).toEqual( + shown(`Shown to the user as your answer to "${shortId("q2")}".`), + ); + }).pipe(Effect.provide(layer)), +); + it.effect("takes one ack before the answer to a message, and none for a new message", () => Effect.gen(function* () { setup(twoSubThreads()); diff --git a/apps/server/src/fork/userMessageTools/replyStage.ts b/apps/server/src/fork/userMessageTools/replyStage.ts index 37b0b901d6fdc..1acd67fd2fb65 100644 --- a/apps/server/src/fork/userMessageTools/replyStage.ts +++ b/apps/server/src/fork/userMessageTools/replyStage.ts @@ -63,8 +63,8 @@ export function replyCallOf(item: OrchestrationV2TurnItem, named: boolean): Repl const event = displayEventFromTurnItem(item); switch (resolveT3McpToolId(item.toolName)) { case SEND_TO_USER_TOOL_NAME: { + if (event?.type === "sent") return { kind: event.ack ? "ack" : "answer", delivered: true }; const kind = isAckInput(item.input) ? "ack" : "answer"; - if (event?.type === "sent") return { kind, delivered: true }; return named && event === null ? { kind, delivered: false } : null; } case REPLACE_SEND_TO_USER_TOOL_NAME: diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 0cffe9459631a..25c67fd9fafe2 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -86,8 +86,6 @@ 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 * as SendToUser from "../fork/userMessageTools/SendToUser.ts"; import * as DashboardTools from "../fork/threadDashboard/DashboardTools.ts"; @@ -1245,7 +1243,11 @@ export const layerOrchestratorToolkit = toolkitRegistration( export const layerThreadToolkit = toolkitRegistration(ThreadToolkit, ThreadHandlers.layer); -const layerMessageToolkit = toolkitRegistration(MessageToolkit, MessageHandlers.layer); +// The fork's send_to_user replaces upstream's: display modes read its calls by name and `text`. +const layerMessageToolkit = toolkitRegistration( + SendToUser.SendToUserToolkit, + SendToUser.layerHandlers, +); const layerWorktreeToolkitRegistration = toolkitRegistration( WorktreeToolkit, @@ -1337,12 +1339,8 @@ export const layerTools = Layer.mergeAll( layerPullRequestsToolkit, layerDeviceToolkit, layerHtmlToolkit, -<<<<<<< origin/main - toolkitRegistration(SendToUser.SendToUserToolkit, SendToUser.layerHandlers), - toolkitRegistration(DashboardTools.DashboardToolkit, DashboardTools.layerHandlers), -======= layerMessageToolkit, ->>>>>>> upstream/main + toolkitRegistration(DashboardTools.DashboardToolkit, DashboardTools.layerHandlers), ); const layerToolExposure = Layer.effect( diff --git a/apps/server/src/mcp/toolkits/message/handlers.ts b/apps/server/src/mcp/toolkits/message/handlers.ts deleted file mode 100644 index defe3609f08ca..0000000000000 --- a/apps/server/src/mcp/toolkits/message/handlers.ts +++ /dev/null @@ -1,15 +0,0 @@ -import * as Effect from "effect/Effect"; - -import * as McpToolAccess from "../../McpToolAccess.ts"; -import { MessageToolkit } from "./tools.ts"; - -const handlers = { - send_to_user: McpToolAccess.actsAsCaller((input) => - Effect.succeed({ - sentMessage: input.message, - message: "Shown to the user. Don't repeat it.", - }), - ), -} satisfies McpToolAccess.Handlers; - -export const layer = McpToolAccess.toLayer(MessageToolkit, handlers); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 1da4e76ee9695..de300e177f5c7 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -2019,6 +2019,8 @@ export function makeClaudeQueryOptions(input: { readonly mcpServers?: ClaudeQueryOptions["mcpServers"]; /** The thread's T3 MCP session holds the `device` capability. */ readonly deviceToolsAvailable?: boolean; + /** False leaves out the send_to_user section, which the fork's display-mode hooks then give. */ + readonly sendToUserInstructions?: boolean; readonly hooks?: ClaudeQueryOptions["hooks"]; readonly tools?: ClaudeAgentSdkQueryTools; readonly allowedTools?: ReadonlyArray; @@ -2177,7 +2179,7 @@ export function makeClaudeQueryOptions(input: { ? "" : t3OrchestrationInstructions(CLAUDE_PROVIDER) + CLAUDE_BACKGROUND_WAIT_INSTRUCTIONS + - CLAUDE_SEND_TO_USER_INSTRUCTIONS + + (input.sendToUserInstructions === false ? "" : CLAUDE_SEND_TO_USER_INSTRUCTIONS) + (deviceInstructions ? `\n${T3_CODE_DEVICE_TOOL_INSTRUCTIONS_WITH_TOOL_SEARCH}\n` : "")), @@ -12271,6 +12273,12 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( turnInput.nativeThreadHasTurns ?? turnInput.providerTurnOrdinal > 1; const shouldResume = resumeSessionAt !== undefined || openedWithResume || hasPersistedProviderTurn; + // Fork: a conversation keeps its first system prompt, so the display-mode hooks give this by mode. + const sendToUserSection = + adapterOptions.displayModeCompactionNote !== undefined && + mcpOverrides.mcpServers !== undefined + ? CLAUDE_SEND_TO_USER_INSTRUCTIONS.trim() + : undefined; const queryOptions = makeClaudeQueryOptions({ modelSelection: turnInput.modelSelection, modelCatalog, @@ -12303,11 +12311,13 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ? {} : { mcpServers: mcpOverrides.mcpServers }), deviceToolsAvailable: mcpSession?.capabilities?.has("device") === true, + sendToUserInstructions: sendToUserSection === undefined, hooks: claudeDisplayModeHooks({ notes: adapterOptions.displayModeCompactionNote, threadId: turnInput.appThread.id, activeTurn: Ref.get(activeTurn).pipe(Effect.map((turn) => turn?.input)), runPromise, + ...(sendToUserSection === undefined ? {} : { sendToUserSection }), }), permissionMode: queryPolicy.permissionMode, ...(queryPolicy.allowDangerouslySkipPermissions === undefined diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 894185d6388f5..ba18f907cdd6e 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -806,15 +806,11 @@ function mayStartSubagentFollowup( ): boolean { if (command.scheduledTaskId !== undefined || command.restartContinuationOfRunId !== undefined) return false; -<<<<<<< origin/main - if (command.notification !== undefined) return command.notification.source.kind !== "monitor"; - if (command.delegatedCompletion !== undefined) return true; -======= if (command.notification !== undefined) return ( command.notification.source.kind !== "monitor" || command.notification.outcome !== "updated" ); ->>>>>>> upstream/main + if (command.delegatedCompletion !== undefined) return true; return ( command.createdBy === "user" || command.creationSource === "mcp" || diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index a6da604127f67..47874f2d41a5c 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -2168,12 +2168,8 @@ export const layer: Layer.Layer = 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"%' -<<<<<<< origin/main - OR item.payload_json LIKE ${SEND_TO_USER_PAYLOAD_LIKE})`; -======= OR item.payload_json LIKE '%"toolName":"%send_to_user"%' OR item.payload_json LIKE '%sentMessage%')`; ->>>>>>> upstream/main // SQLite uses the partial conversation index of migration 082 only for this exact predicate. const CONVERSATION_INDEX_EXCLUDED_TYPES = sql.literal( diff --git a/apps/server/src/preview/PreviewBrowserHost.ts b/apps/server/src/preview/PreviewBrowserHost.ts index af0cf82f152da..bf86f1b2684d8 100644 --- a/apps/server/src/preview/PreviewBrowserHost.ts +++ b/apps/server/src/preview/PreviewBrowserHost.ts @@ -1,12 +1,11 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; -import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as ChildProcess from "effect/process/ChildProcess"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; -import { FULL_CHROME_DEBIAN_PACKAGES } from "../fork/previewChrome.ts"; +import { FULL_CHROME_DEBIAN_PACKAGES, profileNeedsWrite } from "../fork/previewChrome.ts"; export const SETUP_SUBCOMMAND = "browser setup"; @@ -137,18 +136,10 @@ export const usernsRestricted = Effect.gen(function* () { Effect.map((value) => value.trim() === "1"), Effect.orElseSucceed(() => false), ); -<<<<<<< origin/main - if (!restricted) return false; - // Any other profile may miss the installed browser's path; setup rewrites it. - const profile = yield* fs.readFileString(APPARMOR_PROFILE_PATH).pipe(Effect.option); - return Option.getOrUndefined(profile) !== APPARMOR_PROFILE; -======= }).pipe(Effect.withSpan("PreviewBrowserHost.usernsRestricted")); /** A profile on disk can still be unloaded; only a failed launch shows that without root. */ export const sandboxBlocked = Effect.gen(function* () { if (!(yield* usernsRestricted)) return false; - const fs = yield* FileSystem.FileSystem; - return !(yield* fs.exists(APPARMOR_PROFILE_PATH).pipe(Effect.orElseSucceed(() => false))); ->>>>>>> upstream/main + return yield* profileNeedsWrite(APPARMOR_PROFILE_PATH); }).pipe(Effect.withSpan("PreviewBrowserHost.sandboxBlocked")); diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index 0465fdb89e03e..ca870621de710 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -599,9 +599,8 @@ const make = Effect.gen(function* () { }, }); -<<<<<<< origin/main yield* PreviewBrowserMode.restartOnChange(() => contexts.restart()); -======= + // Playwright cannot reload a crashed page, so a fresh page takes over its tab. const reopenCrashedTab = async (tab: ServerTab) => { if (tab.desktop) return dropTab(tab, true); @@ -646,7 +645,6 @@ const make = Effect.gen(function* () { pendingTabs.set(key, { agentId: tab.control.agentId, promise: reopening }); await reopening.catch(constVoid); }; ->>>>>>> upstream/main const dialogStatus = (tab: ServerTab) => tab.dialog diff --git a/apps/server/src/preview/ServerBrowserContexts.ts b/apps/server/src/preview/ServerBrowserContexts.ts index b6e034f761744..4c759de74648b 100644 --- a/apps/server/src/preview/ServerBrowserContexts.ts +++ b/apps/server/src/preview/ServerBrowserContexts.ts @@ -256,11 +256,8 @@ export class ServerBrowserContexts { executablePath, }), ); -<<<<<<< origin/main - if (!options.headless) keepStartPage(context); -======= stubWorkerBadges(context); ->>>>>>> upstream/main + if (!options.headless) keepStartPage(context); return context; } diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index c95fcf060a903..452c6999adab5 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,11 +1,5 @@ { -<<<<<<< origin/main - "claudeSystemAppendBytes": 5294, + "claudeSystemAppendBytes": 5630, "codexAdditionalContextBytes": 7024, - "mcpToolListJsonBytes": 52373 -======= - "claudeSystemAppendBytes": 5930, - "codexAdditionalContextBytes": 7324, - "mcpToolListJsonBytes": 49806 ->>>>>>> upstream/main + "mcpToolListJsonBytes": 54087 } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index e9697a166d403..bf1774e1fca15 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -4057,22 +4057,14 @@ export default function ChatView(props: ChatViewProps) { ) : null} -<<<<<<< origin/main {subThreadDraft === null ? ( ) : null} -======= - ->>>>>>> upstream/main {embedded ? null : } {subThreadDraft === null ? : null} diff --git a/apps/web/src/components/chat/TimelineAssistantRows.tsx b/apps/web/src/components/chat/TimelineAssistantRows.tsx index c8cfb22f9de50..5337c746d9099 100644 --- a/apps/web/src/components/chat/TimelineAssistantRows.tsx +++ b/apps/web/src/components/chat/TimelineAssistantRows.tsx @@ -123,6 +123,7 @@ export function SentMessageTimelineRow({ T3 Code { "Status from the build bot", "Continue where you left off.", "vp test", - "to the user", + "Sent a message", + "Sent 1 message", ]) { expect(text).not.toContain(hidden); } @@ -180,7 +181,8 @@ describe("Messages only timeline", () => { const text = screen.container.textContent ?? ""; expect(text).toContain("Worked for"); expect(text).toContain("Status from the build bot"); - expect(text).not.toContain("to the user"); + expect(text).not.toContain("Sent a message"); + expect(text).not.toContain("Sent 1 message"); }); describe("while the agent works", () => { @@ -271,6 +273,51 @@ describe("Messages only timeline", () => { } }); + // A send made in Normal returns upstream's sentMessage, which upstream's row draws. + const normalSends = entriesOf([ + userMessage("two-ask", 0, "Please ship it."), + { ...base("two-run", 1), type: "command_execution", input: "vp test", exitCode: 0 }, + ...[ + send("two-first", 2, "Tests pass, shipping now."), + { + ...base("two-push", 3), + type: "command_execution" as const, + input: "git push", + exitCode: 0, + }, + send("two-last", 4, "It is shipped."), + ].map((item) => + item.type === "dynamic_tool" + ? { + ...item, + output: { + ok: true, + message: "Shown to the user. Don't repeat it.", + sentMessage: (item.input as { readonly text: string }).text, + }, + } + : item, + ), + ]); + + it("shows each Normal-mode send once in Normal, outside the fold, and as replies in Messages only", async () => { + for (const [mode, repliesChosen] of [ + [NORMAL_DISPLAY_MODE, false], + [MESSAGES_ONLY_DISPLAY_MODE, true], + ] as const) { + await using screen = await renderTimeline( + [...applyDisplayMode(mode, normalSends)], + repliesChosen, + ); + await expect.element(screen.getByText("Tests pass, shipping now.")).toBeVisible(); + await expect.element(screen.getByText("It is shipped.")).toBeVisible(); + const text = screen.container.textContent ?? ""; + for (const sent of ["Tests pass, shipping now.", "It is shipped."]) + expect(text.split(sent)).toHaveLength(2); + expect(text).not.toContain("Sent a message"); + } + }); + it("folds the earlier send in Normal, like any text before the turn's last reply", async () => { await using screen = await renderTimeline([ ...applyDisplayMode(NORMAL_DISPLAY_MODE, twoSends), @@ -321,6 +368,25 @@ describe("Messages only timeline", () => { expect(screen.container.textContent ?? "").not.toContain("The build is red."); }); + it("draws a Normal-mode send with its rewritten text back in Normal", () => { + const normalSend: OrchestrationV2TurnItem = { + ...send, + output: { + ok: true, + message: "Shown to the user. Don't repeat it.", + sentMessage: "The build is red.", + }, + }; + const rewritten = entriesOf([ + userMessage("rewrite-ask", 0, "Is the build green?"), + normalSend, + replace("rewrite-call", "completed"), + ]); + const [, shown] = applyDisplayMode(NORMAL_DISPLAY_MODE, rewritten); + expect(shown).toMatchObject({ kind: "sent-message", text: "The build is green again." }); + expect(applyDisplayMode(NORMAL_DISPLAY_MODE, rewritten)[1]).toBe(shown); + }); + it("draws no row for a replace call in any mode, running or done", () => { for (const mode of [NORMAL_DISPLAY_MODE, MESSAGES_ONLY_DISPLAY_MODE]) for (const status of ["running", "completed"] as const) { diff --git a/apps/web/src/fork/displayMode/displayModeTimeline.ts b/apps/web/src/fork/displayMode/displayModeTimeline.ts index 1b11e91a84992..49dda6135a360 100644 --- a/apps/web/src/fork/displayMode/displayModeTimeline.ts +++ b/apps/web/src/fork/displayMode/displayModeTimeline.ts @@ -27,6 +27,9 @@ function displayEventFromEntry(entry: TimelineEntry): DisplayEvent | null { } case "event": return displayEventFromTurnItem(entry.projectedItem.item); + // A Normal-mode send, drawn from its output; the server reads the same send from its input. + case "sent-message": + return { type: "sent", id: entry.id, text: entry.text }; default: return { type: "other" }; } @@ -36,6 +39,23 @@ function displayEventFromEntry(entry: TimelineEntry): DisplayEvent | null { // until a rewrite changes its text or edit time without changing its entry. const repliesInRun = new WeakMap>(); const repliesOutsideRun = new WeakMap>(); +const rewrittenSends = new WeakMap< + TimelineEntry, + Extract +>(); + +/** Normal draws a Normal-mode send in upstream's row, with the text of its latest rewrite. */ +function sentWithText( + entry: Extract, + text: string, +): TimelineEntry { + if (text === entry.text) return entry; + const known = rewrittenSends.get(entry); + if (known?.text === text) return known; + const rewritten = { ...entry, text }; + rewrittenSends.set(entry, rewritten); + return rewritten; +} /** * A sent message reads as the agent's reply, not as a tool call. The turn fold hides messages of a @@ -242,16 +262,10 @@ export function applyDisplayMode( const item = entry.kind === "work" ? entry.entry.structuredPayload : undefined; if (item !== undefined && isReplaceSendCall(item)) return []; const event = events[index]; - return [ - event?.type === "sent" - ? replyEntry( - entry, - coreTexts.get(entry.id) ?? event.text, - editedAt.get(entry.id), - shownIds === null, - ) - : entry, - ]; + if (event?.type !== "sent") return [entry]; + const text = coreTexts.get(entry.id) ?? event.text; + if (entry.kind === "sent-message" && shownIds === null) return [sentWithText(entry, text)]; + return [replyEntry(entry, text, editedAt.get(entry.id), shownIds === null)]; }); return rows.length === entries.length && rows.every((row, index) => row === entries[index]) ? entries diff --git a/evals/mcp-tool-use/run.ts b/evals/mcp-tool-use/run.ts index bba8e22a2f608..2ae9fe7a2fc52 100644 --- a/evals/mcp-tool-use/run.ts +++ b/evals/mcp-tool-use/run.ts @@ -51,13 +51,14 @@ import { projectComposerContextForProvider } from "@t3tools/shared/composerConte import { formatThreadLink } from "@t3tools/shared/threadLinks"; import { buildCodexAdditionalContext } from "../../apps/server/src/provider/CodexDeveloperInstructions.ts"; import { isHiddenClaudeTool, withoutCodexSubagentTools } from "./fork/hiddenNativeTools.ts"; +import { DashboardToolkit } from "../../apps/server/src/fork/threadDashboard/DashboardTools.ts"; +import { SendToUserToolkit } from "../../apps/server/src/fork/userMessageTools/SendToUser.ts"; import { makeSideChatContext } from "../../apps/server/src/orchestration-v2/ContextHandoffService.ts"; import { withInlineContext } from "../../apps/server/src/orchestration-v2/ContextHandoffDelivery.ts"; import { AttachmentToolkit } from "../../apps/server/src/mcp/toolkits/attachment/tools.ts"; import { DeviceToolkit } from "../../apps/server/src/mcp/toolkits/device/tools.ts"; import { EnvironmentToolkit } from "../../apps/server/src/mcp/toolkits/environment/tools.ts"; import { HtmlToolkit } from "../../apps/server/src/mcp/toolkits/html/tools.ts"; -import { MessageToolkit } from "../../apps/server/src/mcp/toolkits/message/tools.ts"; import { OrchestratorToolkit } from "../../apps/server/src/mcp/toolkits/orchestrator/tools.ts"; import { PreviewToolkit } from "../../apps/server/src/mcp/toolkits/preview/tools.ts"; import { PreviewControlsToolkit } from "../../apps/server/src/mcp/toolkits/previewControls/tools.ts"; @@ -437,7 +438,13 @@ export const normalizeToolName = (name: string) => { }; export const normalizeToolInput = (name: string, input: unknown) => { const decode = t3InputDecoders.get(normalizeToolName(name)); - if (decode !== undefined) return Option.getOrElse(decode(input), () => input); + if (decode !== undefined) { + const decoded = Option.getOrElse(decode(input), () => input); + // Upstream's cases grade send_to_user's `message`; the fork's tool takes `text`. + return normalizeToolName(name) === "send_to_user" && isRecord(decoded) + ? { ...decoded, message: decoded.text } + : decoded; + } return normalizeToolName(name) === "Bash" && isRecord(input) ? { ...input, command: input.command ?? input.cmd } : typeof input === "string" && normalizeToolName(name) === "Edit" @@ -460,7 +467,9 @@ const t3SourceTools: ReadonlyArray = [ PreviewToolkit, DeviceToolkit, HtmlToolkit, - MessageToolkit, + // McpHttpServer registers the fork's send_to_user in place of upstream's MessageToolkit. + SendToUserToolkit, + DashboardToolkit, ].flatMap((toolkit): ReadonlyArray => Object.values(toolkit.tools)); // McpHttpServer decodes arguments with each tool's JSON codec, which drops undeclared keys. const t3InputDecoders = new Map( diff --git a/packages/client-runtime/src/t3ToolSummary.ts b/packages/client-runtime/src/t3ToolSummary.ts index f98bc40e40249..748bd5e824849 100644 --- a/packages/client-runtime/src/t3ToolSummary.ts +++ b/packages/client-runtime/src/t3ToolSummary.ts @@ -479,17 +479,13 @@ export function summarizeT3ToolCalls( label = phrase("Rendered", "render", quantity(selected.length, "HTML page")); break; case "send-to-user": -<<<<<<< origin/main - label = phrase("Sent", "send", `${quantity(selected.length, "message")} to the user`); + label = phrase("Sent", "send", quantity(selected.length, "message")); break; case "rewrite-to-user": label = phrase("Rewrote", "rewrite", `${quantity(selected.length, "message")} to the user`); break; case "dashboard": label = phrase("Used", "use", `the Dashboard ${times}`); -======= - label = phrase("Sent", "send", quantity(selected.length, "message")); ->>>>>>> upstream/main break; case "capabilities": label = phrase("Checked", "check", `orchestration capabilities ${times}`); diff --git a/packages/shared/src/fork/displayModes/displayModes.test.ts b/packages/shared/src/fork/displayModes/displayModes.test.ts index 9c35557f70677..39c81c2d587f9 100644 --- a/packages/shared/src/fork/displayModes/displayModes.test.ts +++ b/packages/shared/src/fork/displayModes/displayModes.test.ts @@ -156,6 +156,16 @@ describe("displayEventFromTurnItem", () => { }, { type: "sent", id: "item-1", text: "On it.", ack: true }, ], + [ + "a Normal-mode send, which ignores the other modes' fields", + { + type: "dynamic_tool", + toolName: "mcp__t3-code__send_to_user", + input: { text: "On it.", ack: true, reply_to: "a1b2c3" }, + output: { ok: true, message: "Shown to the user. Don't repeat it.", sentMessage: "On it." }, + }, + { type: "sent", id: "item-1", text: "On it." }, + ], [ "a send with ack false", { diff --git a/packages/shared/src/fork/displayModes/displayModes.ts b/packages/shared/src/fork/displayModes/displayModes.ts index d699fd3643b50..5d270d1458021 100644 --- a/packages/shared/src/fork/displayModes/displayModes.ts +++ b/packages/shared/src/fork/displayModes/displayModes.ts @@ -295,9 +295,9 @@ export const NORMAL_DISPLAY_MODE: DisplayMode = { id: "normal", label: "Normal", notice: - "The user switched this thread back to the full view. They see all your output again; reply in plain text.", + "The user switched this thread back to the full view. They see your replies and tool calls again; reply as usual, not only through send_to_user.", stateNotice: - "This thread is in the full view. The user sees all your output; reply in plain text.", + "This thread is in the full view. The user sees your replies and tool calls; reply as usual, not only through send_to_user.", visibleCores: null, wrap: null, dashboard: false, @@ -466,6 +466,10 @@ function messageToolEvent( if (typeof input !== "object" || input === null) return null; const text: unknown = Reflect.get(input, tool.field); if (typeof text !== "string") return null; + const output = compactDynamicToolOutput(item.output); + // A Normal-mode send returns `sentMessage` and ignores the other modes' fields. + if (tool.type === "sent" && output?.sentMessage !== undefined) + return { type: "sent", id: item.id, text }; const replyTo = pointerField(input, "reply_to"); return tool.type === "sent" ? { @@ -473,7 +477,7 @@ function messageToolEvent( id: item.id, text, replyTo, - pointer: messagePointer(compactDynamicToolOutput(item.output)?.messageId), + pointer: messagePointer(output?.messageId), ...(isAckInput(input) && { ack: true as const }), } : { type: "rewrite", id: item.id, text, replyTo, subThread: pointerField(input, "sub_thread") }; diff --git a/packages/shared/src/t3McpToolPresentation.ts b/packages/shared/src/t3McpToolPresentation.ts index cb2aed2e19eb0..600738181ff11 100644 --- a/packages/shared/src/t3McpToolPresentation.ts +++ b/packages/shared/src/t3McpToolPresentation.ts @@ -71,13 +71,9 @@ export type T3McpToolSummaryAction = | "device" | "html-preview" | "html-render" -<<<<<<< origin/main | "send-to-user" | "rewrite-to-user" | "dashboard"; -======= - | "send-to-user"; ->>>>>>> upstream/main export interface T3McpToolDefinition { readonly displayName: string; @@ -383,17 +379,13 @@ const T3_MCP_TOOLS: Readonly> = { t3_thread_send_attachments: tool(["Send", "Sending", "Sent", "attachments"], "attachment-send"), html_preview: tool(["Preview", "Previewing", "Previewed", "an HTML page"], "html-preview"), html_render: tool(["Render", "Rendering", "Rendered", "an HTML page"], "html-render"), -<<<<<<< origin/main - send_to_user: tool(["Send", "Sending", "Sent", "a message to the user"], "send-to-user"), + send_to_user: tool(["Send", "Sending", "Sent", "a message"], "send-to-user"), replace_send_to_user: tool( ["Rewrite", "Rewriting", "Rewrote", "a message to the user"], "rewrite-to-user", ), dashboard_get: tool(["Read", "Reading", "Read", "the Dashboard"], "dashboard"), dashboard_set: tool(["Update", "Updating", "Updated", "the Dashboard"], "dashboard"), -======= - send_to_user: tool(["Send", "Sending", "Sent", "a message"], "send-to-user"), ->>>>>>> upstream/main }; export const T3_MCP_TOOL_NAMES: ReadonlySet = new Set(Object.keys(T3_MCP_TOOLS));