Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/lazurio-fork-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
157 changes: 153 additions & 4 deletions apps/server/src/lazurio/environmentBrowser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -189,6 +204,8 @@ const startHost = (cli: {
link: CliAnswer;
window?: (args: ReadonlyArray<string>) => 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;
Expand Down Expand Up @@ -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) =>
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -407,6 +425,7 @@ it.effect.each([
url: null,
title: null,
loading: false,
tabs: [],
});
expect(host.registrations).toEqual([
{
Expand Down Expand Up @@ -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<string> = [];
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<string, unknown>),
Effect.provide(dependencies),
);
const listed = (tabIds: ReadonlyArray<string>) => ({
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<string> }).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* () {
Expand Down
82 changes: 72 additions & 10 deletions apps/server/src/lazurio/environmentBrowser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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<string, PageSummary>()),
);
/**
* 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<ReadonlyArray<EnvironmentBrowserStripTab>>();
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. */
Expand Down Expand Up @@ -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
Expand All @@ -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)),
),
);
Loading
Loading