diff --git a/.gitignore b/.gitignore index 57262578a786..ac8115829384 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,4 @@ node_modules/ *.log .env* !.env.example +.claude/worktrees/ diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx index 8ad497c9a79b..c4b59e98b262 100644 --- a/apps/mobile/src/components/ProviderIcon.tsx +++ b/apps/mobile/src/components/ProviderIcon.tsx @@ -1,5 +1,5 @@ import { Image } from "expo-image"; -import { Path, Svg } from "react-native-svg"; +import { Circle, Path, Svg } from "react-native-svg"; import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; type ProviderIconProps = { @@ -61,6 +61,22 @@ export function ProviderIcon(props: ProviderIconProps) { ); } + if (props.provider === "agentrelay") { + return ( + + + + + + + ); + } + if (props.provider === "opencode") { return ( diff --git a/apps/server/integration/orphanedProviderSessionStartup.integration.test.ts b/apps/server/integration/orphanedProviderSessionStartup.integration.test.ts index e07681ae153b..3de80893400d 100644 --- a/apps/server/integration/orphanedProviderSessionStartup.integration.test.ts +++ b/apps/server/integration/orphanedProviderSessionStartup.integration.test.ts @@ -38,6 +38,7 @@ import { ProviderSessionDirectoryLive } from "../src/provider/Layers/ProviderSes import * as ProviderService from "../src/provider/Services/ProviderService.ts"; import * as ProviderSessionDirectory from "../src/provider/Services/ProviderSessionDirectory.ts"; import * as ProviderSessionReaper from "../src/provider/Services/ProviderSessionReaper.ts"; +import * as AgentRelayThreadDiscoveryReactor from "../src/provider/Services/AgentRelayThreadDiscoveryReactor.ts"; import * as RepositoryIdentityResolver from "../src/project/RepositoryIdentityResolver.ts"; import * as ServerLifecycleEvents from "../src/serverLifecycleEvents.ts"; import * as ServerRuntimeStartup from "../src/serverRuntimeStartup.ts"; @@ -79,6 +80,9 @@ const startupDependencies = Layer.mergeAll( Layer.succeed(ProviderSessionReaper.ProviderSessionReaper, { start: () => Effect.void, }), + Layer.succeed(AgentRelayThreadDiscoveryReactor.AgentRelayThreadDiscoveryReactor, { + start: () => Effect.void, + }), ServerLifecycleEvents.layer, Layer.succeed(ServerEnvironment.ServerEnvironment, { getEnvironmentId: Effect.succeed(EnvironmentId.make("environment-startup-orphan")), diff --git a/apps/server/package.json b/apps/server/package.json index f602f37a38f9..a2bc56881e00 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -22,6 +22,7 @@ "test": "vp test run" }, "dependencies": { + "@agent-relay/sdk": "^11.10.3", "@anthropic-ai/claude-agent-sdk": "^0.3.260", "@effect/platform-bun": "catalog:", "@effect/platform-node": "catalog:", @@ -33,6 +34,7 @@ "node-pty": "^1.1.0", "stream-chain": "^4.2.5", "stream-json": "3.6.0", + "ws": "^8.18.0", "yaml": "catalog:", "yauzl": "^3.4.0" }, @@ -44,6 +46,7 @@ "@t3tools/web": "workspace:*", "@types/bun": "1.3.14", "@types/node": "catalog:", + "@types/ws": "^8.5.13", "@types/yauzl": "^3.4.0", "effect-acp": "workspace:*", "effect-codex-app-server": "workspace:*", diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index d93fec5a3cf6..1364d712996f 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -1258,6 +1258,76 @@ describe("ProviderCommandReactor", () => { }), ); + effectIt.effect("refuses to start a provider session on an external-session marker thread", () => + Effect.gen(function* () { + const harness = yield* Effect.promise(() => createHarness()); + const now = "2026-01-01T00:00:00.000Z"; + // Same id shape ExternalSessionHooks.ts mints for a Claude Code / + // Codex session started outside T3 Code (see + // `externalSessionThreadId`/`isExternalSessionMarkerThreadId`). + const markerThreadId = ThreadId.make("external:claude:session-outside-t3"); + + yield* harness.engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("cmd-marker-thread-create"), + threadId: markerThreadId, + projectId: asProjectId("project-1"), + title: "External Claude Code session", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + createdAt: now, + historyImport: true, + }); + + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-on-marker-thread"), + threadId: markerThreadId, + message: { + messageId: asMessageId("user-message-on-marker-thread"), + role: "user", + text: "hello", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }); + + yield* Effect.promise(() => + waitFor(async () => { + const thread = (await harness.readModel()).threads.find( + (entry) => entry.id === markerThreadId, + ); + return ( + thread?.activities.some( + (activity) => activity.kind === "provider.turn.start.failed", + ) === true + ); + }), + ); + + const thread = (yield* Effect.promise(() => harness.readModel())).threads.find( + (entry) => entry.id === markerThreadId, + ); + expect( + thread?.activities.find((activity) => activity.kind === "provider.turn.start.failed"), + ).toMatchObject({ + summary: "Provider turn start failed", + payload: { detail: expect.stringContaining("read-only marker") }, + }); + expect(thread?.session).toBeNull(); + expect(harness.startSession).not.toHaveBeenCalled(); + expect(harness.sendTurn).not.toHaveBeenCalled(); + }), + ); + effectIt.effect("shows the missing workspace message without a provider stack trace", () => Effect.gen(function* () { const attempted = yield* Deferred.make(); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 5c1086b9e29c..df303a0fa7df 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -31,6 +31,7 @@ import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts"; import { increment, orchestrationEventsProcessedTotal } from "../../observability/Metrics.ts"; +import { isExternalSessionMarkerThreadId } from "../../project/ExternalSessionHooks.ts"; import { ProviderAdapterRequestError, ProviderAdapterValidationError, @@ -1192,6 +1193,26 @@ const make = Effect.gen(function* () { if (!thread) { return; } + // A thread minted by the external-session lifecycle hooks is a read-only + // historical marker for a session T3 Code never ran — there is no PTY or + // provider session to attach to. Nothing server-side stops a client from + // sending it a message (see ExternalSessionHooks.ts), so refuse to start + // a real provider session here instead of silently spawning one inside + // what the user sees as a read-only marker thread. + if (isExternalSessionMarkerThreadId(event.payload.threadId)) { + yield* appendProviderFailureActivity({ + threadId: event.payload.threadId, + kind: "provider.turn.start.failed", + summary: "Provider turn start failed", + detail: + "This thread is a read-only marker for a session started outside T3 Code. It has no " + + "live provider session to attach to and cannot start a new one.", + turnId: null, + createdAt: event.payload.createdAt, + requestId: event.payload.messageId, + }); + return; + } const turnStart = yield* projectionSnapshotQuery.getTurnStartMessage({ threadId: thread.id, messageId: event.payload.messageId, diff --git a/apps/server/src/project/ExternalSessionHooks.test.ts b/apps/server/src/project/ExternalSessionHooks.test.ts new file mode 100644 index 000000000000..725847bb3e01 --- /dev/null +++ b/apps/server/src/project/ExternalSessionHooks.test.ts @@ -0,0 +1,540 @@ +import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { + CommandId, + EventId, + ProjectId, + ThreadId, + type OrchestrationCommand, + type OrchestrationProject, + type OrchestrationReadModel, + type OrchestrationThread, + type OrchestrationThreadShell, +} from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; +import { HttpBody, HttpClient, HttpRouter } from "effect/unstable/http"; + +import { decideOrchestrationCommand } from "../orchestration/decider.ts"; +import { OrchestrationCommandInvariantError } from "../orchestration/Errors.ts"; +import { createEmptyReadModel, projectEvent } from "../orchestration/projector.ts"; +import * as OrchestrationEngine from "../orchestration/Services/OrchestrationEngine.ts"; +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { + EXTERNAL_SESSIONS_ROUTE_PATH, + externalSessionHooksRouteLayer, + isLoopbackRemoteAddress, +} from "./ExternalSessionHooks.ts"; + +const PROJECT_ID = ProjectId.make("project-1"); +const WORKSPACE_ROOT = "/tmp/external-session-project"; +const NOW = "2026-09-07T10:00:00.000Z"; + +function toShell(thread: OrchestrationThread): OrchestrationThreadShell { + return { + id: thread.id, + projectId: thread.projectId, + title: thread.title, + modelSelection: thread.modelSelection, + runtimeMode: thread.runtimeMode, + interactionMode: thread.interactionMode, + branch: thread.branch, + worktreePath: thread.worktreePath, + latestTurn: thread.latestTurn, + createdAt: thread.createdAt, + updatedAt: thread.updatedAt, + archivedAt: thread.archivedAt, + settledOverride: thread.settledOverride, + settledAt: thread.settledAt, + session: thread.session, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + }; +} + +/** + * Drives real commands through the pure decider + projector (no engine/DB), + * so assertions reflect the same projected read state a real server would + * produce — mirroring how decider.import.test.ts exercises this pair. + */ +const makeInMemoryOrchestration = Effect.fn("makeInMemoryOrchestration")(function* () { + const crypto = yield* Crypto.Crypto; + let readModel: OrchestrationReadModel = createEmptyReadModel(NOW); + let sequence = 0; + let failNextDispatchOfType: OrchestrationCommand["type"] | null = null; + + const seedProject = (project: OrchestrationProject) => + Effect.gen(function* () { + sequence += 1; + readModel = yield* projectEvent(readModel, { + sequence, + eventId: EventId.make(`event-project-${project.id}`), + aggregateKind: "project", + aggregateId: project.id, + type: "project.created", + occurredAt: project.createdAt, + commandId: CommandId.make(`command-project-${project.id}`), + causationEventId: null, + correlationId: CommandId.make(`command-project-${project.id}`), + metadata: {}, + payload: { + projectId: project.id, + title: project.title, + workspaceRoot: project.workspaceRoot, + defaultModelSelection: null, + scripts: [], + createdAt: project.createdAt, + updatedAt: project.createdAt, + }, + }); + }); + + const dispatch: OrchestrationEngine.OrchestrationEngineShape["dispatch"] = ( + command: OrchestrationCommand, + ) => { + // One-shot failure injection for the partial-failure recovery + // regression test: a real transient dispatch error surfaces as an + // `OrchestrationDispatchError`, not a defect, so it must flow through + // this typed channel rather than the `Effect.orDie` below. + if (failNextDispatchOfType === command.type) { + failNextDispatchOfType = null; + return Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: "simulated transient dispatch failure", + }), + ); + } + return Effect.gen(function* () { + const produced = yield* decideOrchestrationCommand({ command, readModel }).pipe( + Effect.provideService(Crypto.Crypto, crypto), + ); + const events = Array.isArray(produced) ? produced : [produced]; + for (const event of events) { + sequence += 1; + readModel = yield* projectEvent(readModel, { ...event, sequence }); + } + return { sequence }; + }).pipe(Effect.orDie); + }; + + const engine = OrchestrationEngine.OrchestrationEngineService.of({ + readEvents: () => Stream.die("unused in this test"), + readThreadEvents: () => Stream.die("unused in this test"), + getThreadReplayStats: () => Effect.die("unused in this test"), + dispatch, + streamDomainEvents: Stream.empty, + subscribeDomainEvents: Effect.die("unused in this test"), + latestSequence: Effect.succeed(0), + }); + + const snapshots = ProjectionSnapshotQuery.ProjectionSnapshotQuery.of({ + getUserInputActivity: () => Effect.die("unused in this test"), + getCommandReadModel: () => Effect.die("unused in this test"), + getSnapshot: () => Effect.die("unused in this test"), + getShellSnapshot: () => Effect.die("unused in this test"), + getArchivedShellSnapshot: () => Effect.die("unused in this test"), + searchThreads: () => Effect.die("unused in this test"), + getSnapshotSequence: () => Effect.die("unused in this test"), + getCounts: () => Effect.die("unused in this test"), + getEventReplayStats: () => Effect.die("unused in this test"), + getActiveProjectByWorkspaceRoot: (workspaceRoot) => + Effect.sync(() => + Option.fromNullishOr( + readModel.projects.find( + (project) => project.workspaceRoot === workspaceRoot && project.deletedAt === null, + ), + ), + ), + getProjectShellById: () => Effect.die("unused in this test"), + getFirstActiveThreadIdByProjectId: () => Effect.die("unused in this test"), + getImportedAgentSessionSources: () => Effect.die("unused in this test"), + getThreadCheckpointContext: () => Effect.die("unused in this test"), + getFullThreadDiffContext: () => Effect.die("unused in this test"), + getThreadShellById: (threadId) => + Effect.sync(() => + Option.map( + Option.fromNullishOr(readModel.threads.find((thread) => thread.id === threadId)), + toShell, + ), + ), + getThreadRuntimeContext: () => Effect.die("unused in this test"), + getTurnStartMessage: () => Effect.die("unused in this test"), + getThreadDetailById: (threadId) => + Effect.sync(() => + Option.fromNullishOr(readModel.threads.find((thread) => thread.id === threadId)), + ), + getThreadDetailSnapshot: () => Effect.die("unused in this test"), + }); + + return { + engine, + snapshots, + seedProject, + getThread: (threadId: ThreadId) => readModel.threads.find((thread) => thread.id === threadId), + failNextDispatchOf: (type: OrchestrationCommand["type"]) => { + failNextDispatchOfType = type; + }, + }; +}); + +const servicesLayer = (harness: Effect.Success>) => + Layer.mergeAll( + Layer.succeed(OrchestrationEngine.OrchestrationEngineService, harness.engine), + Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, harness.snapshots), + ); + +const postHookEvent = (input: { + readonly provider: "claude" | "codex"; + readonly pid: number; + readonly cwd: string; + readonly sessionId: string; + readonly event: "start" | "end"; + readonly timestamp: string; +}) => + Effect.flatMap(HttpClient.HttpClient, (httpClient) => + httpClient.post(EXTERNAL_SESSIONS_ROUTE_PATH, { body: HttpBody.jsonUnsafe(input) }), + ); + +/** + * Anchors the TestClock to `NOW`, seeds a project at {@link WORKSPACE_ROOT}, + * and serves the route — the setup every hook-payload-validation test below + * needs before it can post to {@link EXTERNAL_SESSIONS_ROUTE_PATH}. + */ +const setUpMarkerRouterHarness = Effect.fn("setUpMarkerRouterHarness")(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const harness = yield* makeInMemoryOrchestration(); + yield* harness.seedProject({ + id: PROJECT_ID, + title: "Project", + workspaceRoot: WORKSPACE_ROOT, + repositoryIdentity: null, + defaultModelSelection: null, + defaultThreadEnvMode: null, + autoPull: false, + faviconPath: null, + projectIcon: null, + scripts: [], + createdAt: NOW, + updatedAt: NOW, + deletedAt: null, + }); + yield* HttpRouter.serve(externalSessionHooksRouteLayer, { + disableListenLog: true, + disableLogger: true, + }).pipe(Layer.provide(servicesLayer(harness)), Layer.build); + return harness; +}); + +it.effect("materializes a read-only marker thread on start and settles it on end", () => + Effect.scoped( + Effect.gen(function* () { + // The route enforces a small future-timestamp grace window against real + // wall-clock time; anchor the TestClock to this fixture's timestamps. + yield* TestClock.setTime(Date.parse(NOW)); + const harness = yield* makeInMemoryOrchestration(); + yield* harness.seedProject({ + id: PROJECT_ID, + title: "Project", + workspaceRoot: WORKSPACE_ROOT, + repositoryIdentity: null, + defaultModelSelection: null, + defaultThreadEnvMode: null, + autoPull: false, + faviconPath: null, + projectIcon: null, + scripts: [], + createdAt: NOW, + updatedAt: NOW, + deletedAt: null, + }); + yield* HttpRouter.serve(externalSessionHooksRouteLayer, { + disableListenLog: true, + disableLogger: true, + }).pipe(Layer.provide(servicesLayer(harness)), Layer.build); + + const threadId = ThreadId.make("external:claude:session-abc"); + const startResponse = yield* postHookEvent({ + provider: "claude", + pid: 4242, + cwd: WORKSPACE_ROOT, + sessionId: "session-abc", + event: "start", + timestamp: NOW, + }); + expect(startResponse.status).toBe(200); + expect(yield* startResponse.json).toEqual({ recorded: true, threadId }); + + const afterStart = harness.getThread(threadId); + expect(afterStart?.settledAt).toBeNull(); + expect(afterStart?.settledOverride).toBeNull(); + expect(afterStart?.activities.map((activity) => activity.kind)).toEqual([ + "external-session.started", + ]); + expect(afterStart?.activities[0]?.summary).toContain("pid 4242"); + + // A duplicate start (hook fired twice) must not error or re-create. + const duplicateStartResponse = yield* postHookEvent({ + provider: "claude", + pid: 4242, + cwd: WORKSPACE_ROOT, + sessionId: "session-abc", + event: "start", + timestamp: NOW, + }); + expect(yield* duplicateStartResponse.json).toEqual({ + recorded: false, + reason: "already-recorded", + }); + expect(harness.getThread(threadId)?.activities).toHaveLength(1); + + const endResponse = yield* postHookEvent({ + provider: "claude", + pid: 4242, + cwd: WORKSPACE_ROOT, + sessionId: "session-abc", + event: "end", + timestamp: "2026-09-07T10:05:00.000Z", + }); + expect(endResponse.status).toBe(200); + expect(yield* endResponse.json).toEqual({ recorded: true, threadId }); + + const afterEnd = harness.getThread(threadId); + expect(afterEnd?.settledOverride).toBe("settled"); + expect(afterEnd?.settledAt).not.toBeNull(); + expect(afterEnd?.activities.map((activity) => activity.kind)).toEqual([ + "external-session.started", + "external-session.ended", + ]); + }), + ).pipe(Effect.provide(Layer.mergeAll(NodeHttpServer.layerTest, NodeServices.layer))), +); + +it.effect("drops events it cannot attach to a known project or session", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const harness = yield* makeInMemoryOrchestration(); + yield* HttpRouter.serve(externalSessionHooksRouteLayer, { + disableListenLog: true, + disableLogger: true, + }).pipe(Layer.provide(servicesLayer(harness)), Layer.build); + + const startWithoutProject = yield* postHookEvent({ + provider: "codex", + pid: 99, + cwd: "/tmp/never-opened-in-t3", + sessionId: "session-orphan", + event: "start", + timestamp: NOW, + }); + expect(yield* startWithoutProject.json).toEqual({ + recorded: false, + reason: "project-not-found", + }); + + const endWithoutStart = yield* postHookEvent({ + provider: "codex", + pid: 99, + cwd: "/tmp/never-opened-in-t3", + sessionId: "session-never-started", + event: "end", + timestamp: NOW, + }); + expect(yield* endWithoutStart.json).toEqual({ recorded: false, reason: "unknown-session" }); + }), + ).pipe(Effect.provide(Layer.mergeAll(NodeHttpServer.layerTest, NodeServices.layer))), +); + +it("only accepts callers whose socket address is genuinely loopback", () => { + expect(isLoopbackRemoteAddress("127.0.0.1")).toBe(true); + expect(isLoopbackRemoteAddress("127.5.6.7")).toBe(true); + expect(isLoopbackRemoteAddress("::1")).toBe(true); + expect(isLoopbackRemoteAddress("::ffff:127.0.0.1")).toBe(true); + expect(isLoopbackRemoteAddress("192.168.1.50")).toBe(false); + expect(isLoopbackRemoteAddress("10.0.0.5")).toBe(false); + expect(isLoopbackRemoteAddress("::ffff:10.0.0.5")).toBe(false); + expect(isLoopbackRemoteAddress("2001:db8::1")).toBe(false); + expect(isLoopbackRemoteAddress("")).toBe(false); +}); + +it.effect("rejects a hook payload larger than the byte cap before parsing it", () => + Effect.scoped( + Effect.gen(function* () { + yield* setUpMarkerRouterHarness(); + + const oversizedBody = "x".repeat(20 * 1_024); + const response = yield* Effect.flatMap(HttpClient.HttpClient, (httpClient) => + httpClient.post(EXTERNAL_SESSIONS_ROUTE_PATH, { + body: HttpBody.text(oversizedBody, "application/json"), + }), + ); + expect(response.status).toBe(413); + }), + ).pipe(Effect.provide(Layer.mergeAll(NodeHttpServer.layerTest, NodeServices.layer))), +); + +it.effect("rejects a cwd/sessionId longer than the bounded field length", () => + Effect.scoped( + Effect.gen(function* () { + yield* setUpMarkerRouterHarness(); + + const tooLongCwd = yield* postHookEvent({ + provider: "claude", + pid: 1, + cwd: `${WORKSPACE_ROOT}/${"a".repeat(5_000)}`, + sessionId: "session-too-long-cwd", + event: "start", + timestamp: NOW, + }); + expect(tooLongCwd.status).toBe(400); + + const tooLongSessionId = yield* postHookEvent({ + provider: "claude", + pid: 1, + cwd: WORKSPACE_ROOT, + sessionId: "s".repeat(5_000), + event: "start", + timestamp: NOW, + }); + expect(tooLongSessionId.status).toBe(400); + }), + ).pipe(Effect.provide(Layer.mergeAll(NodeHttpServer.layerTest, NodeServices.layer))), +); + +it.effect("rejects an implausible timestamp instead of persisting it", () => + Effect.scoped( + Effect.gen(function* () { + yield* setUpMarkerRouterHarness(); + + // NOW + 30 minutes: past the 5 minute future-clock-skew grace window. + const tooFarFuture = yield* postHookEvent({ + provider: "claude", + pid: 1, + cwd: WORKSPACE_ROOT, + sessionId: "session-future-timestamp", + event: "start", + timestamp: "2026-09-07T10:30:00.000Z", + }); + expect(tooFarFuture.status).toBe(400); + + const notADate = yield* postHookEvent({ + provider: "claude", + pid: 1, + cwd: WORKSPACE_ROOT, + sessionId: "session-garbage-timestamp", + event: "start", + timestamp: "not-a-timestamp", + }); + expect(notADate.status).toBe(400); + + const absurdlyOld = yield* postHookEvent({ + provider: "claude", + pid: 1, + cwd: WORKSPACE_ROOT, + sessionId: "session-ancient-timestamp", + event: "start", + timestamp: "1900-01-01T00:00:00.000Z", + }); + expect(absurdlyOld.status).toBe(400); + }), + ).pipe(Effect.provide(Layer.mergeAll(NodeHttpServer.layerTest, NodeServices.layer))), +); + +it.effect("does not append a duplicate 'ended' activity or re-settle on a retried end hook", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* setUpMarkerRouterHarness(); + const threadId = ThreadId.make("external:claude:session-end-retry"); + + yield* postHookEvent({ + provider: "claude", + pid: 1, + cwd: WORKSPACE_ROOT, + sessionId: "session-end-retry", + event: "start", + timestamp: NOW, + }); + + const firstEnd = yield* postHookEvent({ + provider: "claude", + pid: 1, + cwd: WORKSPACE_ROOT, + sessionId: "session-end-retry", + event: "end", + timestamp: NOW, + }); + expect(yield* firstEnd.json).toEqual({ recorded: true, threadId }); + const settledAtAfterFirstEnd = harness.getThread(threadId)?.settledAt; + expect(settledAtAfterFirstEnd).not.toBeNull(); + + // A retried "end" hook (network retry, at-least-once delivery) must be + // a no-op: no second "ended" activity, no re-settle. + const retriedEnd = yield* postHookEvent({ + provider: "claude", + pid: 1, + cwd: WORKSPACE_ROOT, + sessionId: "session-end-retry", + event: "end", + timestamp: NOW, + }); + expect(yield* retriedEnd.json).toEqual({ recorded: false, reason: "already-recorded" }); + + const thread = harness.getThread(threadId); + expect( + thread?.activities.filter((activity) => activity.kind === "external-session.ended"), + ).toHaveLength(1); + expect(thread?.settledAt).toBe(settledAtAfterFirstEnd); + }), + ).pipe(Effect.provide(Layer.mergeAll(NodeHttpServer.layerTest, NodeServices.layer))), +); + +it.effect("completes a missing start activity on retry after the append dispatch failed", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* setUpMarkerRouterHarness(); + const threadId = ThreadId.make("external:claude:session-partial-start"); + + // Force `thread.create` to succeed but the follow-up + // `thread.activity.append` dispatch to fail transiently — the exact + // partial-failure sequence from the bug report. + harness.failNextDispatchOf("thread.activity.append"); + const firstStart = yield* postHookEvent({ + provider: "claude", + pid: 7, + cwd: WORKSPACE_ROOT, + sessionId: "session-partial-start", + event: "start", + timestamp: NOW, + }); + expect(yield* firstStart.json).toEqual({ recorded: false, reason: undefined }); + const afterFailedStart = harness.getThread(threadId); + expect(afterFailedStart).toBeDefined(); + expect(afterFailedStart?.activities).toHaveLength(0); + + // Retrying the same start hook must notice the thread exists but has + // no start activity, and complete the missing step — not treat the + // existing thread as already fully recorded. + const retriedStart = yield* postHookEvent({ + provider: "claude", + pid: 7, + cwd: WORKSPACE_ROOT, + sessionId: "session-partial-start", + event: "start", + timestamp: NOW, + }); + expect(yield* retriedStart.json).toEqual({ recorded: true, threadId }); + const afterRetry = harness.getThread(threadId); + expect(afterRetry?.activities.map((activity) => activity.kind)).toEqual([ + "external-session.started", + ]); + }), + ).pipe(Effect.provide(Layer.mergeAll(NodeHttpServer.layerTest, NodeServices.layer))), +); diff --git a/apps/server/src/project/ExternalSessionHooks.ts b/apps/server/src/project/ExternalSessionHooks.ts new file mode 100644 index 000000000000..aec42440e3a8 --- /dev/null +++ b/apps/server/src/project/ExternalSessionHooks.ts @@ -0,0 +1,391 @@ +/** + * ExternalSessionHooks - visibility for Claude Code / Codex sessions started + * outside T3 Code entirely. + * + * Agent Relay is the primary way sessions get discovered going forward. This + * module is the deliberately small fallback for the case Agent Relay does not + * cover: a bare `claude` or `codex` invocation in a raw terminal, launched + * without T3 Code and without Agent Relay. Both CLIs support global lifecycle + * hooks (Claude Code's `SessionStart`/`SessionEnd` in `~/.claude/settings.json`, + * Codex's hooks in `~/.codex/config.toml`) that fire regardless of what + * launched them and can `curl` this endpoint. See `docs/user/external-sessions.md` + * for the exact hook configuration. + * + * T3 Code never owns the reported process: there is no PTY, no provider + * session binding, and nothing to attach or resume. The only artifact this + * module produces is a settled thread carrying two informational activity + * entries ("started" / "ended") in a project that already exists for the + * reported `cwd`. If no project matches, the event is dropped — this module + * intentionally does not create projects for directories the user has never + * opened in T3 Code. + * + * @module ExternalSessionHooks + */ +import { + CommandId, + DEFAULT_MODEL, + DEFAULT_MODEL_BY_PROVIDER, + DEFAULT_PROVIDER_INTERACTION_MODE, + DEFAULT_RUNTIME_MODE, + EventId, + IsoDateTime, + PositiveInt, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, + TrimmedNonEmptyString, +} from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; + +import * as OrchestrationEngine from "../orchestration/Services/OrchestrationEngine.ts"; +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; + +export const EXTERNAL_SESSIONS_ROUTE_PATH = "/api/external-sessions"; + +/** + * Every marker thread this module creates uses this id prefix (see + * {@link externalSessionThreadId}) and nothing else in the codebase mints + * thread ids shaped this way. That makes the prefix a durable, already + * persisted signal for "this thread is a read-only external-session marker" + * that other server code can check without a new projected field — see + * {@link isExternalSessionMarkerThreadId} and its use in + * `ProviderCommandReactor.ts` to refuse starting a live provider session on + * one of these threads. + */ +const EXTERNAL_SESSION_THREAD_ID_PREFIX = "external:"; + +/** Generous but bounded: real hook payloads are a path and a session id. */ +const MAX_EXTERNAL_SESSION_HOOK_STRING_LENGTH = 4_096; + +/** Comfortably above two max-length strings plus JSON structure overhead. */ +const MAX_EXTERNAL_SESSION_HOOK_BODY_BYTES = 16 * 1_024; + +/** Tolerates ordinary clock skew between the hook's host and this server. */ +const EXTERNAL_SESSION_HOOK_TIMESTAMP_FUTURE_GRACE_MS = 5 * 60 * 1_000; + +/** Absolute floor used to reject garbage timestamps; needs no "now" reference. */ +const EXTERNAL_SESSION_HOOK_TIMESTAMP_MIN_MS = Date.parse("2000-01-01T00:00:00.000Z"); + +export const ExternalSessionHookProvider = Schema.Literals(["claude", "codex"]); +export type ExternalSessionHookProvider = typeof ExternalSessionHookProvider.Type; + +export const ExternalSessionHookEventKind = Schema.Literals(["start", "end"]); +export type ExternalSessionHookEventKind = typeof ExternalSessionHookEventKind.Type; + +/** A non-empty string, bounded so a hostile caller cannot smuggle megabytes into `cwd`/`sessionId`. */ +const BoundedHookString = TrimmedNonEmptyString.check( + Schema.isMaxLength(MAX_EXTERNAL_SESSION_HOOK_STRING_LENGTH), +); + +/** + * An ISO date-time string, structurally sane enough to persist as `createdAt` + * on the marker thread and its activities (see + * {@link recordExternalSessionHookEvent}) without skewing thread ordering — + * reject garbage instead of silently clamping it. This only checks what a + * pure schema predicate can check without reading the clock (parseable, not + * absurdly old); the "not too far in the future" bound needs real "now" and + * is enforced separately in {@link externalSessionHooksRouteLayer} via + * Effect's `Clock`. + */ +const PlausibleIsoDateTime = IsoDateTime.check( + Schema.makeFilter((value: string) => { + const parsedMs = Date.parse(value); + if (Number.isNaN(parsedMs)) { + return "must be a valid ISO 8601 date-time string"; + } + if (parsedMs < EXTERNAL_SESSION_HOOK_TIMESTAMP_MIN_MS) { + return "is implausibly far in the past"; + } + return true; + }), +); + +/** Body a lifecycle hook posts to {@link EXTERNAL_SESSIONS_ROUTE_PATH}. */ +export const ExternalSessionHookPayload = Schema.Struct({ + provider: ExternalSessionHookProvider, + pid: PositiveInt, + cwd: BoundedHookString, + sessionId: BoundedHookString, + event: ExternalSessionHookEventKind, + timestamp: PlausibleIsoDateTime, +}); +export type ExternalSessionHookPayload = typeof ExternalSessionHookPayload.Type; + +export interface ExternalSessionHookResult { + readonly recorded: boolean; + readonly threadId?: string; + readonly reason?: "project-not-found" | "already-recorded" | "unknown-session"; +} + +const PROVIDER_LABEL: Record = { + claude: "Claude Code", + codex: "Codex", +}; + +// Threads/sessions reference provider *instance* ids, never driver kinds +// directly, but an unconfigured built-in provider's instance id defaults to +// its driver kind (see ClaudeAdapter's `claudeAgent` fallback and Codex's +// `codex` default instance). A marker thread referencing an instance the user +// later renames or removes is rendered "unavailable" by the provider layer, +// the same fallback real threads get — it is never a crash. +function providerInstanceId(provider: ExternalSessionHookProvider): ProviderInstanceId { + return ProviderInstanceId.make(provider === "claude" ? "claudeAgent" : "codex"); +} + +function defaultModel(provider: ExternalSessionHookProvider): string { + const driverKind = ProviderDriverKind.make(provider === "claude" ? "claudeAgent" : "codex"); + return DEFAULT_MODEL_BY_PROVIDER[driverKind] ?? DEFAULT_MODEL; +} + +function externalSessionThreadId(input: { + readonly provider: ExternalSessionHookProvider; + readonly sessionId: string; +}): ThreadId { + return ThreadId.make(`${EXTERNAL_SESSION_THREAD_ID_PREFIX}${input.provider}:${input.sessionId}`); +} + +/** + * True for a thread id this module minted (see {@link externalSessionThreadId}). + * These threads never had a real provider session behind them, so nothing + * should ever start one now — used by `ProviderCommandReactor.ts` to refuse + * turn starts against a read-only external-session marker thread. + */ +export function isExternalSessionMarkerThreadId(threadId: string): boolean { + return threadId.startsWith(EXTERNAL_SESSION_THREAD_ID_PREFIX); +} + +/** + * Record a `start` or `end` lifecycle event reported by a Claude Code / + * Codex hook running outside T3 Code. Best-effort and idempotent: a repeated + * `start` for the same session id is a no-op, and an `end` for a session T3 + * never saw a `start` for is a no-op. + */ +export const recordExternalSessionHookEvent = Effect.fn("recordExternalSessionHookEvent")( + function* (payload: ExternalSessionHookPayload) { + const engine = yield* OrchestrationEngine.OrchestrationEngineService; + const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const crypto = yield* Crypto.Crypto; + const threadId = externalSessionThreadId(payload); + const existingThread = yield* snapshots.getThreadShellById(threadId); + + const appendStartActivity = Effect.all({ + commandId: crypto.randomUUIDv4, + activityId: crypto.randomUUIDv4, + }).pipe( + Effect.flatMap(({ commandId, activityId }) => + engine.dispatch({ + type: "thread.activity.append", + commandId: CommandId.make(commandId), + threadId, + activity: { + id: EventId.make(activityId), + tone: "info", + kind: "external-session.started", + summary: `${PROVIDER_LABEL[payload.provider]} session started outside T3 Code (pid ${payload.pid})`, + payload: { pid: payload.pid, cwd: payload.cwd, sessionId: payload.sessionId }, + turnId: null, + createdAt: payload.timestamp, + }, + createdAt: payload.timestamp, + }), + ), + ); + + if (payload.event === "end") { + if (Option.isNone(existingThread)) { + return { recorded: false, reason: "unknown-session" } satisfies ExternalSessionHookResult; + } + // Idempotent: once the thread is settled its "ended" marker is already + // recorded. Without this check a retried hook call (network retry, + // at-least-once delivery) appends another "ended" activity and + // re-settles the thread on every retry. + if (existingThread.value.settledAt !== null) { + return { recorded: false, reason: "already-recorded" } satisfies ExternalSessionHookResult; + } + yield* engine.dispatch({ + type: "thread.activity.append", + commandId: CommandId.make(yield* crypto.randomUUIDv4), + threadId, + activity: { + id: EventId.make(yield* crypto.randomUUIDv4), + tone: "info", + kind: "external-session.ended", + summary: `${PROVIDER_LABEL[payload.provider]} session ended (pid ${payload.pid})`, + payload: { pid: payload.pid, cwd: payload.cwd, sessionId: payload.sessionId }, + turnId: null, + createdAt: payload.timestamp, + }, + createdAt: payload.timestamp, + }); + // Best-effort: a thread whose session is somehow live, or that was + // archived/deleted since, must not block recording the "ended" marker. + yield* engine + .dispatch({ + type: "thread.settle", + commandId: CommandId.make(yield* crypto.randomUUIDv4), + threadId, + }) + .pipe( + Effect.catch((cause) => + Effect.logWarning("Could not settle an external session's marker thread", { + threadId, + cause, + }), + ), + ); + return { recorded: true, threadId } satisfies ExternalSessionHookResult; + } + + if (Option.isSome(existingThread)) { + // The thread already exists, but `thread.create` succeeding does not + // guarantee the follow-up start-activity dispatch also succeeded (a + // transient failure between the two leaves a thread with no start + // marker). Check for the actual activity instead of trusting thread + // existence alone, so a retry of the same start hook completes the + // missing step rather than being swallowed as "already-recorded". + const existingDetail = yield* snapshots.getThreadDetailById(threadId, { + activityKinds: ["external-session.started"], + }); + const hasStartActivity = + Option.isSome(existingDetail) && + existingDetail.value.activities.some( + (activity) => activity.kind === "external-session.started", + ); + if (hasStartActivity) { + return { recorded: false, reason: "already-recorded" } satisfies ExternalSessionHookResult; + } + yield* appendStartActivity; + return { recorded: true, threadId } satisfies ExternalSessionHookResult; + } + const project = yield* snapshots.getActiveProjectByWorkspaceRoot(payload.cwd); + if (Option.isNone(project)) { + return { recorded: false, reason: "project-not-found" } satisfies ExternalSessionHookResult; + } + + yield* engine.dispatch({ + type: "thread.create", + commandId: CommandId.make(yield* crypto.randomUUIDv4), + threadId, + projectId: project.value.id, + title: `External ${PROVIDER_LABEL[payload.provider]} session`, + modelSelection: { + instanceId: providerInstanceId(payload.provider), + model: defaultModel(payload.provider), + }, + runtimeMode: DEFAULT_RUNTIME_MODE, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + branch: null, + worktreePath: null, + createdAt: payload.timestamp, + historyImport: true, + }); + yield* appendStartActivity; + return { recorded: true, threadId } satisfies ExternalSessionHookResult; + }, +); + +const decodeExternalSessionHookPayload = Schema.decodeUnknownEffect(ExternalSessionHookPayload); + +/** + * True when the raw remote address that opened this connection is the local + * machine. Compared against the socket's own `remoteAddress`, never a + * client-suppliable header, so this holds regardless of what host this + * server is bound to (LAN, Tailscale, T3 Connect) — a hook config always + * targets `127.0.0.1` (see `docs/user/external-sessions.md`), so a request + * arriving from anywhere else cannot be a legitimate lifecycle hook. + */ +export function isLoopbackRemoteAddress(remoteAddress: string): boolean { + const normalized = remoteAddress.startsWith("::ffff:") + ? remoteAddress.slice("::ffff:".length) + : remoteAddress; + return normalized === "127.0.0.1" || normalized === "::1" || normalized.startsWith("127."); +} + +/** + * `POST /api/external-sessions` — see module docs. Restricted to genuinely + * loopback callers (see {@link isLoopbackRemoteAddress}): this route has no + * other authentication, and once the server is reachable over the network + * (LAN, Tailscale, T3 Connect) an unauthenticated write endpoint would let + * any network client fabricate unlimited marker threads. + */ +export const externalSessionHooksRouteLayer = HttpRouter.add( + "POST", + EXTERNAL_SESSIONS_ROUTE_PATH, + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + + const remoteAddress = Option.getOrUndefined(request.remoteAddress); + if (remoteAddress === undefined || !isLoopbackRemoteAddress(remoteAddress)) { + return HttpServerResponse.text( + "External session hooks are only accepted from the local machine.", + { status: 403 }, + ); + } + + // Reject an oversized declared length before reading anything. + const contentLengthHeader = request.headers["content-length"]; + if (contentLengthHeader !== undefined) { + const declaredBytes = Number(contentLengthHeader); + if (!Number.isFinite(declaredBytes) || declaredBytes > MAX_EXTERNAL_SESSION_HOOK_BODY_BYTES) { + return HttpServerResponse.text("External session hook payload is too large.", { + status: 413, + }); + } + } + + // Cap the actual bytes read too: Content-Length can be absent or wrong + // (chunked transfer, a lying client), so the declared-length check alone + // is not a real bound. + const collected = yield* collectUint8StreamText({ + stream: request.stream, + maxBytes: MAX_EXTERNAL_SESSION_HOOK_BODY_BYTES, + }).pipe(Effect.orElseSucceed(() => null)); + if (collected === null || collected.truncated) { + return HttpServerResponse.text("External session hook payload is too large.", { + status: 413, + }); + } + + const bodyJson = yield* Effect.try(() => { + if (collected.text.length === 0) { + return null; + } + // Raw parse ahead of `decodeExternalSessionHookPayload` below, which is + // the actual schema validation; this only needs an `unknown` value to + // hand it, and the body is already byte-capped above. + // @effect-diagnostics-next-line preferSchemaOverJson:off + return JSON.parse(collected.text) as unknown; + }).pipe(Effect.orElseSucceed(() => null)); + const payload = + bodyJson === null + ? null + : yield* decodeExternalSessionHookPayload(bodyJson).pipe(Effect.orElseSucceed(() => null)); + if (payload === null) { + return HttpServerResponse.text("Invalid external session hook payload.", { status: 400 }); + } + + // The schema only checks that `timestamp` parses and isn't absurdly old + // (see PlausibleIsoDateTime); the future bound needs real "now". + const nowMs = yield* Clock.currentTimeMillis; + if (Date.parse(payload.timestamp) - nowMs > EXTERNAL_SESSION_HOOK_TIMESTAMP_FUTURE_GRACE_MS) { + return HttpServerResponse.text("External session hook timestamp is too far in the future.", { + status: 400, + }); + } + + const result = yield* recordExternalSessionHookEvent(payload).pipe( + Effect.catch((cause) => + Effect.logWarning("Failed to record an external session hook event", { cause }).pipe( + Effect.as({ recorded: false, reason: undefined } as const), + ), + ), + ); + return HttpServerResponse.jsonUnsafe(result, { status: 200 }); + }), +); diff --git a/apps/server/src/provider/Drivers/AgentRelayDriver.ts b/apps/server/src/provider/Drivers/AgentRelayDriver.ts new file mode 100644 index 000000000000..21dbc242ba86 --- /dev/null +++ b/apps/server/src/provider/Drivers/AgentRelayDriver.ts @@ -0,0 +1,134 @@ +/** + * AgentRelayDriver — `ProviderDriver` for the Agent Relay broker. + * + * Unlike every other built-in driver, `create()` never spawns or resolves a + * local executable — there is nothing to install or update, so maintenance + * capabilities are always manual-only. The adapter (`AgentRelayAdapter.ts`) + * owns the one real piece of lifecycle: the outbound WebSocket connection to + * the broker. See `docs/internals/providers.md` for why. + * + * @module provider/Drivers/AgentRelayDriver + */ +import { AgentRelaySettings, ProviderDriverKind } from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { HttpClient } from "effect/unstable/http"; + +import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { makeAgentRelayTextGeneration } from "../../textGeneration/AgentRelayTextGeneration.ts"; +import { ProviderDriverError } from "../Errors.ts"; +import { makeAgentRelayAdapter } from "../Layers/AgentRelayAdapter.ts"; +import { + buildInitialAgentRelayProviderSnapshot, + checkAgentRelayProviderStatus, +} from "../Layers/AgentRelayProvider.ts"; +import { makeAgentRelayWorkspaceClient } from "../Layers/AgentRelayWorkspaceClientLive.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { + defaultProviderContinuationIdentity, + type ProviderDriver, + type ProviderInstance, +} from "../ProviderDriver.ts"; +import { withInstanceIdentity } from "./instanceIdentity.ts"; +import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; +import { + haveProviderSnapshotSettingsChanged, + makeProviderSnapshotSettingsSource, + type ProviderSnapshotSettings, +} from "../providerUpdateSettings.ts"; + +const decodeAgentRelaySettings = Schema.decodeSync(AgentRelaySettings); + +const DRIVER_KIND = ProviderDriverKind.make("agentrelay"); + +export type AgentRelayDriverEnv = + | BackgroundPolicy.BackgroundPolicy + | Crypto.Crypto + | HttpClient.HttpClient + | ServerSettingsService; + +export const AgentRelayDriver: ProviderDriver = { + driverKind: DRIVER_KIND, + metadata: { + displayName: "Agent Relay", + supportsMultipleInstances: true, + }, + configSchema: AgentRelaySettings, + defaultConfig: (): AgentRelaySettings => decodeAgentRelaySettings({}), + create: ({ instanceId, displayName, accentColor, enabled, config }) => + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsService; + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const stampIdentity = withInstanceIdentity({ + instanceId, + driverKind: DRIVER_KIND, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + const effectiveConfig = { ...config, enabled } satisfies AgentRelaySettings; + + // Workspace mode discovers/spawns agents through a Relaycast workspace + // key; single mode attaches directly and has no use for this client. + const workspaceClient = + effectiveConfig.mode === "workspace" && effectiveConfig.workspaceKey.trim() + ? yield* makeAgentRelayWorkspaceClient(effectiveConfig.workspaceKey, instanceId) + : undefined; + const adapter = yield* makeAgentRelayAdapter(effectiveConfig, { + instanceId, + ...(workspaceClient ? { workspaceClient } : {}), + }); + const textGeneration = yield* makeAgentRelayTextGeneration; + + const checkProvider = checkAgentRelayProviderStatus(effectiveConfig).pipe( + Effect.map(stampIdentity), + ); + + const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); + const snapshot = yield* makeManagedServerProvider< + ProviderSnapshotSettings + >({ + // No local binary — nothing this driver could offer to update. + resolveMaintenance: () => + Effect.succeed( + makeManualOnlyProviderMaintenanceCapabilities({ + provider: DRIVER_KIND, + packageName: null, + }), + ), + getSettings: snapshotSettings.getSettings, + streamSettings: snapshotSettings.streamSettings, + haveSettingsChanged: haveProviderSnapshotSettingsChanged, + initialSnapshot: (settings) => + buildInitialAgentRelayProviderSnapshot(settings.provider).pipe(Effect.map(stampIdentity)), + checkProvider, + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to build Agent Relay snapshot: ${cause.message ?? String(cause)}`, + cause, + }), + ), + ); + + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot, + adapter, + textGeneration, + } satisfies ProviderInstance; + }), +}; diff --git a/apps/server/src/provider/Layers/AgentRelayAdapter.test.ts b/apps/server/src/provider/Layers/AgentRelayAdapter.test.ts new file mode 100644 index 000000000000..c136cbd4c948 --- /dev/null +++ b/apps/server/src/provider/Layers/AgentRelayAdapter.test.ts @@ -0,0 +1,851 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeHttp from "node:http"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; +import { FetchHttpClient } from "effect/unstable/http"; +import { WebSocketServer, type WebSocket as WsSocket } from "ws"; + +import { AgentRelaySettings, ThreadId, type ProviderRuntimeEvent } from "@t3tools/contracts"; + +import type { AgentRelayWorkspaceClientShape } from "../Services/AgentRelayWorkspaceClient.ts"; +import { makeAgentRelayAdapter } from "./AgentRelayAdapter.ts"; + +const decodeAgentRelaySettings = Schema.decodeSync(AgentRelaySettings); + +/** + * Real shape confirmed both from source (`crates/broker/src/protocol.rs`'s + * `BrokerEvent::WorkerStream` and its serde round-trip test) and live + * against a real `agent-relay-broker`: discriminated by `kind` (not + * `type`), payload in `chunk` (not `data`), and carrying the worker's + * `name` since the broker broadcasts every worker on the connection. + */ +const WorkerStreamFrame = Schema.Struct({ + kind: Schema.Literal("worker_stream"), + name: Schema.String, + stream: Schema.String, + chunk: Schema.String, + offset: Schema.optional(Schema.Number), +}); +const encodeWorkerStreamFrame = Schema.encodeSync(Schema.fromJsonString(WorkerStreamFrame)); + +interface RecordedInput { + readonly name: string; + readonly data: string; + readonly apiKeyHeader: string | undefined; +} + +/** + * A real local server standing in for `agent-relay-broker`: a plain HTTP + * server (for `POST /api/input/:name`, recording each call) with the WS + * server for `/ws` attached to the same port — the same single-port shape + * the real broker uses. + */ +interface MockBrokerInputFailure { + /** When true, the next `/api/input/:name` request 500s instead of + * recording the input — lets a test exercise `postInput` failure without + * a second server. Auto-resets after one failed request. */ + failNext: boolean; +} + +function startMockBroker(): Promise<{ + readonly server: WebSocketServer; + readonly httpServer: NodeHttp.Server; + readonly url: string; + readonly inputs: RecordedInput[]; + readonly inputFailure: MockBrokerInputFailure; +}> { + const inputs: RecordedInput[] = []; + const inputFailure: MockBrokerInputFailure = { failNext: false }; + return new Promise((resolve) => { + const httpServer = NodeHttp.createServer((req, res) => { + const match = /^\/api\/input\/([^/]+)$/.exec(req.url ?? ""); + if (!match || req.method !== "POST") { + res.writeHead(404).end(); + return; + } + if (inputFailure.failNext) { + inputFailure.failNext = false; + res.writeHead(500).end(); + return; + } + const chunks: Buffer[] = []; + req.on("data", (chunk: Buffer) => chunks.push(chunk)); + req.on("end", () => { + const body = JSON.parse(Buffer.concat(chunks).toString("utf8")) as { data: string }; + inputs.push({ + name: decodeURIComponent(match[1]!), + data: body.data, + apiKeyHeader: req.headers["x-api-key"] as string | undefined, + }); + res + .writeHead(200, { "Content-Type": "application/json" }) + .end(JSON.stringify({ name: match[1], bytes_written: body.data.length })); + }); + }); + const server = new WebSocketServer({ server: httpServer, path: "/ws" }); + // `ws` does not close its own connections/server when the Node HTTP + // server it was attached to closes, so every call site's + // `httpServer.close()` finalizer would otherwise leave any still-open + // WebSocket (a test failing or interrupted before `stopSession` closes + // the client socket) keeping the event loop alive. Tie its lifetime to + // the HTTP server's here once, instead of every call site remembering + // a second `server.close()`. + httpServer.on("close", () => server.close()); + httpServer.listen(0, "127.0.0.1", () => { + const address = httpServer.address(); + const port = typeof address === "object" && address !== null ? address.port : 0; + resolve({ server, httpServer, url: `http://127.0.0.1:${port}`, inputs, inputFailure }); + }); + }); +} + +/** + * Forks a one-shot Node event wait and yields the current fiber a couple of + * turns before returning, so the forked fiber has actually reached its + * `.once(...)` registration before the caller triggers whatever produces + * the event. Without this, a synchronous trigger can fire before the + * scheduler ever runs the newly forked fiber, and the `.once` listener + * attaches too late to see it. + */ +function forkNodeEventWait(register: (resume: (value: A) => void) => void) { + return Effect.gen(function* () { + const fiber = yield* Effect.forkChild( + Effect.callback((resume) => { + register((value) => resume(Effect.succeed(value))); + }).pipe(Effect.timeout("2 seconds"), TestClock.withLive, Effect.orDie), + ); + yield* Effect.yieldNow; + yield* Effect.yieldNow; + return fiber; + }); +} + +const forkConnectionWait = (server: WebSocketServer) => + forkNodeEventWait((resume) => server.once("connection", resume)); + +/** + * A workspace client whose `spawnAgent` immediately marks the spawned name + * "online" for `listAgents`, so `waitForAgentOnline`'s polling fallback + * resolves on its very first check with no clock manipulation needed — + * `onPresenceChange` is left a no-op here to specifically exercise that + * fallback rather than the (unverifiable, see AgentRelayWorkspaceClientLive.ts) + * presence push path. + */ +const makeFakeWorkspaceClient = (options?: { readonly spawnDelayMs?: number }) => + Effect.gen(function* () { + const spawnCalls = yield* Ref.make>([]); + const online = yield* Ref.make>([]); + const activeSpawns = yield* Ref.make(0); + const maxConcurrentSpawns = yield* Ref.make(0); + const shape: AgentRelayWorkspaceClientShape = { + listAgents: () => + Ref.get(online).pipe( + Effect.map((names) => names.map((name) => ({ name, status: "online" as const }))), + ), + spawnAgent: (input) => + Effect.gen(function* () { + const active = yield* Ref.updateAndGet(activeSpawns, (n) => n + 1); + yield* Ref.update(maxConcurrentSpawns, (max) => Math.max(max, active)); + if (options?.spawnDelayMs) { + // A real (not virtual-clock) delay, so two `startSession` + // calls fired concurrently actually overlap in wall-clock + // time — wide enough to expose the race this simulates if + // `startSession` isn't serialized per thread. + yield* Effect.sleep(Duration.millis(options.spawnDelayMs)).pipe(TestClock.withLive); + } + yield* Ref.update(spawnCalls, (calls) => [...calls, input.name]); + yield* Ref.update(online, (names) => [...names, input.name]); + yield* Ref.update(activeSpawns, (n) => n - 1); + return { name: input.name }; + }), + onPresenceChange: () => () => {}, + }; + return { shape, spawnCalls, maxConcurrentSpawns }; + }); + +/** + * Collects every event the adapter emits from the moment this is called. + * Forked once per test (not re-subscribed per wait) so a wait started after + * the fact still sees events published earlier in the same test. + */ +const makeEventCollector = (streamEvents: Stream.Stream) => + Effect.gen(function* () { + const events = yield* Ref.make>([]); + yield* Stream.runForEach(streamEvents, (event) => + Ref.update(events, (current) => [...current, event]), + ).pipe(Effect.forkScoped); + // Give the forked subscriber a turn to attach to the PubSub before the + // caller triggers the action that publishes the event it wants. + yield* Effect.yieldNow; + yield* Effect.yieldNow; + return events; + }); + +const waitForEvent = ( + events: Ref.Ref>, + eventType: T, +): Effect.Effect> => + waitForMatchingEvent( + events, + (event): event is Extract => event.type === eventType, + ); + +/** + * Like `waitForEvent`, but for when a test needs a *specific* occurrence of + * an event type rather than the first one ever recorded — `events` only + * ever grows, so a second `waitForEvent(events, "session.state.changed")` + * call after an earlier one already matched would just find that same + * first event again, not wait for a new one. + */ +const waitForMatchingEvent = ( + events: Ref.Ref>, + predicate: (event: ProviderRuntimeEvent) => event is A, +): Effect.Effect => + Effect.gen(function* () { + while (true) { + const current = yield* Ref.get(events); + const found = current.find(predicate); + if (found) return found; + yield* Effect.sleep(Duration.millis(10)); + } + }).pipe(Effect.timeout("2 seconds"), TestClock.withLive, Effect.orDie); + +const waitUntil = (predicate: () => boolean): Effect.Effect => + Effect.gen(function* () { + while (!predicate()) { + yield* Effect.sleep(Duration.millis(10)); + } + }).pipe(Effect.timeout("2 seconds"), TestClock.withLive, Effect.orDie); + +const agentRelayAdapterTestLayer = Layer.provideMerge(NodeServices.layer, FetchHttpClient.layer); + +const makeTestAdapter = (brokerUrl: string, agentName = "Worker1", apiKey = "test-key") => + makeAgentRelayAdapter( + decodeAgentRelaySettings({ enabled: true, mode: "single", brokerUrl, agentName, apiKey }), + ); + +it.layer(agentRelayAdapterTestLayer)("AgentRelayAdapterLive", (it) => { + it.effect("streams worker_stream frames as content.delta and posts input over HTTP", () => + Effect.gen(function* () { + const { server, httpServer, url, inputs } = yield* Effect.promise(startMockBroker); + yield* Effect.addFinalizer(() => Effect.sync(() => httpServer.close())); + + const adapter = yield* makeTestAdapter(url, "Worker1"); + const events = yield* makeEventCollector(adapter.streamEvents); + const connectionFiber = yield* forkConnectionWait(server); + + const threadId = ThreadId.make("agentrelay-worker-stream"); + yield* adapter.startSession({ threadId, runtimeMode: "full-access" }); + const brokerSocket = yield* Fiber.join(connectionFiber); + + const ready = yield* waitForEvent(events, "session.state.changed"); + assert.deepEqual(ready.payload, { + state: "ready", + reason: "Connected to the Agent Relay broker.", + }); + + // The exact frame captured live from a real `agent-relay-broker` + // running a real `claude --version` under its PTY. + brokerSocket.send( + '{"chunk":"2.1.263 (Claude Code)\\r\\n\\u001b[?25h","kind":"worker_stream","name":"Worker1","offset":29,"stream":"stdout"}', + ); + const delta = yield* waitForEvent(events, "content.delta"); + // Tagged `"assistant_text"`, not `"command_output"`: Agent Relay has + // no structured split between "model output" and "tool output" — + // it's all one raw terminal stream — and `ProviderRuntimeIngestion` + // only turns `"assistant_text"` deltas into visible transcript + // content, silently dropping every other stream kind. + assert.equal(delta.payload.streamKind, "assistant_text"); + assert.equal(delta.payload.delta, "2.1.263 (Claude Code)\r\n[?25h"); + + yield* adapter.sendTurn({ threadId, input: "hello agent" }); + yield* waitUntil(() => inputs.length === 1); + assert.deepEqual(inputs[0], { + name: "Worker1", + data: "hello agent\n", + apiKeyHeader: "test-key", + }); + + const started = yield* waitForEvent(events, "turn.started"); + assert.isDefined(started.turnId); + + yield* adapter.stopSession(threadId); + yield* waitForEvent(events, "session.exited"); + }), + ); + + it.effect("ignores worker_stream frames for a different worker on the same connection", () => + Effect.gen(function* () { + // The broker broadcasts every worker on the connection over one + // socket — a session must only react to its own agent's frames. + const { server, httpServer, url } = yield* Effect.promise(startMockBroker); + yield* Effect.addFinalizer(() => Effect.sync(() => httpServer.close())); + + const adapter = yield* makeTestAdapter(url, "Worker1"); + const events = yield* makeEventCollector(adapter.streamEvents); + const connectionFiber = yield* forkConnectionWait(server); + + const threadId = ThreadId.make("agentrelay-multiplex-filter"); + yield* adapter.startSession({ threadId, runtimeMode: "full-access" }); + const brokerSocket = yield* Fiber.join(connectionFiber); + yield* waitForEvent(events, "session.state.changed"); + + brokerSocket.send( + encodeWorkerStreamFrame({ + kind: "worker_stream", + name: "SomeOtherWorker", + stream: "stdout", + chunk: "not for us\n", + }), + ); + brokerSocket.send( + encodeWorkerStreamFrame({ + kind: "worker_stream", + name: "Worker1", + stream: "stdout", + chunk: "for us\n", + }), + ); + + const delta = yield* waitForEvent(events, "content.delta"); + assert.equal(delta.payload.delta, "for us\n"); + assert.isUndefined( + (yield* Ref.get(events)).find( + (event) => event.type === "content.delta" && event.payload.delta === "not for us\n", + ), + ); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("completes a turn once the broker never responds at all", () => + Effect.gen(function* () { + const { server, httpServer, url } = yield* Effect.promise(startMockBroker); + yield* Effect.addFinalizer(() => Effect.sync(() => httpServer.close())); + + const adapter = yield* makeTestAdapter(url); + const events = yield* makeEventCollector(adapter.streamEvents); + const connectionFiber = yield* forkConnectionWait(server); + + const threadId = ThreadId.make("agentrelay-idle-complete"); + yield* adapter.startSession({ threadId, runtimeMode: "full-access" }); + yield* Fiber.join(connectionFiber); + yield* waitForEvent(events, "session.state.changed"); + + yield* adapter.sendTurn({ threadId, input: "run the tests" }); + yield* waitForEvent(events, "turn.started"); + // Let the forked watchdog actually reach its first `Queue.take` / + // `Effect.sleep` race before advancing the clock — otherwise this + // adjust can run before the fiber the scheduler hasn't gotten to yet + // registers its sleep, and the sleep starts counting from the + // already-advanced time instead of firing. + yield* Effect.yieldNow; + yield* Effect.yieldNow; + + // No output ever arrives: the watchdog first waits up to + // TURN_FIRST_ACTIVITY_TIMEOUT_MS (30s) for *any* activity before + // falling back to the same idle-complete behavior it applies between + // frames (TURN_IDLE_COMPLETE_MS, 1.5s) once that grace period runs + // out too. + yield* TestClock.adjust(Duration.millis(30_000)); + yield* TestClock.adjust(Duration.millis(1_500)); + + const completed = yield* waitForEvent(events, "turn.completed"); + assert.deepEqual(completed.payload, { state: "completed", stopReason: null }); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect( + "does not complete a turn during startup latency, only after real idle-quiet following output", + () => + Effect.gen(function* () { + const { server, httpServer, url } = yield* Effect.promise(startMockBroker); + yield* Effect.addFinalizer(() => Effect.sync(() => httpServer.close())); + + const adapter = yield* makeTestAdapter(url); + const events = yield* makeEventCollector(adapter.streamEvents); + const connectionFiber = yield* forkConnectionWait(server); + + const threadId = ThreadId.make("agentrelay-slow-start"); + yield* adapter.startSession({ threadId, runtimeMode: "full-access" }); + const brokerSocket = yield* Fiber.join(connectionFiber); + yield* waitForEvent(events, "session.state.changed"); + + yield* adapter.sendTurn({ threadId, input: "run the tests" }); + yield* waitForEvent(events, "turn.started"); + yield* Effect.yieldNow; + yield* Effect.yieldNow; + + // Startup latency longer than TURN_IDLE_COMPLETE_MS (1.5s), but well + // inside TURN_FIRST_ACTIVITY_TIMEOUT_MS (30s): the turn must still + // be active when output finally arrives — this is exactly the + // premature-completion bug being regression-tested. + yield* TestClock.adjust(Duration.millis(5_000)); + assert.isUndefined( + (yield* Ref.get(events)).find((event) => event.type === "turn.completed"), + ); + + brokerSocket.send( + encodeWorkerStreamFrame({ + kind: "worker_stream", + name: "Worker1", + stream: "stdout", + chunk: "still working\n", + }), + ); + // Give the incoming frame's handler a turn to run and nudge the + // watchdog's activity queue before the next clock advance. + yield* waitForEvent(events, "content.delta"); + yield* Effect.yieldNow; + yield* Effect.yieldNow; + + assert.isUndefined( + (yield* Ref.get(events)).find((event) => event.type === "turn.completed"), + ); + + // Now the broker actually goes quiet: idle-complete fires off the + // *real* idle window (1.5s after the last frame), not the startup + // grace period. + yield* TestClock.adjust(Duration.millis(1_500)); + const completed = yield* waitForEvent(events, "turn.completed"); + assert.deepEqual(completed.payload, { state: "completed", stopReason: null }); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("interrupting a turn posts Ctrl-C as input and completes it as cancelled", () => + Effect.gen(function* () { + const { server, httpServer, url, inputs } = yield* Effect.promise(startMockBroker); + yield* Effect.addFinalizer(() => Effect.sync(() => httpServer.close())); + + const adapter = yield* makeTestAdapter(url); + const events = yield* makeEventCollector(adapter.streamEvents); + const connectionFiber = yield* forkConnectionWait(server); + + const threadId = ThreadId.make("agentrelay-interrupt"); + yield* adapter.startSession({ threadId, runtimeMode: "full-access" }); + yield* Fiber.join(connectionFiber); + yield* waitForEvent(events, "session.state.changed"); + + yield* adapter.sendTurn({ threadId, input: "run forever" }); + yield* waitUntil(() => inputs.length === 1); + + yield* adapter.interruptTurn(threadId); + yield* waitUntil(() => inputs.length === 2); + assert.equal(inputs[1]!.data, ""); + + const completed = yield* waitForEvent(events, "turn.completed"); + assert.deepEqual(completed.payload, { state: "cancelled", stopReason: null }); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("reconnects after a remote close with code 1000 instead of stopping the session", () => + Effect.gen(function* () { + const { server, httpServer, url } = yield* Effect.promise(startMockBroker); + yield* Effect.addFinalizer(() => Effect.sync(() => httpServer.close())); + + const adapter = yield* makeTestAdapter(url); + const events = yield* makeEventCollector(adapter.streamEvents); + const firstConnection = yield* forkConnectionWait(server); + + const threadId = ThreadId.make("agentrelay-reconnect-1000"); + yield* adapter.startSession({ threadId, runtimeMode: "full-access" }); + const brokerSocket = yield* Fiber.join(firstConnection); + yield* waitForEvent(events, "session.state.changed"); + + // A *remote* close with code 1000 — normal closure, but not one + // this adapter asked for (`ctx.stopped` is only set by its own + // `stopSession`/`stopSessionInternal`). Broker restarts close this + // way; treating every 1000 as a deliberate local stop (the + // previous behavior) tore the session down and skipped the + // reconnect/backoff loop entirely on exactly the closes it exists + // for. + const secondConnection = yield* forkConnectionWait(server); + brokerSocket.close(1000, "broker restarting"); + + const errorEvent = yield* waitForMatchingEvent( + events, + (event): event is Extract => + event.type === "session.state.changed" && event.payload.state === "error", + ); + assert.equal(errorEvent.payload.state, "error"); + + // RECONNECT_DELAYS_MS[0]. + yield* TestClock.adjust(Duration.millis(1_000)); + // A new connection arriving proves `connect()` ran again instead + // of the session being torn down for good. + yield* Fiber.join(secondConnection); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect( + "aborts the active turn and keeps the session in error when the broker disconnects mid-turn", + () => + Effect.gen(function* () { + const { server, httpServer, url } = yield* Effect.promise(startMockBroker); + yield* Effect.addFinalizer(() => Effect.sync(() => httpServer.close())); + + const adapter = yield* makeTestAdapter(url); + const events = yield* makeEventCollector(adapter.streamEvents); + const connectionFiber = yield* forkConnectionWait(server); + + const threadId = ThreadId.make("agentrelay-disconnect-mid-turn"); + yield* adapter.startSession({ threadId, runtimeMode: "full-access" }); + const brokerSocket = yield* Fiber.join(connectionFiber); + yield* waitForEvent(events, "session.state.changed"); + + yield* adapter.sendTurn({ threadId, input: "run the tests" }); + yield* waitForEvent(events, "turn.started"); + + // The broker drops the connection with no socket left to ever + // deliver this turn's output. The turn must be aborted immediately + // here, not left for the idle watchdog to eventually (and + // incorrectly) mark "completed" against a session with no socket. + // `.terminate()` (not `.close()`) simulates a real abnormal + // disconnect: 1006 is a reserved code the WebSocket protocol + // forbids ever sending explicitly, so `ws` rejects `.close(1006)` + // outright — `.terminate()` drops the TCP connection without a + // close handshake, which is what actually produces a 1006 on the + // other end. + brokerSocket.terminate(); + + const completed = yield* waitForEvent(events, "turn.completed"); + assert.deepEqual(completed.payload, { state: "cancelled", stopReason: null }); + + const [session] = yield* adapter.listSessions(); + assert.equal(session?.status, "error"); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("normalizes a broker URL with a trailing slash before deriving ws/input routes", () => + Effect.gen(function* () { + const { server, httpServer, url, inputs } = yield* Effect.promise(startMockBroker); + yield* Effect.addFinalizer(() => Effect.sync(() => httpServer.close())); + + // A doubled "//ws" or "//api/input/" would never match the + // mock broker's exact-path routing (`WebSocketServer`'s `path: "/ws"` + // and the `/^\/api\/input\/([^/]+)$/` regex respectively) — this + // test fails by timing out (connection) or by `inputs` staying empty + // (POST) if the trailing slash was not stripped. + const adapter = yield* makeTestAdapter(`${url}/`); + const events = yield* makeEventCollector(adapter.streamEvents); + const connectionFiber = yield* forkConnectionWait(server); + + const threadId = ThreadId.make("agentrelay-trailing-slash"); + yield* adapter.startSession({ threadId, runtimeMode: "full-access" }); + yield* Fiber.join(connectionFiber); + yield* waitForEvent(events, "session.state.changed"); + + yield* adapter.sendTurn({ threadId, input: "go" }); + yield* waitUntil(() => inputs.length === 1); + assert.equal(inputs[0]!.data, "go\n"); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("rolls back turn registration and emits turn.aborted when postInput fails", () => + Effect.gen(function* () { + const { server, httpServer, url, inputFailure } = yield* Effect.promise(startMockBroker); + yield* Effect.addFinalizer(() => Effect.sync(() => httpServer.close())); + + const adapter = yield* makeTestAdapter(url); + const events = yield* makeEventCollector(adapter.streamEvents); + const connectionFiber = yield* forkConnectionWait(server); + + const threadId = ThreadId.make("agentrelay-postinput-fails"); + yield* adapter.startSession({ threadId, runtimeMode: "full-access" }); + yield* Fiber.join(connectionFiber); + yield* waitForEvent(events, "session.state.changed"); + + inputFailure.failNext = true; + const failure = yield* adapter + .sendTurn({ threadId, input: "this never reaches the broker" }) + .pipe(Effect.flip); + assert.equal(failure._tag, "ProviderAdapterRequestError"); + + // `turn.started` went out (registered before the POST, so an + // in-flight response would already have somewhere to attach), so a + // failed POST must also publish a terminal event for it rather than + // leaving that turn permanently open. + const aborted = yield* waitForEvent(events, "turn.aborted"); + assert.equal(aborted.turnId, (yield* waitForEvent(events, "turn.started")).turnId); + + // The rollback must leave the session able to accept a normal turn + // afterwards — proving `ctx.activeTurnId`/`ctx.session`/`ctx.turns` + // were actually restored, not left pointing at the failed turn. + inputFailure.failNext = false; + const { turnId } = yield* adapter.sendTurn({ threadId, input: "try again" }); + assert.isDefined(turnId); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("has no session before startSession and none after stopSession", () => + Effect.gen(function* () { + const { httpServer, url } = yield* Effect.promise(startMockBroker); + yield* Effect.addFinalizer(() => Effect.sync(() => httpServer.close())); + + const adapter = yield* makeTestAdapter(url); + const threadId = ThreadId.make("agentrelay-hasSession"); + assert.isFalse(yield* adapter.hasSession(threadId)); + + // `hasSession` flips true as soon as `startSession` records the + // context, independent of the underlying socket finishing its + // handshake — no need to wait on the mock broker here. + yield* adapter.startSession({ threadId, runtimeMode: "full-access" }); + assert.isTrue(yield* adapter.hasSession(threadId)); + + yield* adapter.stopSession(threadId); + assert.isFalse(yield* adapter.hasSession(threadId)); + }), + ); + + it.effect("rejects starting a session with no broker URL configured", () => + Effect.gen(function* () { + const adapter = yield* makeAgentRelayAdapter( + decodeAgentRelaySettings({ + enabled: true, + mode: "single", + brokerUrl: "", + agentName: "Worker1", + apiKey: "", + }), + ); + const threadId = ThreadId.make("agentrelay-missing-url"); + const failure = yield* adapter + .startSession({ threadId, runtimeMode: "full-access" }) + .pipe(Effect.flip); + assert.equal(failure._tag, "ProviderAdapterValidationError"); + }), + ); + + it.effect("rejects starting a session with no agent name configured", () => + Effect.gen(function* () { + const adapter = yield* makeAgentRelayAdapter( + decodeAgentRelaySettings({ + enabled: true, + mode: "single", + brokerUrl: "http://127.0.0.1:1", + agentName: "", + apiKey: "", + }), + ); + const threadId = ThreadId.make("agentrelay-missing-agent-name"); + const failure = yield* adapter + .startSession({ threadId, runtimeMode: "full-access" }) + .pipe(Effect.flip); + assert.equal(failure._tag, "ProviderAdapterValidationError"); + }), + ); + + it.effect("does not persist a resume cursor in Single mode", () => + Effect.gen(function* () { + const { httpServer, url } = yield* Effect.promise(startMockBroker); + yield* Effect.addFinalizer(() => Effect.sync(() => httpServer.close())); + + // `resumeCursor` only means something in Workspace mode (which + // spawned agent to reattach to). Persisting it in Single mode too + // meant switching an instance from Single to Workspace left the old + // single-mode agent's name behind as a stale cursor — the next + // Workspace start would skip spawning and silently attach to that + // unrelated agent. + const adapter = yield* makeTestAdapter(url); + const threadId = ThreadId.make("agentrelay-single-no-resume-cursor"); + const session = yield* adapter.startSession({ threadId, runtimeMode: "full-access" }); + assert.isUndefined(session.resumeCursor); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("caps retained turn history instead of growing it without bound", () => + Effect.gen(function* () { + const { server, httpServer, url } = yield* Effect.promise(startMockBroker); + yield* Effect.addFinalizer(() => Effect.sync(() => httpServer.close())); + + const adapter = yield* makeTestAdapter(url); + const events = yield* makeEventCollector(adapter.streamEvents); + const connectionFiber = yield* forkConnectionWait(server); + + const threadId = ThreadId.make("agentrelay-turn-history-cap"); + yield* adapter.startSession({ threadId, runtimeMode: "full-access" }); + yield* Fiber.join(connectionFiber); + // Wait for "ready", not just the connection being accepted: + // `sendTurn` falls back to polling `awaitSessionConnected` on a + // virtual-clock sleep otherwise, which never fires without a + // `TestClock.adjust` this test has no reason to do. + yield* waitForEvent(events, "session.state.changed"); + + // MAX_RETAINED_TURNS is 50 — send well past it. Each call steers the + // same still-open turn (the mock broker never sends a reply, so the + // watchdog never completes it), matching the common "keep typing" + // case that actually grows this array in practice. + for (let i = 0; i < 55; i++) { + yield* adapter.sendTurn({ threadId, input: `message ${i}` }); + } + + const thread = yield* adapter.readThread(threadId); + assert.equal(thread.turns.length, 50); + + yield* adapter.stopSession(threadId); + }), + ); +}); + +it.layer(agentRelayAdapterTestLayer)("AgentRelayAdapterLive workspace mode", (it) => { + const makeWorkspaceTestAdapter = ( + brokerUrl: string, + workspaceClient: AgentRelayWorkspaceClientShape | undefined, + ) => + makeAgentRelayAdapter( + decodeAgentRelaySettings({ + enabled: true, + mode: "workspace", + brokerUrl, + apiKey: "test-key", + workspaceKey: "rk_live_test", + defaultSpawnCli: "claude", + }), + workspaceClient ? { workspaceClient } : {}, + ); + + it.effect( + "spawns a new agent, waits for it online, and filters frames by its resolved name", + () => + Effect.gen(function* () { + const { server, httpServer, url, inputs } = yield* Effect.promise(startMockBroker); + yield* Effect.addFinalizer(() => Effect.sync(() => httpServer.close())); + + const { shape: workspaceClient, spawnCalls } = yield* makeFakeWorkspaceClient(); + const adapter = yield* makeWorkspaceTestAdapter(url, workspaceClient); + const events = yield* makeEventCollector(adapter.streamEvents); + const connectionFiber = yield* forkConnectionWait(server); + + const threadId = ThreadId.make("agentrelay-workspace-spawn"); + const session = yield* adapter.startSession({ threadId, runtimeMode: "full-access" }); + const brokerSocket = yield* Fiber.join(connectionFiber); + + assert.deepEqual(yield* Ref.get(spawnCalls), ["t3code-agentrelay-workspace-spawn"]); + assert.deepEqual(session.resumeCursor, { agentName: "t3code-agentrelay-workspace-spawn" }); + yield* waitForEvent(events, "session.state.changed"); + + // A frame for some unrelated worker on the same broker connection + // must not reach this thread, only one addressed to the spawned name. + brokerSocket.send( + encodeWorkerStreamFrame({ + kind: "worker_stream", + name: "unrelated-worker", + stream: "stdout", + chunk: "ignore me\n", + }), + ); + brokerSocket.send( + encodeWorkerStreamFrame({ + kind: "worker_stream", + name: "t3code-agentrelay-workspace-spawn", + stream: "stdout", + chunk: "hello\n", + }), + ); + const delta = yield* waitForEvent(events, "content.delta"); + assert.equal(delta.payload.delta, "hello\n"); + + yield* adapter.sendTurn({ threadId, input: "go" }); + yield* waitUntil(() => inputs.length === 1); + assert.equal(inputs[0]!.name, "t3code-agentrelay-workspace-spawn"); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("reconnecting with a persisted resume cursor does not spawn again", () => + Effect.gen(function* () { + const { server, httpServer, url } = yield* Effect.promise(startMockBroker); + yield* Effect.addFinalizer(() => Effect.sync(() => httpServer.close())); + + const { shape: workspaceClient, spawnCalls } = yield* makeFakeWorkspaceClient(); + const adapter = yield* makeWorkspaceTestAdapter(url, workspaceClient); + const connectionFiber = yield* forkConnectionWait(server); + + const threadId = ThreadId.make("agentrelay-workspace-resume"); + const session = yield* adapter.startSession({ + threadId, + runtimeMode: "full-access", + resumeCursor: { agentName: "already-running-agent" }, + }); + yield* Fiber.join(connectionFiber); + + assert.deepEqual(yield* Ref.get(spawnCalls), []); + assert.deepEqual(session.resumeCursor, { agentName: "already-running-agent" }); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("rejects starting a new thread with no workspace client configured", () => + Effect.gen(function* () { + const adapter = yield* makeWorkspaceTestAdapter("http://127.0.0.1:1", undefined); + const threadId = ThreadId.make("agentrelay-workspace-missing-client"); + const failure = yield* adapter + .startSession({ threadId, runtimeMode: "full-access" }) + .pipe(Effect.flip); + assert.equal(failure._tag, "ProviderAdapterValidationError"); + }), + ); + + it.effect("serializes overlapping startSession calls for the same thread", () => + Effect.gen(function* () { + const { httpServer, url } = yield* Effect.promise(startMockBroker); + yield* Effect.addFinalizer(() => Effect.sync(() => httpServer.close())); + + const { + shape: workspaceClient, + spawnCalls, + maxConcurrentSpawns, + } = yield* makeFakeWorkspaceClient({ + spawnDelayMs: 20, + }); + const adapter = yield* makeWorkspaceTestAdapter(url, workspaceClient); + + const threadId = ThreadId.make("agentrelay-workspace-concurrent-start"); + // Two callers racing `startSession` for the same thread (e.g. a + // duplicate client request) must not both observe "no session yet" + // and spawn/attach independently — the loser's socket and spawned + // agent would be orphaned when the winner's `sessions.set` silently + // overwrote its context. + yield* Effect.all( + [ + adapter.startSession({ threadId, runtimeMode: "full-access" }), + adapter.startSession({ threadId, runtimeMode: "full-access" }), + ], + { concurrency: "unbounded" }, + ); + + assert.equal(yield* Ref.get(maxConcurrentSpawns), 1); + assert.equal((yield* Ref.get(spawnCalls)).length, 2); + + yield* adapter.stopSession(threadId); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/AgentRelayAdapter.ts b/apps/server/src/provider/Layers/AgentRelayAdapter.ts new file mode 100644 index 000000000000..62144ea64b67 --- /dev/null +++ b/apps/server/src/provider/Layers/AgentRelayAdapter.ts @@ -0,0 +1,989 @@ +/** + * AgentRelayAdapterLive — Agent Relay broker (terminal/PTY transport). + * + * Unlike every other adapter in this directory, Agent Relay does not spawn + * or own a local subprocess. It opens an outbound WebSocket connection to an + * already-running Agent Relay broker (`agent-relay-broker`, a separate + * process this server does not manage) and attaches to one already-running + * agent the same way Agent Relay's own external terminal clients do: + * receiving `worker_stream` terminal-output frames over `/ws` and sending + * keystrokes via a separate HTTP POST. See `docs/internals/providers.md` + * for why this is v1 scope and what a v2 structured-protocol adapter would + * add. + * + * Wire format: verified directly against Agent Relay's own source + * (`crates/broker/src/protocol.rs`'s `BrokerEvent::WorkerStream` and its + * serde round-trip test, plus `@agent-relay/harness-driver`'s + * `HarnessDriverClient`/`BrokerTransport`), and confirmed live against a + * real `agent-relay-broker` — not a guess: + * + * - Output: every event over `/ws` is a JSON object discriminated by + * `kind` (not `type`). A `worker_stream` event carries `name`, `stream`, + * `chunk` (not `data`), and an optional `offset`. The broker broadcasts + * every worker on it over the same socket, so `parseAgentRelayFrame` + * returns the frame's `name` and `handleIncomingText` filters by + * `ctx.agentName` — a session must not react to another worker's output. + * - Auth: both `/ws` and the HTTP API take the API key as an `X-API-Key` + * header, not `Authorization: Bearer`. + * - Input: `POST {brokerBaseUrl}/api/input/{name}` with JSON body + * `{ data: string }`, not a message sent over `/ws`. Agent Relay also + * has a higher-throughput streaming input WebSocket + * (`/api/input/{name}/stream`, with acks and keepalives) that this + * adapter does not use — the plain POST is simpler and was enough to + * verify correct end-to-end. + * + * Workspace mode: when `agentRelaySettings.mode === "workspace"`, a thread + * with no agent bound to it yet spawns one through `workspaceClient` and + * waits for it to come online (see `waitForAgentOnline`) before + * connecting — everything from `connect()` down is untouched, reused exactly + * as v1 built it. See `docs/internals/providers.md` for the credential + * caveat that comes with this. + * + * @module AgentRelayAdapterLive + */ +import { + type AgentRelaySettings, + EventId, + type ProviderRuntimeEvent, + type ProviderSession, + ProviderDriverKind, + ProviderInstanceId, + type ThreadId, + TurnId, +} from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as Option from "effect/Option"; +import * as PubSub from "effect/PubSub"; +import * as Queue from "effect/Queue"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import * as SynchronizedRef from "effect/SynchronizedRef"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; +import WebSocket from "ws"; + +import { + ProviderAdapterRequestError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, +} from "../Errors.ts"; +import { type AgentRelayAdapterShape } from "../Services/AgentRelayAdapter.ts"; +import type { AgentRelayWorkspaceClientShape } from "../Services/AgentRelayWorkspaceClient.ts"; + +const PROVIDER = ProviderDriverKind.make("agentrelay"); + +// Reconnect backoff after an unexpected close. The last entry repeats for +// every attempt beyond it, so a broker that stays down does not get hammered. +const RECONNECT_DELAYS_MS = [1_000, 2_000, 5_000, 10_000, 30_000]; + +// Turns have no native "done" signal over a raw terminal stream. A turn is +// considered complete once the broker has been quiet for this long after the +// last worker_stream frame. This is a heuristic, not a protocol guarantee — +// see the "protocol traps" note in docs/internals/providers.md. +const TURN_IDLE_COMPLETE_MS = 1_500; + +// The idle-quiet heuristic above only makes sense once the agent has +// actually started responding. Starting that 1.5s countdown immediately +// after `postInput` (the original behavior) settled the turn during +// ordinary startup latency — broker round-trip, agent cold start — before +// any output existed, so a slightly slow agent had its turn marked +// "completed" while still working, and every frame after that arrived with +// no active turn to attach to. This bounds how long the watchdog waits for +// the *first* frame before falling back to the same idle-complete behavior +// as before; it is deliberately far more generous than the between-frames +// quiet window above. +const TURN_FIRST_ACTIVITY_TIMEOUT_MS = 30_000; + +// How long to wait for a freshly-spawned agent to report itself online (via +// `workspaceClient.onPresenceChange`, raced against polling `listAgents`) +// before giving up. Agent Relay's own `add_agent` MCP tool documents spawns +// as fire-and-forget, so this has to be generous — CLI installs and cold +// starts are not instant. +const AGENT_SPAWN_WAIT_TIMEOUT = Duration.seconds(90); +const AGENT_SPAWN_POLL_INTERVAL = Duration.seconds(2); + +// `startSession` returns as soon as the WebSocket connect is *initiated* +// (`connect()` is fire-and-forget — see its own comment) so the session is +// still `"connecting"` when orchestration's first `sendTurn` call lands +// moments later; the handshake has not necessarily finished. Give it a +// short grace window to reach `"ready"` before treating that as a real +// failure, since the alternative is every new Agent Relay thread's first +// message reliably losing this race and erroring out. +const CONNECT_WAIT_TIMEOUT = Duration.seconds(15); +const CONNECT_POLL_INTERVAL = Duration.millis(50); + +// `ctx.turns` retains this many most-recent entries per session — see +// `sendTurn`'s trim comment for why an unbounded history isn't needed here. +const MAX_RETAINED_TURNS = 50; + +/** Durable per-thread continuation state for workspace mode, round-tripped + * through `ProviderSession.resumeCursor` / `ProviderSessionDirectory` the + * same way `CodexResumeCursorSchema` persists a rollout id. Absent (or + * invalid) means "no agent bound to this thread yet — spawn one". + * + * Exported for `AgentRelayThreadDiscoveryReactor`, which scans persisted + * bindings to tell whether an agent already has a thread before + * materializing one for it — the same "which field carries the attach + * signal" question `startSession` answers below. */ +export const AgentRelayResumeCursorSchema = Schema.Struct({ agentName: Schema.String }); +export const isAgentRelayResumeCursor = Schema.is(AgentRelayResumeCursorSchema); + +/** `https://broker.example.com` -> `wss://broker.example.com/ws`. */ +function toWsUrl(brokerBaseUrl: string): string { + return `${brokerBaseUrl.replace(/^http/, "ws")}/ws`; +} + +/** `https://broker.example.com` -> `https://broker.example.com/api/input/`. */ +function toInputUrl(brokerBaseUrl: string, agentName: string): string { + return `${brokerBaseUrl}/api/input/${encodeURIComponent(agentName)}`; +} + +/** Derive a valid, deterministic Relaycast agent name for a thread's spawn, + * so retrying `startSession` on the same thread (before a resume cursor is + * persisted) asks for the same name instead of leaking one per attempt. */ +function spawnAgentNameForThread(threadId: ThreadId): string { + const slug = String(threadId) + .replace(/[^a-zA-Z0-9_-]/g, "-") + .slice(0, 40); + return `t3code-${slug}`; +} + +/** + * Waits for `name` to report online, racing a live presence subscription + * against polling `listAgents` — belt and suspenders, since this module + * could not confirm from static reading alone that Agent Relay's presence + * events reach `workspaceClient.onPresenceChange` for every workspace (see + * `AgentRelayWorkspaceClientLive.ts`). Polling alone is sufficient for + * correctness; the subscription only makes the common case faster. + */ +function waitForAgentOnline( + workspaceClient: AgentRelayWorkspaceClientShape, + name: string, +): Effect.Effect { + const awaitPresenceEvent = Effect.callback((resume) => { + const unsubscribe = workspaceClient.onPresenceChange((eventName, status) => { + if (eventName === name && status === "online") resume(Effect.void); + }); + return Effect.sync(unsubscribe); + }); + + const pollUntilOnline = Effect.gen(function* () { + while (true) { + const agents = yield* workspaceClient.listAgents().pipe(Effect.orElseSucceed(() => [])); + if (agents.some((agent) => agent.name === name && agent.status === "online")) return; + yield* Effect.sleep(AGENT_SPAWN_POLL_INTERVAL); + } + }); + + return Effect.race(awaitPresenceEvent, pollUntilOnline).pipe( + Effect.timeoutOption(AGENT_SPAWN_WAIT_TIMEOUT), + Effect.flatMap((result) => + Option.isSome(result) + ? Effect.void + : new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "startSession", + detail: `Agent Relay did not report '${name}' online within ${Duration.toSeconds(AGENT_SPAWN_WAIT_TIMEOUT)}s of spawning it. The spawn may still be starting up — try sending another message to retry the attach.`, + }), + ), + ); +} + +/** + * Blocks while `ctx.session.status` is still `"connecting"`, so a `sendTurn` + * that lands right after `startSession` waits out the WebSocket handshake + * instead of immediately erroring. Returns as soon as the status moves to + * anything else (`"ready"` from `handleOpen`, or `"error"` from a failed + * connect/close) — `sendTurn`'s existing status check is what turns an + * `"error"` outcome here into the right user-facing message. + */ +function awaitSessionConnected( + ctx: AgentRelaySessionContext, +): Effect.Effect { + const pollUntilSettled = Effect.gen(function* () { + // `ctx.stopped` also exits the wait: a session stopped mid-connect + // (e.g. `stopSession` racing a still-connecting `sendTurn`) should fail + // fast on the next status check instead of polling until the timeout. + while (ctx.session.status === "connecting" && !ctx.stopped) { + yield* Effect.sleep(CONNECT_POLL_INTERVAL); + } + }); + + return pollUntilSettled.pipe(Effect.timeoutOption(CONNECT_WAIT_TIMEOUT), Effect.asVoid); +} + +export interface AgentRelayAdapterLiveOptions { + /** Selections are honored when routed to this instance id. Defaults to + * the legacy built-in instance id (`agentrelay`). */ + readonly instanceId?: ProviderInstanceId; + /** Required (and only used) when `agentRelaySettings.mode === "workspace"`: + * discovers and spawns agents in the configured Relaycast workspace. */ + readonly workspaceClient?: AgentRelayWorkspaceClientShape; +} + +interface AgentRelaySessionContext { + readonly threadId: ThreadId; + session: ProviderSession; + readonly scope: Scope.Closeable; + /** Base HTTP(S) broker URL (`agentRelaySettings.brokerUrl`, trimmed). + * `/ws` and `/api/input/` are both derived from this. */ + readonly brokerBaseUrl: string; + /** The specific worker this session attaches to. Resolved once at + * `startSession` (single mode: `agentRelaySettings.agentName`; workspace + * mode: the resumed or freshly-spawned agent's name) and fixed for the + * life of the session — reconnects keep attaching to the same agent. + * The broker's `/ws` broadcasts every worker on it, so every incoming + * frame is filtered against this before the session reacts to it. */ + readonly agentName: string; + socket: WebSocket | undefined; + reconnectAttempt: number; + activeTurnId: TurnId | undefined; + readonly activitySignals: Queue.Queue; + turnWatchdogFiber: Fiber.Fiber | undefined; + readonly turns: Array<{ readonly id: TurnId; readonly items: Array }>; + stopped: boolean; +} + +/** + * Parse one incoming `/ws` frame. Returns `undefined` for anything that is + * not a `worker_stream` event for some worker (non-JSON, a different + * `kind`, or a missing `name`/`chunk`) so an unrecognized or irrelevant + * broker message is ignored instead of tearing down the session. Matching + * `name` against the session's target agent is the caller's job + * (`handleIncomingText`) — the broker broadcasts every worker on it over + * this one socket. + */ +function parseAgentRelayFrame( + raw: string, +): { readonly name: string; readonly text: string } | undefined { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return undefined; + } + if (typeof parsed !== "object" || parsed === null) return undefined; + const record = parsed as Record; + if (record.kind !== "worker_stream") return undefined; + if (typeof record.name !== "string" || typeof record.chunk !== "string") return undefined; + return { name: record.name, text: record.chunk }; +} + +function describeError(cause: unknown): string { + return cause instanceof Error ? cause.message : String(cause); +} + +export function makeAgentRelayAdapter( + agentRelaySettings: AgentRelaySettings, + options?: AgentRelayAdapterLiveOptions, +) { + return Effect.gen(function* () { + const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("agentrelay"); + const crypto = yield* Crypto.Crypto; + const context = yield* Effect.context(); + const fork = Effect.runForkWith(context); + const httpClient = yield* HttpClient.HttpClient; + + const sessions = new Map(); + const threadLocksRef = yield* SynchronizedRef.make(new Map()); + const runtimeEventPubSub = yield* PubSub.unbounded(); + + const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + const randomUUIDv4 = crypto.randomUUIDv4.pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "crypto/randomUUIDv4", + detail: "Failed to generate an Agent Relay runtime identifier.", + cause, + }), + ), + ); + const nextEventId = randomUUIDv4.pipe(Effect.map((id) => EventId.make(id))); + const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso }); + + const offerRuntimeEvent = (event: ProviderRuntimeEvent) => + PubSub.publish(runtimeEventPubSub, event).pipe(Effect.asVoid); + + /** Run a background (socket-callback-triggered) effect, logging instead + * of losing failures — there is no caller left to observe them. */ + const dispatch = (effect: Effect.Effect): void => { + fork( + effect.pipe( + Effect.catchCause((cause) => + Effect.logError("Agent Relay adapter background task failed.", { cause }), + ), + ), + ); + }; + + // Serializes `startSession` per thread — see its call site for why: + // spawning/waiting-online and installing the session context both + // happen inside the lock, matching `CursorAdapter`'s pattern. + const getThreadSemaphore = (threadId: string) => + SynchronizedRef.modifyEffect(threadLocksRef, (current) => { + const existing = Option.fromNullishOr(current.get(threadId)); + return Option.match(existing, { + onNone: () => + Semaphore.make(1).pipe( + Effect.map((semaphore) => { + const next = new Map(current); + next.set(threadId, semaphore); + return [semaphore, next] as const; + }), + ), + onSome: (semaphore) => Effect.succeed([semaphore, current] as const), + }); + }); + + const withThreadLock = (threadId: string, effect: Effect.Effect) => + Effect.flatMap(getThreadSemaphore(threadId), (semaphore) => semaphore.withPermit(effect)); + + const requireSession = ( + threadId: ThreadId, + ): Effect.Effect => { + const ctx = sessions.get(threadId); + if (!ctx || ctx.stopped) { + return Effect.fail( + new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId }), + ); + } + return Effect.succeed(ctx); + }; + + /** `POST /api/input/{name}` — see the module doc for why this is a + * separate HTTP call rather than a message over the `/ws` socket. */ + const postInput = ( + ctx: AgentRelaySessionContext, + data: string, + ): Effect.Effect => + Effect.gen(function* () { + const apiKey = agentRelaySettings.apiKey.trim(); + let request = HttpClientRequest.post(toInputUrl(ctx.brokerBaseUrl, ctx.agentName)).pipe( + HttpClientRequest.bodyJsonUnsafe({ data }), + ); + if (apiKey) { + request = request.pipe(HttpClientRequest.setHeader("X-API-Key", apiKey)); + } + yield* httpClient.execute(request).pipe(Effect.flatMap(HttpClientResponse.filterStatusOk)); + }).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "sendInput", + detail: `Failed to send input to the Agent Relay broker: ${describeError(cause)}`, + cause, + }), + ), + ); + + const completeActiveTurn = ( + ctx: AgentRelaySessionContext, + turnId: TurnId, + state: "completed" | "cancelled", + ) => + Effect.gen(function* () { + if (ctx.activeTurnId !== turnId) return; + ctx.activeTurnId = undefined; + // Does not interrupt `ctx.turnWatchdogFiber` itself: the watchdog's + // own timeout branch calls this and then returns, so interrupting + // here would be the fiber interrupting itself mid-step. A caller on + // a different fiber (`interruptTurn`) is responsible for stopping + // the watchdog before it calls this. + ctx.turnWatchdogFiber = undefined; + const updatedAt = yield* nowIso; + const { activeTurnId: _activeTurnId, ...rest } = ctx.session; + // Preserve "error" rather than unconditionally restoring "ready": + // a disconnect during this turn (`handleClose`) already set status + // to "error" and cleared `ctx.socket`. Without this check, this + // watchdog-driven completion would resurrect the session to + // "ready" with no socket to serve it — `sendTurn` would then + // accept a next message it can never actually deliver. + const status = ctx.session.status === "error" ? "error" : "ready"; + ctx.session = { ...rest, status, updatedAt }; + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: ctx.threadId, + turnId, + payload: { state, stopReason: null }, + }); + }); + + const startTurnWatchdog = ( + ctx: AgentRelaySessionContext, + turnId: TurnId, + ): Effect.Effect => + Effect.gen(function* () { + // Wait for the first sign of activity before starting the + // idle-quiet countdown — see `TURN_FIRST_ACTIVITY_TIMEOUT_MS`'s + // comment. If nothing ever arrives within that bound, fall through + // to the same idle-complete behavior the loop below applies + // between frames, rather than hanging forever. + yield* Effect.raceFirst( + Queue.take(ctx.activitySignals), + Effect.sleep(Duration.millis(TURN_FIRST_ACTIVITY_TIMEOUT_MS)), + ); + while (ctx.activeTurnId === turnId) { + const woke = yield* Effect.raceFirst( + Effect.sleep(Duration.millis(TURN_IDLE_COMPLETE_MS)).pipe( + Effect.as("timeout" as const), + ), + Queue.take(ctx.activitySignals).pipe(Effect.as("activity" as const)), + ); + if (woke === "timeout") { + yield* completeActiveTurn(ctx, turnId, "completed"); + return; + } + } + }).pipe(Effect.catch(() => Effect.void)); + + const scheduleReconnect = (ctx: AgentRelaySessionContext) => + Effect.gen(function* () { + const attempt = ctx.reconnectAttempt; + ctx.reconnectAttempt = attempt + 1; + const delayMs = RECONNECT_DELAYS_MS[Math.min(attempt, RECONNECT_DELAYS_MS.length - 1)]!; + yield* Effect.sleep(Duration.millis(delayMs)); + if (ctx.stopped) return; + connect(ctx); + }).pipe(Effect.forkIn(ctx.scope)); + + const handleOpen = (ctx: AgentRelaySessionContext) => + Effect.gen(function* () { + const liveCtx = sessions.get(ctx.threadId); + if (liveCtx !== ctx || ctx.stopped) return; + ctx.reconnectAttempt = 0; + const updatedAt = yield* nowIso; + const { lastError: _lastError, ...clearedSession } = ctx.session; + ctx.session = { ...clearedSession, status: "ready", updatedAt }; + yield* offerRuntimeEvent({ + type: "session.state.changed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: ctx.threadId, + payload: { state: "ready", reason: "Connected to the Agent Relay broker." }, + }); + }); + + const handleIncomingText = (ctx: AgentRelaySessionContext, raw: string) => + Effect.gen(function* () { + const liveCtx = sessions.get(ctx.threadId); + if (liveCtx !== ctx || ctx.stopped) return; + const frame = parseAgentRelayFrame(raw); + // Not a worker_stream frame, or another worker's output — the + // broker broadcasts every worker on this connection. + if (!frame || frame.name !== ctx.agentName) return; + yield* Queue.offer(ctx.activitySignals, undefined); + yield* offerRuntimeEvent({ + type: "content.delta", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: ctx.threadId, + ...(ctx.activeTurnId ? { turnId: ctx.activeTurnId } : {}), + // `"command_output"` (what this reads as, structurally) is for a + // structured adapter to stream a tool call's output alongside its + // own separate `"assistant_text"` — `ProviderRuntimeIngestion` + // only turns `"assistant_text"` deltas into visible message + // content and silently drops every other stream kind. Agent + // Relay has no such split: the raw terminal stream *is* the + // entire response, so it has to be tagged `"assistant_text"` to + // ever reach the transcript at all. Confirmed live: broker + // frames arrived and the session reached "ready" with this + // tagged as `"command_output"`, but nothing rendered. + payload: { streamKind: "assistant_text", delta: frame.text }, + }); + }); + + const handleClose = (ctx: AgentRelaySessionContext, code: number, reason: string) => + Effect.gen(function* () { + const liveCtx = sessions.get(ctx.threadId); + // `ctx.stopped` is the authoritative "we asked for this" signal: + // `stopSessionInternal` sets it synchronously before ever calling + // `socket.close()`, so by the time this handler's "close" event + // fires for our own intentional close, this guard has already + // returned. Reaching past it therefore means the *other* side + // closed the socket — including a clean code 1000, which `ws` + // servers send for perfectly ordinary reasons (a broker restart, + // for instance). Treating every 1000 as deliberate (the previous + // behavior) tore the session down and permanently skipped the + // reconnect/backoff loop below on exactly the closes it exists + // for. Every non-local close now goes through the same + // error-then-reconnect path regardless of code. + if (liveCtx !== ctx || ctx.stopped) return; + ctx.socket = undefined; + const detail = reason.trim() || `Broker connection closed (code ${code}).`; + const updatedAt = yield* nowIso; + ctx.session = { ...ctx.session, status: "error", updatedAt, lastError: detail }; + yield* offerRuntimeEvent({ + type: "session.state.changed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: ctx.threadId, + payload: { state: "error", reason: detail }, + }); + // Abort any in-flight turn now instead of leaving it to the idle + // watchdog: with no socket, nothing will ever deliver its output, + // and (per `completeActiveTurn`'s status guard above) letting the + // watchdog's own timeout fire later would just complete the turn + // against a session already marked "error". + const turnId = ctx.activeTurnId; + if (turnId !== undefined) { + const watchdog = ctx.turnWatchdogFiber; + ctx.turnWatchdogFiber = undefined; + if (watchdog) { + yield* Fiber.interrupt(watchdog); + } + yield* completeActiveTurn(ctx, turnId, "cancelled"); + } + yield* scheduleReconnect(ctx); + }); + + const handleSocketError = (ctx: AgentRelaySessionContext, detail: string) => + Effect.logWarning("Agent Relay broker socket reported an error.", { + threadId: ctx.threadId, + detail, + }); + + const handleConnectFailure = (ctx: AgentRelaySessionContext, detail: string) => + Effect.gen(function* () { + const liveCtx = sessions.get(ctx.threadId); + if (liveCtx !== ctx || ctx.stopped) return; + const updatedAt = yield* nowIso; + ctx.session = { ...ctx.session, status: "error", updatedAt, lastError: detail }; + yield* offerRuntimeEvent({ + type: "session.state.changed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: ctx.threadId, + payload: { + state: "error", + reason: `Could not connect to the Agent Relay broker: ${detail}`, + }, + }); + }); + + // Plain (non-Effect) glue: opens the socket and bridges its callback + // API into the Effect world via `dispatch`, the same shape `NodePtyAdapter` + // uses for `onData`/`onExit`. Not itself an Effect because `new WebSocket` + // and `.on(...)` registration are synchronous and side-effecting. + const connect = (ctx: AgentRelaySessionContext): void => { + if (ctx.stopped) return; + const apiKey = agentRelaySettings.apiKey.trim(); + const wsUrl = toWsUrl(ctx.brokerBaseUrl); + let socket: WebSocket; + try { + socket = apiKey + ? new WebSocket(wsUrl, { headers: { "X-API-Key": apiKey } }) + : new WebSocket(wsUrl); + } catch (cause) { + dispatch(handleConnectFailure(ctx, describeError(cause))); + return; + } + ctx.socket = socket; + socket.on("open", () => dispatch(handleOpen(ctx))); + socket.on("message", (data) => dispatch(handleIncomingText(ctx, data.toString("utf8")))); + socket.on("close", (code, reason) => + dispatch(handleClose(ctx, code, reason.toString("utf8"))), + ); + // `close` always follows `error` for a `ws` client socket, so the + // reconnect decision lives entirely in `handleClose`; this only logs. + socket.on("error", (error) => dispatch(handleSocketError(ctx, describeError(error)))); + }; + + const stopSessionInternal = (ctx: AgentRelaySessionContext) => + Effect.gen(function* () { + if (ctx.stopped) return; + ctx.stopped = true; + const socket = ctx.socket; + ctx.socket = undefined; + if ( + socket && + (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING) + ) { + // Best-effort — the socket is being discarded either way. + yield* Effect.try(() => socket.close(1000, "session stopped")).pipe(Effect.ignore); + } + yield* Scope.close(ctx.scope, Exit.void).pipe(Effect.ignore); + sessions.delete(ctx.threadId); + yield* offerRuntimeEvent({ + type: "session.exited", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: ctx.threadId, + payload: { exitKind: "graceful" }, + }); + }); + + const startSession: AgentRelayAdapterShape["startSession"] = (input) => + // Serialized per thread: without this, two overlapping `startSession` + // calls for the same thread (e.g. a duplicate client request) could + // both observe no existing context, both spawn/wait in Workspace + // mode, and race to install their own `ctx` into `sessions` — the + // loser's socket and workspace agent are then orphaned instead of + // torn down. Matches `CursorAdapter`'s `withThreadLock`. + withThreadLock( + input.threadId, + Effect.gen(function* () { + if (input.provider !== undefined && input.provider !== PROVIDER) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`, + }); + } + if (!agentRelaySettings.brokerUrl.trim()) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: "Agent Relay broker URL is not configured for this instance.", + }); + } + + // Resolve which agent this thread attaches to. Single mode always + // targets the one configured `agentName`. Workspace mode: a resume + // cursor from a prior `startSession` on this thread means an agent + // is already bound — reconnect to that same one; no cursor means + // this is the thread's first session, so spawn a fresh agent and + // wait for it to come online before attaching, so a brand-new + // Agent Relay thread never requires a pre-existing target the way + // single mode does. + let targetAgentName: string; + if (agentRelaySettings.mode === "workspace") { + if (isAgentRelayResumeCursor(input.resumeCursor)) { + targetAgentName = input.resumeCursor.agentName; + } else { + const workspaceClient = options?.workspaceClient; + if (!workspaceClient) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: + "Agent Relay is in Workspace mode but no workspace key is configured for this instance.", + }); + } + const requestedName = spawnAgentNameForThread(input.threadId); + const spawned = yield* workspaceClient + .spawnAgent({ + name: requestedName, + cli: agentRelaySettings.defaultSpawnCli, + ...(input.title ? { task: input.title } : {}), + }) + .pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "startSession", + detail: `Failed to spawn an Agent Relay worker '${requestedName}': ${cause.detail}`, + cause, + }), + ), + ); + yield* waitForAgentOnline(workspaceClient, spawned.name); + targetAgentName = spawned.name; + } + } else { + const configuredName = agentRelaySettings.agentName.trim(); + if (!configuredName) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: + "Agent Relay Single agent mode requires an agent name: the broker's WebSocket stream carries every worker on it, and T3 Code needs a name to tell them apart.", + }); + } + targetAgentName = configuredName; + } + // Strip trailing slashes: `toWsUrl`/`toInputUrl` each append their + // own leading `/`, so a URL saved with one (e.g. + // "https://broker.example.com/") would otherwise derive "//ws" and + // "//api/input/" — the broker's exact-path routing rejects + // both. + const brokerBaseUrl = agentRelaySettings.brokerUrl.trim().replace(/\/+$/, ""); + + const existing = sessions.get(input.threadId); + if (existing) { + yield* stopSessionInternal(existing); + } + + const scope = yield* Scope.make(); + const activitySignals = yield* Queue.sliding(1); + const now = yield* nowIso; + const session: ProviderSession = { + provider: PROVIDER, + providerInstanceId: boundInstanceId, + status: "connecting", + runtimeMode: input.runtimeMode, + ...(input.cwd ? { cwd: input.cwd } : {}), + threadId: input.threadId, + // Only meaningful in Workspace mode, where it's how a later + // `startSession` on this thread knows which spawned agent to + // reattach to instead of spawning another. Persisting it in + // Single mode too meant switching an instance from Single to + // Workspace left the old single-mode agent's name behind as a + // stale resume cursor — the next Workspace start would skip + // spawning and silently attach to that unrelated agent. + ...(agentRelaySettings.mode === "workspace" + ? { resumeCursor: { agentName: targetAgentName } } + : {}), + createdAt: now, + updatedAt: now, + }; + const ctx: AgentRelaySessionContext = { + threadId: input.threadId, + session, + scope, + brokerBaseUrl, + agentName: targetAgentName, + socket: undefined, + reconnectAttempt: 0, + activeTurnId: undefined, + activitySignals, + turnWatchdogFiber: undefined, + turns: [], + stopped: false, + }; + sessions.set(input.threadId, ctx); + + yield* offerRuntimeEvent({ + type: "session.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: {}, + }); + yield* offerRuntimeEvent({ + type: "thread.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: {}, + }); + + connect(ctx); + return session; + }), + ); + + const sendTurn: AgentRelayAdapterShape["sendTurn"] = (input) => + Effect.gen(function* () { + const ctx = yield* requireSession(input.threadId); + if (ctx.session.status === "connecting") { + yield* awaitSessionConnected(ctx); + } + if (ctx.session.status !== "ready" && ctx.session.status !== "running") { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "sendInput", + detail: "Agent Relay session is not connected to the broker yet.", + }); + } + const text = input.input?.trim(); + if (!text) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: + "Turn requires non-empty text. Agent Relay's terminal transport cannot carry attachments.", + }); + } + // Register the turn *before* posting input, not after: `postInput` + // is an HTTP round-trip, and the broker can start streaming + // `worker_stream` output back over the already-open `/ws` socket + // before that POST even resolves. `handleIncomingText` reads + // `ctx.activeTurnId` to stamp outgoing `content.delta` events — + // registering it only after `postInput` succeeded left a real + // window where the first frames of a response arrived with no + // active turn, and before `turn.started` had even been published. + const isNewTurn = ctx.activeTurnId === undefined; + const turnId = ctx.activeTurnId ?? TurnId.make(yield* randomUUIDv4); + const previousActiveTurnId = ctx.activeTurnId; + const previousSession = ctx.session; + const previousTurnsLength = ctx.turns.length; + + ctx.activeTurnId = turnId; + ctx.turns.push({ id: turnId, items: [{ input: text }] }); + const updatedAt = yield* nowIso; + ctx.session = { ...ctx.session, status: "running", activeTurnId: turnId, updatedAt }; + + if (isNewTurn) { + yield* offerRuntimeEvent({ + type: "turn.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: {}, + }); + ctx.turnWatchdogFiber = yield* startTurnWatchdog(ctx, turnId).pipe( + Effect.forkIn(ctx.scope), + ); + } else { + // Steering an in-flight turn: nudge the watchdog so a user still + // typing does not race the idle-completion timer. + yield* Queue.offer(ctx.activitySignals, undefined); + } + + const posted = yield* postInput(ctx, `${text}\n`).pipe(Effect.result); + if (Result.isFailure(posted)) { + // The broker never got this input — undo the registration above + // so the session doesn't sit on a permanently "running" turn + // nothing will ever complete. `turn.started` already went out to + // any subscriber, so tell them it's over too. + if (isNewTurn) { + const watchdog = ctx.turnWatchdogFiber; + ctx.turnWatchdogFiber = undefined; + if (watchdog) { + yield* Fiber.interrupt(watchdog); + } + yield* offerRuntimeEvent({ + type: "turn.aborted", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: { reason: posted.failure.detail }, + }); + } + ctx.activeTurnId = previousActiveTurnId; + ctx.session = previousSession; + ctx.turns.length = previousTurnsLength; + return yield* posted.failure; + } + + // `rollbackThread` is a no-op here (`supportsConversationRollback` + // is false, so orchestration never actually calls it — see its own + // comment) and there's no other reader that needs the full + // history, only `readThread`'s most-recent view. Without a cap, + // `ctx.turns` grows one entry per message for the life of a + // session with nothing to ever trim it. + if (ctx.turns.length > MAX_RETAINED_TURNS) { + ctx.turns.splice(0, ctx.turns.length - MAX_RETAINED_TURNS); + } + + return { threadId: input.threadId, turnId }; + }); + + const interruptTurn: AgentRelayAdapterShape["interruptTurn"] = (threadId) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + const turnId = ctx.activeTurnId; + if (turnId === undefined) return; + // Stop the watchdog from this (different) fiber before completing + // the turn -- `completeActiveTurn` itself never self-interrupts. + const watchdog = ctx.turnWatchdogFiber; + ctx.turnWatchdogFiber = undefined; + if (watchdog) { + yield* Fiber.interrupt(watchdog); + } + // Ctrl-C: the terminal-native interrupt signal, matching how a human + // attached to the same broker session would cancel a running command. + // Best-effort, same as the original WS-send version was — a + // failed cancel should not fail the interrupt itself. + yield* postInput(ctx, "\u0003").pipe(Effect.ignore); + yield* completeActiveTurn(ctx, turnId, "cancelled"); + }); + + const respondToRequest: AgentRelayAdapterShape["respondToRequest"] = (threadId, requestId) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "respondToRequest", + detail: `Agent Relay's terminal transport has no pending approval requests (thread ${threadId}, request ${requestId}).`, + }); + + const respondToUserInput: AgentRelayAdapterShape["respondToUserInput"] = ( + threadId, + requestId, + ) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "respondToUserInput", + detail: `Agent Relay's terminal transport has no pending user-input requests (thread ${threadId}, request ${requestId}).`, + }); + + const readThread: AgentRelayAdapterShape["readThread"] = (threadId) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + return { threadId, turns: ctx.turns }; + }); + + const rollbackThread: AgentRelayAdapterShape["rollbackThread"] = (threadId, numTurns) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + if (!Number.isInteger(numTurns) || numTurns < 1) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "rollbackThread", + issue: "numTurns must be an integer >= 1.", + }); + } + // `capabilities.supportsConversationRollback` is false: a raw + // terminal has no native conversation state to rewind, only a live + // process. Orchestration checks the capability before calling this + // (see docs/internals/overview.md#turn-completion-and-checkpoints), + // so in practice this never runs — it returns the thread unchanged + // rather than pretending turns were dropped. + return { threadId, turns: ctx.turns }; + }); + + const stopSession: AgentRelayAdapterShape["stopSession"] = (threadId) => + Effect.gen(function* () { + const ctx = sessions.get(threadId); + if (!ctx) return; + yield* stopSessionInternal(ctx); + }); + + const listSessions: AgentRelayAdapterShape["listSessions"] = () => + Effect.sync(() => Array.from(sessions.values(), (c) => ({ ...c.session }))); + + const hasSession: AgentRelayAdapterShape["hasSession"] = (threadId) => + Effect.sync(() => { + const ctx = sessions.get(threadId); + return ctx !== undefined && !ctx.stopped; + }); + + const stopAll: AgentRelayAdapterShape["stopAll"] = () => + Effect.forEach(sessions.values(), stopSessionInternal, { discard: true }); + + yield* Effect.addFinalizer(() => + Effect.forEach(sessions.values(), stopSessionInternal, { discard: true }).pipe( + Effect.catch((cause) => + Effect.logError("Failed to emit Agent Relay session shutdown event.", { cause }), + ), + Effect.tap(() => PubSub.shutdown(runtimeEventPubSub)), + ), + ); + + const streamEvents = Stream.fromPubSub(runtimeEventPubSub); + + return { + provider: PROVIDER, + capabilities: { sessionModelSwitch: "unsupported", supportsConversationRollback: false }, + startSession, + sendTurn, + interruptTurn, + respondToRequest, + respondToUserInput, + stopSession, + listSessions, + hasSession, + readThread, + rollbackThread, + stopAll, + streamEvents, + ...(options?.workspaceClient + ? { listWorkspaceAgents: options.workspaceClient.listAgents } + : {}), + } satisfies AgentRelayAdapterShape; + }); +} diff --git a/apps/server/src/provider/Layers/AgentRelayProvider.test.ts b/apps/server/src/provider/Layers/AgentRelayProvider.test.ts new file mode 100644 index 000000000000..2be07205c93a --- /dev/null +++ b/apps/server/src/provider/Layers/AgentRelayProvider.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import { AgentRelaySettings } from "@t3tools/contracts"; + +import { checkAgentRelayProviderStatus } from "./AgentRelayProvider.ts"; + +const decodeAgentRelaySettings = Schema.decodeSync(AgentRelaySettings); + +describe("checkAgentRelayProviderStatus", () => { + it("reports the base HTTP(S) URL, not a WebSocket URL, when none is configured", async () => { + const snapshot = await Effect.runPromise( + checkAgentRelayProviderStatus( + decodeAgentRelaySettings({ enabled: true, mode: "single", brokerUrl: "" }), + ), + ); + expect(snapshot.status).toBe("error"); + expect(snapshot.message).toMatch(/base HTTP\(S\) URL/i); + expect(snapshot.message).not.toMatch(/websocket url/i); + }); + + it("reports an error, not ready, when Workspace mode has no workspace key", async () => { + const snapshot = await Effect.runPromise( + checkAgentRelayProviderStatus( + decodeAgentRelaySettings({ + enabled: true, + mode: "workspace", + brokerUrl: "http://127.0.0.1:18797", + apiKey: "test-key", + workspaceKey: "", + }), + ), + ); + // A Workspace-mode instance with a broker URL but no workspace key + // still reported "ready" before this fix — every new session on it + // would then fail in `AgentRelayAdapter.startSession` (no + // `workspaceClient` gets built without a workspace key), only + // surfacing the misconfiguration on the first real thread instead of + // in Settings. + expect(snapshot.status).toBe("error"); + expect(snapshot.message).toMatch(/workspace key/i); + }); + + it("reports ready for a fully-configured Workspace instance", async () => { + const snapshot = await Effect.runPromise( + checkAgentRelayProviderStatus( + decodeAgentRelaySettings({ + enabled: true, + mode: "workspace", + brokerUrl: "http://127.0.0.1:18797", + apiKey: "test-key", + workspaceKey: "rk_live_test", + }), + ), + ); + expect(snapshot.status).toBe("ready"); + }); + + it("reports ready for a fully-configured Single agent instance, ignoring workspaceKey", async () => { + const snapshot = await Effect.runPromise( + checkAgentRelayProviderStatus( + decodeAgentRelaySettings({ + enabled: true, + mode: "single", + brokerUrl: "http://127.0.0.1:18797", + agentName: "Worker1", + apiKey: "test-key", + workspaceKey: "", + }), + ), + ); + expect(snapshot.status).toBe("ready"); + }); + + it("warns (not error) about a plaintext API key to a non-loopback broker", async () => { + const snapshot = await Effect.runPromise( + checkAgentRelayProviderStatus( + decodeAgentRelaySettings({ + enabled: true, + mode: "single", + brokerUrl: "http://broker.example.com", + agentName: "Worker1", + apiKey: "test-key", + }), + ), + ); + expect(snapshot.status).toBe("warning"); + expect(snapshot.message).toMatch(/cleartext/i); + }); + + it("does not warn about a plaintext API key to a loopback broker", async () => { + const snapshot = await Effect.runPromise( + checkAgentRelayProviderStatus( + decodeAgentRelaySettings({ + enabled: true, + mode: "single", + brokerUrl: "http://127.0.0.1:18797", + agentName: "Worker1", + apiKey: "test-key", + }), + ), + ); + expect(snapshot.status).toBe("ready"); + }); + + it("does not warn about a non-loopback broker over https://", async () => { + const snapshot = await Effect.runPromise( + checkAgentRelayProviderStatus( + decodeAgentRelaySettings({ + enabled: true, + mode: "single", + brokerUrl: "https://broker.example.com", + agentName: "Worker1", + apiKey: "test-key", + }), + ), + ); + expect(snapshot.status).toBe("ready"); + }); +}); diff --git a/apps/server/src/provider/Layers/AgentRelayProvider.ts b/apps/server/src/provider/Layers/AgentRelayProvider.ts new file mode 100644 index 000000000000..b183e135da4b --- /dev/null +++ b/apps/server/src/provider/Layers/AgentRelayProvider.ts @@ -0,0 +1,199 @@ +/** + * AgentRelayProvider — status/snapshot helpers for the Agent Relay driver. + * + * Agent Relay has no local binary and no login flow to probe (see + * `docs/internals/providers.md`), so unlike the CLI-backed providers in this + * directory, the health check here never opens a network connection. It only + * looks at whether a broker URL and API key are configured. Actually + * connecting happens per-thread in `AgentRelayAdapter.startSession`, which + * keeps a background health probe from silently attaching to (and stealing + * input from) an already-running agent — the same "setup must not happen as + * a health-check side effect" rule Grok and Antigravity follow. + * + * @module AgentRelayProvider + */ +import { type AgentRelaySettings, type ServerProviderModel } from "@t3tools/contracts"; +import { createModelCapabilities } from "@t3tools/shared/model"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; + +import { + buildServerProvider, + providerModelsFromSettings, + type ServerProviderDraft, +} from "../providerSnapshot.ts"; + +const AGENT_RELAY_PRESENTATION = { + displayName: "Agent Relay", + badgeLabel: "Early Access", + showInteractionModeToggle: false, +} as const; + +const EMPTY_CAPABILITIES = createModelCapabilities({ optionDescriptors: [] }); + +const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]); + +/** True when `brokerUrl` would send the configured API key in cleartext to + * a *non-loopback* host — `AgentRelayAdapter.connect`/`postInput` send it + * over plain `http://`/`ws://` as-is with no transport-level guard, since a + * local, self-hosted broker (the primary documented setup — see + * `docs/user/providers-agentrelay.md`) has no TLS to speak of. Requiring + * `https://` unconditionally would break that primary case; this only + * flags the case that actually matters, a plaintext credential leaving the + * machine. */ +function isCleartextRemoteBrokerUrl(brokerUrl: string): boolean { + if (!/^http:\/\//i.test(brokerUrl)) return false; + let hostname: string; + try { + hostname = new URL(brokerUrl).hostname.toLowerCase(); + } catch { + return false; + } + return !LOOPBACK_HOSTNAMES.has(hostname); +} + +// Agent Relay does not expose a model catalog to t3code — the underlying +// agent's model is chosen inside Agent Relay, not here. This single entry +// gives the composer something to select so the thread has a model label. +// Exported so `AgentRelayThreadDiscoveryReactor` can stamp the same slug on +// threads it materializes for already-running agents, instead of forking a +// second "the model label" constant that could drift from this one. +export const AGENT_RELAY_DEFAULT_MODEL_SLUG = "relay-agent"; + +const AGENT_RELAY_BUILT_IN_MODELS: ReadonlyArray = [ + { + slug: AGENT_RELAY_DEFAULT_MODEL_SLUG, + name: "Relay Agent", + isCustom: false, + capabilities: EMPTY_CAPABILITIES, + }, +]; + +function agentRelayModelsFromSettings( + settings: AgentRelaySettings, +): ReadonlyArray { + return providerModelsFromSettings( + AGENT_RELAY_BUILT_IN_MODELS, + settings.customModels, + EMPTY_CAPABILITIES, + ); +} + +export function buildInitialAgentRelayProviderSnapshot( + settings: AgentRelaySettings, +): Effect.Effect { + return Effect.gen(function* () { + const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); + return buildServerProvider({ + presentation: AGENT_RELAY_PRESENTATION, + enabled: settings.enabled, + checkedAt, + models: agentRelayModelsFromSettings(settings), + probe: settings.enabled + ? { + installed: settings.brokerUrl.trim().length > 0, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Checking Agent Relay configuration...", + } + : { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Agent Relay is disabled in T3 Code settings.", + }, + }); + }); +} + +export function checkAgentRelayProviderStatus( + settings: AgentRelaySettings, +): Effect.Effect { + return Effect.gen(function* () { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const models = agentRelayModelsFromSettings(settings); + + if (!settings.enabled) { + return buildServerProvider({ + presentation: AGENT_RELAY_PRESENTATION, + enabled: false, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Agent Relay is disabled in T3 Code settings.", + }, + }); + } + + const brokerUrl = settings.brokerUrl.trim(); + if (!brokerUrl) { + return buildServerProvider({ + presentation: AGENT_RELAY_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "error", + auth: { status: "unknown" }, + message: "No broker URL configured. Paste the base HTTP(S) URL from the Agent Relay CLI.", + }, + }); + } + + // Workspace mode without a workspace key can never actually attach: + // `AgentRelayDriver` only builds a `workspaceClient` when one is + // configured, and `AgentRelayAdapter.startSession` fails every new + // thread on that instance without it. Reporting "ready" here (the + // previous behavior, which only checked `brokerUrl`/`apiKey`) hid that + // until the first real session start. + if (settings.mode === "workspace" && !settings.workspaceKey.trim()) { + return buildServerProvider({ + presentation: AGENT_RELAY_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version: null, + status: "error", + auth: { status: "unknown" }, + message: + "Workspace mode needs a Relaycast workspace key to discover or spawn agents. Add one, or switch to Single agent mode.", + }, + }); + } + + const hasApiKey = settings.apiKey.trim().length > 0; + const cleartextWarning = + hasApiKey && isCleartextRemoteBrokerUrl(brokerUrl) + ? "This broker URL is plain http:// to a non-local host — the API key is sent in cleartext. Use https:// if the broker isn't running on this machine." + : undefined; + return buildServerProvider({ + presentation: AGENT_RELAY_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version: null, + status: cleartextWarning ? "warning" : "ready", + auth: hasApiKey + ? { status: "authenticated", type: "api_key", label: "Agent Relay API key" } + : { status: "unauthenticated" }, + ...(cleartextWarning + ? { message: cleartextWarning } + : hasApiKey + ? {} + : { message: "No API key configured. The broker may reject the connection." }), + }, + }); + }); +} diff --git a/apps/server/src/provider/Layers/AgentRelayThreadDiscoveryReactor.test.ts b/apps/server/src/provider/Layers/AgentRelayThreadDiscoveryReactor.test.ts new file mode 100644 index 000000000000..d7bcb33f465e --- /dev/null +++ b/apps/server/src/provider/Layers/AgentRelayThreadDiscoveryReactor.test.ts @@ -0,0 +1,542 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { + type OrchestrationCommand, + type OrchestrationProject, + type OrchestrationThreadShell, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; +import { TestClock } from "effect/testing"; + +import { ServerConfig } from "../../config.ts"; +import { OrchestrationEngineService } from "../../orchestration/Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as WorkspacePaths from "../../workspace/WorkspacePaths.ts"; +import { ProviderAdapterRequestError } from "../Errors.ts"; +import type { ProviderInstance } from "../ProviderDriver.ts"; +import type { AgentRelayAdapterShape } from "../Services/AgentRelayAdapter.ts"; +import { AgentRelayThreadDiscoveryReactor } from "../Services/AgentRelayThreadDiscoveryReactor.ts"; +import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; +import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; +import type { + ProviderRuntimeBinding, + ProviderRuntimeBindingWithMetadata, + ProviderSessionDirectoryShape, +} from "../Services/ProviderSessionDirectory.ts"; +import { AGENT_RELAY_DEFAULT_MODEL_SLUG } from "./AgentRelayProvider.ts"; +import { makeAgentRelayThreadDiscoveryReactorLive } from "./AgentRelayThreadDiscoveryReactor.ts"; + +const AGENT_RELAY = ProviderDriverKind.make("agentrelay"); +const INSTANCE_ID = ProviderInstanceId.make("agentrelay-workspace"); + +/** Polls with the *real* clock while `Effect.sleep`/`Schedule.spaced` inside + * the reactor run on the virtual `TestClock` — the same combination + * `AgentRelayAdapter.test.ts`'s `waitUntil` uses, since a sweep's real + * filesystem work (creating the synthetic project directory) resolves on + * Node's real event loop, not the virtual clock `TestClock.adjust` drives. */ +const waitUntil = (predicate: () => boolean): Effect.Effect => + Effect.gen(function* () { + while (!predicate()) { + yield* Effect.sleep(Duration.millis(10)); + } + }).pipe(Effect.timeout("2 seconds"), TestClock.withLive, Effect.orDie); + +/** A behaviorally-accurate in-memory `ProviderSessionDirectory` fake: real + * `onConflict` semantics and a real `listBindings()` scan, so the reactor's + * "already claimed" logic runs against the same shape it does in + * production, without wiring the SQL-backed persistence layer. Plain + * mutable state (not `Ref`) so test assertions and `waitUntil` predicates + * can read it synchronously between/while the reactor's fiber runs. */ +function makeFakeDirectory() { + const bindings = new Map(); + + const upsert: ProviderSessionDirectoryShape["upsert"] = (binding, options) => + Effect.sync(() => { + const existing = bindings.get(binding.threadId); + if (existing && options?.onConflict === "ignore") { + return; + } + bindings.set(binding.threadId, { + ...existing, + ...binding, + lastSeenAt: "2026-01-01T00:00:00.000Z", + }); + }); + + const shape: ProviderSessionDirectoryShape = { + upsert, + recordImportedTranscript: () => Effect.void, + getProvider: () => Effect.die("unused"), + getBinding: (threadId) => Effect.sync(() => Option.fromUndefinedOr(bindings.get(threadId))), + listThreadIds: () => Effect.die("unused"), + listBindings: () => Effect.sync(() => Array.from(bindings.values())), + }; + // Synchronous, direct seeding for test setup (outside the Effect world) — + // `upsert` itself is only ever invoked through the reactor under test. + const seed = (binding: ProviderRuntimeBinding) => + bindings.set(binding.threadId, { ...binding, lastSeenAt: "2026-01-01T00:00:00.000Z" }); + return { shape, bindings, seed }; +} + +interface FakeAgent { + readonly name: string; + readonly status: "online" | "offline" | "unknown"; +} + +function makeAgentRelayAdapter( + listWorkspaceAgents?: AgentRelayAdapterShape["listWorkspaceAgents"], +): AgentRelayAdapterShape { + return { + provider: AGENT_RELAY, + capabilities: { sessionModelSwitch: "unsupported" }, + startSession: () => Effect.die("unused"), + sendTurn: () => Effect.die("unused"), + interruptTurn: () => Effect.die("unused"), + respondToRequest: () => Effect.die("unused"), + respondToUserInput: () => Effect.die("unused"), + stopSession: () => Effect.die("unused"), + listSessions: () => Effect.succeed([]), + hasSession: () => Effect.succeed(false), + readThread: () => Effect.die("unused"), + rollbackThread: () => Effect.die("unused"), + stopAll: () => Effect.die("unused"), + streamEvents: Stream.empty, + ...(listWorkspaceAgents ? { listWorkspaceAgents } : {}), + }; +} + +function makeAgentRelayInstance(input: { + readonly adapter: AgentRelayAdapterShape; + readonly displayName?: string; + readonly enabled?: boolean; +}): ProviderInstance { + return { + instanceId: INSTANCE_ID, + driverKind: AGENT_RELAY, + continuationIdentity: { + driverKind: AGENT_RELAY, + continuationKey: `agentrelay:instance:${INSTANCE_ID}`, + }, + displayName: input.displayName, + enabled: input.enabled ?? true, + snapshot: { + resolveMaintenance: () => Effect.die("unused"), + getSnapshot: Effect.die("unused"), + refresh: Effect.die("unused"), + streamChanges: Stream.empty, + applyUsageLimits: () => Effect.die("unused"), + }, + adapter: input.adapter, + textGeneration: { + generateCommitMessage: () => Effect.die("unused"), + generatePrContent: () => Effect.die("unused"), + generateBranchName: () => Effect.die("unused"), + generateThreadTitle: () => Effect.die("unused"), + }, + }; +} + +function makeProject(id: ProjectId, workspaceRoot: string, title: string): OrchestrationProject { + return { + id, + title, + workspaceRoot, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + deletedAt: null, + }; +} + +function makeThreadShell( + id: ThreadId, + projectId: ProjectId, + title: string, + modelSelection: { readonly instanceId: ProviderInstanceId; readonly model: string }, +): OrchestrationThreadShell { + return { + id, + projectId, + title, + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + archivedAt: null, + settledOverride: null, + settledAt: null, + session: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + }; +} + +/** Minimal but behaviorally real orchestration model: `dispatch` mutates it + * the same way the real decider would for the four command types this + * reactor issues, and the `ProjectionSnapshotQuery` mock reads back from it — + * so `getActiveProjectByWorkspaceRoot` / `getThreadShellById` reflect exactly + * what the reactor dispatched, not a canned fixture. */ +function makeHarness(input: { + readonly agents: ReadonlyArray; + readonly enabled?: boolean; + readonly seedBinding?: ProviderRuntimeBinding; +}) { + let agents: ReadonlyArray = input.agents; + let listShouldFail = false; + const directory = makeFakeDirectory(); + if (input.seedBinding) { + directory.seed(input.seedBinding); + } + const projectsByRoot = new Map(); + const threadsById = new Map(); + const commands: Array = []; + + const dispatch: OrchestrationEngineService["Service"]["dispatch"] = (command) => + Effect.sync(() => { + commands.push(command); + if (command.type === "project.create") { + projectsByRoot.set( + command.workspaceRoot, + makeProject(command.projectId, command.workspaceRoot, command.title), + ); + } else if (command.type === "thread.create") { + threadsById.set( + command.threadId, + makeThreadShell( + command.threadId, + command.projectId, + command.title, + command.modelSelection, + ), + ); + } else if (command.type === "thread.settle") { + const existing = threadsById.get(command.threadId); + if (existing) { + threadsById.set(command.threadId, { + ...existing, + settledOverride: "settled", + settledAt: "2026-01-01T00:00:00.000Z", + }); + } + } else if (command.type !== "thread.activity.append") { + throw new Error(`Unexpected command: ${command.type}`); + } + return { sequence: 1 }; + }); + + const instance = makeAgentRelayInstance({ + ...(input.enabled !== undefined ? { enabled: input.enabled } : {}), + adapter: makeAgentRelayAdapter(() => + listShouldFail + ? Effect.fail( + new ProviderAdapterRequestError({ + provider: AGENT_RELAY, + method: "workspace.agents.list", + detail: "simulated transport failure", + }), + ) + : Effect.sync(() => agents), + ), + }); + + const dependencies = Layer.mergeAll( + Layer.mock(ProviderInstanceRegistry)({ + listInstances: Effect.succeed([instance]), + }), + Layer.mock(ProjectionSnapshotQuery)({ + getActiveProjectByWorkspaceRoot: (workspaceRoot) => + Effect.sync(() => Option.fromUndefinedOr(projectsByRoot.get(workspaceRoot))), + getThreadShellById: (threadId) => + Effect.sync(() => Option.fromUndefinedOr(threadsById.get(threadId))), + }), + Layer.mock(OrchestrationEngineService)({ dispatch }), + Layer.succeed(ProviderSessionDirectory, directory.shape), + WorkspacePaths.layer, + ServerConfig.layerTest(process.cwd(), { prefix: "agentrelay-discovery-test-" }), + ).pipe(Layer.provideMerge(NodeServices.layer)); + + return { + directory, + commands, + projectsByRoot, + threadsById, + setAgents: (next: ReadonlyArray) => { + agents = next; + }, + setListFailing: (fail: boolean) => { + listShouldFail = fail; + }, + dependencies, + }; +} + +const startReactor = ( + dependencies: Layer.Layer, + options?: { readonly sweepIntervalMs?: number; readonly offlineDebounceMs?: number }, +) => + Effect.gen(function* () { + const reactor = yield* Effect.provide( + Effect.service(AgentRelayThreadDiscoveryReactor), + makeAgentRelayThreadDiscoveryReactorLive(options).pipe(Layer.provide(dependencies)), + ); + yield* reactor.start(); + }); + +it.layer(NodeServices.layer)("AgentRelayThreadDiscoveryReactor", (it) => { + it.effect("materializes a thread for an unclaimed online agent", () => + Effect.scoped( + Effect.gen(function* () { + const harness = makeHarness({ agents: [{ name: "Worker1", status: "online" }] }); + yield* startReactor(harness.dependencies); + yield* waitUntil(() => + harness.commands.some((command) => command.type === "thread.create"), + ); + + const threadCreate = harness.commands.find((command) => command.type === "thread.create"); + assert.isDefined(threadCreate); + if (threadCreate?.type !== "thread.create") return assert.fail("expected thread.create"); + assert.strictEqual(threadCreate.title, "Worker1"); + assert.deepStrictEqual(threadCreate.modelSelection, { + instanceId: INSTANCE_ID, + model: AGENT_RELAY_DEFAULT_MODEL_SLUG, + }); + + const projectCreate = harness.commands.find((command) => command.type === "project.create"); + assert.isDefined(projectCreate); + if (projectCreate?.type !== "project.create") return assert.fail("expected project.create"); + const fs = yield* FileSystem.FileSystem; + const exists = yield* fs.exists(projectCreate.workspaceRoot); + assert.isTrue(exists); + + const bindings = yield* harness.directory.shape.listBindings(); + const binding = bindings.find((entry) => entry.threadId === threadCreate.threadId); + assert.isDefined(binding); + assert.deepStrictEqual(binding?.resumeCursor, { agentName: "Worker1" }); + }), + ), + ); + + it.effect("does not double-materialize an agent already bound to a thread", () => + Effect.scoped( + Effect.gen(function* () { + const existingThreadId = ThreadId.make("thread-already-bound"); + const harness = makeHarness({ + agents: [{ name: "Worker2", status: "online" }], + seedBinding: { + threadId: existingThreadId, + provider: AGENT_RELAY, + providerInstanceId: INSTANCE_ID, + status: "stopped", + resumeCursor: { agentName: "Worker2" }, + }, + }); + // The binding alone isn't enough to count as "claimed" (see + // `claimedAgentNamesForInstance`'s comment) — a real thread has to + // exist for it too, so this fixture needs one, same as production. + harness.threadsById.set( + existingThreadId, + makeThreadShell(existingThreadId, ProjectId.make("existing-project"), "Worker2", { + instanceId: INSTANCE_ID, + model: AGENT_RELAY_DEFAULT_MODEL_SLUG, + }), + ); + yield* startReactor(harness.dependencies); + // Give the sweep a chance to run: since it must NOT dispatch + // anything, wait on the real clock instead of a signal that never + // arrives. + yield* Effect.sleep(Duration.millis(50)).pipe(TestClock.withLive); + + assert.isUndefined(harness.commands.find((command) => command.type === "thread.create")); + assert.isUndefined(harness.commands.find((command) => command.type === "project.create")); + }), + ), + ); + + it.effect( + "retries materializing an agent whose binding points at a thread that was never created", + () => + Effect.scoped( + Effect.gen(function* () { + // Simulates the crash window in `materializeThread`: the + // `ProviderSessionDirectory` binding is installed before + // `thread.create` dispatches, so a failure between those two + // steps leaves a binding whose `threadId` was never actually + // created. Without checking that the thread exists, this agent + // would be "claimed" forever and never retried. + const phantomThreadId = ThreadId.make("thread-never-created"); + const harness = makeHarness({ + agents: [{ name: "Worker5", status: "online" }], + seedBinding: { + threadId: phantomThreadId, + provider: AGENT_RELAY, + providerInstanceId: INSTANCE_ID, + status: "stopped", + resumeCursor: { agentName: "Worker5" }, + }, + }); + // Deliberately no `harness.threadsById.set(phantomThreadId, ...)` + // — that's the point: the binding exists, the thread doesn't. + + yield* startReactor(harness.dependencies); + yield* waitUntil(() => + harness.commands.some((command) => command.type === "thread.create"), + ); + + const threadCreate = harness.commands.find((command) => command.type === "thread.create"); + assert.isDefined(threadCreate); + if (threadCreate?.type !== "thread.create") return assert.fail("expected thread.create"); + assert.strictEqual(threadCreate.title, "Worker5"); + assert.notStrictEqual(threadCreate.threadId, phantomThreadId); + }), + ), + ); + + it.effect( + "settles a materialized thread after its agent stays offline past the debounce window", + () => + Effect.scoped( + Effect.gen(function* () { + const boundThreadId = ThreadId.make("thread-goes-offline"); + const harness = makeHarness({ + agents: [{ name: "Worker3", status: "online" }], + seedBinding: { + threadId: boundThreadId, + provider: AGENT_RELAY, + providerInstanceId: INSTANCE_ID, + status: "running", + resumeCursor: { agentName: "Worker3" }, + }, + }); + // A pre-existing thread needs a matching thread row for the + // "already settled?" check the reactor runs before dispatching. + harness.threadsById.set( + boundThreadId, + makeThreadShell(boundThreadId, ProjectId.make("existing-project"), "Worker3", { + instanceId: INSTANCE_ID, + model: AGENT_RELAY_DEFAULT_MODEL_SLUG, + }), + ); + + yield* startReactor(harness.dependencies, { + sweepIntervalMs: 1_000, + offlineDebounceMs: 5_000, + }); + // First sweep: online, seeds "last seen online". + yield* Effect.sleep(Duration.millis(50)).pipe(TestClock.withLive); + + harness.setAgents([]); + // Still within the debounce window: must not settle yet. + yield* TestClock.adjust("2 seconds"); + yield* Effect.sleep(Duration.millis(50)).pipe(TestClock.withLive); + assert.isUndefined(harness.commands.find((command) => command.type === "thread.settle")); + + // Past the debounce window: now it must settle. + yield* TestClock.adjust("6 seconds"); + yield* waitUntil(() => + harness.commands.some((command) => command.type === "thread.settle"), + ); + + const settle = harness.commands.find((command) => command.type === "thread.settle"); + assert.isDefined(settle); + if (settle?.type !== "thread.settle") return assert.fail("expected thread.settle"); + assert.strictEqual(settle.threadId, boundThreadId); + assert.strictEqual(harness.threadsById.get(boundThreadId)?.settledOverride, "settled"); + }), + ), + ); + + it.effect( + "does not settle a bound thread when listWorkspaceAgents fails, even past the debounce window", + () => + Effect.scoped( + Effect.gen(function* () { + // A failed listing is not the same as a successful listing that + // came back empty (the previous test): substituting `[]` for a + // transport/SDK failure would run this exact scenario through + // the same "not in onlineNames" path a real offline agent + // takes, settling the thread even though nothing actually went + // offline — an outage is not proof of absence. + const boundThreadId = ThreadId.make("thread-listing-fails"); + const harness = makeHarness({ + agents: [{ name: "Worker4", status: "online" }], + seedBinding: { + threadId: boundThreadId, + provider: AGENT_RELAY, + providerInstanceId: INSTANCE_ID, + status: "running", + resumeCursor: { agentName: "Worker4" }, + }, + }); + harness.threadsById.set( + boundThreadId, + makeThreadShell(boundThreadId, ProjectId.make("existing-project"), "Worker4", { + instanceId: INSTANCE_ID, + model: AGENT_RELAY_DEFAULT_MODEL_SLUG, + }), + ); + + yield* startReactor(harness.dependencies, { + sweepIntervalMs: 1_000, + offlineDebounceMs: 5_000, + }); + // First sweep: online, seeds "last seen online". + yield* Effect.sleep(Duration.millis(50)).pipe(TestClock.withLive); + + harness.setListFailing(true); + // Every sweep from here on fails to list — well past the + // debounce window, but the thread must stay untouched. + yield* TestClock.adjust("10 seconds"); + yield* Effect.sleep(Duration.millis(50)).pipe(TestClock.withLive); + + assert.isUndefined(harness.commands.find((command) => command.type === "thread.settle")); + assert.strictEqual(harness.threadsById.get(boundThreadId)?.settledOverride, null); + }), + ), + ); + + it.effect("leaves a Single-mode instance untouched (no listWorkspaceAgents)", () => + Effect.scoped( + Effect.gen(function* () { + const singleModeInstance = makeAgentRelayInstance({ + adapter: makeAgentRelayAdapter(), + }); + + const commands: Array = []; + const dependencies = Layer.mergeAll( + Layer.mock(ProviderInstanceRegistry)({ + listInstances: Effect.succeed([singleModeInstance]), + }), + Layer.mock(ProjectionSnapshotQuery)({}), + Layer.mock(OrchestrationEngineService)({ + dispatch: (command) => + Effect.sync(() => commands.push(command)).pipe(Effect.as({ sequence: 1 })), + }), + Layer.succeed(ProviderSessionDirectory, makeFakeDirectory().shape), + WorkspacePaths.layer, + ServerConfig.layerTest(process.cwd(), { prefix: "agentrelay-discovery-single-test-" }), + ).pipe(Layer.provideMerge(NodeServices.layer)); + + yield* startReactor(dependencies); + yield* Effect.sleep(Duration.millis(50)).pipe(TestClock.withLive); + + assert.strictEqual(commands.length, 0); + }), + ), + ); +}); diff --git a/apps/server/src/provider/Layers/AgentRelayThreadDiscoveryReactor.ts b/apps/server/src/provider/Layers/AgentRelayThreadDiscoveryReactor.ts new file mode 100644 index 000000000000..982ac18d05d4 --- /dev/null +++ b/apps/server/src/provider/Layers/AgentRelayThreadDiscoveryReactor.ts @@ -0,0 +1,445 @@ +/** + * AgentRelayThreadDiscoveryReactorLive — auto-materializes T3 Code threads + * for agents that are already running in a Workspace-mode Agent Relay + * instance's workspace, however they were spawned (Agent Relay's own CLI, + * its MCP tools, a fleet trigger, or an earlier T3 Code thread). + * + * Shaped like `ProviderSessionReaper.ts`: one polling sweep, forked once at + * `start()` and left to run for the life of the server. See + * `docs/internals/providers.md` ("Left out: auto-materializing threads for + * already-running agents", now replaced by the design note this reactor + * implements) for the investigation this is built on: + * + * - `workspaceRoot` is an opaque uniqueness key at the command-decider + * level (`commandInvariants.ts`'s `requireActiveProjectWorkspaceRootAbsent` + * only string-compares it) and checkpointing already no-ops on a + * non-git directory (`CheckpointReactor.ts`'s `isGitRepository` guard) — + * so a synthetic project directory needs to exist, but does not need to + * be git-initialized. Nothing writes to it since the actual agent runs on + * Agent Relay's side, not on this filesystem. + * - `AgentSessionImporter.ts` already establishes the precedent this + * reactor follows for "materialize a thread that attaches instead of + * spawning": install a `ProviderSessionDirectory` binding carrying a + * `resumeCursor` *before* the thread becomes visible (`onConflict: + * "ignore"`), then dispatch `thread.create`. `ProviderService.startSession` + * already prefers a persisted binding's `resumeCursor` over spawning fresh + * (see its `effectiveResumeCursor` fallback) — the same mechanism + * `AgentRelayAdapter.startSession` reads via `isAgentRelayResumeCursor`. + * No new attach-signal plumbing is needed. + * + * One project per provider *instance*, not per agent: created lazily (via + * `WorkspacePaths.normalizeWorkspaceRoot(..., { createIfMissing: true })`, + * the same helper `project.create`'s own client-command normalizer uses) + * the first time a sweep finds an unclaimed online agent for that instance, + * under `/agent-relay/` — there is nothing for a human + * to browse to and pick, so this never prompts a file dialog. + * + * @module AgentRelayThreadDiscoveryReactor + */ +import { + CommandId, + DEFAULT_PROVIDER_INTERACTION_MODE, + DEFAULT_RUNTIME_MODE, + EventId, + ProjectId, + ProviderDriverKind, + type ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Result from "effect/Result"; +import * as Schedule from "effect/Schedule"; + +import { ServerConfig } from "../../config.ts"; +import { OrchestrationEngineService } from "../../orchestration/Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { forkParked } from "../../serverActivation.ts"; +import { WorkspacePaths } from "../../workspace/WorkspacePaths.ts"; +import type { ProviderInstance } from "../ProviderDriver.ts"; +import type { AgentRelayAdapterShape } from "../Services/AgentRelayAdapter.ts"; +import { + AgentRelayThreadDiscoveryReactor, + type AgentRelayThreadDiscoveryReactorShape, +} from "../Services/AgentRelayThreadDiscoveryReactor.ts"; +import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; +import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; +import { isAgentRelayResumeCursor } from "./AgentRelayAdapter.ts"; +import { AGENT_RELAY_DEFAULT_MODEL_SLUG } from "./AgentRelayProvider.ts"; + +const AGENT_RELAY_DRIVER_KIND = ProviderDriverKind.make("agentrelay"); + +// How often to re-list every configured Workspace-mode instance's agents. +// Agent Relay's own presence push channel is unverified end-to-end (see +// `AgentRelayWorkspaceClientLive.ts`), so this reactor polls only — +// `waitForAgentOnline` already established that polling alone is sufficient +// for correctness here, just slower than a push would be. +const DEFAULT_SWEEP_INTERVAL_MS = 30_000; + +// An agent missing from one `listAgents()` sweep is not proof it is gone — +// the same presence-reliability uncertainty documented above. Require it to +// stay unconfirmed online across several sweep cycles (a comfortable margin +// past a single transient miss) before treating it as offline. +const DEFAULT_OFFLINE_DEBOUNCE_MS = 120_000; + +export interface AgentRelayThreadDiscoveryReactorLiveOptions { + readonly sweepIntervalMs?: number; + readonly offlineDebounceMs?: number; +} + +function slugify(value: string, maxLength = 48): string { + const slug = value.replace(/[^a-zA-Z0-9_-]/g, "-").slice(0, maxLength); + return slug.length > 0 ? slug : "default"; +} + +interface AgentRelayWorkspaceInstance { + readonly instanceId: ProviderInstanceId; + readonly displayName: string | undefined; + readonly listWorkspaceAgents: NonNullable; +} + +/** Only enabled Workspace-mode instances discover anything — Single mode has + * no `listWorkspaceAgents` (see `AgentRelayAdapterShape`), and a disabled + * instance's driver can still hold a live workspace client (its `create()` + * does not gate that on `enabled`), so this must check both. */ +function asAgentRelayWorkspaceInstance( + instance: ProviderInstance, +): AgentRelayWorkspaceInstance | undefined { + if (instance.driverKind !== AGENT_RELAY_DRIVER_KIND || !instance.enabled) { + return undefined; + } + const listWorkspaceAgents = (instance.adapter as AgentRelayAdapterShape).listWorkspaceAgents; + return listWorkspaceAgents + ? { instanceId: instance.instanceId, displayName: instance.displayName, listWorkspaceAgents } + : undefined; +} + +const makeAgentRelayThreadDiscoveryReactor = ( + options?: AgentRelayThreadDiscoveryReactorLiveOptions, +) => + Effect.gen(function* () { + const instanceRegistry = yield* ProviderInstanceRegistry; + const directory = yield* ProviderSessionDirectory; + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; + const engine = yield* OrchestrationEngineService; + const workspacePaths = yield* WorkspacePaths; + const serverConfig = yield* ServerConfig; + const path = yield* Path.Path; + const crypto = yield* Crypto.Crypto; + + const sweepIntervalMs = Math.max(1, options?.sweepIntervalMs ?? DEFAULT_SWEEP_INTERVAL_MS); + const offlineDebounceMs = Math.max( + 1, + options?.offlineDebounceMs ?? DEFAULT_OFFLINE_DEBOUNCE_MS, + ); + + // Resolved synthetic project per instance, cached so a sweep that finds + // several unclaimed agents for the same instance does not dispatch + // `project.create` more than once before the read model catches up. + const projectIdByInstance = new Map(); + // `${instanceId}:${agentName}` -> last sweep time this agent was seen + // online. Absence here (not "0") means "never confirmed online since + // this reactor started" — see the seeding comment in `sweepInstance`. + const lastOnlineAtMs = new Map(); + + const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + const serverCommandId = (tag: string) => + crypto.randomUUIDv4.pipe( + Effect.map((uuid) => CommandId.make(`server:agentrelay-discovery:${tag}:${uuid}`)), + ); + + const resolveInstanceProject = Effect.fn("resolveInstanceProject")(function* ( + instance: AgentRelayWorkspaceInstance, + ) { + const cached = projectIdByInstance.get(instance.instanceId); + if (cached !== undefined) { + return cached; + } + + const workspaceRoot = path.join( + serverConfig.baseDir, + "agent-relay", + slugify(instance.instanceId), + ); + const normalizedRoot = yield* workspacePaths.normalizeWorkspaceRoot(workspaceRoot, { + createIfMissing: true, + }); + + const existingProject = + yield* projectionSnapshotQuery.getActiveProjectByWorkspaceRoot(normalizedRoot); + if (Option.isSome(existingProject)) { + projectIdByInstance.set(instance.instanceId, existingProject.value.id); + return existingProject.value.id; + } + + const projectId = ProjectId.make(yield* crypto.randomUUIDv4); + const createdAt = yield* nowIso; + yield* engine.dispatch({ + type: "project.create", + commandId: yield* serverCommandId("project-create"), + projectId, + title: `Agent Relay: ${instance.displayName ?? instance.instanceId}`, + workspaceRoot: normalizedRoot, + createdAt, + }); + projectIdByInstance.set(instance.instanceId, projectId); + return projectId; + }); + + /** Agent names already bound to a thread for this instance, discovered + * the same way `ProviderSessionReaper` already scans bindings — reusing + * `ProviderSessionDirectory` instead of a second, parallel "is this + * agent claimed" table. + * + * `materializeThread` installs the binding *before* dispatching + * `thread.create` (see its own comment for why), so a dispatch failure + * between those two steps leaves a binding pointing at a thread that + * was never actually created. Without checking that the thread exists, + * that binding would mark the agent "claimed" forever — permanently + * skipping it on every future sweep even though nothing ever + * materialized. Filtering to bindings with a real thread means a + * failed attempt just gets retried (with a new thread id) on the next + * sweep instead, at the cost of leaving a harmless orphaned binding + * row behind for the failed attempt. */ + const claimedAgentNamesForInstance = Effect.fn("claimedAgentNamesForInstance")(function* ( + instanceId: ProviderInstanceId, + ) { + const bindings = yield* directory.listBindings(); + const candidates = bindings.filter( + (binding) => + binding.provider === AGENT_RELAY_DRIVER_KIND && + binding.providerInstanceId === instanceId && + isAgentRelayResumeCursor(binding.resumeCursor), + ); + const claimed = new Map(); + yield* Effect.forEach( + candidates, + (binding) => + projectionSnapshotQuery.getThreadShellById(binding.threadId).pipe( + Effect.map((thread) => { + if (Option.isSome(thread) && isAgentRelayResumeCursor(binding.resumeCursor)) { + claimed.set(binding.resumeCursor.agentName, binding.threadId); + } + }), + ), + { discard: true }, + ); + return claimed; + }); + + const materializeThread = Effect.fn("materializeThread")(function* (input: { + readonly instance: AgentRelayWorkspaceInstance; + readonly agentName: string; + }) { + const projectId = yield* resolveInstanceProject(input.instance); + const threadId = ThreadId.make(yield* crypto.randomUUIDv4); + const createdAt = yield* nowIso; + + // Install the cursor before the thread becomes visible — the same + // ordering `AgentSessionImporter` uses, so a client that opens this + // thread the instant it appears attaches to `agentName` rather than + // racing the spawn path. + yield* directory.upsert( + { + threadId, + provider: AGENT_RELAY_DRIVER_KIND, + providerInstanceId: input.instance.instanceId, + status: "stopped", + resumeCursor: { agentName: input.agentName }, + }, + { onConflict: "ignore" }, + ); + + yield* engine.dispatch({ + type: "thread.create", + commandId: yield* serverCommandId("thread-create"), + threadId, + projectId, + title: input.agentName, + modelSelection: { + instanceId: input.instance.instanceId, + model: AGENT_RELAY_DEFAULT_MODEL_SLUG, + }, + runtimeMode: DEFAULT_RUNTIME_MODE, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + branch: null, + worktreePath: null, + createdAt, + }); + + yield* engine.dispatch({ + type: "thread.activity.append", + commandId: yield* serverCommandId("thread-discovered-activity"), + threadId, + activity: { + id: EventId.make(yield* crypto.randomUUIDv4), + tone: "info", + kind: "agentrelay.agent.discovered", + summary: `Attached to '${input.agentName}', already running in the Agent Relay workspace`, + payload: { + agentName: input.agentName, + providerInstanceId: input.instance.instanceId, + }, + turnId: null, + createdAt, + }, + createdAt, + }); + }); + + /** Reverse of `materializeThread`: an agent this reactor (or T3 Code + * itself) previously bound to a thread has dropped out of + * `listAgents()` for at least `offlineDebounceMs`. Settling (rather than + * deleting or archiving) matches `ExternalSessionHooks`' "session ended" + * marker — the thread and its history stay, just no longer shown live. + * Sending it a new message unsettles it automatically (see + * `decider.ts`'s `thread.turn-start-requested` handling), so nothing + * here needs to reverse this if the agent comes back. */ + const markAgentOffline = Effect.fn("markAgentOffline")(function* (input: { + readonly instanceId: ProviderInstanceId; + readonly agentName: string; + readonly threadId: ThreadId; + }) { + const thread = yield* projectionSnapshotQuery + .getThreadShellById(input.threadId) + .pipe(Effect.map(Option.getOrUndefined)); + // Already gone, already settled, or actively mid-turn (thread.settle + // itself would reject that case) — nothing to do. + if (!thread || thread.settledOverride === "settled") { + return; + } + + const createdAt = yield* nowIso; + yield* engine.dispatch({ + type: "thread.activity.append", + commandId: yield* serverCommandId("thread-offline-activity"), + threadId: input.threadId, + activity: { + id: EventId.make(yield* crypto.randomUUIDv4), + tone: "info", + kind: "agentrelay.agent.offline", + summary: `'${input.agentName}' is no longer running in the Agent Relay workspace`, + payload: { + agentName: input.agentName, + providerInstanceId: input.instanceId, + }, + turnId: null, + createdAt, + }, + createdAt, + }); + yield* engine.dispatch({ + type: "thread.settle", + commandId: yield* serverCommandId("thread-offline-settle"), + threadId: input.threadId, + }); + }); + + const sweepInstance = Effect.fn("sweepInstance")(function* ( + instance: AgentRelayWorkspaceInstance, + ) { + // A failed listing is not the same thing as a successful listing + // that came back empty: substituting `[]` for a transport/SDK + // failure (the previous behavior) would run every currently-claimed + // agent below through the same "not in onlineNames" path a real + // offline agent takes — an outage lasting longer than + // `offlineDebounceMs` would then settle every one of this + // instance's threads even though nothing actually went offline. + // Skip this sweep for the instance entirely instead, leaving + // existing presence untouched until a listing actually succeeds. + const agentsResult = yield* instance.listWorkspaceAgents().pipe(Effect.result); + if (Result.isFailure(agentsResult)) { + yield* Effect.logWarning("agentrelay.discovery.list-agents-failed", { + instanceId: instance.instanceId, + cause: agentsResult.failure, + }); + return; + } + const agents = agentsResult.success; + const onlineNames = new Set( + agents.filter((agent) => agent.status === "online").map((agent) => agent.name), + ); + const claimed = yield* claimedAgentNamesForInstance(instance.instanceId); + const now = yield* Clock.currentTimeMillis; + + for (const name of onlineNames) { + lastOnlineAtMs.set(`${instance.instanceId}:${name}`, now); + if (claimed.has(name)) continue; + yield* materializeThread({ instance, agentName: name }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("agentrelay.discovery.materialize-failed", { + instanceId: instance.instanceId, + agentName: name, + cause, + }), + ), + ); + } + + for (const [agentName, threadId] of claimed) { + if (onlineNames.has(agentName)) continue; + const key = `${instance.instanceId}:${agentName}`; + const lastSeen = lastOnlineAtMs.get(key); + if (lastSeen === undefined) { + // First time this reactor has observed this binding: assume it + // was online until now rather than settling a pre-existing + // thread on the very first sweep after a server restart. + lastOnlineAtMs.set(key, now); + continue; + } + if (now - lastSeen < offlineDebounceMs) continue; + yield* markAgentOffline({ instanceId: instance.instanceId, agentName, threadId }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("agentrelay.discovery.offline-settle-failed", { + instanceId: instance.instanceId, + agentName, + threadId, + cause, + }), + ), + ); + } + }); + + const sweep = Effect.gen(function* () { + const instances = yield* instanceRegistry.listInstances; + const workspaceInstances = instances.flatMap((instance) => { + const workspaceInstance = asAgentRelayWorkspaceInstance(instance); + return workspaceInstance ? [workspaceInstance] : []; + }); + yield* Effect.forEach(workspaceInstances, sweepInstance, { discard: true }); + }); + + const start: AgentRelayThreadDiscoveryReactorShape["start"] = () => + Effect.gen(function* () { + yield* forkParked( + sweep.pipe( + Effect.catch((error: unknown) => + Effect.logWarning("agentrelay.discovery.sweep-failed", { error }), + ), + Effect.catchDefect((defect: unknown) => + Effect.logWarning("agentrelay.discovery.sweep-defect", { defect }), + ), + Effect.repeat(Schedule.spaced(Duration.millis(sweepIntervalMs))), + ), + ); + yield* Effect.logInfo("agentrelay.discovery.started", { + sweepIntervalMs, + offlineDebounceMs, + }); + }); + + return { start } satisfies AgentRelayThreadDiscoveryReactorShape; + }); + +export const makeAgentRelayThreadDiscoveryReactorLive = ( + options?: AgentRelayThreadDiscoveryReactorLiveOptions, +) => Layer.effect(AgentRelayThreadDiscoveryReactor, makeAgentRelayThreadDiscoveryReactor(options)); + +export const AgentRelayThreadDiscoveryReactorLive = makeAgentRelayThreadDiscoveryReactorLive(); diff --git a/apps/server/src/provider/Layers/AgentRelayWorkspaceClientLive.test.ts b/apps/server/src/provider/Layers/AgentRelayWorkspaceClientLive.test.ts new file mode 100644 index 000000000000..4e58f3a6507b --- /dev/null +++ b/apps/server/src/provider/Layers/AgentRelayWorkspaceClientLive.test.ts @@ -0,0 +1,63 @@ +import * as NodeAssert from "node:assert/strict"; + +import { describe, it } from "vite-plus/test"; + +import { readPresenceTransition } from "./AgentRelayWorkspaceClientLive.ts"; + +describe("readPresenceTransition", () => { + it("reads the legacy agentOnline/agentOffline messaging event shape", () => { + NodeAssert.deepEqual( + readPresenceTransition({ type: "agentOnline", agent: { name: "Worker" } }), + { name: "Worker", status: "online" }, + ); + NodeAssert.deepEqual( + readPresenceTransition({ type: "agentOffline", agent: { name: "Worker" } }), + { name: "Worker", status: "offline" }, + ); + }); + + it("reads the agent.status.* dotted event shape for online and offline only", () => { + NodeAssert.deepEqual( + readPresenceTransition({ type: "agent.status.offline", agentId: "Worker" }), + { name: "Worker", status: "offline" }, + ); + NodeAssert.deepEqual( + readPresenceTransition({ type: "agent.status.online", agentId: "Worker" }), + { name: "Worker", status: "online" }, + ); + }); + + it("ignores agent.status.* events that are neither online nor offline", () => { + // Previously defaulted anything but literal "offline" to "online" — + // an intermediate/unrelated status (connecting, error, a future + // status this module doesn't know about yet) could resolve + // `waitForAgentOnline` for an agent that was never actually + // attachable. Whitelisting is the safer default: a missed real + // transition still gets caught by `AgentRelayAdapter`'s polling + // fallback, but a wrongly-guessed one cannot. + NodeAssert.equal( + readPresenceTransition({ type: "agent.status.active", agentId: "Worker" }), + undefined, + ); + NodeAssert.equal( + readPresenceTransition({ type: "agent.status.idle", agentId: "Worker" }), + undefined, + ); + NodeAssert.equal( + readPresenceTransition({ type: "agent.status.connecting", agentId: "Worker" }), + undefined, + ); + }); + + it("ignores events that are not a recognized presence transition", () => { + NodeAssert.equal(readPresenceTransition({ type: "message.created" }), undefined); + NodeAssert.equal(readPresenceTransition({ type: "agentOnline", agent: {} }), undefined); + NodeAssert.equal( + readPresenceTransition({ type: "agent.status.offline", agentId: "" }), + undefined, + ); + NodeAssert.equal(readPresenceTransition(null), undefined); + NodeAssert.equal(readPresenceTransition("agentOnline"), undefined); + NodeAssert.equal(readPresenceTransition(42), undefined); + }); +}); diff --git a/apps/server/src/provider/Layers/AgentRelayWorkspaceClientLive.ts b/apps/server/src/provider/Layers/AgentRelayWorkspaceClientLive.ts new file mode 100644 index 000000000000..b0382f3d2e2a --- /dev/null +++ b/apps/server/src/provider/Layers/AgentRelayWorkspaceClientLive.ts @@ -0,0 +1,203 @@ +/** + * AgentRelayWorkspaceClientLive — real `@agent-relay/sdk` implementation of + * {@link AgentRelayWorkspaceClientShape}. + * + * Two separate SDK surfaces are combined here, deliberately, because they are + * not the same thing: + * + * - `createWorkspaceClient` (`@agent-relay/sdk/messaging`) is a thin, + * workspace-key-scoped pass-through over `@relaycast/sdk`'s raw client. + * It is the *only* surface that exposes `agents.spawn` — the reshaped + * `AgentRelay` facade's `.agents` (below) omits it. This is exactly what + * Agent Relay's own `add_agent`/`list_agents` MCP tools call under the + * hood (`packages/cli/src/cli/agent-relay-mcp.ts`'s `getRelay()` returns + * this same thin client, typed as `RelayCastLike`). + * - `AgentRelay` (`@agent-relay/sdk`) is the richer facade with a live + * `addListener` event fan-in, used here only for presence. Listening + * requires at least one registered agent identity for events to fan + * through (`relay.workspace.register(...)`), so this module registers a + * lightweight, deterministically-named identity for that purpose alone — + * it never sends messages or spawns through it. + * + * Presence uncertainty: this module could not be verified against a live + * Agent Relay workspace (see `docs/internals/providers.md`). The predicate + * below matches both the raw messaging-level `agentOnline`/`agentOffline` + * events (`@agent-relay/sdk`'s `messaging/types.ts`) and the newer + * `agent.status.*` dotted events, because static reading of the SDK left it + * unclear which (if either) reaches the top-level `addListener` fan-in for a + * plain workspace-key registration. `AgentRelayAdapter.ts`'s + * `waitForAgentOnline` races this against polling `listAgents`, so a spawn + * still resolves correctly even if this listener never fires. + * + * @module AgentRelayWorkspaceClientLive + */ +import { AgentRelay } from "@agent-relay/sdk"; +import { createWorkspaceClient, type RelayAgent } from "@agent-relay/sdk/messaging"; +import * as Effect from "effect/Effect"; + +import { ProviderAdapterRequestError } from "../Errors.ts"; +import type { + AgentRelayPresenceStatus, + AgentRelayWorkspaceAgentSummary, + AgentRelayWorkspaceClientShape, +} from "../Services/AgentRelayWorkspaceClient.ts"; + +const PROVIDER = "agentrelay"; + +function describeError(cause: unknown): string { + return cause instanceof Error ? cause.message : String(cause); +} + +function toAgentSummary(agent: RelayAgent): AgentRelayWorkspaceAgentSummary { + return { + name: agent.name, + status: agent.status === "online" || agent.status === "offline" ? agent.status : "unknown", + }; +} + +/** + * Narrow an arbitrary event from `AgentRelay#addListener`'s untyped predicate + * overload down to a `(name, status)` presence transition, whichever of the + * two event shapes described above it turns out to be. Returns `undefined` + * for anything else so callers can ignore it. + */ +export function readPresenceTransition( + event: unknown, +): { readonly name: string; readonly status: AgentRelayPresenceStatus } | undefined { + if (typeof event !== "object" || event === null || !("type" in event)) return undefined; + const record = event as Record; + const type = record.type; + if (type === "agentOnline" || type === "agentOffline") { + const agent = record.agent; + const name = + typeof agent === "object" && + agent !== null && + typeof (agent as { name?: unknown }).name === "string" + ? (agent as { name: string }).name + : undefined; + if (!name) return undefined; + return { name, status: type === "agentOnline" ? "online" : "offline" }; + } + if (type === "agent.status.online" || type === "agent.status.offline") { + const agentId = record.agentId; + if (typeof agentId !== "string" || !agentId) return undefined; + return { name: agentId, status: type === "agent.status.online" ? "online" : "offline" }; + } + // Any other `agent.status.*` event (e.g. `connecting`, `error`) is + // deliberately ignored rather than defaulted to "online" — this + // module's presence uncertainty (see the module doc) cuts both ways: + // guessing wrong here can resolve `waitForAgentOnline` for an agent + // that isn't actually attachable yet. `AgentRelayAdapter`'s poll-based + // fallback still covers every real transition even when this listener + // drops one. + return undefined; +} + +/** + * Build a stable, valid Relaycast agent name for the identity this server + * registers solely to receive presence events. Deterministic per instance so + * restarts adopt (rotate) the same identity instead of accumulating one + * per boot. + */ +function rosterWatcherName(instanceId: string): string { + const slug = instanceId.replace(/[^a-zA-Z0-9_-]/g, "-").slice(0, 48) || "default"; + return `t3code-roster-${slug}`; +} + +export function makeAgentRelayWorkspaceClient( + workspaceKey: string, + instanceId: string, +): Effect.Effect { + return Effect.sync(() => { + const workspaceClient = createWorkspaceClient({ workspaceKey }); + // Built lazily and best-effort: a workspace that rejects registration + // (bad key, offline) must not block listing/spawning, which have their + // own error handling. + let presenceRelay: AgentRelay | undefined; + let presenceReady: Promise | undefined; + + const ensurePresenceRelay = (): Promise => { + if (presenceReady) return presenceReady; + const relay = new AgentRelay({ workspaceKey }); + presenceRelay = relay; + presenceReady = relay.workspace + .register({ name: rosterWatcherName(instanceId), type: "agent" }) + .then(() => undefined) + .catch((cause) => { + // Presence is a best-effort push channel — `waitForAgentOnline`'s + // polling fallback covers this. Reset so a later transient failure + // (e.g. the workspace was briefly unreachable at boot) gets + // retried on the next `onPresenceChange` call instead of wedging. + presenceReady = undefined; + presenceRelay = undefined; + Effect.runFork( + Effect.logWarning("Agent Relay workspace presence registration failed.", { + instanceId, + detail: describeError(cause), + }), + ); + }); + return presenceReady; + }; + + const listAgents: AgentRelayWorkspaceClientShape["listAgents"] = (filter) => + Effect.tryPromise({ + try: () => + workspaceClient.agents.list(filter?.status ? { status: filter.status } : undefined), + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "workspace.agents.list", + detail: describeError(cause), + }), + }).pipe(Effect.map((agents) => agents.map((agent) => toAgentSummary(agent as RelayAgent)))); + + const spawnAgent: AgentRelayWorkspaceClientShape["spawnAgent"] = (input) => + Effect.tryPromise({ + try: () => + workspaceClient.agents.spawn({ + name: input.name, + cli: input.cli, + ...(input.task ? { task: input.task } : {}), + }), + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "workspace.agents.spawn", + detail: describeError(cause), + }), + }).pipe( + Effect.map((result) => { + const name = + typeof result.name === "string" && result.name.trim().length > 0 + ? result.name + : input.name; + return { name }; + }), + ); + + const onPresenceChange: AgentRelayWorkspaceClientShape["onPresenceChange"] = (handler) => { + let unsubscribe: (() => void) | undefined; + let cancelled = false; + void ensurePresenceRelay().then(() => { + if (cancelled || !presenceRelay) return; + // `"agent.status.*"` is the one wildcard-typed selector confirmed in + // `RelayEventMap` (`packages/sdk/src/listeners.ts`) that plausibly + // carries agent connectivity — `addListener` otherwise only accepts + // exact dotted names or a `ListenerPredicate` *object* (not a plain + // filter function), so a raw `agentOnline`/`agentOffline` selector + // is not something this surface's string/predicate overloads support. + unsubscribe = presenceRelay!.addListener("agent.status.*", (event) => { + const transition = readPresenceTransition(event); + if (transition) handler(transition.name, transition.status); + }); + }); + return () => { + cancelled = true; + unsubscribe?.(); + }; + }; + + return { listAgents, spawnAgent, onPresenceChange }; + }); +} diff --git a/apps/server/src/provider/Services/AgentRelayAdapter.ts b/apps/server/src/provider/Services/AgentRelayAdapter.ts new file mode 100644 index 000000000000..cbe385cb37ec --- /dev/null +++ b/apps/server/src/provider/Services/AgentRelayAdapter.ts @@ -0,0 +1,30 @@ +/** + * AgentRelayAdapter — shape type for the Agent Relay provider adapter. + * + * Mirrors the naming pattern in `CursorAdapter.ts` / `GrokAdapter.ts`: a + * driver bundles one adapter per instance as a captured closure, so this + * module only retains the shape interface as a naming anchor. + * + * @module AgentRelayAdapter + */ +import type { ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; +import type { AgentRelayWorkspaceClientShape } from "./AgentRelayWorkspaceClient.ts"; + +/** + * AgentRelayAdapterShape — per-instance Agent Relay adapter contract. + */ +export interface AgentRelayAdapterShape extends ProviderAdapterShape { + /** + * Present only when this instance is configured in Workspace mode (a + * `AgentRelayWorkspaceClient` was supplied to `makeAgentRelayAdapter`). + * `undefined` in Single mode, where there is nothing to discover. + * + * Exposed here (rather than reaching for a second, independently + * constructed `AgentRelayWorkspaceClient`) so + * `AgentRelayThreadDiscoveryReactor` reuses this instance's already-live + * client instead of registering a second presence identity for the same + * workspace. + */ + readonly listWorkspaceAgents?: AgentRelayWorkspaceClientShape["listAgents"]; +} diff --git a/apps/server/src/provider/Services/AgentRelayThreadDiscoveryReactor.ts b/apps/server/src/provider/Services/AgentRelayThreadDiscoveryReactor.ts new file mode 100644 index 000000000000..82f70d50b384 --- /dev/null +++ b/apps/server/src/provider/Services/AgentRelayThreadDiscoveryReactor.ts @@ -0,0 +1,17 @@ +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import type * as Scope from "effect/Scope"; + +export interface AgentRelayThreadDiscoveryReactorShape { + /** + * Start the background Agent Relay discovery reactor within the provided + * scope. Shaped like `ProviderSessionReaper.start` — one long-lived sweep + * loop, forked and left to run until the scope closes. + */ + readonly start: () => Effect.Effect; +} + +export class AgentRelayThreadDiscoveryReactor extends Context.Service< + AgentRelayThreadDiscoveryReactor, + AgentRelayThreadDiscoveryReactorShape +>()("t3/provider/Services/AgentRelayThreadDiscoveryReactor") {} diff --git a/apps/server/src/provider/Services/AgentRelayWorkspaceClient.ts b/apps/server/src/provider/Services/AgentRelayWorkspaceClient.ts new file mode 100644 index 000000000000..dda1d82944a0 --- /dev/null +++ b/apps/server/src/provider/Services/AgentRelayWorkspaceClient.ts @@ -0,0 +1,46 @@ +/** + * AgentRelayWorkspaceClient — shape for talking to an Agent Relay *workspace* + * (as opposed to `AgentRelayAdapterShape`, which owns a single agent's PTY + * attach socket). This is the collaborator `AgentRelayAdapter.ts` uses in + * "workspace" mode to discover who is already running and to spawn a new + * agent for a thread that has none yet. + * + * @module AgentRelayWorkspaceClient + */ +import * as Effect from "effect/Effect"; + +import type { ProviderAdapterRequestError } from "../Errors.ts"; + +export interface AgentRelayWorkspaceAgentSummary { + readonly name: string; + readonly status: "online" | "offline" | "unknown"; +} + +export interface AgentRelayWorkspaceSpawnInput { + readonly name: string; + readonly cli: string; + readonly task?: string; +} + +export type AgentRelayPresenceStatus = "online" | "offline"; + +export interface AgentRelayWorkspaceClientShape { + readonly listAgents: (filter?: { + readonly status?: AgentRelayPresenceStatus; + }) => Effect.Effect, ProviderAdapterRequestError>; + + readonly spawnAgent: ( + input: AgentRelayWorkspaceSpawnInput, + ) => Effect.Effect<{ readonly name: string }, ProviderAdapterRequestError>; + + /** + * Registers a presence-change callback and returns an unsubscribe + * function. Fires best-effort for online/offline transitions this client + * observes live; callers that need a guarantee (e.g. confirming a spawn + * came online) should still race this against polling `listAgents` — see + * `waitForAgentOnline` in `AgentRelayAdapter.ts`. + */ + readonly onPresenceChange: ( + handler: (name: string, status: AgentRelayPresenceStatus) => void, + ) => () => void; +} diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 60e3402eed42..5dbdbe0a707a 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -20,6 +20,7 @@ * * @module provider/builtInDrivers */ +import { AgentRelayDriver, type AgentRelayDriverEnv } from "./Drivers/AgentRelayDriver.ts"; import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; @@ -34,6 +35,7 @@ import type { AnyProviderDriver } from "./ProviderDriver.ts"; * layer must provide every service in this union. */ export type BuiltInDriversEnv = + | AgentRelayDriverEnv | ClaudeDriverEnv | CodexDriverEnv | CursorDriverEnv @@ -53,4 +55,5 @@ export const BUILT_IN_DRIVERS: ReadonlyArray const keybindings = yield* Keybindings.Keybindings; const orchestrationReactor = yield* OrchestrationReactor.OrchestrationReactor; const providerSessionReaper = yield* ProviderSessionReaper.ProviderSessionReaper; + const agentRelayThreadDiscoveryReactor = + yield* AgentRelayThreadDiscoveryReactor.AgentRelayThreadDiscoveryReactor; const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents; const serverSettings = yield* ServerSettings.ServerSettingsService; const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; @@ -871,6 +874,7 @@ export const make = (options?: StartupOptions) => Effect.gen(function* () { yield* orchestrationReactor.start().pipe(Scope.provide(reactorScope)); yield* providerSessionReaper.start().pipe(Scope.provide(reactorScope)); + yield* agentRelayThreadDiscoveryReactor.start().pipe(Scope.provide(reactorScope)); }), ); diff --git a/apps/server/src/textGeneration/AgentRelayTextGeneration.ts b/apps/server/src/textGeneration/AgentRelayTextGeneration.ts new file mode 100644 index 000000000000..8c0e07fec26f --- /dev/null +++ b/apps/server/src/textGeneration/AgentRelayTextGeneration.ts @@ -0,0 +1,39 @@ +/** + * AgentRelayTextGeneration — deliberately unsupported. + * + * Text generation (commit messages, PR descriptions, branch names, thread + * titles) needs a structured request/response call into a model. Agent + * Relay's v1 transport is a raw terminal stream with no such call — there is + * no way to ask "generate a commit message" and get a parseable answer back + * without typing it into the live agent's terminal and scraping the reply. + * + * Per this repo's "provider-shaped features need a decision, even if the + * decision is 'not supported here'" rule, this is that decision: every + * operation fails with a clear `TextGenerationError` instead of the driver + * omitting the field (which the `ProviderInstance` contract does not allow). + * + * @module textGeneration/AgentRelayTextGeneration + */ +import { TextGenerationError } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; + +import * as TextGeneration from "./TextGeneration.ts"; + +const unsupported = (operation: string) => + Effect.fail( + new TextGenerationError({ + operation, + detail: + "Agent Relay does not support text generation shortcuts (commit messages, PR content, branch names, thread titles). Pick a different provider for this in Settings.", + }), + ); + +export const makeAgentRelayTextGeneration: Effect.Effect = + Effect.succeed( + TextGeneration.TextGeneration.of({ + generateCommitMessage: () => unsupported("generateCommitMessage"), + generatePrContent: () => unsupported("generatePrContent"), + generateBranchName: () => unsupported("generateBranchName"), + generateThreadTitle: () => unsupported("generateThreadTitle"), + }), + ); diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 199d0ba834d0..f8bbbf3d39cd 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -703,6 +703,20 @@ export const ACPRegistryIcon: Icon = ({ className, ...props }) => ( ); +export const AgentRelayIcon: Icon = ({ className, ...props }) => ( + + + + + + +); + export const PiAgentIcon: Icon = ({ className, ...props }) => ( diff --git a/apps/web/src/components/chat/providerIconUtils.ts b/apps/web/src/components/chat/providerIconUtils.ts index db0e5ca222f3..2a24f47f1ca3 100644 --- a/apps/web/src/components/chat/providerIconUtils.ts +++ b/apps/web/src/components/chat/providerIconUtils.ts @@ -1,5 +1,6 @@ import { ProviderDriverKind } from "@t3tools/contracts"; import { + AgentRelayIcon, AntigravityIcon, ClaudeAI, CursorIcon, @@ -16,6 +17,7 @@ export const PROVIDER_ICON_BY_PROVIDER: Partial [ProviderDriverKind.make("cursor")]: CursorIcon, [ProviderDriverKind.make("grok")]: GrokIcon, [ProviderDriverKind.make("antigravity")]: AntigravityIcon, + [ProviderDriverKind.make("agentrelay")]: AgentRelayIcon, }; export type ModelEsque = { diff --git a/apps/web/src/components/settings/providerDriverMeta.ts b/apps/web/src/components/settings/providerDriverMeta.ts index 4bf4da3919ba..27bb50304ad4 100644 --- a/apps/web/src/components/settings/providerDriverMeta.ts +++ b/apps/web/src/components/settings/providerDriverMeta.ts @@ -1,4 +1,5 @@ import { + AgentRelaySettings, AntigravitySettings, ClaudeSettings, CodexSettings, @@ -9,6 +10,7 @@ import { } from "@t3tools/contracts"; import type * as Schema from "effect/Schema"; import { + AgentRelayIcon, AntigravityIcon, ClaudeAI, CursorIcon, @@ -82,6 +84,13 @@ const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = [ icon: AntigravityIcon, settingsSchema: AntigravitySettings, }, + { + value: ProviderDriverKind.make("agentrelay"), + label: "Agent Relay", + icon: AgentRelayIcon, + badgeLabel: "Early Access", + settingsSchema: AgentRelaySettings, + }, ]; const PROVIDER_CLIENT_DEFINITION_BY_VALUE: Partial< diff --git a/docs/README.md b/docs/README.md index 4e6f82bfb826..bbe613b9f59a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -17,7 +17,8 @@ - [Remote access](./user/remote-access.md) - [Running in the background](./user/background-service.md) - [Updating T3 Code](./user/updating.md) -- Provider guides: [Codex](./user/providers-codex.md) · [Claude](./user/providers-claude.md) · [OpenCode](./user/providers-opencode.md) · [Antigravity](./user/providers-antigravity.md) +- [External sessions](./user/external-sessions.md) +- Provider guides: [Codex](./user/providers-codex.md) · [Claude](./user/providers-claude.md) · [OpenCode](./user/providers-opencode.md) · [Antigravity](./user/providers-antigravity.md) · [Agent Relay](./user/providers-agentrelay.md) --- diff --git a/docs/internals/providers.md b/docs/internals/providers.md index ec40c49810dc..fcb223094438 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -35,6 +35,152 @@ client connections and provider-instance rebuilds. Releases are immutable, with selecting the version for new processes. Running processes hold leases on their version. Updates and removal must respect those leases instead of replacing executables under a running agent. +## Agent Relay attaches instead of spawning + +Every other adapter owns a local subprocess: `startSession` spawns it, `stopSession` +kills it, and the adapter is the sole client of its stdio. [Agent Relay's +adapter](../../apps/server/src/provider/Layers/AgentRelayAdapter.ts) does neither — +it opens an outbound WebSocket to a broker process this server does not manage, and +attaches to an agent Agent Relay is already running. That agent's lifecycle is +independent of the thread: stopping the T3 Code session closes this client's socket, +not the agent, and other clients (including Agent Relay's own terminal UI) can be +attached to the same broker session concurrently. Assumptions elsewhere in this +directory that the adapter is the only thing writing to the process (e.g. approval +gating) do not hold here — a message another attached client typed can appear as +input this adapter never sent. + +v1 speaks only the broker's terminal transport: `worker_stream` events over `/ws` +(discriminated by `kind`, not `type`; payload in `chunk`, not `data`) and a plain +`POST /api/input/{name}` for keystrokes. This was verified against Agent Relay's +own source (`crates/broker/src/protocol.rs`'s `BrokerEvent::WorkerStream` and its +serde round-trip test, `@agent-relay/harness-driver`'s `HarnessDriverClient`) and +confirmed live against a real `agent-relay-broker` running a real `claude +--version` under its PTY — not a guess. One consequence that is easy to miss: the +broker broadcasts every worker on it over the same `/ws` connection, so +`AgentRelayAdapter` filters incoming frames by `name` against the session's +resolved agent — a session that skipped this would occasionally render another +thread's output. There are no structured turn, tool-call, or approval events, so +turn completion is inferred from the terminal going quiet (`TURN_IDLE_COMPLETE_MS`), +not reported by the agent. Agent Relay's structured `AgentEventEnvelope` protocol +(`@agent-relay/harness-driver`) would remove that heuristic and add real +approvals, but only two Agent Relay harnesses speak it natively today; adopting +it is a distinct v2 adapter, not a v1 extension. + +### Workspace mode: two credential domains that do not bridge today + +`AgentRelaySettings.mode` adds a second shape (`"workspace"`) alongside the +original single-agent mode, following `AntigravitySettings.authMethod`'s +flat-struct-with-a-selector pattern rather than a schema union. In workspace mode, +[`AgentRelayAdapter.startSession`](../../apps/server/src/provider/Layers/AgentRelayAdapter.ts) +spawns an agent for a thread that has none yet (via `AgentRelayWorkspaceClient`, +[`Live`](../../apps/server/src/provider/Layers/AgentRelayWorkspaceClientLive.ts)), +waits for it to report online, and persists the resolved agent name as the thread's +`ProviderSession.resumeCursor` — the same mechanism `CodexSessionRuntime.ts` uses to +persist a rollout id — so reconnects and restarts attach to the same agent instead +of spawning a new one every time. + +This was built against a concrete finding from reading Agent Relay's own source +(`agent-relay-mcp.ts`, `local-agent.ts`, `harness-driver/src/transport.ts`, +`sdk/src/agent-relay.ts`): **there is no existing, non-interactive way to derive a +write-capable broker attach credential from a workspace key.** They are two +separate systems: + +- `list_agents`/`add_agent` (and the `AgentRelay` SDK facade behind them) talk to + the hosted **Relaycast** workspace — a messaging/identity control plane, scoped + by a `rk_live_...` workspace key. Spawning specifically goes through the raw + pass-through thin client (`createWorkspaceClient` in + `@agent-relay/sdk/messaging`, the same one `agent-relay-mcp.ts`'s `getRelay()` + returns) — the nicer typed `AgentRelay#agents` facade omits `spawn` entirely. + Neither surface's agent/node records (`RelayAgent`, `RelayNode`) carry a broker + URL or API key. +- The actual PTY attach (`HarnessDriverClient`/`BrokerTransport` in + `@agent-relay/harness-driver`) is a _different_ credential: the local + `agent-relay-broker` process's own `X-API-Key`-authenticated HTTP/WS API + (`/ws`, `/api/input/{name}/stream`), resolved by the CLI's `attach` command from + `--broker-url`/`--api-key`, `RELAY_BROKER_URL`/`RELAY_BROKER_API_KEY`, or + `connection.json` — never from a workspace key. + +So workspace mode still asks for both a workspace key (discovery/spawn) _and_ a +broker URL/API key (attach) — it cannot derive one from the other. Filed +upstream as AgentWorkforce/relay#1698 (open, deliberately without a proposed +fix — bridging these is an auth-boundary design decision, not something to +guess at from the integration side). Do not confuse this with +AgentWorkforce/relay#1382, a separate, already-fixed bug about the CLI's own +`resolveBrokerConnection` mixing an env-sourced key with a file-sourced URL +_within_ the broker-credential domain — orthogonal to the gap _between_ +domains described here. If Agent Relay ever adds a workspace-key-derivable +attach credential (a `brokerUrl`/`apiKey` on `RelayAgent`/`RelayNode`, or a new +MCP tool), that field is exactly what should replace the second credential +here. + +A second, narrower gap: `harness-driver/src/transport.ts` also exposes a +**streaming** input path (`/api/input/{name}/stream`, a persistent, ack'd, +keepalive'd WebSocket with `pty_input_ready`/`pty_input_ack` flow control) for +high-throughput writes. `AgentRelayAdapter` uses the plain one-shot +`POST /api/input/{name}` instead — the same call `HarnessDriverClient.sendInput` +makes — which is a real, correct, verified endpoint, just not the +highest-throughput one. Moving to the streaming variant is a v2 adapter, not a +v1 gap: nothing about it is wrong today, it just does not need the extra +machinery for a chat-turn cadence of input. Workspace mode's `brokerUrl` is the +same plain broker base URL single mode uses — there is no per-mode URL +convention; only which `name` a session filters/targets differs. + +Presence (used to detect a freshly-spawned agent coming online, and to notice one +going offline) uses `AgentRelay#addListener("agent.status.*", ...)` — the one +wildcard-typed selector confirmed in `packages/sdk/src/listeners.ts`. This could +not be verified end-to-end against a live workspace, so `waitForAgentOnline` +races it against polling `listAgents()` every two seconds (bounded by +`AGENT_SPAWN_WAIT_TIMEOUT`, 90s); polling alone is sufficient for correctness. + +### Auto-materializing threads for already-running agents + +Every online agent in a Workspace-mode instance now gets a T3 Code thread +automatically, whether it was spawned by T3 Code, Agent Relay's own CLI, its +MCP tools, or a fleet trigger. `AgentRelayThreadDiscoveryReactor.ts` +(`apps/server/src/provider/Layers/`) polls `AgentRelayWorkspaceClient.listAgents` +the same way `ProviderSessionReaper.ts` polls provider bindings, and reuses two +mechanisms that already existed rather than inventing new ones: + +- **Which project houses a discovered agent.** `thread.create` + (`apps/server/src/orchestration/decider.ts`) requires a `projectId`, and + `commandInvariants.ts`'s `requireActiveProjectWorkspaceRootAbsent` only + string-compares `workspaceRoot` for uniqueness — it never checks the + filesystem, and `CheckpointReactor.ts`'s `isGitRepository` guard already + no-ops checkpoint capture on a non-git directory. So a synthetic project + only needs a directory to exist, never a git init: nothing ever writes to + it, since the actual agent runs on Agent Relay's side, not on this + filesystem. The reactor creates one project per provider _instance_ (not + per agent, since agents come and go but the project is their durable home) + lazily, the first time a sweep finds an unclaimed online agent for that + instance, under `/agent-relay/` via + `WorkspacePaths.normalizeWorkspaceRoot(..., { createIfMissing: true })` — + the same helper `project.create`'s own client-command normalizer uses, so + there is nothing for a human to browse to and pick. +- **How a materialized thread attaches instead of spawning.** No new + attach-signal plumbing was needed: `AgentSessionImporter.ts` already + established the precedent of installing a `ProviderSessionDirectory` + binding carrying a `resumeCursor` _before_ the thread becomes visible + (`onConflict: "ignore"`), then dispatching `thread.create`. + `ProviderService.startSession` already prefers a persisted binding's + `resumeCursor` over spawning fresh, so a freshly materialized thread's + first turn attaches to the discovered agent by name + (`AgentRelayAdapter.startSession`'s `isAgentRelayResumeCursor` check) the + same way a resumed thread does. + +"Already claimed" is answered by scanning `ProviderSessionDirectory.listBindings()` +for this instance's bindings and reading each one's `resumeCursor.agentName` — +the same directory `ProviderSessionReaper` already scans — rather than a second, +parallel bookkeeping table. + +An agent that drops out of `listAgents()` settles its thread (the same +`thread.settle` verb `ExternalSessionHooks` uses for "session ended") once it +has stayed unconfirmed online for a debounce window, not on the first miss: +presence data here is the same unverified-end-to-end signal described above, +so a single missed sweep is not proof an agent is gone. Sending the settled +thread a new message unsettles it automatically (`decider.ts`'s +`thread.turn-start-requested` handling already resets any settled override on +real activity), so nothing needed to reverse this if the agent comes back. + ## Setup must not happen as a health-check side effect Opening a provider session can start MCP servers, run hooks, or launch a login browser. @@ -107,3 +253,16 @@ current client support. Model classification has its own [manifest constraints](./model-manifest.md). Assistant-reference handling is documented under [citations](./assistant-citations.md). + +## External sessions are visibility only, never a provider adapter + +A Claude Code or Codex session started outside T3 Code entirely (a bare `claude`/`codex` +invocation in a terminal, bypassing Agent Relay too) can only ever produce a settled, read-only +marker thread ([`ExternalSessionHooks`](../../apps/server/src/project/ExternalSessionHooks.ts)), +not a live session. T3 Code never spawned the reported process, so unlike every real provider +adapter there is no PTY, no provider session binding, and no broker to attach to — only a pid, a +cwd, and a timestamp reported by a global lifecycle hook. This is also why it stays a separate +mechanism from `AgentSessionImporter`'s resumable transcript import (`import::` +threads carrying a `resumeCursor`): a lifecycle hook reports process existence, never a transcript, +so there is nothing to resume, and reusing that importer's thread namespace or resume machinery +would advertise a resume affordance this thread can never honor. diff --git a/docs/user/external-sessions.md b/docs/user/external-sessions.md new file mode 100644 index 000000000000..ebbf46e72550 --- /dev/null +++ b/docs/user/external-sessions.md @@ -0,0 +1,81 @@ +# External sessions + +Agent Relay is the primary way T3 Code discovers and controls sessions. This feature is a small +safety net for the case Agent Relay does not cover: a `claude` or `codex` you start directly in a +plain terminal, bypassing T3 Code and Agent Relay entirely. Without it, a session like that leaves +no trace anywhere in T3 Code. With it, T3 Code shows a marker so you at least know the session +existed — a read-only note, not a controllable thread. You do not need this for anything already +visible through T3 Code or Agent Relay. + +## What you get + +Once configured, starting or ending a bare `claude`/`codex` session on the same machine adds a +thread showing the working directory, the process id, and the time it started (and, once it ends, +that it ended). You cannot resume, attach to, or send messages to the original process from that +thread — T3 Code never ran it and has nothing to reconnect to. The thread only appears in a project +already open in T3 Code for that working directory; a directory you have never opened in T3 Code +gets no marker. + +## Set up the Claude Code hook + +Add a `SessionStart` and `SessionEnd` hook to your global `~/.claude/settings.json` (not a +per-project `.claude/settings.json` — this needs to fire for every `claude` invocation on the +machine, not just ones inside a T3 Code project). Requires `jq` and `curl`. Replace `3773` if your +T3 Code server runs on a different port: + +```json +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "jq --arg pid \"$PPID\" --arg ts \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\" '{provider:\"claude\", pid: ($pid|tonumber), cwd: .cwd, sessionId: .session_id, event:\"start\", timestamp: $ts}' | curl -fsS --max-time 1 -X POST http://127.0.0.1:3773/api/external-sessions -H 'Content-Type: application/json' -d @- >/dev/null 2>&1 &" + } + ] + } + ], + "SessionEnd": [ + { + "hooks": [ + { + "type": "command", + "command": "jq --arg pid \"$PPID\" --arg ts \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\" '{provider:\"claude\", pid: ($pid|tonumber), cwd: .cwd, sessionId: .session_id, event:\"end\", timestamp: $ts}' | curl -fsS --max-time 1 -X POST http://127.0.0.1:3773/api/external-sessions -H 'Content-Type: application/json' -d @- >/dev/null 2>&1 &" + } + ] + } + ] + } +} +``` + +Merge these into your existing `hooks` object if you already have other `SessionStart`/`SessionEnd` +hooks configured. The trailing `&` and `--max-time 1` keep a slow or unreachable T3 Code server +from delaying Claude Code's startup or shutdown. + +## Set up the Codex hook + +Codex reads lifecycle hooks from `~/.codex/config.toml`. Add an inline `[hooks]` table (or point +`hooks.json` at equivalent commands, if you prefer keeping them out of `config.toml`): + +```toml +[hooks.session_start] +command = ["bash", "-c", "jq --arg pid \"$PPID\" --arg ts \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\" '{provider:\"codex\", pid: ($pid|tonumber), cwd: .cwd, sessionId: .session_id, event:\"start\", timestamp: $ts}' | curl -fsS --max-time 1 -X POST http://127.0.0.1:3773/api/external-sessions -H \"Content-Type: application/json\" -d @- >/dev/null 2>&1 &"] + +[hooks.session_end] +command = ["bash", "-c", "jq --arg pid \"$PPID\" --arg ts \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\" '{provider:\"codex\", pid: ($pid|tonumber), cwd: .cwd, sessionId: .session_id, event:\"end\", timestamp: $ts}' | curl -fsS --max-time 1 -X POST http://127.0.0.1:3773/api/external-sessions -H \"Content-Type: application/json\" -d @- >/dev/null 2>&1 &"] +``` + +Codex's hooks configuration is newer and still evolving. If `session_start`/`session_end` are not +the exact table names your installed Codex version expects, check `codex --help` or that version's +release notes for the current hook event names — the payload T3 Code needs (`cwd`, `session_id`) +stays the same either way. + +## Limitations + +- Read-only marker only — this is not session import. T3 Code does not scan `~/.claude/projects` + or `~/.codex/sessions` for past transcripts, and this feature does not make an external session + resumable or attachable. +- Only covers sessions on the same machine as the T3 Code server the hook posts to. +- Only appears for a working directory that already has a project open in T3 Code. diff --git a/docs/user/install.md b/docs/user/install.md index 17e9291bf1a0..147cc000be4f 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -75,6 +75,7 @@ computer. | Grok Build | Install [Grok Build CLI](https://x.ai/cli), then run `grok login`. | | OpenCode | Install [OpenCode](https://opencode.ai), then run `opencode auth login`. | | Antigravity | Install and sign in with Google from T3 Code's provider settings. | +| Agent Relay | Paste the broker URL and API key from the Agent Relay CLI into provider settings. | Provider CLIs must be on the server's `PATH`. If T3 Code cannot find one, set its **Binary path** in provider settings, especially when using a version manager. @@ -94,8 +95,9 @@ base URL. Mark secret values as sensitive; after saving, T3 Code does not displa their original values. For provider-specific setup and accounts, see [Codex](./providers-codex.md), -[Claude](./providers-claude.md), [OpenCode](./providers-opencode.md), and -[Antigravity](./providers-antigravity.md). +[Claude](./providers-claude.md), [OpenCode](./providers-opencode.md), +[Antigravity](./providers-antigravity.md), and +[Agent Relay](./providers-agentrelay.md). ## Next steps diff --git a/docs/user/providers-agentrelay.md b/docs/user/providers-agentrelay.md new file mode 100644 index 000000000000..7213d4a8d510 --- /dev/null +++ b/docs/user/providers-agentrelay.md @@ -0,0 +1,90 @@ +# Agent Relay + +Agent Relay is a different kind of provider: instead of installing and logging in to +a CLI on the environment's machine, T3 Code attaches to agents that Agent Relay is +already running elsewhere. It streams an agent's terminal into the thread and sends +what you type back as input, the same way Agent Relay's own terminal clients attach. + +There are two modes. + +## Workspace mode (recommended) + +Point T3 Code at an Agent Relay workspace once, and every agent already running +there — however it was spawned (Agent Relay's CLI, its MCP tools, a fleet trigger, +or an earlier T3 Code thread) — shows up as a thread automatically, not just the +ones you started from T3 Code. Starting a **new** thread on this instance spawns +a fresh agent through Agent Relay instead of requiring one to already exist. + +In **Settings → Providers**, add an Agent Relay instance, set **Mode** to +**Workspace**, and enter: + +- **Workspace key** — the Relaycast workspace key (`rk_live_...`) from the Agent + Relay CLI (`agent-relay workspace` or wherever you provisioned it). +- **Broker URL** — the base URL of the `agent-relay-broker` process itself, e.g. + `https://broker.example.com` (no `/ws` or other path — T3 Code derives both the + output stream and the input endpoint from this one URL). +- **API key** — the broker's own attach credential (separate from the workspace + key — see "Two different credentials" below). +- **Spawn CLI** — which CLI (Claude Code, Codex, Gemini, ...) Agent Relay launches + for a brand-new thread. + +A broker can run more than one agent at once, and T3 Code tells them apart by +name: whichever agent Workspace mode resolves for a thread (spawned fresh or +resumed from a prior session) is the only one that thread's output and input +apply to, even though the underlying connection carries every agent on that +broker. + +Starting a thread that already has an agent bound to it (including one it spawned +itself in a previous session) reconnects to that same agent rather than spawning +another. + +New agents in the workspace appear as threads within about 30 seconds of coming +online, each named after the agent and pre-attached — open one and it connects +immediately, the same as any other thread. If an agent stops running, its +thread settles automatically after a couple of minutes rather than sitting +there looking live forever; sending it a new message brings it back if the +agent returns. + +### Two different credentials + +The workspace key and the broker URL/API key are not interchangeable, and Agent +Relay does not currently derive one from the other. The workspace key lists and +spawns agents through Agent Relay's hosted Relaycast service; the broker URL and +API key are a separate credential for the `agent-relay-broker` process's own +attach API. You need both configured for Workspace mode to actually connect once +an agent is found or spawned. See `docs/internals/providers.md` for why. + +## Single agent mode (legacy) + +Attach to exactly one already-running agent with no discovery: set **Mode** to +**Single agent**, and enter the **Broker URL**, **API key**, and **Agent name** +for that one agent, as printed by the Agent Relay CLI. The agent name is +required here — the broker's connection carries every agent running on it, and +without a name T3 Code has no way to tell them apart. Nothing is spawned and +nothing else in the workspace is visible from this instance. Use this when you +only ever want T3 Code to see one specific agent. + +## Credentials are Agent Relay's, not T3 Code's + +T3 Code does not manage sign-in for the agents Agent Relay runs — no CLI login, no +OAuth flow, no stored account. Whatever provider the underlying agent itself uses +(Claude, Codex, or otherwise) is authenticated on Agent Relay's side, not from +T3 Code. + +## What you see + +Because this is a raw terminal stream, Agent Relay threads look different from other +providers: there is no plan view, no tool-call cards, and no approval prompts — just +the agent's terminal output as it happens, and a text box that sends what you type +followed by Enter. Turn completion is a best-effort guess based on the terminal going +quiet, not a signal from the agent, so an agent that pauses mid-task can briefly show +as done before more output arrives. + +## Reconnecting + +If the connection to the broker drops, T3 Code retries with increasing delays and +shows the thread as disconnected in the meantime. Stopping the thread's session +closes the connection; starting it again (or sending a new message) reconnects. +Because multiple clients can attach to the same broker session, you can keep an +external Agent Relay terminal open on the same agent while a T3 Code thread is +attached to it. diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 7e25c8444dbb..5a807c58b324 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -762,6 +762,148 @@ export const OpenCodeSettings = makeProviderSettingsSchema( ); export type OpenCodeSettings = typeof OpenCodeSettings.Type; +/** + * Agent Relay is unlike every other built-in driver: it does not spawn or + * own a local subprocess. It attaches over WebSocket to an already-running + * agent managed by a separate Agent Relay broker process. There is no + * binary path and no local login flow. + * + * Two modes share this one schema, the same way `AntigravitySettings.authMethod` + * keeps every method's fields flat instead of branching the struct: + * + * - `workspace` (default): `workspaceKey` is a Relaycast workspace key + * (`rk_live_...`) used to list and spawn agents in that workspace. The + * broker URL and API key are still required in this mode — they are a + * *separate* credential domain (the local `agent-relay-broker`'s own PTY + * attach API, not Relaycast) that nothing in Agent Relay's MCP/SDK + * surface currently derives from a workspace key. See + * `docs/internals/providers.md`. + * - `single` (manual opt-in, legacy v1): the broker URL and API key + * identify one already-running agent directly. Nothing to discover or + * spawn. + */ +// Order matters beyond readability: `ProviderSettingsForm.tsx`'s select +// control treats the first entry as the default for a fresh, unconfigured +// instance (independent of `Schema.withDecodingDefault` below, which only +// applies when decoding an already-persisted config) — so Workspace must +// stay first for the Add Provider wizard to actually default to it. +export const AGENT_RELAY_MODES = [ + { value: "workspace", label: "Workspace (auto-discover and spawn)" }, + { value: "single", label: "Single agent (manual)" }, +] as const satisfies ReadonlyArray; +export const AgentRelayMode = Schema.Literals(AGENT_RELAY_MODES.map((mode) => mode.value)); +export type AgentRelayMode = typeof AgentRelayMode.Type; + +export const AGENT_RELAY_SPAWN_CLIS = [ + { value: "claude", label: "Claude Code" }, + { value: "codex", label: "Codex" }, + { value: "gemini", label: "Gemini" }, + { value: "aider", label: "Aider" }, + { value: "goose", label: "Goose" }, + { value: "grok", label: "Grok" }, + { value: "opencode", label: "OpenCode" }, +] as const satisfies ReadonlyArray; +export const AgentRelaySpawnCli = Schema.Literals(AGENT_RELAY_SPAWN_CLIS.map((cli) => cli.value)); +export type AgentRelaySpawnCli = typeof AgentRelaySpawnCli.Type; + +export const AgentRelaySettings = makeProviderSettingsSchema( + { + // Off by default like Cursor, Grok, and OpenCode: this driver needs a + // broker URL before it can do anything. Users opt in from Settings. + enabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + mode: AgentRelayMode.pipe( + // Workspace is the default: it needs no manually-typed agent name and + // discovers/spawns agents automatically. Single is the deliberate + // manual opt-in (see docs/user/providers-agentrelay.md). + Schema.withDecodingDefault(Effect.succeed("workspace" as const)), + Schema.annotateKey({ + title: "Mode", + description: + "Workspace (default) discovers every agent in the workspace and spawns new ones for new threads. Single agent attaches to exactly one agent, identified by name below, with no discovery.", + providerSettingsForm: { + control: "select", + options: AGENT_RELAY_MODES, + clearWhenEmpty: "omit", + }, + }), + ), + workspaceKey: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "Workspace key", + description: + "Relaycast workspace key (rk_live_...) used to list and spawn agents. Only used in Workspace mode. Stored in plain text on this environment.", + providerSettingsForm: { + control: "password", + placeholder: "rk_live_...", + clearWhenEmpty: "omit", + }, + }), + ), + brokerUrl: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "Broker URL", + description: + "Base HTTP(S) URL for the Agent Relay broker (agent-relay-broker), from the Agent Relay CLI, e.g. https://broker.example.com. T3 Code derives the WebSocket output stream and the HTTP input endpoint from this one URL — do not include /ws or a trailing path.", + providerSettingsForm: { + placeholder: "https://broker.example.com", + clearWhenEmpty: "omit", + }, + }), + ), + apiKey: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "API key", + description: + "Sent as the broker's X-API-Key header. Stored in plain text on this environment.", + providerSettingsForm: { + control: "password", + placeholder: "Optional", + clearWhenEmpty: "omit", + }, + }), + ), + agentName: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "Agent name", + description: + "Name of the already-running Agent Relay worker to attach to. Required in Single agent mode: the broker's WebSocket stream carries every worker on that broker, and this is how T3 Code tells them apart. Not used in Workspace mode, which resolves a name per thread instead.", + providerSettingsForm: { + placeholder: "Worker1", + clearWhenEmpty: "omit", + }, + }), + ), + defaultSpawnCli: AgentRelaySpawnCli.pipe( + Schema.withDecodingDefault(Effect.succeed("claude" as const)), + Schema.annotateKey({ + title: "Spawn CLI", + description: + "CLI Agent Relay launches when a new thread on this instance has no agent to attach to yet. Only used in Workspace mode.", + providerSettingsForm: { + control: "select", + options: AGENT_RELAY_SPAWN_CLIS, + clearWhenEmpty: "omit", + }, + }), + ), + customModels: Schema.Array(CustomModelSetting).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + }, + { + order: ["mode", "workspaceKey", "brokerUrl", "apiKey", "agentName", "defaultSpawnCli"], + }, +); +export type AgentRelaySettings = typeof AgentRelaySettings.Type; + /** * A read-only quota source outside this environment's provider CLIs. The * only kind today is a CLIProxyAPI hub, whose management API reports the diff --git a/packages/shared/src/Net.test.ts b/packages/shared/src/Net.test.ts index 93a1649b25ba..ae73a297880b 100644 --- a/packages/shared/src/Net.test.ts +++ b/packages/shared/src/Net.test.ts @@ -81,6 +81,19 @@ it.layer(NetService.layer)("NetService", (it) => { }), ); + it.effect("canListenOnHost never blocks on ::1, with or without IPv6 support", () => + Effect.gen(function* () { + // Regression: a sandboxed/containerized host with no IPv6 stack at + // all raises EAFNOSUPPORT binding ::1, not EADDRNOTAVAIL — dev + // startup must tolerate both, since neither means the port is + // actually taken. Port 0 (ephemeral) so this passes identically on + // a host that does have working IPv6. + const net = yield* NetService.NetService; + const available = yield* net.canListenOnHost(0, "::1"); + assert.equal(available, true); + }), + ); + it.effect("findAvailablePort falls back when a wildcard listener occupies IPv4", () => Effect.acquireUseRelease( openServer("0.0.0.0"), diff --git a/packages/shared/src/Net.ts b/packages/shared/src/Net.ts index e3b653692880..9ca2c143b756 100644 --- a/packages/shared/src/Net.ts +++ b/packages/shared/src/Net.ts @@ -67,8 +67,11 @@ export class NetService extends Context.Service()( export const make = () => { /** * Returns true when a TCP server can bind to {host, port}. - * `EADDRNOTAVAIL` is treated as available so IPv6-absent hosts don't fail - * loopback availability checks. + * `EADDRNOTAVAIL` and `EAFNOSUPPORT` are treated as available so + * IPv6-absent hosts don't fail loopback availability checks — a host + * with no IPv6 stack at all (some sandboxed/containerized environments) + * raises `EAFNOSUPPORT` for `::1`, not `EADDRNOTAVAIL`; both mean "this + * address family isn't usable here," not "the port is taken." */ const canListenOnHost = (port: number, host: string): Effect.Effect => Effect.callback((resume) => { @@ -84,7 +87,10 @@ export const make = () => { server.unref(); server.once("error", (cause) => { - if (isErrnoExceptionWithCode(cause) && cause.code === "EADDRNOTAVAIL") { + if ( + isErrnoExceptionWithCode(cause) && + (cause.code === "EADDRNOTAVAIL" || cause.code === "EAFNOSUPPORT") + ) { settle(true); return; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 567d72e6da35..5faa19c1e712 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -475,6 +475,9 @@ importers: apps/server: dependencies: + '@agent-relay/sdk': + specifier: ^11.10.3 + version: 11.10.3 '@anthropic-ai/claude-agent-sdk': specifier: ^0.3.260 version: 0.3.260(@anthropic-ai/sdk@0.93.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3) @@ -508,6 +511,9 @@ importers: stream-json: specifier: 3.6.0 version: 3.6.0 + ws: + specifier: ^8.18.0 + version: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) yaml: specifier: ^2.9.0 version: 2.9.0 @@ -536,6 +542,9 @@ importers: '@types/node': specifier: 24.12.4 version: 24.12.4 + '@types/ws': + specifier: ^8.5.13 + version: 8.18.1 '@types/yauzl': specifier: ^3.4.0 version: 3.4.0 @@ -1006,6 +1015,10 @@ importers: packages: + '@agent-relay/sdk@11.10.3': + resolution: {integrity: sha512-/1vEi8uU2QWZHjAlsBTSLFSGxAuCoaftcdIb1oCfCKvZw0mU6tkjV2CDYK7zMc3ikbeN5SzNx18lbSgE0YN8aA==} + engines: {node: '>=22.0.0'} + '@ai-sdk/provider-utils@4.0.49': resolution: {integrity: sha512-8e7pd+82bobqrFOaD5dG/PiEuvLYr5olaE3I56ch0jipR0H7sGD6ohwTUynv6k8O8QidWiyIbEsZCtr/2dyXIA==} engines: {node: '>=18.17'} @@ -4279,6 +4292,12 @@ packages: '@react-navigation/routers@7.6.0': resolution: {integrity: sha512-lblhDXfS75jLc7G2K7BZGM+7cjqQXk13X/MA4fq/12r62zM+fBhhreLzYflSitrDDXFRJpSvJXy0ziiGU04Xow==} + '@relaycast/sdk@8.4.0': + resolution: {integrity: sha512-k6OsQ+0GBwnNv/PZ2bVLg7Yr/mruKm2X/uYfn9VIhlPj7NllDyUuIEiKuEHU3kGSIlpkW68fmWiVKZTnuxV3xQ==} + + '@relaycast/types@8.4.0': + resolution: {integrity: sha512-m5DIqV/ngA9p3bpi9wGIY/oUElwrNW5pqMKf3Kg10Xh/a5bmQaIH8dGOrqocHYB4sOsnaJlnacvWdTw5+VpXLA==} + '@rolldown/binding-android-arm64@1.0.0-rc.17': resolution: {integrity: sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -10812,6 +10831,12 @@ packages: snapshots: + '@agent-relay/sdk@11.10.3': + dependencies: + '@relaycast/sdk': 8.4.0 + '@relaycast/types': 8.4.0 + zod: 4.4.3 + '@ai-sdk/provider-utils@4.0.49(zod@4.4.3)': dependencies: '@ai-sdk/provider': 3.0.15 @@ -14482,6 +14507,15 @@ snapshots: dependencies: nanoid: 3.3.12 + '@relaycast/sdk@8.4.0': + dependencies: + '@relaycast/types': 8.4.0 + zod: 4.4.3 + + '@relaycast/types@8.4.0': + dependencies: + zod: 4.4.3 + '@rolldown/binding-android-arm64@1.0.0-rc.17': optional: true