authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-08 17:12:28-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-08 17:12:28-07:00
logdbd83435347d13d5f456271f4f1f5efe294a111d
treef71af277987e8fda5fc00bcc2127f691b7aa0d4e
parentc724c7cb02aaeb0a23ff6e5e224d17c0fdb9a36b
signature Signed by PGP key B5690EEEBB952194

fix(server): agent browser tools stop bloating history, fall back sensibly, and respect ownership (#16956)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

24 files changed, 743 insertions(+), 122 deletions(-)

apps/server/src/auth/RpcAuthorization.test.ts+1
......@@ -152,6 +152,7 @@ describe("RPC authorization scopes", () => {
152152 WS_METHODS.previewReportStatus,
153153 WS_METHODS.previewAdjust,
154154 WS_METHODS.previewClearProfile,
155 WS_METHODS.previewReportProfiles,
155156 ]) {
156157 expect(requiredScopeForRpcMethod(method)).toBe(AuthPreviewOperateScope);
157158 }
apps/server/src/auth/RpcAuthorization.ts+1
......@@ -189,6 +189,7 @@ export const RPC_REQUIRED_SCOPES = {
189189 [WS_METHODS.previewClose]: AuthPreviewOperateScope,
190190 [WS_METHODS.previewList]: AuthOrchestrationReadScope,
191191 [WS_METHODS.previewClearProfile]: AuthPreviewOperateScope,
192 [WS_METHODS.previewReportProfiles]: AuthPreviewOperateScope,
192193 [WS_METHODS.previewReportStatus]: AuthPreviewOperateScope,
193194 [WS_METHODS.subscribePreviewEvents]: AuthOrchestrationReadScope,
194195 [WS_METHODS.subscribeDiscoveredLocalServers]: AuthOrchestrationReadScope,
apps/server/src/mcp/McpDeviceToolkit.test.ts+22-1
......@@ -2,6 +2,7 @@ import { expect, it } from "@effect/vitest";
22import * as NodeServices from "@effect/platform-node/NodeServices";
33import {
44 DeviceHostUnavailableError,
5 DeviceId,
56 EnvironmentId,
67 ProviderInstanceId,
78 ThreadId,
......@@ -93,7 +94,21 @@ const layerDeviceServiceMock = Layer.mock(DeviceService.DeviceService)({
9394 platform: input.platform,
9495 openedAt: "2026-09-08T00:00:00.000Z",
9596 }),
96 sessionsForThread: () => Effect.succeed([]),
97 // UDID-1 is open in the test thread; UDID-2 exists but belongs to another thread.
98 sessionsForThread: (id) =>
99 Effect.succeed(
100 id === threadId
101 ? [
102 {
103 threadId,
104 hostId: "local",
105 deviceId: DeviceId.make("UDID-1"),
106 platform: "ios" as const,
107 openedAt: "2026-09-08T00:00:00.000Z",
108 },
109 ]
110 : [],
111 ),
97112 screenshot: () => Effect.succeed({ device, png }),
98113 close: () => Effect.void,
99114 agentCli: Effect.succeed("/cli"),
......@@ -135,6 +150,12 @@ it.effect("registers the device tools and returns the screenshot as image conten
135150 screenshot: { mimeType: "image/png", width: 1206, height: 2622 },
136151 });
137152
153 const foreign = yield* server
154 .callTool({ name: "device_screenshot", arguments: { deviceId: "UDID-2" } })
155 .pipe(callWith(["device"]), Effect.provideService(McpSchema.McpServerClient, client));
156 expect(foreign.isError).toBe(true);
157 expect(foreign.content.map((entry) => entry.type)).toEqual(["text"]);
158
138159 const denied = yield* server
139160 .callTool({ name: "device_list", arguments: {} })
140161 .pipe(callWith(["preview"]), Effect.provideService(McpSchema.McpServerClient, client));
apps/server/src/mcp/McpHttpServer.test.ts+11-12
......@@ -244,7 +244,7 @@ it.effect("tells the agent how to fall back when no desktop app can run the snap
244244);
245245
246246it.effect.each([
247 { mode: "default", input: {}, images: true },
247 { mode: "default", input: {}, images: false },
248248 { mode: "explicit image", input: { includeImage: true }, images: true },
249249 { mode: "text only", input: { includeImage: false }, images: false },
250250])("returns fresh $mode snapshots on repeated MCP calls", ({ input, images }) =>
......@@ -351,12 +351,7 @@ it.effect.each([
351351 Effect.provideService(McpInvocationContext.McpInvocationContext, invocation),
352352 Effect.provideService(McpSchema.McpServerClient, client),
353353 );
354 expect(nextDefault.content.map((content) => content.type)).toEqual([
355 "text",
356 "text",
357 "text",
358 "image",
359 ]);
354 expect(nextDefault.content.map((content) => content.type)).toEqual(["text", "text", "text"]);
360355 expect(nextDefault.structuredContent).toMatchObject({ title: "Snapshot 7", screenshot });
361356 expect(nextDefault.structuredContent).not.toHaveProperty("accessibilityTree");
362357 expect(requests).toBe(7);
......@@ -416,11 +411,12 @@ it.effect("saves the snapshot PNG on request and reports its path", () =>
416411 const path = yield* Path.Path;
417412 const inputs = yield* serveSnapshots("mcp-save-client", snapshotResult);
418413
419 const snapshot = yield* callSnapshot({ save: true });
414 const snapshot = yield* callSnapshot({ save: true, includeImage: true });
420415
421416 expect(snapshot.isError).toBe(false);
422417 // The browser never receives the server-only `save` flag.
423418 expect(inputs).toEqual([{}]);
419 expect(snapshot.content.map((content) => content.type)).toContain("image");
424420 const structured = snapshot.structuredContent as { readonly screenshotPath?: string };
425421 const screenshotPath = structured.screenshotPath;
426422 expect(typeof screenshotPath).toBe("string");
......@@ -436,7 +432,7 @@ it.effect("saves the snapshot PNG on request and reports its path", () =>
436432 expect(unsaved.structuredContent).not.toHaveProperty("screenshotPath");
437433
438434 // A save without the image skips the page dump.
439 const pathOnly = yield* callSnapshot({ save: true, includeImage: false });
435 const pathOnly = yield* callSnapshot({ save: true });
440436 const saved = pathOnly.structuredContent as { readonly screenshotPath: string };
441437 expect(saved).toEqual({ url: snapshotResult.url, screenshotPath: expect.any(String) });
442438 expect(Buffer.from(yield* fileSystem.readFile(saved.screenshotPath)).toString()).toBe("png");
......@@ -851,8 +847,8 @@ it.effect("registers annotated tools and preserves authenticated request context
851847 expect(statusTool?.tool.annotations?.destructiveHint).toBe(false);
852848
853849 const snapshotTool = server.tools.find(({ tool }) => tool.name === "preview_snapshot");
854 expect(snapshotTool?.tool.annotations?.readOnlyHint).toBe(true);
855 expect(snapshotTool?.tool.annotations?.idempotentHint).toBe(true);
850 expect(snapshotTool?.tool.annotations?.readOnlyHint).toBe(false);
851 expect(snapshotTool?.tool.annotations?.idempotentHint).toBe(false);
856852 expect(snapshotTool?.tool.annotations?.openWorldHint).toBe(true);
857853
858854 const clickTool = server.tools.find(({ tool }) => tool.name === "preview_click");
......@@ -891,7 +887,10 @@ it.effect("registers annotated tools and preserves authenticated request context
891887 expect(malformed._tag).toBe("InvalidParams");
892888
893889 const snapshot = yield* server
894 .callTool({ name: "preview_snapshot", arguments: { tabId: alternateTabId } })
890 .callTool({
891 name: "preview_snapshot",
892 arguments: { tabId: alternateTabId, includeImage: true },
893 })
895894 .pipe(
896895 Effect.provideService(McpInvocationContext.McpInvocationContext, invocation),
897896 Effect.provideService(McpSchema.McpServerClient, client),
apps/server/src/mcp/McpHttpServer.ts+9-6
......@@ -503,7 +503,10 @@ const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot
503503 const png = new Uint8Array(Buffer.from(screenshot.data, "base64"));
504504 const screenshotPath =
505505 payload?.save === true ? yield* saveScreenshot(snapshot.url, png) : undefined;
506 if (screenshotPath !== undefined && payload?.includeImage === false) {
506 // Images stay out of tool history unless asked for: providers replay them on every
507 // later request, and some reject inline images outright.
508 const includeImage = payload?.includeImage === true;
509 if (screenshotPath !== undefined && !includeImage) {
507510 // The agent only wants a file to show the user. The url keeps the site icon on the tool row.
508511 const saved = {
509512 url: cutText(snapshot.url, MAX_SNAPSHOT_IDENTIFIER_CHARS),
......@@ -548,9 +551,9 @@ const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot
548551 text: `Snapshot text was bounded. Omitted: ${bounded.omitted.join("; ")}.`,
549552 },
550553 ]),
551 ...(payload?.includeImage === false
552 ? []
553 : [{ type: "image" as const, data: png, mimeType: screenshot.mimeType }]),
554 ...(includeImage
555 ? [{ type: "image" as const, data: png, mimeType: screenshot.mimeType }]
556 : []),
554557 ],
555558 });
556559 }),
......@@ -815,7 +818,7 @@ const layerEnvironmentRegistration = toolkitRegistration(
815818
816819const layerProjectRegistration = toolkitRegistration(ProjectToolkit, ProjectHandlers.layer);
817820
818const layerAttachmentRegistration = toolkitRegistration(
821export const layerAttachmentToolkit = toolkitRegistration(
819822 AttachmentToolkit,
820823 AttachmentHandlers.layer,
821824);
......@@ -851,7 +854,7 @@ export const layer = Layer.mergeAll(
851854 layerPreviewToolkit,
852855 layerOrchestratorToolkit,
853856 layerThreadToolkit,
854 layerAttachmentRegistration,
857 layerAttachmentToolkit,
855858 layerProjectRegistration,
856859 layerEnvironmentRegistration,
857860 layerPreviewControlsRegistration,
apps/server/src/mcp/toolkits/attachment/handlers.ts+99-51
......@@ -1,5 +1,12 @@
1import { type ChatAttachment, MessageId, OrchestratorMcpFailure } from "@t3tools/contracts";
1import {
2 ATTACHMENT_UPLOAD_URL_TTL_MS,
3 type ChatAttachment,
4 MessageId,
5 OrchestratorMcpFailure,
6} from "@t3tools/contracts";
7import * as Clock from "effect/Clock";
28import * as Effect from "effect/Effect";
9import * as McpInvocationContext from "../../McpInvocationContext.ts";
310import * as Upload from "../../../assets/AttachmentUpload.ts";
411import * as Claims from "../../../orchestration-v2/AttachmentClaims.ts";
512import * as ThreadMessageIntake from "../../../orchestration-v2/ThreadMessageIntake.ts";
......@@ -27,53 +34,94 @@ export function resolveAttachmentReferences(
2734 });
2835}
2936
30export const layer = McpToolAccess.toLayer(AttachmentToolkit, {
31 t3_attachment_prepare_upload: McpToolAccess.writes((input) =>
32 Upload.issueAttachmentUploadUrl(input.upload).pipe(Effect.mapError(unavailable)),
33 ),
34 t3_attachment_discard: McpToolAccess.writes((input) =>
35 Upload.deletePendingAttachment(input.attachmentId).pipe(Effect.as({})),
36 ),
37 t3_thread_send_attachments: McpToolAccess.writesThreads(
38 (input) => [input.threadId],
39 (input) =>
40 Effect.gen(function* () {
41 const { caller, projection } = yield* readThread(input.threadId, ["messages"]);
42 if (projection.thread.archivedAt !== null)
43 return yield* new OrchestratorMcpFailure({
44 code: "invalid_request",
45 message: "Unarchive the target thread before sending attachments.",
46 });
47 const attachments = yield* resolveAttachmentReferences(
48 input.attachments,
49 projection.messages.flatMap((message) => message.attachments),
50 );
51 const commandId = yield* newCommandId();
52 const messageId = MessageId.make(commandId);
53 const result = yield* ThreadMessageIntake.sendToThread({
54 projectId: projection.thread.projectId,
55 threadId: projection.thread.id,
56 commandId,
57 messageId,
58 ...(caller === undefined ? {} : { senderThreadId: caller.id }),
59 text: input.message ?? "",
60 attachments,
61 mode: "auto",
62 createdBy: "agent",
63 creationSource: "mcp",
64 }).pipe(
65 Effect.mapError((error) =>
66 error._tag === "AttachmentClaimError"
67 ? new OrchestratorMcpFailure({ code: "orchestration_error", message: error.message })
68 : unavailable(),
69 ),
70 );
71 return {
72 threadId: projection.thread.id,
73 messageId,
74 runId: result.run.id,
75 status: result.run.status,
76 };
77 }),
78 ),
79});
37/**
38 * As long as the pending file can live. The sweep counts its 24 hours from when
39 * the upload finishes, which can be up to the upload URL's lifetime after issue.
40 */
41const UPLOAD_OWNER_TTL_MS = 24 * 60 * 60 * 1000 + ATTACHMENT_UPLOAD_URL_TTL_MS;
42
43/** A thread owns its uploads across provider sessions; an outside client per MCP session. */
44const uploadOwner = McpInvocationContext.McpInvocationContext.pipe(
45 Effect.map((scope) => scope.thread?.threadId ?? scope.requestNamespace),
46);
47
48export const layer = McpToolAccess.toLayer(
49 AttachmentToolkit,
50 Effect.sync(() => {
51 // Which caller prepared each pending upload, so only that caller can discard it.
52 const uploadOwners = new Map<string, { readonly owner: string; readonly issuedAt: number }>();
53 return {
54 t3_attachment_prepare_upload: McpToolAccess.writes((input) =>
55 Effect.gen(function* () {
56 const result = yield* Upload.issueAttachmentUploadUrl(input.upload).pipe(
57 Effect.mapError(unavailable),
58 );
59 const now = yield* Clock.currentTimeMillis;
60 for (const [id, entry] of uploadOwners) {
61 if (now - entry.issuedAt > UPLOAD_OWNER_TTL_MS) uploadOwners.delete(id);
62 }
63 uploadOwners.set(result.attachmentId, { owner: yield* uploadOwner, issuedAt: now });
64 return result;
65 }),
66 ),
67 t3_attachment_discard: McpToolAccess.writes((input) =>
68 Effect.gen(function* () {
69 if (uploadOwners.get(input.attachmentId)?.owner !== (yield* uploadOwner)) {
70 return yield* new OrchestratorMcpFailure({
71 code: "invalid_request",
72 message: "Only the caller that prepared a pending upload can discard it.",
73 });
74 }
75 yield* Upload.deletePendingAttachment(input.attachmentId);
76 uploadOwners.delete(input.attachmentId);
77 return {};
78 }),
79 ),
80 t3_thread_send_attachments: McpToolAccess.writesThreads(
81 (input) => [input.threadId],
82 (input) =>
83 Effect.gen(function* () {
84 const { caller, projection } = yield* readThread(input.threadId, ["messages"]);
85 if (projection.thread.archivedAt !== null)
86 return yield* new OrchestratorMcpFailure({
87 code: "invalid_request",
88 message: "Unarchive the target thread before sending attachments.",
89 });
90 const attachments = yield* resolveAttachmentReferences(
91 input.attachments,
92 projection.messages.flatMap((message) => message.attachments),
93 );
94 const commandId = yield* newCommandId();
95 const messageId = MessageId.make(commandId);
96 const result = yield* ThreadMessageIntake.sendToThread({
97 projectId: projection.thread.projectId,
98 threadId: projection.thread.id,
99 commandId,
100 messageId,
101 ...(caller === undefined ? {} : { senderThreadId: caller.id }),
102 text: input.message ?? "",
103 attachments,
104 mode: "auto",
105 createdBy: "agent",
106 creationSource: "mcp",
107 }).pipe(
108 Effect.mapError((error) =>
109 error._tag === "AttachmentClaimError"
110 ? new OrchestratorMcpFailure({
111 code: "orchestration_error",
112 message: error.message,
113 })
114 : unavailable(),
115 ),
116 );
117 return {
118 threadId: projection.thread.id,
119 messageId,
120 runId: result.run.id,
121 status: result.run.status,
122 };
123 }),
124 ),
125 };
126 }),
127);
apps/server/src/mcp/toolkits/core.test.ts+60
......@@ -14,6 +14,7 @@ import {
1414 type OrchestrationV2ThreadShell,
1515} from "@t3tools/contracts";
1616import * as Effect from "effect/Effect";
17import * as TestClock from "effect/testing/TestClock";
1718import * as Layer from "effect/Layer";
1819import * as Schema from "effect/Schema";
1920import { McpAttachmentInput } from "./attachment/input.ts";
......@@ -26,6 +27,7 @@ import {
2627 OrchestratorProjectionError,
2728} from "../../orchestration-v2/Orchestrator.ts";
2829
30import * as ServerSecretStore from "../../auth/ServerSecretStore.ts";
2931import * as ServerConfig from "../../config.ts";
3032import * as ProviderAdapterRegistry from "../../orchestration-v2/ProviderAdapterRegistry.ts";
3133import * as ThreadManagement from "../../orchestration-v2/ThreadManagementService.ts";
......@@ -696,3 +698,61 @@ it.effect("a caller cannot interrupt a thread that runs above its own modes", ()
696698 ),
697699 ),
698700);
701
702it.effect("only the caller that prepared a pending upload can discard it", () =>
703 Effect.gen(function* () {
704 const server = yield* McpServer.McpServer;
705 const call = (
706 name: string,
707 args: Record<string, unknown>,
708 invocation: McpInvocationContext.McpInvocationScope,
709 ) =>
710 server
711 .callTool({ name, arguments: args })
712 .pipe(
713 Effect.provideService(McpInvocationContext.McpInvocationContext, invocation),
714 Effect.provideService(McpSchema.McpServerClient, client),
715 );
716 const prepared = yield* call(
717 "t3_attachment_prepare_upload",
718 { upload: { name: "shot.png", mimeType: "image/png", sizeBytes: 4 } },
719 scope,
720 );
721 const { attachmentId } = prepared.structuredContent as { readonly attachmentId: string };
722 const otherThread = {
723 ...scope,
724 requestNamespace: "other-session",
725 thread: { ...scope.thread!, threadId: ThreadId.make("other-thread") },
726 };
727
728 const refused = yield* call("t3_attachment_discard", { attachmentId }, otherThread);
729 expect(declaredFailure(refused)).toMatchObject({ code: "invalid_request" });
730
731 // Another prepare a day later keeps it: the file outlives its URL's 24 hours
732 // by as long as the upload took, up to the URL's own lifetime.
733 yield* TestClock.adjust(24 * 60 * 60 * 1000 + 5 * 60_000);
734 yield* call(
735 "t3_attachment_prepare_upload",
736 { upload: { name: "later.png", mimeType: "image/png", sizeBytes: 4 } },
737 scope,
738 );
739
740 // A new provider session of the same thread still owns the upload.
741 const discarded = yield* call(
742 "t3_attachment_discard",
743 { attachmentId },
744 { ...scope, requestNamespace: "mcp-core-session-2" },
745 );
746 expect(discarded.isError).toBe(false);
747 }).pipe(
748 Effect.provide(
749 McpHttpServer.layerAttachmentToolkit.pipe(
750 Layer.provideMerge(McpServer.McpServer.layer),
751 Layer.provide(McpToolAccessTestkit.liveThreadsLayer),
752 Layer.provide(ServerSecretStore.layer),
753 Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-attachment-" })),
754 Layer.provide(NodeServices.layer),
755 ),
756 ),
757 ),
758);
apps/server/src/mcp/toolkits/device/handlers.ts+12-7
......@@ -211,15 +211,20 @@ const handlers = {
211211 const scope = yield* requireDeviceAccess;
212212 const devices = yield* DeviceService.DeviceService;
213213 const sessions = yield* devices.sessionsForThread(scope.thread.threadId);
214 const target =
215 input.deviceId !== undefined
216 ? { hostId: input.hostId ?? LOCAL_DEVICE_HOST_ID, deviceId: input.deviceId }
217 : sessions
218 .filter((session) => input.hostId === undefined || session.hostId === input.hostId)
219 .at(-1);
214 // Only devices this thread opened: another thread's device is not this agent's to watch.
215 const hostId =
216 input.deviceId === undefined ? input.hostId : (input.hostId ?? LOCAL_DEVICE_HOST_ID);
217 const target = sessions.findLast(
218 (session) =>
219 (hostId === undefined || session.hostId === hostId) &&
220 (input.deviceId === undefined || session.deviceId === input.deviceId),
221 );
220222 if (!target) {
221223 return yield* new DeviceToolUnavailableError({
222 reason: "No device is open in this thread. Call device_open first.",
224 reason:
225 input.deviceId === undefined
226 ? "No device is open in this thread. Call device_open first."
227 : `Device ${input.deviceId} on host ${hostId} is not open in this thread. Call device_open first.`,
223228 });
224229 }
225230 const shot = yield* devices.screenshot(target);
apps/server/src/mcp/toolkits/preview/tools.ts+9-8
......@@ -64,7 +64,7 @@ const readonlyBrowserTool = <T extends Tool.Any>(tool: T): T =>
6464
6565const PreviewStatusTool = Tool.make("preview_status", {
6666 description:
67 "Report whether a collaborative browser tab is automation-capable, including its control owner, pending dialog, URL, title, visibility, loading state, viewport mode, and measured CSS-pixel size. Pass tabId to inspect a specific tab; omit it to use this agent session's current tab.",
67 "Report whether a collaborative browser tab is automation-capable, including its control owner, pending dialog, URL, title, visibility, loading state, viewport mode, and measured CSS-pixel size. Pass tabId to inspect a specific tab; omit it to use this agent session's current tab, or the tab the user is viewing when you have none. Server hosts also list every tab in the thread (tabs) with its owner, including tabs the user opened, and the browser profiles preview_open accepts. You can read any listed tab; act only on your own tabs, or on an unclaimed tab while no human controls it.",
6868 parameters: PreviewAutomationTabTargetInput,
6969 success: PreviewAutomationStatus,
7070 failure: PreviewToolFailure,
......@@ -78,7 +78,7 @@ const PreviewStatusTool = Tool.make("preview_status", {
7878const PreviewOpenTool = browserTool(
7979 Tool.make("preview_open", {
8080 description:
81 "Initialize a collaborative browser tab and open its thread-bound inline preview by default. Set open=false for background-only automation. Pass tabId to reuse a specific existing tab, set reuseExistingTab=false to create another tab, or omit both to use this agent session's current tab. Parallel subagents sharing a provider session must each open with reuseExistingTab=false and pass their returned tabId on every call. Server tabs use isolated storage and cannot be operated by a different agent session.",
81 "Initialize a collaborative browser tab and open its thread-bound inline preview by default. Set open=false for background-only automation. Pass tabId to reuse a specific existing tab, set reuseExistingTab=false to create another tab, or omit both to use this agent session's current tab. Parallel subagents sharing a provider session must each open with reuseExistingTab=false and pass their returned tabId on every call. Pass profileId (an id or name from preview_status profiles) to open under a browser profile and its saved logins; omit it for the user's default profile. Another agent session's tabs can be read but not operated.",
8282 parameters: PreviewAutomationOpenInput,
8383 success: PreviewAutomationStatus,
8484 failure: PreviewToolFailure,
......@@ -91,7 +91,7 @@ const PreviewOpenTool = browserTool(
9191const PreviewDialogTool = browserTool(
9292 Tool.make("preview_dialog", {
9393 description:
94 "Accept or dismiss the server browser dialog reported by preview_status. For a prompt, supply promptText when accepting. Requires this agent to own the tab. Desktop hosts may not support this operation.",
94 "Accept or dismiss the server browser dialog reported by preview_status. For a prompt, supply promptText when accepting. Requires this agent to own the tab, or the tab to be unclaimed with no human in control. Desktop hosts may not support this operation.",
9595 parameters: PreviewAutomationDialogInput,
9696 success: PreviewAutomationStatus,
9797 failure: PreviewToolFailure,
......@@ -139,22 +139,23 @@ const PreviewSetAppearanceTool = safeBrowserTool(
139139 .annotate(Tool.Idempotent, true),
140140);
141141
142export const PreviewSnapshotTool = readonlyBrowserTool(
142// Not read-only: save=true writes the screenshot to disk.
143export const PreviewSnapshotTool = safeBrowserTool(
143144 Tool.make("preview_snapshot", {
144145 description:
145 "Inspect a page before interacting. Pass tabId to inspect a specific tab; omit it to use this agent session's current tab. Returns page state, semantic elements, diagnostics, action history, and a PNG screenshot. Server snapshots include an accessibilityTree with refs; pass locator=aria-ref=<ref> to target one exact element, including inside frames. Refresh refs after navigation, another snapshot, or human takeover. The text is capped near 20 KB and lists what it omitted; use preview_evaluate to read more. Set includeImage=false for text-only output with the same page metadata. Set save=true to also write the PNG to disk and get screenshotPath back; with includeImage=false, save=true returns only the url and screenshotPath. Embed that path in your reply as ![alt](screenshotPath) so the user sees it. This is the only way to show the user a screenshot; the image in the tool result is not saved anywhere.",
146 "Inspect a page before interacting. Pass tabId to inspect a specific tab, including one the user opened (see preview_status tabs), even while they control it; omit it to use this agent session's current tab, or the tab the user is viewing when you have none. Returns page state, semantic elements, diagnostics, action history, and screenshot dimensions. Server snapshots include an accessibilityTree with refs; pass locator=aria-ref=<ref> to target one exact element, including inside frames. Refresh refs after navigation, another snapshot, or human takeover. The text is capped near 20 KB and lists what it omitted; use preview_evaluate to read more. Set includeImage=true only when you need to see the page; the image stays in tool history. Set save=true to write the PNG to disk and get back only the url and screenshotPath. Embed that path in your reply as ![alt](screenshotPath) so the user sees it. This is the only way to show the user a screenshot; the image in the tool result is not saved anywhere.",
146147 parameters: Schema.Struct({
147148 ...PreviewAutomationTabTargetInput.fields,
148149 includeImage: Schema.optional(
149150 Schema.Boolean.annotate({
150151 description:
151 "Include the PNG image in the tool response. Defaults to true. Set false for text-only output.",
152 "Include the PNG image in the tool response. Defaults to false. Set true only when you need to see the page.",
152153 }),
153154 ),
154155 save: Schema.optional(
155156 Schema.Boolean.annotate({
156157 description:
157 "Write the screenshot PNG to disk and return its absolute path as screenshotPath. With includeImage=false, return only the url and screenshotPath. Defaults to false.",
158 "Write the screenshot PNG to disk and return its absolute path as screenshotPath. Unless includeImage=true, return only the url and screenshotPath. Defaults to false.",
158159 }),
159160 ),
160161 }),
......@@ -270,7 +271,7 @@ export const PreviewEvaluateResult = Schema.Struct({
270271const PreviewEvaluateTool = browserTool(
271272 Tool.make("preview_evaluate", {
272273 description:
273 "Evaluate JavaScript in the tab selected by tabId, or this agent session's current tab when omitted. Returns {value} with a serializable result up to 64 KB; the expression may mutate page state.",
274 "Evaluate JavaScript in the tab selected by tabId, or this agent session's current tab when omitted. Needs the same control as clicking: it works on your own tabs and on the user's tab only while nobody controls it. To read a tab the user is driving, use preview_snapshot. Returns {value} with a serializable result up to 64 KB; the expression may mutate page state.",
274275 parameters: PreviewAutomationEvaluateInput,
275276 success: PreviewEvaluateResult,
276277 failure: PreviewToolFailure,
apps/server/src/observability/RpcInstrumentation.ts+1
......@@ -183,6 +183,7 @@ const RPC_AGGREGATES = {
183183 [WS_METHODS.previewClose]: "preview",
184184 [WS_METHODS.previewList]: "preview",
185185 [WS_METHODS.previewClearProfile]: "preview",
186 [WS_METHODS.previewReportProfiles]: "preview",
186187 [WS_METHODS.previewReportStatus]: "preview",
187188 [WS_METHODS.subscribePreviewEvents]: "preview",
188189 [WS_METHODS.subscribeDiscoveredLocalServers]: "preview",
apps/server/src/preview/ServerBrowser.test.ts+201-5
......@@ -38,7 +38,8 @@ vi.mock("./ServerBrowserContexts.ts", () => ({
3838 constructor(options: { onContextClose?: (context: BrowserContext) => void }) {
3939 this.onClose = options.onContextClose;
4040 }
41 async contextFor() {
41 async contextFor(profileId: string, isolationKey?: string) {
42 contextRequests.push({ profileId, isolated: isolationKey !== undefined });
4243 if (contextFailure) throw contextFailure;
4344 await contextGate?.promise;
4445 const context = makeContext(this.onClose);
......@@ -149,6 +150,8 @@ function makeContext(onClose?: (context: BrowserContext) => void) {
149150}
150151
151152const contexts: ReturnType<typeof makeContext>[] = [];
153/** The profile and isolation each headless tab asked its context for. */
154const contextRequests: Array<{ profileId: string; isolated: boolean }> = [];
152155let contextGate: PromiseWithResolvers<void> | null = null;
153156type ClipboardBinding = (source: { page: unknown }, text: unknown) => void;
154157let clipboardBinding: ClipboardBinding | null = null;
......@@ -266,6 +269,7 @@ const viewerInput = (tabId: string, canOperate: boolean) => ({
266269
267270beforeEach(() => {
268271 contexts.length = 0;
272 contextRequests.length = 0;
269273 contextGate = null;
270274 contextFailure = null;
271275 desktopTabs.clear();
......@@ -380,18 +384,37 @@ it.live("enforces provider ownership and explicit targets when a session has mul
380384 .invoke<void>({
381385 scope: asSession("agent-b"),
382386 tabId,
383 operation: "evaluate",
384 input: { expression: "foreign()" },
387 operation: "navigate",
388 input: { url: "http://localhost:5173/foreign" },
385389 })
386390 .pipe(Effect.flip);
387391 expect(foreign).toMatchObject({
388392 _tag: "PreviewAutomationControlInterruptedError",
389393 reason: "agentMismatch",
390394 });
391 expect(contexts[0]!.sessions[0]!.send).not.toHaveBeenCalledWith(
392 "Runtime.evaluate",
395 expect(contexts[0]!.page.goto).not.toHaveBeenCalledWith(
396 "http://localhost:5173/foreign",
393397 expect.anything(),
394398 );
399 // Another session may still read the tab, but not run page script in it.
400 yield* broker.invoke<PreviewAutomationSnapshot>({
401 scope: asSession("agent-b"),
402 tabId,
403 operation: "snapshot",
404 input: {},
405 });
406 const foreignEvaluate = yield* broker
407 .invoke<void>({
408 scope: asSession("agent-b"),
409 tabId,
410 operation: "evaluate",
411 input: { expression: "read()" },
412 })
413 .pipe(Effect.flip);
414 expect(foreignEvaluate).toMatchObject({
415 _tag: "PreviewAutomationControlInterruptedError",
416 reason: "agentMismatch",
417 });
395418 yield* broker.invoke({
396419 scope,
397420 operation: "open",
......@@ -1323,6 +1346,179 @@ it.live("a desktop page the desktop takes back reconnects instead of closing", (
13231346 ).pipe(Effect.provide(layer)),
13241347);
13251348
1349it.live("agents read a human's tab with no arguments and act on it only while nobody drives", () =>
1350 Effect.scoped(
1351 Effect.gen(function* () {
1352 const browser = yield* ServerBrowser.ServerBrowser;
1353 const broker = yield* Broker.PreviewAutomationBroker;
1354 const manager = yield* Manager.PreviewManager;
1355 yield* Effect.yieldNow;
1356 const opened = yield* manager.open({
1357 threadId: scope.thread.threadId,
1358 url: "http://localhost:5173/mine",
1359 runtime: "server",
1360 });
1361 const tabId = PreviewTabId.make(opened.tabId);
1362 // The user opened and is looking at the tab; attaching takes control.
1363 const viewer = yield* browser.attachViewer(viewerInput(tabId, true));
1364 const status = yield* broker.invoke<PreviewAutomationStatus>({
1365 scope,
1366 operation: "status",
1367 input: {},
1368 });
1369 expect(status).toMatchObject({
1370 tabId,
1371 control: { owner: "human", ownedByCaller: false },
1372 });
1373 expect(status.tabs).toEqual([
1374 expect.objectContaining({ tabId, owner: "human", ownedByCaller: false, visible: true }),
1375 ]);
1376 // Reads work while the user drives; page script does not, since it can change the page.
1377 const evaluated = yield* broker
1378 .invoke<void>({ scope, operation: "evaluate", input: { expression: "read()" } })
1379 .pipe(Effect.flip);
1380 expect(evaluated).toMatchObject({
1381 _tag: "PreviewAutomationControlInterruptedError",
1382 reason: "humanControl",
1383 });
1384 yield* broker.invoke<PreviewAutomationSnapshot>({
1385 scope,
1386 tabId,
1387 operation: "snapshot",
1388 input: {},
1389 });
1390 // Acting is refused while the user controls the tab.
1391 const refused = yield* broker
1392 .invoke<void>({
1393 scope,
1394 tabId,
1395 operation: "navigate",
1396 input: { url: "http://localhost:5173/agent" },
1397 })
1398 .pipe(Effect.flip);
1399 expect(refused).toMatchObject({
1400 _tag: "PreviewAutomationControlInterruptedError",
1401 reason: "humanControl",
1402 });
1403 // Once they let go, the tab is unclaimed and the agent may act on it.
1404 yield* viewer.input({ type: "releaseControl" });
1405 const released = yield* broker.invoke<PreviewAutomationStatus>({
1406 scope,
1407 tabId,
1408 operation: "status",
1409 input: {},
1410 });
1411 expect(released.control).toMatchObject({ owner: "unclaimed", ownedByCaller: false });
1412 yield* broker.invoke({
1413 scope,
1414 tabId,
1415 operation: "navigate",
1416 input: { url: "http://localhost:5173/agent" },
1417 });
1418 expect(contexts[0]!.page.goto).toHaveBeenCalledWith(
1419 "http://localhost:5173/agent",
1420 expect.anything(),
1421 );
1422 // Taking control back refuses the agent again.
1423 yield* viewer.input({ type: "takeControl" });
1424 const retaken = yield* broker
1425 .invoke<void>({ scope, tabId, operation: "press", input: { key: "Enter" } })
1426 .pipe(Effect.flip);
1427 expect(retaken).toMatchObject({ reason: "humanControl" });
1428 }),
1429 ).pipe(Effect.provide(layer)),
1430);
1431
1432it.live("a session's own tab stays its default while other sessions' tabs are listed", () =>
1433 Effect.scoped(
1434 Effect.gen(function* () {
1435 const { broker, tabId } = yield* ready;
1436 const other = yield* broker.invoke<PreviewAutomationStatus>({
1437 scope: asSession("agent-b"),
1438 operation: "open",
1439 input: { reuseExistingTab: false, show: false },
1440 });
1441 const status = yield* broker.invoke<PreviewAutomationStatus>({
1442 scope,
1443 operation: "status",
1444 input: {},
1445 });
1446 expect(status.tabId).toBe(tabId);
1447 expect(status.tabs).toEqual(
1448 expect.arrayContaining([
1449 expect.objectContaining({ tabId, owner: "agent", ownedByCaller: true }),
1450 expect.objectContaining({ tabId: other.tabId, owner: "agent", ownedByCaller: false }),
1451 ]),
1452 );
1453 }),
1454 ).pipe(Effect.provide(layer)),
1455);
1456
1457it.live(
1458 "preview_open picks a reported profile by id or name and defaults to the reported one",
1459 () =>
1460 Effect.scoped(
1461 Effect.gen(function* () {
1462 const browser = yield* ServerBrowser.ServerBrowser;
1463 const broker = yield* Broker.PreviewAutomationBroker;
1464 const manager = yield* Manager.PreviewManager;
1465 yield* Effect.yieldNow;
1466 yield* browser.reportProfiles({
1467 profiles: [{ id: "profile-work", name: "Work", kind: "persistent" }],
1468 defaultProfileId: "profile-work",
1469 });
1470 const openWith = (profileId?: string) =>
1471 broker.invoke<PreviewAutomationStatus>({
1472 scope,
1473 operation: "open",
1474 input: {
1475 reuseExistingTab: false,
1476 show: false,
1477 ...(profileId === undefined ? {} : { profileId }),
1478 },
1479 });
1480 const byDefault = yield* openWith();
1481 const byName = yield* openWith("WORK");
1482 const byId = yield* openWith("incognito");
1483 const { sessions } = yield* manager.list({ threadId: scope.thread.threadId });
1484 const profileOf = (tabId: string | null) =>
1485 sessions.find((session) => session.tabId === tabId)?.profileId;
1486 expect(profileOf(byDefault.tabId)).toBe("profile-work");
1487 expect(profileOf(byName.tabId)).toBe("profile-work");
1488 expect(profileOf(byId.tabId)).toBe("incognito");
1489 // The chosen profile reaches the headless tab's storage.
1490 expect(contextRequests).toEqual([
1491 { profileId: "profile-work", isolated: false },
1492 { profileId: "profile-work", isolated: false },
1493 { profileId: "incognito", isolated: true },
1494 ]);
1495 expect(byDefault).toMatchObject({
1496 defaultProfileId: "profile-work",
1497 profiles: [
1498 { id: "default", name: "Default" },
1499 { id: "incognito", name: "Incognito", incognito: true },
1500 { id: "profile-work", name: "Work" },
1501 ],
1502 });
1503 const unknown = yield* openWith("Personal").pipe(Effect.flip);
1504 expect(unknown).toMatchObject({
1505 _tag: "PreviewAutomationExecutionError",
1506 reason:
1507 'No browser profile is named "Personal". Use one of: Default (id default), Incognito (id incognito), Work (id profile-work).',
1508 });
1509 }),
1510 ).pipe(Effect.provide(layer)),
1511);
1512
1513it.live("an agent tab keeps throwaway storage when no client reported profiles", () =>
1514 Effect.scoped(
1515 Effect.gen(function* () {
1516 yield* ready;
1517 expect(contextRequests).toEqual([{ profileId: "default", isolated: true }]);
1518 }),
1519 ).pipe(Effect.provide(layer)),
1520);
1521
13261522it.live("a desktop page that comes back reconnects without waiting for a viewer", () =>
13271523 Effect.scoped(
13281524 Effect.gen(function* () {
apps/server/src/preview/ServerBrowser.ts+148-22
......@@ -2,8 +2,14 @@
22// Screencasts ignore emulated device scale; real 2x keeps captures sharp.
33// --disable-gpu uses cheaper software compositing while preserving SwiftShader WebGL.
44import {
5 BUILT_IN_BROWSER_PROFILES,
6 DEFAULT_BROWSER_PROFILE_ID,
57 FILL_PREVIEW_VIEWPORT,
8 findBrowserProfile,
69 INCOGNITO_BROWSER_PROFILE_ID,
10 resolveBrowserProfiles,
11 type BrowserProfile,
12 type PreviewReportProfilesInput,
713 PREVIEW_AUTOMATION_SERVER_OPERATIONS,
814 PreviewViewportSetting as PreviewViewportSettingSchema,
915 PROVIDER_SEND_TURN_MAX_FILE_BYTES,
......@@ -243,6 +249,11 @@ export class ServerBrowser extends Context.Service<
243249 }) => Effect.Effect<Option.Option<{ readonly path: string; readonly fileName: string }>>;
244250 /** Deletes a human profile's server-side storage, closing its open tabs first. */
245251 readonly clearProfile: (profileId: string) => Effect.Effect<void, PreviewClearProfileError>;
252 /**
253 * Records a client's browser profiles for agents' preview_open. In memory:
254 * clients report again on connect and whenever the list changes.
255 */
256 readonly reportProfiles: (input: PreviewReportProfilesInput) => Effect.Effect<void>;
246257 }
247258>()("t3/preview/ServerBrowser") {}
248259
......@@ -346,6 +357,17 @@ interface ServerDownload {
346357}
347358
348359/** One agent session and the whole server; each tab holds a renderer process. */
360/**
361 * Operations an agent may run on any tab in its thread, even while a human
362 * drives it. Evaluate is not one: page script can change anything, so it needs
363 * the same control as clicking.
364 */
365const READ_OPERATIONS: ReadonlySet<PreviewAutomationRequest["operation"]> = new Set([
366 "status",
367 "snapshot",
368 "waitFor",
369]);
370
349371const AGENT_TAB_LIMIT = 8;
350372const SERVER_TAB_LIMIT = 32;
351373/** Agent tabs nobody watches close after this long without an agent request. */
......@@ -727,10 +749,11 @@ const make = Effect.gen(function* () {
727749 adopted === undefined && (await desktopRenders(snapshot))
728750 ? await connectDesktop(snapshot)
729751 : null;
752 // An agent tab without a profile (no client reported one) keeps throwaway storage.
730753 const isolatedContext =
731754 adopted === undefined &&
732755 desktop === null &&
733 (snapshot.automationOwner !== undefined ||
756 ((snapshot.automationOwner !== undefined && snapshot.profileId === undefined) ||
734757 snapshot.profileId === INCOGNITO_BROWSER_PROFILE_ID);
735758 const context =
736759 adopted?.page.context() ??
......@@ -1199,10 +1222,75 @@ const make = Effect.gen(function* () {
11991222 }
12001223 };
12011224
1202 const latestThreadTab = (threadId: string, agentSessionId?: string) =>
1203 [...tabs.values()]
1204 .filter((tab) => tab.threadId === threadId && tab.control.agentId === agentSessionId)
1205 .sort((left, right) => right.createdAt - left.createdAt)[0];
1225 /**
1226 * The tab a request without a tabId means: the caller's newest tab, else the
1227 * thread's tab the user is looking at or used last, so "this page" works.
1228 */
1229 const latestThreadTab = (threadId: string, agentSessionId?: string) => {
1230 const threadTabs = [...tabs.values()].filter((tab) => tab.threadId === threadId);
1231 return (
1232 threadTabs
1233 .filter((tab) => tab.control.agentId === agentSessionId)
1234 .sort((left, right) => right.createdAt - left.createdAt)[0] ??
1235 threadTabs.sort(
1236 (left, right) =>
1237 Number(right.viewers.size > 0) - Number(left.viewers.size > 0) ||
1238 right.usedAt - left.usedAt,
1239 )[0]
1240 );
1241 };
1242
1243 const tabOwner = (tab: ServerTab) =>
1244 tab.control.controller !== null
1245 ? ("human" as const)
1246 : tab.control.agentId !== null
1247 ? ("agent" as const)
1248 : ("unclaimed" as const);
1249
1250 /** The latest profile list a client reported; agents choose from it. */
1251 let reportedProfiles: {
1252 readonly profiles: ReadonlyArray<BrowserProfile>;
1253 readonly defaultProfileId: string;
1254 } | null = null;
1255
1256 const reportProfiles: ServerBrowser["Service"]["reportProfiles"] = (input) =>
1257 Effect.sync(() => {
1258 const profiles = resolveBrowserProfiles(input.profiles);
1259 reportedProfiles = {
1260 profiles,
1261 defaultProfileId:
1262 findBrowserProfile(profiles, input.defaultProfileId)?.id ?? DEFAULT_BROWSER_PROFILE_ID,
1263 };
1264 });
1265
1266 const profileStatus = () => {
1267 const profiles = reportedProfiles?.profiles ?? BUILT_IN_BROWSER_PROFILES;
1268 return {
1269 profiles: profiles.map((profile) => ({
1270 id: profile.id,
1271 name: profile.name,
1272 ...(profile.kind === "incognito" ? { incognito: true } : {}),
1273 })),
1274 ...(reportedProfiles ? { defaultProfileId: reportedProfiles.defaultProfileId } : {}),
1275 };
1276 };
1277
1278 /** Matches an id, then a case-insensitive name. Omitted means the reported default. */
1279 const resolveOpenProfile = (requested: string | undefined): string | undefined => {
1280 if (requested === undefined) return reportedProfiles?.defaultProfileId;
1281 const profiles = reportedProfiles?.profiles ?? BUILT_IN_BROWSER_PROFILES;
1282 const wanted = requested.toLowerCase();
1283 const match =
1284 findBrowserProfile(profiles, requested) ??
1285 profiles.find((profile) => profile.name.toLowerCase() === wanted);
1286 if (match) return match.id;
1287 throw new ServerBrowserPage.ServerBrowserOperationError(
1288 "PreviewAutomationUnknownProfileError",
1289 `No browser profile is named "${requested}". Use one of: ${profiles
1290 .map((profile) => `${profile.name} (id ${profile.id})`)
1291 .join(", ")}.`,
1292 );
1293 };
12061294
12071295 const statusWithTitle = async (
12081296 tab: ServerTab | undefined,
......@@ -1216,6 +1304,7 @@ const make = Effect.gen(function* () {
12161304 url: null,
12171305 title: null,
12181306 loading: false,
1307 ...profileStatus(),
12191308 };
12201309 }
12211310 const url = tab.page.url();
......@@ -1228,12 +1317,7 @@ const make = Effect.gen(function* () {
12281317 title: null,
12291318 loading: tab.loading,
12301319 control: {
1231 owner:
1232 tab.control.controller !== null
1233 ? ("human" as const)
1234 : tab.control.agentId !== null
1235 ? ("agent" as const)
1236 : ("unclaimed" as const),
1320 owner: tabOwner(tab),
12371321 ownedByCaller: tab.control.agentId === agentSessionId,
12381322 generation: tab.control.generation,
12391323 },
......@@ -1244,15 +1328,17 @@ const make = Effect.gen(function* () {
12441328 viewportSetting: tab.setting,
12451329 ...(viewport ? { viewport } : {}),
12461330 tabs: [...tabs.values()]
1247 .filter(
1248 (candidate) =>
1249 candidate.threadId === tab.threadId && candidate.control.agentId === agentSessionId,
1250 )
1331 .filter((candidate) => candidate.threadId === tab.threadId)
12511332 .map((candidate) => ({
12521333 tabId: candidate.tabId,
12531334 url: candidate.page.url() === "about:blank" ? null : candidate.page.url(),
12541335 ...(candidate.openerTabId === undefined ? {} : { openerTabId: candidate.openerTabId }),
1336 owner: tabOwner(candidate),
1337 ownedByCaller: candidate.control.agentId === agentSessionId,
1338 visible: candidate.viewers.size > 0,
1339 ...(candidate.profileId === undefined ? {} : { profileId: candidate.profileId }),
12551340 })),
1341 ...profileStatus(),
12561342 downloads: tab.downloads.map(({ fileName, path, sizeBytes, url, completedAt }) => ({
12571343 fileName,
12581344 path,
......@@ -1554,19 +1640,26 @@ const make = Effect.gen(function* () {
15541640 "No server preview tab is open for this thread. Call preview_open first.",
15551641 );
15561642 }
1557 if (tab.control.agentId !== request.agentSessionId)
1643 // Any tab in the thread can be read; acting is checked by its control.
1644 if (!READ_OPERATIONS.has(request.operation) && !tab.control.agentMayAct(request.agentSessionId))
15581645 throw new BrowserControlInterrupted(
1559 "This tab belongs to another agent session or a human. Open your own tab.",
1646 "This tab belongs to another agent session. You can read it, but open your own tab to act.",
15601647 "agentMismatch",
15611648 );
15621649 return tab;
15631650 };
15641651
1565 /** Any request, even a failed one, keeps the agent's tabs on the thread from idling out. */
1652 /**
1653 * Any request, even a failed one, keeps the agent's tabs on the thread from
1654 * idling out. The tab it targeted becomes the thread's most recently used.
1655 */
15661656 const markUsed = (request: PreviewAutomationRequest) => {
15671657 const now = Date.now();
15681658 for (const tab of tabs.values()) {
1569 if (tab.threadId === request.threadId && tab.control.agentId === request.agentSessionId)
1659 if (
1660 tab.threadId === request.threadId &&
1661 (tab.control.agentId === request.agentSessionId || tab.tabId === request.tabId)
1662 )
15701663 tab.usedAt = now;
15711664 }
15721665 };
......@@ -1602,7 +1695,7 @@ const make = Effect.gen(function* () {
16021695 "tabRequired",
16031696 );
16041697 // A tab still launching exists only as a session, so resolve it like a viewer would.
1605 const existing =
1698 const found =
16061699 reuse && request.tabId !== undefined
16071700 ? await Effect.runPromise(
16081701 findTab(request.threadId, request.tabId).pipe(
......@@ -1612,7 +1705,14 @@ const make = Effect.gen(function* () {
16121705 ),
16131706 )
16141707 : undefined;
1708 // Only an explicit tabId reuses a tab this session did not open; a tab
1709 // it last read (such as the user's) is not taken over implicitly.
1710 const existing =
1711 found && (request.tabIdExplicit || found.control.agentId === request.agentSessionId)
1712 ? found
1713 : undefined;
16151714 const navigationTimeout = Math.min(request.timeoutMs, NAVIGATION_TIMEOUT_MS);
1715 const profileId = existing ? undefined : resolveOpenProfile(open.profileId);
16161716 if (!existing) {
16171717 closeIdleAgentTabs();
16181718 assertTabCapacity(request.agentSessionId);
......@@ -1624,6 +1724,7 @@ const make = Effect.gen(function* () {
16241724 manager.open({
16251725 threadId: request.threadId,
16261726 ...(url ? { url } : {}),
1727 ...(profileId === undefined ? {} : { profileId }),
16271728 runtime: "server",
16281729 reveal: false,
16291730 automationOwner: request.agentSessionId,
......@@ -1681,7 +1782,7 @@ const make = Effect.gen(function* () {
16811782 const recordings = [...tabs.values()].filter(
16821783 (candidate) =>
16831784 candidate.threadId === request.threadId &&
1684 candidate.control.agentId === request.agentSessionId &&
1785 candidate.control.agentMayAct(request.agentSessionId ?? "") &&
16851786 (candidate.recording || candidate.recordingStart),
16861787 );
16871788 const targetTabId =
......@@ -1701,6 +1802,11 @@ const make = Effect.gen(function* () {
17011802 const tab = await requireTab(request);
17021803 // Closing must unblock an action waiting on a dialog, without queueing behind it.
17031804 if (request.operation === "close") {
1805 if (tab.control.agentId !== request.agentSessionId)
1806 throw new BrowserControlInterrupted(
1807 "Only the agent session that opened this tab can close it.",
1808 "agentMismatch",
1809 );
17041810 if (tab.control.controller !== null)
17051811 throw new BrowserControlInterrupted("A human controls this tab.", "humanControl");
17061812 void tab.control.close().catch(constVoid);
......@@ -1716,7 +1822,26 @@ const make = Effect.gen(function* () {
17161822 await resolveDialog(tab, input as PreviewAutomationDialogInput);
17171823 return statusWithTitle(tab, request.agentSessionId);
17181824 }
1719 return tab.control.agent(request.agentSessionId!, async () => {
1825 const agentSessionId = request.agentSessionId!;
1826 // A tab this session did not open, while it cannot act there (another
1827 // agent's, or the user's while they drive): read it in place, without
1828 // queueing behind the human's input. Its own tabs keep the takeover
1829 // contract: a human taking control interrupts every agent call.
1830 if (
1831 READ_OPERATIONS.has(request.operation) &&
1832 tab.control.agentId !== agentSessionId &&
1833 !tab.control.agentCanActNow(agentSessionId)
1834 ) {
1835 return tab.control.observe(async () => {
1836 if (tab.dialog)
1837 throw new BrowserControlInterrupted(
1838 "A browser dialog is pending. Read preview_status.",
1839 "dialogPending",
1840 );
1841 return executeTabOperation(tab, request);
1842 });
1843 }
1844 return tab.control.agent(agentSessionId, async () => {
17201845 if (tab.dialog)
17211846 throw new BrowserControlInterrupted(
17221847 "A browser dialog is pending. Read preview_status and use preview_dialog first.",
......@@ -2328,6 +2453,7 @@ const make = Effect.gen(function* () {
23282453 return ServerBrowser.of({
23292454 attachViewer,
23302455 clearProfile,
2456 reportProfiles,
23312457 openDownload,
23322458 answerFileChooser,
23332459 });
apps/server/src/preview/ServerBrowserStream.test.ts+5
......@@ -87,6 +87,7 @@ it.effect.each([
8787 });
8888 const browser = ServerBrowser.ServerBrowser.of({
8989 clearProfile: () => Effect.void,
90 reportProfiles: () => Effect.void,
9091 openDownload: () => Effect.succeedNone,
9192 answerFileChooser: () => Effect.succeed(false),
9293 attachViewer: (input) =>
......@@ -163,6 +164,7 @@ it.effect.each([
163164 let attachments = 0;
164165 const browser = ServerBrowser.ServerBrowser.of({
165166 clearProfile: () => Effect.void,
167 reportProfiles: () => Effect.void,
166168 openDownload: () => Effect.succeedNone,
167169 answerFileChooser: () => Effect.succeed(false),
168170 attachViewer: () => {
......@@ -203,6 +205,7 @@ it.effect("serves a tab's download only to an authorized session", () =>
203205 const requests: Array<unknown> = [];
204206 const browser = ServerBrowser.ServerBrowser.of({
205207 clearProfile: () => Effect.void,
208 reportProfiles: () => Effect.void,
206209 openDownload: (input) =>
207210 Effect.sync(() => {
208211 requests.push(input);
......@@ -250,6 +253,7 @@ it.effect("passes uploaded files to the page's open picker and needs operate sco
250253 const answers: Array<{ chooserId: string; files: Array<{ name: string; text: string }> }> = [];
251254 const browser = ServerBrowser.ServerBrowser.of({
252255 clearProfile: () => Effect.void,
256 reportProfiles: () => Effect.void,
253257 openDownload: () => Effect.succeedNone,
254258 answerFileChooser: (input) =>
255259 Effect.sync(() => {
......@@ -317,6 +321,7 @@ it.effect.each([
317321 Effect.gen(function* () {
318322 const browser = ServerBrowser.ServerBrowser.of({
319323 clearProfile: () => Effect.void,
324 reportProfiles: () => Effect.void,
320325 openDownload: () => Effect.succeedNone,
321326 answerFileChooser: () => Effect.succeed(false),
322327 attachViewer: () => Effect.fail(new ServerBrowser.ServerBrowserLaunchError({ cause: error })),
apps/server/src/preview/SessionControl.test.ts+12
......@@ -166,4 +166,16 @@ describe("SessionControl", () => {
166166 ).rejects.toThrow("navigation failed");
167167 await expect(control.agent("agent", async () => "recovered")).resolves.toBe("recovered");
168168 });
169
170 it("lets any agent act on a tab no agent opened, but only while no human controls it", async () => {
171 const control = new SessionControl(null);
172 await expect(control.agent("agent-a", async () => "acted")).resolves.toBe("acted");
173 await control.take("viewer-a");
174 await expect(control.agent("agent-a", async () => "racing")).rejects.toMatchObject({
175 reason: "humanControl",
176 });
177 await expect(control.observe(async () => "read")).resolves.toBe("read");
178 await control.release("viewer-a");
179 await expect(control.agent("agent-b", async () => "acted")).resolves.toBe("acted");
180 });
169181});
apps/server/src/preview/SessionControl.ts+25-2
......@@ -73,8 +73,17 @@ export class SessionControl {
7373 });
7474 }
7575
76 /** Whether an agent may act here: its own tab, or a tab no agent opened. */
77 agentMayAct(agentId: string) {
78 return this.agentId === null || this.agentId === agentId;
79 }
80
81 /**
82 * An agent action. It may act on its own tab or on one a human opened, but
83 * never while a human controls the tab; taking control interrupts it.
84 */
7685 agent<A>(agentId: string, run: () => Promise<A>) {
77 if (this.agentId !== agentId)
86 if (!this.agentMayAct(agentId))
7887 return Promise.reject(
7988 new BrowserControlInterrupted("This tab belongs to another agent.", "agentMismatch"),
8089 );
......@@ -82,7 +91,21 @@ export class SessionControl {
8291 return Promise.reject(
8392 new BrowserControlInterrupted("A human controls this tab.", "humanControl"),
8493 );
85 return this.action(() => this.agentId === agentId && this.owner === null, run);
94 return this.action(() => this.agentMayAct(agentId) && this.owner === null, run);
95 }
96
97 /** Whether this agent's action would run now rather than be refused. */
98 agentCanActNow(agentId: string) {
99 return this.agentMayAct(agentId) && this.owner === null;
100 }
101
102 /**
103 * A read that needs no control and does not queue, so an agent can look at
104 * a page while a human drives it without holding up the human's input.
105 */
106 observe<A>(run: () => Promise<A>) {
107 this.assertOpen();
108 return run();
86109 }
87110
88111 /**
apps/server/src/ws.ts+1
......@@ -2899,6 +2899,7 @@ const layerWsRpc = (
28992899 [WS_METHODS.previewClose]: (input) => previewManager.close(input),
29002900 [WS_METHODS.previewList]: (input) => previewManager.list(input),
29012901 [WS_METHODS.previewClearProfile]: (input) => serverBrowser.clearProfile(input.profileId),
2902 [WS_METHODS.previewReportProfiles]: (input) => serverBrowser.reportProfiles(input),
29022903 [WS_METHODS.previewReportStatus]: (input) => previewManager.reportStatus(input),
29032904 [WS_METHODS.subscribePreviewEvents]: (_input) => previewManager.events,
29042905 [WS_METHODS.deviceConfigure]: (input) => deviceService.configure(input),
apps/web/src/AppRoot.tsx+2
......@@ -1,5 +1,6 @@
11import { RouterProvider } from "@tanstack/react-router";
22
3import { BrowserProfileReporter } from "./browser/BrowserProfileReporter";
34import { ElectronBrowserHost } from "./browser/ElectronBrowserHost";
45import { QuitHoldOverlay } from "./components/QuitHoldOverlay";
56import { AppAtomRegistryProvider } from "./rpc/atomRegistry";
......@@ -15,6 +16,7 @@ export function AppRoot({ router }: { readonly router: AppRouter }) {
1516 <AppAtomRegistryProvider>
1617 <RouterProvider router={router} />
1718 <ElectronBrowserHost />
19 <BrowserProfileReporter />
1820 <QuitHoldOverlay />
1921 </AppAtomRegistryProvider>
2022 );
apps/web/src/browser/BrowserProfileReporter.tsx created+40
......@@ -0,0 +1,40 @@
1import { AuthPreviewOperateScope, type BrowserProfile } from "@t3tools/contracts";
2import { useEffect } from "react";
3
4import { useClientSettings, useClientSettingsHydrated } from "~/hooks/useSettings";
5import { useServerConfigs } from "~/state/entities";
6import { useEnvironments } from "~/state/environments";
7import { previewEnvironment } from "~/state/preview";
8import { useEnvironmentsWithScope } from "~/state/session";
9import { useAtomCommand } from "~/state/use-atom-command";
10
11const selectProfiles = (settings: { readonly browserProfiles: ReadonlyArray<BrowserProfile> }) =>
12 settings.browserProfiles;
13const selectDefaultProfileId = (settings: { readonly browserDefaultProfileId: string }) =>
14 settings.browserDefaultProfileId;
15
16/**
17 * Tells each environment that hosts browser tabs which profiles this client
18 * has, so agents can open tabs under them. Profiles live in client settings;
19 * the server keeps only the latest report in memory. A reconnect brings a new
20 * server config, which sends the report again.
21 */
22export function BrowserProfileReporter() {
23 const hydrated = useClientSettingsHydrated();
24 const profiles = useClientSettings(selectProfiles);
25 const defaultProfileId = useClientSettings(selectDefaultProfileId);
26 const { environments } = useEnvironments();
27 const serverConfigs = useServerConfigs();
28 const operable = useEnvironmentsWithScope(environments, AuthPreviewOperateScope);
29 const report = useAtomCommand(previewEnvironment.reportProfiles, { reportFailure: false });
30
31 useEffect(() => {
32 if (!hydrated) return;
33 for (const [environmentId, config] of serverConfigs) {
34 if (!config.environment.capabilities.serverBrowser || !operable.has(environmentId)) continue;
35 void report({ environmentId, input: { profiles, defaultProfileId } });
36 }
37 }, [defaultProfileId, hydrated, operable, profiles, report, serverConfigs]);
38
39 return null;
40}
apps/web/src/browser/ElectronBrowserHost.tsx+22-2
......@@ -3,11 +3,12 @@
33import { parseScopedThreadKey } from "@t3tools/client-runtime/environment";
44import { AuthPreviewOperateScope, FILL_PREVIEW_VIEWPORT } from "@t3tools/contracts";
55import { useAtomValue } from "@effect/atom-react";
6import { type ComponentProps, useEffect, useMemo, useRef } from "react";
6import { type ComponentProps, useEffect, useMemo, useRef, useState } from "react";
77
88import { primaryEnvironmentIdAtom } from "~/state/primaryEnvironment";
99
1010import { isElectron } from "~/env";
11import { useClientSettingsHydrated } from "~/hooks/useSettings";
1112import { useTheme } from "~/hooks/useTheme";
1213import { useActivePreviewSessions } from "~/previewStateStore";
1314import { previewEnvironment } from "~/state/preview";
......@@ -15,6 +16,7 @@ import { useEnvironmentScope } from "~/state/session";
1516import { useAtomCommand } from "~/state/use-atom-command";
1617
1718import { readPreviewAnnotationTheme } from "./annotationTheme";
19import { useBrowserDefaults } from "./browserDefaults";
1820import { useBrowserPointerStore } from "./browserPointerStore";
1921import { HostedBrowserWebview } from "./HostedBrowserWebview";
2022import { openUrlInPreview } from "./openFileInPreview";
......@@ -158,5 +160,23 @@ function AuthorizedBrowserWebview(props: ComponentProps<typeof HostedBrowserWebv
158160 props.threadRef.environmentId,
159161 AuthPreviewOperateScope,
160162 );
161 return canOperatePreview ? <HostedBrowserWebview {...props} /> : null;
163 const profileId = useTabProfileId(props.profileId);
164 return canOperatePreview ? <HostedBrowserWebview {...props} profileId={profileId} /> : null;
165}
166
167/**
168 * Agent `preview_open` normally carries the profile clients reported (see
169 * BrowserProfileReporter). An agent tab opened before any client reported has
170 * none, so it falls back to the configured default here. It is latched once
171 * settings load: Electron fixes the partition when the guest attaches, so a
172 * later settings change must not move a live tab.
173 */
174function useTabProfileId(profileId: string | undefined): string | undefined {
175 const hydrated = useClientSettingsHydrated();
176 const defaultProfileId = useBrowserDefaults().profileId;
177 const [fallback, setFallback] = useState<string | undefined>(undefined);
178 if (profileId === undefined && hydrated && fallback === undefined) {
179 setFallback(defaultProfileId);
180 }
181 return profileId ?? fallback;
162182}
packages/client-runtime/src/state/preview.ts+5
......@@ -76,6 +76,11 @@ export function createPreviewEnvironmentAtoms<R, E>(
7676 label: "environment-data:preview:clear-profile",
7777 tag: WS_METHODS.previewClearProfile,
7878 }),
79 reportProfiles: createEnvironmentRpcCommand(runtime, {
80 label: "environment-data:preview:report-profiles",
81 tag: WS_METHODS.previewReportProfiles,
82 concurrency: { mode: "latest", key: ({ environmentId }) => environmentId },
83 }),
7984 reportStatus: createEnvironmentRpcCommand(runtime, {
8085 label: "environment-data:preview:report-status",
8186 tag: WS_METHODS.previewReportStatus,
packages/contracts/src/preview.ts+9-1
......@@ -10,7 +10,7 @@
1010 */
1111import { Schema } from "effect";
1212import { NonNegativeInt, PositiveInt, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts";
13import { BrowserProfileId } from "./browserProfile.ts";
13import { BROWSER_PROFILE_MAX_COUNT, BrowserProfile, BrowserProfileId } from "./browserProfile.ts";
1414
1515export const PREVIEW_URL_MAX_LENGTH = 2_048;
1616export const CONFIGURED_LOCAL_SERVER_URLS_MAX_ITEMS = 32;
......@@ -295,6 +295,14 @@ export const PreviewCloseInput = Schema.Struct({
295295});
296296export type PreviewCloseInput = typeof PreviewCloseInput.Type;
297297
298/** A client's browser profiles, which agents choose from when they open a tab. */
299export const PreviewReportProfilesInput = Schema.Struct({
300 /** The user's own profiles; the server adds the built-ins. */
301 profiles: Schema.Array(BrowserProfile).check(Schema.isMaxLength(BROWSER_PROFILE_MAX_COUNT)),
302 defaultProfileId: BrowserProfileId,
303});
304export type PreviewReportProfilesInput = typeof PreviewReportProfilesInput.Type;
305
298306export const PreviewClearProfileInput = Schema.Struct({
299307 profileId: BrowserProfileId,
300308});
packages/contracts/src/previewAutomation.ts+34-4
......@@ -10,12 +10,15 @@ import {
1010 PreviewViewportSize,
1111} from "./preview.ts";
1212import { ProviderInstanceId } from "./providerInstance.ts";
13import { BrowserProfileId } from "./browserProfile.ts";
1314
1415const BoundedUrl = Schema.String.check(Schema.isTrimmed())
1516 .check(Schema.isNonEmpty())
1617 .check(Schema.isMaxLength(2048));
1718const URL_GUIDANCE =
1819 "Absolute http(s) URL or a schemeless host such as t3.chat or localhost:5173. Schemeless public hosts use https; loopback hosts use http.";
20const PROFILE_GUIDANCE =
21 "Browser profile for a new tab, by id or name (case-insensitive) from preview_status profiles. Omit to use the user's default profile. A tab keeps the profile it opened with.";
1922const OptionalTimeoutMs = Schema.optional(
2023 Schema.Int.check(Schema.isGreaterThan(0))
2124 .check(Schema.isLessThanOrEqualTo(60_000))
......@@ -61,17 +64,27 @@ const PreviewAutomationTabTargetFields = {
6164 tabId: Schema.optional(
6265 PreviewTabId.annotate({
6366 description:
64 "Exact collaborative browser tab to target. Omit to use this agent session's current tab.",
67 "Exact collaborative browser tab to target, including a tab the user opened (see preview_status tabs). Omit to use this agent session's current tab, or the user's visible tab when this session has none.",
6568 }),
6669 ).annotate({
6770 description:
68 "Exact collaborative browser tab to target. Omit to use this agent session's current tab.",
71 "Exact collaborative browser tab to target, including a tab the user opened (see preview_status tabs). Omit to use this agent session's current tab, or the user's visible tab when this session has none.",
6972 }),
7073};
7174
7275export const PreviewAutomationTabTargetInput = Schema.Struct(PreviewAutomationTabTargetFields);
7376export type PreviewAutomationTabTargetInput = typeof PreviewAutomationTabTargetInput.Type;
7477
78/** `human`: a person controls the tab now; `agent`: an agent session opened it; `unclaimed`: neither. */
79export const PreviewAutomationTabOwner = Schema.Literals(["agent", "human", "unclaimed"]);
80
81export const PreviewAutomationProfile = Schema.Struct({
82 id: BrowserProfileId,
83 name: Schema.String,
84 incognito: Schema.optional(Schema.Boolean),
85});
86export type PreviewAutomationProfile = typeof PreviewAutomationProfile.Type;
87
7588export const PreviewAutomationStatus = Schema.Struct({
7689 available: Schema.Boolean,
7790 visible: Schema.Boolean,
......@@ -81,7 +94,7 @@ export const PreviewAutomationStatus = Schema.Struct({
8194 loading: Schema.Boolean,
8295 control: Schema.optional(
8396 Schema.Struct({
84 owner: Schema.Literals(["agent", "human", "unclaimed"]),
97 owner: PreviewAutomationTabOwner,
8598 ownedByCaller: Schema.Boolean,
8699 generation: Schema.Number,
87100 }),
......@@ -115,16 +128,28 @@ export const PreviewAutomationStatus = Schema.Struct({
115128 }),
116129 ),
117130 ),
118 /** Server hosts: every tab this agent session owns, including popups its pages opened. */
131 /**
132 * Server hosts: every tab in the thread, including ones the user or another
133 * agent session opened. Any of them can be read; acting needs `ownedByCaller`,
134 * or an `unclaimed` tab while no human controls it.
135 */
119136 tabs: Schema.optional(
120137 Schema.Array(
121138 Schema.Struct({
122139 tabId: PreviewTabId,
123140 url: Schema.NullOr(Schema.String),
124141 openerTabId: Schema.optional(PreviewTabId),
142 owner: Schema.optional(PreviewAutomationTabOwner),
143 ownedByCaller: Schema.optional(Schema.Boolean),
144 visible: Schema.optional(Schema.Boolean),
145 profileId: Schema.optional(BrowserProfileId),
125146 }),
126147 ),
127148 ),
149 /** Server hosts: the browser profiles preview_open accepts, as the user's client reported them. */
150 profiles: Schema.optional(Schema.Array(PreviewAutomationProfile)),
151 /** Server hosts: the profile preview_open uses when given none. */
152 defaultProfileId: Schema.optional(BrowserProfileId),
128153});
129154export type PreviewAutomationStatus = typeof PreviewAutomationStatus.Type;
130155
......@@ -162,6 +187,11 @@ export const PreviewAutomationOpenInput = Schema.Struct({
162187 "Reuse tabId when supplied, otherwise this agent session's current tab. Defaults to true; set false to create a new tab.",
163188 }),
164189 ),
190 profileId: Schema.optional(
191 TrimmedNonEmptyString.check(Schema.isMaxLength(64)).annotate({
192 description: PROFILE_GUIDANCE,
193 }),
194 ).annotate({ description: PROFILE_GUIDANCE }),
165195})
166196 .check(
167197 Schema.makeFilter(
packages/contracts/src/rpc.ts+8
......@@ -256,6 +256,7 @@ import {
256256 PreviewListResult,
257257 PreviewClearProfileError,
258258 PreviewClearProfileInput,
259 PreviewReportProfilesInput,
259260 PreviewNavigateInput,
260261 PreviewOpenInput,
261262 PreviewRefreshInput,
......@@ -444,6 +445,7 @@ export const WS_METHODS = {
444445 previewClose: "preview.close",
445446 previewList: "preview.list",
446447 previewClearProfile: "preview.clearProfile",
448 previewReportProfiles: "preview.reportProfiles",
447449 previewReportStatus: "preview.reportStatus",
448450
449451 // Device methods
......@@ -1475,6 +1477,11 @@ const WsPreviewClearProfileRpc = Rpc.make(WS_METHODS.previewClearProfile, {
14751477 error: Schema.Union([PreviewClearProfileError, EnvironmentAuthorizationError]),
14761478});
14771479
1480const WsPreviewReportProfilesRpc = Rpc.make(WS_METHODS.previewReportProfiles, {
1481 payload: PreviewReportProfilesInput,
1482 error: EnvironmentAuthorizationError,
1483});
1484
14781485const WsPreviewReportStatusRpc = Rpc.make(WS_METHODS.previewReportStatus, {
14791486 payload: PreviewReportStatusInput,
14801487 error: Schema.Union([PreviewError, EnvironmentAuthorizationError]),
......@@ -1955,6 +1962,7 @@ export const WsRpcGroup = RpcGroup.make(
19551962 WsPreviewCloseRpc,
19561963 WsPreviewListRpc,
19571964 WsPreviewClearProfileRpc,
1965 WsPreviewReportProfilesRpc,
19581966 WsPreviewReportStatusRpc,
19591967 WsSubscribePreviewEventsRpc,
19601968 WsSubscribeDiscoveredLocalServersRpc,
packages/provider-core/src/server/orchestrationInstructions.ts+6-1
......@@ -44,7 +44,12 @@ You are running inside T3 Code. The \`t3-code\` MCP server is the product-native
4444
4545For browser work, first call \`preview_status\`. If no automation-capable preview is attached, call \`preview_open\` before concluding that the browser is unavailable. Then use \`preview_navigate\`, \`preview_snapshot\`, and the focused interaction tools. Prefer snapshot-provided locators over coordinates.
4646
47Do not switch to global browser skills, Chrome, Node REPL browser automation, standalone Playwright, or agent-browser merely because the preview is initially closed or a first call fails. Use an alternative browser system only when the T3 preview tools are absent, the user explicitly requests another browser, or \`preview_open\` returns an explicit unsupported/unavailable error. A failed T3 preview tool call should be inspected and retried with corrected arguments when the error is actionable.
47\`preview_status\` lists every browser tab in this thread, including tabs the user opened. When the user asks about "this page" or a page they have open, read their tab: pass its \`tabId\` to \`preview_snapshot\` or \`preview_wait_for\`, or omit \`tabId\` when you have no tab of your own. You may act on the user's tab, including \`preview_evaluate\`, only while its owner is \`unclaimed\`; while it is \`human\`, the user is driving, so read it with \`preview_snapshot\` or open your own tab. To use a browser profile (a set of saved logins), pass \`profileId\` from \`preview_status\` profiles to \`preview_open\`.
48
49Do not switch to global browser skills, Chrome, Node REPL browser automation, standalone Playwright, or agent-browser merely because the preview is initially closed or a first call fails. Inspect a failed preview call and retry with corrected arguments when the error is actionable. Use another browser system when:
50- the T3 preview tools are absent, or \`preview_open\` returns an explicit unsupported/unavailable error;
51- the user asks for another browser, or invokes a skill or documented repository workflow that names one; follow that workflow and report any prerequisite it is missing;
52- preview calls on an open tab have failed twice on the same step (timeouts, \`chrome-error://\` pages, a different client answering). Quote the raw error and switch without asking the user which browser to use.
4853`;
4954
5055const T3_CODE_ACP_DEFAULT_MODE_INSTRUCTIONS = `## T3 Code interaction mode: Default