diff --git a/.github/workflows/lazurio-fork-ci.yml b/.github/workflows/lazurio-fork-ci.yml index 841589ca861bd..32a162b6e6a09 100644 --- a/.github/workflows/lazurio-fork-ci.yml +++ b/.github/workflows/lazurio-fork-ci.yml @@ -204,6 +204,12 @@ jobs: # view underneath, and the handler test fails without it. apps/server/src/mcp/toolkits/preview/handlers.ts apps/server/src/mcp/toolkits/preview/handlers.test.ts + # t3_preview_list and t3_preview_close see the thread's strip, which upstream's + # preview list never sees: the list carries its tabs and their views, and close lets + # go of one through the host, or says the thread holds no such tab + # (Lazurio/t3code#200). + apps/server/src/mcp/toolkits/previewControls/handlers.ts + apps/server/src/mcp/toolkits/previewControls/tools.ts apps/server/src/terminal/Manager.ts apps/web/src/components/chat/ThreadServersPanel.tsx # The managed Lazurio Folder (LazurioPlatform F51): with T3CODE_LAZURIO_FOLDER, Chat diff --git a/apps/server/src/lazurio/environmentBrowser.test.ts b/apps/server/src/lazurio/environmentBrowser.test.ts index 7280940ea742e..24c6fc8739d3a 100644 --- a/apps/server/src/lazurio/environmentBrowser.test.ts +++ b/apps/server/src/lazurio/environmentBrowser.test.ts @@ -2,6 +2,7 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; import { + DEFAULT_SERVER_SETTINGS, EnvironmentId, PREVIEW_AUTOMATION_SERVER_OPERATIONS, PreviewAutomationExecutionError, @@ -17,23 +18,35 @@ import { type PreviewAutomationOperation, } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Context from "effect/Context"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Result from "effect/Result"; import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { TestClock } from "effect/testing"; import { ChildProcessSpawner } from "effect/process"; +import * as ServerConfig from "../config.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; -import type * as McpInvocationContext from "../mcp/McpInvocationContext.ts"; +import * as McpInvocationContext from "../mcp/McpInvocationContext.ts"; +import * as McpToolAccess from "../mcp/McpToolAccess.ts"; +import * as McpToolAccessTestkit from "../mcp/McpToolAccess.testkit.ts"; import * as PreviewAutomationBroker from "../mcp/PreviewAutomationBroker.ts"; import { PreviewStandardToolkit } from "../mcp/toolkits/preview/tools.ts"; +import * as PreviewControlsHandlers from "../mcp/toolkits/previewControls/handlers.ts"; +import { PreviewControlsToolkit } from "../mcp/toolkits/previewControls/tools.ts"; +import * as Preview from "../preview/Manager.ts"; import * as ProcessRunner from "../processRunner.ts"; +import * as ServerSettings from "../serverSettings.ts"; import { agentBrowserSessionName } from "./agentBrowserSession.ts"; import * as EnvironmentBrowser from "./environmentBrowser.ts"; +import { EnvironmentBrowserTabs } from "./environmentBrowserTabs.ts"; +import { ThreadLifecycle } from "./threadLifecycle.ts"; import * as ThreadTabs from "./threadTabs.ts"; import { contractCases, foreignId, foreignView, ownHost, personTab } from "./viewAddressCases.ts"; import type { @@ -131,6 +144,8 @@ const makeFakeBrowser = () => { const page = pages.get(targetId); return page === undefined ? undefined : tabOf(page); }, + pages: async () => + [...pages].map(([targetId, page]) => ({ targetId, url: page.url, title: page.title })), openWindow: async (url) => { opened.push(url); const targetId = `C${opened.length}`.padEnd(32, "C"); @@ -189,6 +204,8 @@ const startHost = (cli: { link: CliAnswer; window?: (args: ReadonlyArray) => CliAnswer | "hang"; lazurio?: boolean; + /** The real strips instead of the observed fake, for what lands in a holder file. */ + strips?: ThreadTabs.ThreadTabs["Service"]; }) => Effect.gen(function* () { const broker = yield* PreviewAutomationBroker.make; @@ -222,7 +239,7 @@ const startHost = (cli: { }; // The strips' answer for a thread: the host opens a person's page only while it is available. const readAnswer: { current: ThreadTabs.ThreadTabsAnswer } = { - current: { available: true } as ThreadTabs.ThreadTabsSnapshot, + current: { available: true, tabs: [] } as unknown as ThreadTabs.ThreadTabsSnapshot, }; const threadTabs = ThreadTabs.ThreadTabs.of({ read: (thread) => @@ -309,7 +326,7 @@ const startHost = (cli: { }; }), }); - const pages = yield* EnvironmentBrowser.make.pipe( + const { pages, tabs: stripTabs } = yield* EnvironmentBrowser.make.pipe( Effect.provideService(PreviewAutomationBroker.PreviewAutomationBroker, observed), Effect.provideService( ServerEnvironment.ServerEnvironment, @@ -325,12 +342,13 @@ const startHost = (cli: { cli.lazurio === false ? {} : { T3CODE_LAZURIO_FOLDER: `${home}/Lazurio` }, ), Effect.provideService(EnvironmentBrowser.EnvironmentBrowserConnect, browser.connect), - Effect.provideService(ThreadTabs.ThreadTabs, threadTabs), + Effect.provideService(ThreadTabs.ThreadTabs, cli.strips ?? threadTabs), ); return { broker, browser, pages, + stripTabs, readAnswer, runs, registrations, @@ -407,6 +425,7 @@ it.effect.each([ url: null, title: null, loading: false, + tabs: [], }); expect(host.registrations).toEqual([ { @@ -1371,6 +1390,136 @@ it.effect("lets go of a tab for the thread on close, and leaves the tab open", ( ), ); +/** The real strips on a runtime directory of their own, where only `threadId` lives. */ +const realStrips = (runtime: string) => + Effect.map( + Layer.build( + ThreadTabs.layer.pipe( + Layer.provide( + Layer.succeed( + ThreadLifecycle, + ThreadLifecycle.of({ + ready: Effect.void, + events: Stream.never, + liveThread: (thread) => + Effect.succeed( + thread === threadId ? Option.some({ id: threadId, title: "Web" }) : Option.none(), + ), + liveThreads: Effect.succeed([{ id: threadId, title: "Web" }]), + }), + ), + ), + Layer.provide(Layer.succeed(HostProcess.Environment, { XDG_RUNTIME_DIR: runtime })), + Layer.provide( + Layer.succeed(ThreadTabs.ThreadTabsSeams, { + epoch: "epoch-1", + bootClock: async () => ({ boot: "6f2c1d0e-1a2b-4c3d-8e4f-5a6b7c8d9e0f", at: 100 }), + watch: () => ({ retry: () => undefined, stop: () => undefined }), + }), + ), + ), + ), + (context) => Context.get(context, ThreadTabs.ThreadTabs), + ); + +// Lazurio/t3code#200: the thread's tabs live in its strip, which upstream's preview list never saw. +it.effect( + "t3_preview_list and t3_preview_close work on the thread's strip and its holder file", + () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const runtime = yield* fs.makeTempDirectoryScoped({ prefix: "t3-environment-browser-" }); + const holderFile = `${runtime}/lazurio-browser/holders/${agentBrowserSessionName(threadId)}.json`; + const host = yield* startHost({ link: declared, strips: yield* realStrips(runtime) }); + yield* host.registered; + const opened: Array = []; + for (const page of ["a", "b", "c"]) { + const status = yield* invoke<{ readonly tabId: string }>(host.broker, "open", { + url: `http://localhost:3000/${page}`, + reuseExistingTab: false, + open: false, + }); + host.browser.pages.get(status.tabId)!.title = `Page ${page}`; + opened.push(status.tabId); + } + + const manager = yield* Preview.make.pipe( + Effect.provide( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-environment-browser-" }).pipe( + Layer.provide(NodeServices.layer), + ), + ), + ); + const dependencies = Layer.mergeAll( + Layer.succeed(PreviewAutomationBroker.PreviewAutomationBroker, host.broker), + Layer.succeed(Preview.PreviewManager, manager), + Layer.succeed(McpInvocationContext.McpInvocationContext, scopeOf(threadId)), + Layer.succeed(EnvironmentBrowserTabs, host.stripTabs), + McpToolAccessTestkit.liveThreadsLayer, + Layer.mock(ServerSettings.ServerSettingsService)({ + getSettings: Effect.succeed({ + ...DEFAULT_SERVER_SETTINGS, + enableAgentBrowserAccess: true, + }), + }), + ); + const toolkit = yield* PreviewControlsToolkit.pipe( + Effect.provide( + McpToolAccess.HandlersLayer.layer(PreviewControlsHandlers.layer).pipe( + Layer.provide(dependencies), + ), + ), + ); + const call = (name: "t3_preview_list" | "t3_preview_close", input: object) => + toolkit.handle(name, input as never).pipe( + Stream.unwrap, + Stream.runCollect, + Effect.map((results) => results.at(-1)?.result as Record), + Effect.provide(dependencies), + ); + const listed = (tabIds: ReadonlyArray) => ({ + sessions: tabIds.map((tabId) => ({ + threadId, + tabId, + navStatus: { + _tag: "Success", + url: `http://localhost:3000/${"abc"[opened.indexOf(tabId)]}`, + title: `Page ${"abc"[opened.indexOf(tabId)]}`, + }, + runtime: "server", + view: `https://browser.example.test/t/${tabId}`, + })), + nextCursor: null, + }); + const holderTabs = Effect.map( + fs.readFileString(holderFile), + (text) => (JSON.parse(text) as { readonly tabs: ReadonlyArray }).tabs, + ); + + expect(yield* call("t3_preview_list", {})).toMatchObject(listed(opened)); + expect(yield* holderTabs).toEqual(opened); + + expect(yield* call("t3_preview_close", { tabId: opened[1] })).toEqual({}); + const kept = [opened[0]!, opened[2]!]; + expect(yield* call("t3_preview_list", {})).toMatchObject(listed(kept)); + expect(yield* holderTabs).toEqual(kept); + // preview_status lists the same strip; the window stays for whoever else views it. + expect(yield* invoke(host.broker, "status", {})).toMatchObject({ + tabs: kept.map((tabId) => ({ tabId })), + }); + expect(host.browser.pages.has(opened[1]!)).toBe(true); + + // A tab the thread does not hold is an error, not a quiet {}. + expect(yield* call("t3_preview_close", { tabId: opened[1] })).toMatchObject({ + _tag: "OrchestratorMcpFailure", + code: "invalid_request", + }); + expect(yield* holderTabs).toEqual(kept); + }), + ).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), +); + it.effect("never registers off a Lazurio Environment, even where a browser is declared", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/lazurio/environmentBrowser.ts b/apps/server/src/lazurio/environmentBrowser.ts index df3c33a9d6291..34d3dd27232cd 100644 --- a/apps/server/src/lazurio/environmentBrowser.ts +++ b/apps/server/src/lazurio/environmentBrowser.ts @@ -42,10 +42,15 @@ import { connectEnvironmentBrowser, type EnvironmentBrowserConnection, type EnvironmentBrowserTab, + type PageSummary, type TabOperation, type TabState, } from "./environmentBrowserConnection.ts"; import { resolveNavigationUrl } from "./environmentBrowserPage.ts"; +import { + EnvironmentBrowserTabs, + type EnvironmentBrowserStripTab, +} from "./environmentBrowserTabs.ts"; import { isLazurioEnvironment } from "./lazurioEnvironment.ts"; import * as ThreadTabs from "./threadTabs.ts"; @@ -490,23 +495,74 @@ export const make = Effect.gen(function* () { }), ); + /** The browser's pages by target id; a browser that does not answer lists none. */ + const pageSummaries = Effect.flatMap(browserConnection, (connection) => + Effect.tryPromise({ try: () => connection.pages(), catch: toOperationError }), + ).pipe( + Effect.map((pages) => new Map(pages.map((page) => [page.targetId, page]))), + Effect.orElseSucceed(() => new Map()), + ); + /** + * The thread's strip (Lazurio/t3code#200): the tabs it holds, persisted in its holder file, so + * also the ones it held before this server started. None while the strips are off. + */ + const stripTabs = Effect.fn("LazurioEnvironmentBrowser.stripTabs")(function* ( + threadId: ThreadId, + ) { + const answer = yield* threadTabs.read(threadId); + if (!answer.available) return Option.none>(); + const pages = answer.tabs.length === 0 ? new Map() : yield* pageSummaries; + return Option.some( + answer.tabs.map(({ tabId }): EnvironmentBrowserStripTab => { + const page = pages.get(tabId); + return { + tabId, + url: page?.url ?? null, + title: page?.title ?? null, + view: `${answer.viewOrigin}/t/${tabId}`, + }; + }), + ); + }); + /** preview_status `tabs`: the strip, as t3_preview_list lists it. */ + const statusTabs = (threadId: ThreadId) => + stripTabs(threadId).pipe( + Effect.map( + Option.match({ + onNone: () => ({}), + onSome: (tabs) => ({ + tabs: tabs.map(({ tabId, url }) => ({ + tabId, + url, + visible: shown(threadId, tabId), + })), + }), + }), + ), + Effect.orElseSucceed(() => ({})), + ); + const status = Effect.fn("LazurioEnvironmentBrowser.status")(function* ( request: PreviewAutomationRequest, ) { const targetId = requestTab(request); - if (targetId === undefined) return NO_TAB; + const tabs = yield* statusTabs(request.threadId); + if (targetId === undefined) return { ...NO_TAB, ...tabs }; const tab = yield* tabOf(targetId); if (tab === undefined) { yield* forget(targetId); // The thread's own tab that closed leaves it with none; a named tab must exist. if (request.tabIdExplicit === true) return yield* missingTab(targetId); - return NO_TAB; + return { ...NO_TAB, ...tabs }; } - return statusOf( - targetId, - yield* Effect.tryPromise({ try: () => tab.state(), catch: toOperationError }), - shown(request.threadId, targetId), - ); + return { + ...statusOf( + targetId, + yield* Effect.tryPromise({ try: () => tab.state(), catch: toOperationError }), + shown(request.threadId, targetId), + ), + ...tabs, + }; }); /** The thread's own window: the one `lazurio browser window` binds to its agent-browser session. */ @@ -831,9 +887,10 @@ export const make = Effect.gen(function* () { // Off a Lazurio Environment upstream's server browser serves T3's tools, and the threads' // strips stay off. const pages = EnvironmentBrowserPages.of({ open: openPage }); + const tabs = { list: stripTabs }; if (!(yield* isLazurioEnvironment)) { yield* threadTabs.declare(undefined); - return pages; + return { pages, tabs }; } // Registered while the Environment declares its browser. The broker ends a host's stream when // a request goes unanswered; the host then registers again. The threads' strips share the @@ -849,7 +906,12 @@ export const make = Effect.gen(function* () { yield* Effect.exit(hostSession); yield* Effect.sleep(HOST_RECONNECT_DELAY); }).pipe(Effect.forever, Effect.forkIn(hostScope)); - return pages; + return { pages, tabs }; }); -export const layer = Layer.effect(EnvironmentBrowserPages, make); +/** The host's pages, and the threads' strips for the preview tools of /mcp. */ +export const layer = Layer.effectContext( + Effect.map(make, ({ pages, tabs }) => + Context.make(EnvironmentBrowserPages, pages).pipe(Context.add(EnvironmentBrowserTabs, tabs)), + ), +); diff --git a/apps/server/src/lazurio/environmentBrowserConnection.ts b/apps/server/src/lazurio/environmentBrowserConnection.ts index 46be0844804c6..4285313be15e6 100644 --- a/apps/server/src/lazurio/environmentBrowserConnection.ts +++ b/apps/server/src/lazurio/environmentBrowserConnection.ts @@ -75,9 +75,18 @@ export interface EnvironmentBrowserTab { readonly act: (operation: TabOperation, input: unknown, timeoutMs: number) => Promise; } +/** A page target as the browser lists it, without asking the page. */ +export interface PageSummary { + readonly targetId: string; + readonly url: string | null; + readonly title: string | null; +} + export interface EnvironmentBrowserConnection { /** The page target with this target id, or undefined when the browser has no such page. */ readonly tab: (targetId: string) => Promise; + /** Every page target, in one call: a page that hangs or shows a dialog still answers. */ + readonly pages: () => Promise>; /** Opens a window in the default context and returns its target id. */ readonly openWindow: (url: string | undefined) => Promise; /** Closes the window with this target id. */ @@ -269,6 +278,15 @@ export const connectEnvironmentBrowser = async ( const page = await findPage(targetId); return page === undefined ? undefined : tabOf(page); }, + pages: async () => + (await browserSession.send("Target.getTargets")).targetInfos + .filter((info) => info.type === "page") + .map(({ targetId, url, title }) => ({ + targetId, + url: url === "about:blank" || url === "" ? null : url, + // Chromium names a page without a title by its address. + title: title === "" || title === url ? null : title, + })), openWindow: async (url) => ( await browserSession.send("Target.createTarget", { diff --git a/apps/server/src/lazurio/environmentBrowserTabs.ts b/apps/server/src/lazurio/environmentBrowserTabs.ts new file mode 100644 index 0000000000000..a4c60a4603fbf --- /dev/null +++ b/apps/server/src/lazurio/environmentBrowserTabs.ts @@ -0,0 +1,31 @@ +/** + * Lazurio overlay (Lazurio/t3code#200): t3_preview_list and t3_preview_close see the thread's tabs + * of the Environment browser, which live in its strip (threadTabs.ts), not in upstream's + * PreviewManager. The host provides this to /mcp; by default there is no strip and upstream's + * preview list answers. + */ + +import type { ThreadId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; + +import type { ThreadTabsError } from "./threadTabs.ts"; + +/** A tab in the thread's strip: its page as the browser shows it, and where a person views it. */ +export interface EnvironmentBrowserStripTab { + readonly tabId: string; + readonly url: string | null; + readonly title: string | null; + /** `/t/`, as preview_open answers. */ + readonly view: string; +} + +export const EnvironmentBrowserTabs = Context.Reference<{ + /** The thread's strip, or none while the Environment declares no browser. */ + readonly list: ( + threadId: ThreadId, + ) => Effect.Effect>, ThreadTabsError>; +}>("t3/lazurio/EnvironmentBrowserTabs", { + defaultValue: () => ({ list: () => Effect.succeedNone }), +}); diff --git a/apps/server/src/lazurio/overlay.ts b/apps/server/src/lazurio/overlay.ts index 888bf74c56c1d..ca632c8b142d5 100644 --- a/apps/server/src/lazurio/overlay.ts +++ b/apps/server/src/lazurio/overlay.ts @@ -18,8 +18,12 @@ import * as ThreadLifecycle from "./threadLifecycle.ts"; import * as ThreadTabs from "./threadTabs.ts"; import * as ThreadTabsRoute from "./threadTabsRoute.ts"; +const threadTabs = ThreadTabs.layer.pipe(Layer.provide(ThreadLifecycle.layer)); +// One host and one set of strips, by layer memoization, for the routes and for /mcp. +const environmentBrowser = EnvironmentBrowser.layer.pipe(Layer.provide(threadTabs)); + export const layer = Layer.mergeAll( - ThreadTabsRoute.layer.pipe(Layer.provide(EnvironmentBrowser.layer)), + ThreadTabsRoute.layer.pipe(Layer.provide(environmentBrowser)), ModelRecommendationRoute.layer, ProviderRefreshRoute.layer.pipe(Layer.provide(ProviderRefresh.layer)), BuddySurfaceThreadRoute.layer, @@ -27,4 +31,8 @@ export const layer = Layer.mergeAll( Layer.mergeAll(BuddySurfaceDays.layer, ActingSendersRoute.layer).pipe( Layer.provide(ActingSenders.layer), ), -).pipe(Layer.provide(ThreadTabs.layer.pipe(Layer.provide(ThreadLifecycle.layer)))); +).pipe(Layer.provide(threadTabs)); + +/** /mcp's preview list and close see the threads' strips (Lazurio/t3code#200). */ +export const withEnvironmentBrowserTabs = (mcp: Layer.Layer) => + mcp.pipe(Layer.provide(environmentBrowser)); diff --git a/apps/server/src/mcp/toolkits/previewControls/handlers.ts b/apps/server/src/mcp/toolkits/previewControls/handlers.ts index 4b775233cd80d..9ede41685a824 100644 --- a/apps/server/src/mcp/toolkits/previewControls/handlers.ts +++ b/apps/server/src/mcp/toolkits/previewControls/handlers.ts @@ -1,5 +1,17 @@ +import { + OrchestratorMcpFailure, + PREVIEW_URL_MAX_LENGTH, + type PreviewTabId, + type ThreadId, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; import * as Preview from "../../../preview/Manager.ts"; +import { + EnvironmentBrowserTabs, + type EnvironmentBrowserStripTab, +} from "../../../lazurio/environmentBrowserTabs.ts"; import { requireThreadMcpCapability } from "../../McpInvocationContext.ts"; import * as McpToolAccess from "../../McpToolAccess.ts"; import { unavailable } from "../../threadAccess.ts"; @@ -10,17 +22,57 @@ const access = Effect.gen(function* () { const scope = yield* requireThreadMcpCapability("preview"); return { scope, manager: yield* Preview.PreviewManager }; }); + +// Lazurio overlay (Lazurio/t3code#200): where the Environment declares its browser, the thread's +// server tabs are its strip of that browser, which upstream's PreviewManager never sees. +const stripOf = Effect.fn("PreviewControls.stripOf")( + function* (threadId: ThreadId) { + const tabs = yield* EnvironmentBrowserTabs; + return yield* tabs.list(threadId); + }, + Effect.mapError( + (error) => new OrchestratorMcpFailure({ code: "orchestration_error", message: error.message }), + ), +); +const stripSession = ( + threadId: string, + updatedAt: string, + { tabId, url, title, view }: EnvironmentBrowserStripTab, +) => ({ + threadId, + tabId: tabId as PreviewTabId, + navStatus: + url === null || url.length > PREVIEW_URL_MAX_LENGTH + ? { _tag: "Idle" as const } + : { _tag: "Success" as const, url, title: (title ?? "").slice(0, 512) }, + canGoBack: false, + canGoForward: false, + runtime: "server" as const, + view, + updatedAt, +}); + export const layer = McpToolAccess.toLayer(PreviewControlsToolkit, { t3_preview_list: McpToolAccess.readsAsCaller((input) => Effect.gen(function* () { const { scope, manager } = yield* access; - const result = yield* manager.list({ threadId: scope.thread.threadId }); + const threadId = scope.thread.threadId; + const result = yield* manager.list({ threadId }); + const strip = yield* stripOf(threadId); + const now = DateTime.formatIso(yield* DateTime.now); + const sessions = Option.match(strip, { + onNone: () => result.sessions, + onSome: (tabs) => [ + ...tabs.map((tab) => stripSession(threadId, now, tab)), + ...result.sessions, + ], + }); const start = input.cursor ?? 0; const end = start + (input.limit ?? 20); return { ...result, - sessions: result.sessions.slice(start, end), - nextCursor: end < result.sessions.length ? end : null, + sessions: sessions.slice(start, end), + nextCursor: end < sessions.length ? end : null, }; }), ), @@ -28,13 +80,31 @@ export const layer = McpToolAccess.toLayer(PreviewControlsToolkit, { Effect.gen(function* () { const { scope, manager } = yield* access; const { sessions } = yield* manager.list({ threadId: scope.thread.threadId }); + const strip = yield* stripOf(scope.thread.threadId); + const held = Option.flatMap(strip, (tabs) => + Option.fromNullishOr( + tabs.find((tab) => tab.tabId.toUpperCase() === input.tabId.toUpperCase()), + ), + ); if ( + Option.isSome(held) || sessions.some((session) => session.tabId === input.tabId && session.runtime === "server") ) { const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; - yield* broker.invoke({ scope, operation: "close", input: {}, tabId: input.tabId }); + const tabId = Option.match(held, { + onNone: () => input.tabId, + onSome: (tab) => tab.tabId as PreviewTabId, + }); + yield* broker.invoke({ scope, operation: "close", input: {}, tabId }); return {}; } + // Upstream's close of an unknown tab does nothing; a strip's thread is told it holds none. + if (Option.isSome(strip) && !sessions.some((session) => session.tabId === input.tabId)) { + return yield* new OrchestratorMcpFailure({ + code: "invalid_request", + message: `This thread holds no tab ${input.tabId}. Call t3_preview_list for the tabs it holds.`, + }); + } yield* manager .close({ threadId: scope.thread.threadId, tabId: input.tabId }) .pipe(Effect.mapError(unavailable)); diff --git a/apps/server/src/mcp/toolkits/previewControls/tools.ts b/apps/server/src/mcp/toolkits/previewControls/tools.ts index 0d3ea5a853ee2..4f3eb2d391127 100644 --- a/apps/server/src/mcp/toolkits/previewControls/tools.ts +++ b/apps/server/src/mcp/toolkits/previewControls/tools.ts @@ -3,6 +3,7 @@ import { OrchestratorMcpFailure, PreviewAutomationError, PreviewListResult, + PreviewSessionSnapshot, PreviewTabId, } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; @@ -32,6 +33,14 @@ const PreviewListTool = Tool.make("t3_preview_list", { }), success: Schema.Struct({ ...PreviewListResult.fields, + // Lazurio overlay (Lazurio/t3code#200): an Environment browser tab carries its view, as + // preview_open answers it. + sessions: Schema.Array( + PreviewSessionSnapshot.mapFields((fields) => ({ + ...fields, + view: Schema.optional(Schema.String), + })), + ), nextCursor: Schema.NullOr(NonNegativeInt), }), }) diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 2d91af2aead15..fbf90366cf7c0 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -818,7 +818,8 @@ const layerMakeRoutes = Layer.mergeAll( Ws.layer, speechStreamRouteLayer, ), - McpHttpServer.layer.pipe( + // Lazurio overlay (Lazurio/t3code#200): the preview list and close see the threads' strips. + LazurioOverlay.withEnvironmentBrowserTabs(McpHttpServer.layer).pipe( Layer.provide(ProviderAdapterRegistry.layerFromProviderInstanceRegistry), // Lazurio overlay (plan DEV-6676): an agent command's idempotency key holds across commands. Layer.provide( diff --git a/docs/operations/lazurio-overlay.md b/docs/operations/lazurio-overlay.md index c535e15cc33ff..d6375c4cb4b71 100644 --- a/docs/operations/lazurio-overlay.md +++ b/docs/operations/lazurio-overlay.md @@ -94,7 +94,10 @@ k přestavbě na v0.1.0, Lazurio/t3code#70), a tak overlay dělá tohle: změnily, co člověk vidí, a nahrávat host neumí: odpoví chybou, která řekne, co místo toho. `upload` umí jen s lokátorem pole souboru (výběr souboru, který stránka otevře sama, zůstává člověku) a `close` záložku pro vlákno pustí jako - × člověka a nechá ji otevřenou. Každou záložku, kterou + × člověka a nechá ji otevřenou. Záložky vlákna upstream `PreviewManager` nezná, + proto `t3_preview_list` a `tabs` v `preview_status` čtou záložky vlákna + (s adresou, titulkem a `view`) a `t3_preview_close` pošle `close` hostiteli jen + pro záložku, kterou vlákno drží; jinou odmítne chybou (Lazurio/t3code#200). Každou záložku, kterou `preview_open` otevře nebo převezme, zapíše host do záložek vlákna (viz níže), se žádostí ji ukázat, pokud nežádá práci na pozadí (`open: false`, nebo zastaralé `show: false`; `open` má přednost jako v desktopové aplikaci). diff --git a/scripts/lazurio-release-contract.test.mjs b/scripts/lazurio-release-contract.test.mjs index 2ad6e2ec6baba..b57bdd96ffb3c 100644 --- a/scripts/lazurio-release-contract.test.mjs +++ b/scripts/lazurio-release-contract.test.mjs @@ -2104,11 +2104,22 @@ NodeTest.test("a thread's tabs live in its holder file behind an overlay route", ); // The overlay's one layer: the overlay's routes, the thread tabs route over the Environment // browser host, whose pages it opens, all over one thread tabs service, which follows the - // threads' lifecycle. + // threads' lifecycle. /mcp's preview list and close get the same host and strips, by layer + // memoization (Lazurio/t3code#200). NodeAssert.match( overlay, - /\nexport const layer = Layer\.mergeAll\(\s*ThreadTabsRoute\.layer\.pipe\(Layer\.provide\(EnvironmentBrowser\.layer\)\),\s*ModelRecommendationRoute\.layer,\s*ProviderRefreshRoute\.layer\.pipe\(Layer\.provide\(ProviderRefresh\.layer\)\),\s*BuddySurfaceThreadRoute\.layer,\s*AgentCliSendState\.layer,\s*Layer\.mergeAll\(BuddySurfaceDays\.layer, ActingSendersRoute\.layer\)\.pipe\(\s*Layer\.provide\(ActingSenders\.layer\),?\s*\),?\s*\)\.pipe\(Layer\.provide\(ThreadTabs\.layer\.pipe\(Layer\.provide\(ThreadLifecycle\.layer\)\)\)\);/, + /\nconst threadTabs = ThreadTabs\.layer\.pipe\(Layer\.provide\(ThreadLifecycle\.layer\)\);\n(?:\/\/[^\n]*\n)*const environmentBrowser = EnvironmentBrowser\.layer\.pipe\(Layer\.provide\(threadTabs\)\);\n/, ); + NodeAssert.match( + overlay, + /\nexport const layer = Layer\.mergeAll\(\s*ThreadTabsRoute\.layer\.pipe\(Layer\.provide\(environmentBrowser\)\),\s*ModelRecommendationRoute\.layer,\s*ProviderRefreshRoute\.layer\.pipe\(Layer\.provide\(ProviderRefresh\.layer\)\),\s*BuddySurfaceThreadRoute\.layer,\s*AgentCliSendState\.layer,\s*Layer\.mergeAll\(BuddySurfaceDays\.layer, ActingSendersRoute\.layer\)\.pipe\(\s*Layer\.provide\(ActingSenders\.layer\),?\s*\),?\s*\)\.pipe\(Layer\.provide\(threadTabs\)\);/, + ); + NodeAssert.match( + overlay, + /export const withEnvironmentBrowserTabs = \(mcp: Layer\.Layer\) =>\n\s+mcp\.pipe\(Layer\.provide\(environmentBrowser\)\);/, + ); + NodeAssert.equal(overlay.match(/EnvironmentBrowser\.layer/g)?.length, 1); + NodeAssert.equal(overlay.match(/ThreadTabs\.layer/g)?.length, 1); // Archiving or deleting a thread lets go of its tabs: the lifecycle follows upstream's // orchestrator's domain events, and the sweep of threads that are gone waits until commands are // ready, so a starting server never lets go of every thread's tabs. @@ -2144,7 +2155,13 @@ NodeTest.test("T3's browser tools reach the Environment browser through their se ); const seam = routes.indexOf("\n LazurioOverlay.layer,\n"); NodeAssert.ok(seam > 0, "layerMakeRoutes must start the server overlay"); - NodeAssert.equal(server.match(/LazurioOverlay/g)?.length, 2); + // /mcp, in the same routes, sees the threads' strips through the overlay (Lazurio/t3code#200). + const mcpSeam = routes.indexOf( + "\n LazurioOverlay.withEnvironmentBrowserTabs(McpHttpServer.layer).pipe(\n", + ); + NodeAssert.ok(mcpSeam > 0, "layerMakeRoutes must hand /mcp the threads' strips"); + NodeAssert.equal(server.match(/LazurioOverlay/g)?.length, 3); + NodeAssert.equal(server.match(/McpHttpServer\.layer/g)?.length, 1); NodeAssert.ok(seam < routes.indexOf("Layer.provide(PreviewAutomationBroker.layer)")); // One predicate, T3CODE_LAZURIO_FOLDER, decides where the Environment browser is the browser. NodeAssert.match(predicate, /export const LAZURIO_FOLDER_ENV = "T3CODE_LAZURIO_FOLDER";/); @@ -2189,7 +2206,7 @@ NodeTest.test("T3's browser tools reach the Environment browser through their se ); NodeAssert.match( host, - /if \(!\(yield\* isLazurioEnvironment\)\) \{\n\s+yield\* threadTabs\.declare\(undefined\);\n\s+return pages;\n\s+\}/, + /if \(!\(yield\* isLazurioEnvironment\)\) \{\n\s+yield\* threadTabs\.declare\(undefined\);\n\s+return \{ pages, tabs \};\n\s+\}/, ); NodeAssert.match( host,