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));