From b61772245c087adf0ea2eaf901789a332b3a5905 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 06:39:16 +0000 Subject: [PATCH 01/16] chore: ignore .claude/worktrees/ scratch directory Agent harness isolation worktrees are local tooling state, not project content, and shouldn't be tracked. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01HXsTUKG2oXAfj6dmW838mG --- .gitignore | 1 + 1 file changed, 1 insertion(+) 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/ From 9e77080c84995286950714413f1758c96409d227 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 07:21:10 +0000 Subject: [PATCH 02/16] feat(server): add Agent Relay provider adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T3 Code can only talk to agents whose CLI it spawns and owns as a local subprocess. Agent Relay runs agents remotely via its own broker process and lets external clients attach to them over a WebSocket terminal stream — a different enough model (attach vs. spawn) that no existing adapter fit, so this adds `agentrelay` as a new provider driver end to end: contracts, server adapter, and settings UI. - packages/contracts/src/settings.ts: new `AgentRelaySettings` schema (brokerUrl + apiKey, no binary path or login flow — this driver has neither). `ProviderDriverKind` is already an open branded slug, so no closed-union changes were needed anywhere else in contracts. - apps/server/src/provider/Layers/AgentRelayAdapter.ts: the adapter. Connects outbound over `ws` to the broker, translates `worker_stream` frames into `content.delta` runtime events and outgoing text into `sendInput` frames. Models connect/reconnect(backoff)/error/disconnect lifecycle, and completes a turn via an idle-timeout heuristic since the terminal transport has no native "done" signal. Approvals/user-input are explicitly "not supported here" (no structured protocol in v1). - apps/server/src/provider/Layers/AgentRelayProvider.ts + Drivers/AgentRelayDriver.ts: status snapshot (config-presence health check only — no live probe, so a background check can't attach to a running agent) and driver registration in builtInDrivers.ts. - apps/server/src/textGeneration/AgentRelayTextGeneration.ts: text generation (commit messages, etc.) is deliberately unsupported — no structured call exists over a raw terminal. - apps/web: AgentRelayIcon, providerDriverMeta.ts entry (drives the generic Add-Provider-Instance settings form), providerIconUtils.ts; apps/mobile/ProviderIcon.tsx gets a matching icon instead of falling back to Codex's. - docs/user/providers-agentrelay.md + install.md: how to configure it and that credentials are Agent Relay's, not T3 Code's. - docs/internals/providers.md: short note on attach-vs-spawn as the one hard-to-discover deviation, and pointing at Agent Relay's structured `AgentEventEnvelope` protocol as the natural v2. - apps/server/package.json: adds `ws` (dependency) and `@types/ws` (devDependency) — `ws` ships no bundled type declarations. Verified: targeted `tsgo --noEmit` on packages/contracts, apps/server, apps/web, and `tsc --noEmit` on apps/mobile all pass clean. `vp lint` on every touched file is clean. New adapter tests (5, against a real local `ws` mock broker, no mocks/stubs) and existing provider-registry tests pass; contracts settings tests pass. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01HXsTUKG2oXAfj6dmW838mG --- apps/mobile/src/components/ProviderIcon.tsx | 18 +- apps/server/package.json | 2 + .../src/provider/Drivers/AgentRelayDriver.ts | 122 ++++ .../provider/Layers/AgentRelayAdapter.test.ts | 235 +++++++ .../src/provider/Layers/AgentRelayAdapter.ts | 606 ++++++++++++++++++ .../src/provider/Layers/AgentRelayProvider.ts | 144 +++++ .../provider/Services/AgentRelayAdapter.ts | 16 + apps/server/src/provider/builtInDrivers.ts | 3 + .../AgentRelayTextGeneration.ts | 39 ++ apps/web/src/components/Icons.tsx | 14 + .../src/components/chat/providerIconUtils.ts | 2 + .../components/settings/providerDriverMeta.ts | 9 + docs/internals/providers.md | 22 + docs/user/install.md | 6 +- docs/user/providers-agentrelay.md | 44 ++ packages/contracts/src/settings.ts | 51 ++ pnpm-lock.yaml | 6 + 17 files changed, 1336 insertions(+), 3 deletions(-) create mode 100644 apps/server/src/provider/Drivers/AgentRelayDriver.ts create mode 100644 apps/server/src/provider/Layers/AgentRelayAdapter.test.ts create mode 100644 apps/server/src/provider/Layers/AgentRelayAdapter.ts create mode 100644 apps/server/src/provider/Layers/AgentRelayProvider.ts create mode 100644 apps/server/src/provider/Services/AgentRelayAdapter.ts create mode 100644 apps/server/src/textGeneration/AgentRelayTextGeneration.ts create mode 100644 docs/user/providers-agentrelay.md 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/package.json b/apps/server/package.json index f602f37a38f9..41c6062fb77a 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -33,6 +33,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 +45,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/provider/Drivers/AgentRelayDriver.ts b/apps/server/src/provider/Drivers/AgentRelayDriver.ts new file mode 100644 index 000000000000..f1e44b7b8795 --- /dev/null +++ b/apps/server/src/provider/Drivers/AgentRelayDriver.ts @@ -0,0 +1,122 @@ +/** + * 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 * 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 { 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 + | 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; + + const adapter = yield* makeAgentRelayAdapter(effectiveConfig, { instanceId }); + 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..ee4f8d563f11 --- /dev/null +++ b/apps/server/src/provider/Layers/AgentRelayAdapter.test.ts @@ -0,0 +1,235 @@ +// @effect-diagnostics nodeBuiltinImport:off +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 Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; +import { WebSocketServer, type WebSocket as WsSocket } from "ws"; + +import { AgentRelaySettings, ThreadId, type ProviderRuntimeEvent } from "@t3tools/contracts"; + +import { makeAgentRelayAdapter } from "./AgentRelayAdapter.ts"; + +const decodeAgentRelaySettings = Schema.decodeSync(AgentRelaySettings); + +const WorkerStreamFrame = Schema.Struct({ + type: Schema.Literal("worker_stream"), + data: Schema.String, +}); +const encodeWorkerStreamFrame = Schema.encodeSync(Schema.fromJsonString(WorkerStreamFrame)); + +const SendInputFrame = Schema.Struct({ type: Schema.Literal("sendInput"), data: Schema.String }); +const decodeSendInputFrame = Schema.decodeUnknownSync(Schema.fromJsonString(SendInputFrame)); + +function startMockBroker(): Promise<{ readonly server: WebSocketServer; readonly url: string }> { + return new Promise((resolve) => { + const server = new WebSocketServer({ port: 0 }, () => { + const address = server.address(); + const port = typeof address === "object" && address !== null ? address.port : 0; + resolve({ server, url: `ws://127.0.0.1:${port}` }); + }); + }); +} + +/** + * 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 (e.g. `sendFrame` on an + * already-open socket) 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)); + +const forkMessageWait = (socket: WsSocket) => + forkNodeEventWait((resume) => + socket.once("message", (data: Buffer) => resume(data.toString("utf8"))), + ); + +/** + * 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> => + Effect.gen(function* () { + while (true) { + const current = yield* Ref.get(events); + const found = current.find( + (event): event is Extract => event.type === eventType, + ); + if (found) return found; + yield* Effect.sleep(Duration.millis(10)); + } + }).pipe(Effect.timeout("2 seconds"), TestClock.withLive, Effect.orDie); + +const agentRelayAdapterTestLayer = NodeServices.layer; + +const makeTestAdapter = (brokerUrl: string, apiKey = "test-key") => + makeAgentRelayAdapter(decodeAgentRelaySettings({ enabled: true, brokerUrl, apiKey })); + +it.layer(agentRelayAdapterTestLayer)("AgentRelayAdapterLive", (it) => { + it.effect("streams worker_stream frames as content.delta and forwards sendInput", () => + Effect.gen(function* () { + const { server, url } = yield* Effect.promise(startMockBroker); + yield* Effect.addFinalizer(() => Effect.sync(() => server.close())); + + const adapter = yield* makeTestAdapter(url); + 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.", + }); + + brokerSocket.send( + encodeWorkerStreamFrame({ type: "worker_stream", data: "$ echo hi\nhi\n" }), + ); + const delta = yield* waitForEvent(events, "content.delta"); + assert.equal(delta.payload.streamKind, "command_output"); + assert.equal(delta.payload.delta, "$ echo hi\nhi\n"); + + const inboundInputFiber = yield* forkMessageWait(brokerSocket); + yield* adapter.sendTurn({ threadId, input: "hello agent" }); + const inbound = yield* Fiber.join(inboundInputFiber); + assert.deepEqual(decodeSendInputFrame(inbound), { type: "sendInput", data: "hello agent\n" }); + + const started = yield* waitForEvent(events, "turn.started"); + assert.isDefined(started.turnId); + + yield* adapter.stopSession(threadId); + yield* waitForEvent(events, "session.exited"); + }), + ); + + it.effect("completes a turn once the broker goes quiet", () => + Effect.gen(function* () { + const { server, url } = yield* Effect.promise(startMockBroker); + yield* Effect.addFinalizer(() => Effect.sync(() => server.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"); + + // No further output arrives: the idle watchdog should complete the + // turn on its own once TURN_IDLE_COMPLETE_MS has elapsed. + 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 sends Ctrl-C and completes it as cancelled", () => + Effect.gen(function* () { + const { server, url } = yield* Effect.promise(startMockBroker); + yield* Effect.addFinalizer(() => Effect.sync(() => server.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" }); + const brokerSocket = yield* Fiber.join(connectionFiber); + yield* waitForEvent(events, "session.state.changed"); + + const firstInputFiber = yield* forkMessageWait(brokerSocket); + yield* adapter.sendTurn({ threadId, input: "run forever" }); + yield* Fiber.join(firstInputFiber); + + const interruptFiber = yield* forkMessageWait(brokerSocket); + yield* adapter.interruptTurn(threadId); + const interruptFrame = yield* Fiber.join(interruptFiber); + + assert.deepEqual(decodeSendInputFrame(interruptFrame), { type: "sendInput", data: "" }); + const completed = yield* waitForEvent(events, "turn.completed"); + assert.deepEqual(completed.payload, { state: "cancelled", stopReason: null }); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("has no session before startSession and none after stopSession", () => + Effect.gen(function* () { + const { server, url } = yield* Effect.promise(startMockBroker); + yield* Effect.addFinalizer(() => Effect.sync(() => server.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, brokerUrl: "", 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"); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/AgentRelayAdapter.ts b/apps/server/src/provider/Layers/AgentRelayAdapter.ts new file mode 100644 index 000000000000..170280e08fc9 --- /dev/null +++ b/apps/server/src/provider/Layers/AgentRelayAdapter.ts @@ -0,0 +1,606 @@ +/** + * 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 and replying with + * `sendInput` keystroke frames over the same socket. See + * `docs/internals/providers.md` for why this is v1 scope and what a v2 + * structured-protocol adapter would add. + * + * Wire-format note: the exact JSON shape of `worker_stream` / `sendInput` + * frames is not vendored into this repo, so `parseAgentRelayFrame` and + * `encodeSendInputFrame` below are the single, isolated boundary that + * assumes a shape (`{ type: "worker_stream", data: string }` in, + * `{ type: "sendInput", data: string }` out). If the real broker's frames + * differ, only these two functions need to change. + * + * @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 PubSub from "effect/PubSub"; +import * as Queue from "effect/Queue"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import WebSocket from "ws"; + +import { + ProviderAdapterRequestError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, +} from "../Errors.ts"; +import { type AgentRelayAdapterShape } from "../Services/AgentRelayAdapter.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; + +export interface AgentRelayAdapterLiveOptions { + /** Selections are honored when routed to this instance id. Defaults to + * the legacy built-in instance id (`agentrelay`). */ + readonly instanceId?: ProviderInstanceId; +} + +interface AgentRelaySessionContext { + readonly threadId: ThreadId; + session: ProviderSession; + readonly scope: Scope.Closeable; + 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 text frame. Returns `undefined` for anything that does + * not match the assumed `worker_stream` shape (non-JSON, a different + * `type`, or a missing text field) so an unrecognized broker message is + * ignored instead of tearing down the session. + */ +function parseAgentRelayFrame(raw: 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.type !== "worker_stream" && record.type !== "workerStream") return undefined; + const text = + typeof record.data === "string" + ? record.data + : typeof record.chunk === "string" + ? record.chunk + : typeof record.text === "string" + ? record.text + : undefined; + return text !== undefined ? { text } : undefined; +} + +function encodeSendInputFrame(data: string): string { + return JSON.stringify({ type: "sendInput", data }); +} + +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 sessions = 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 }), + ), + ), + ); + }; + + 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); + }; + + const sendFrame = (ctx: AgentRelaySessionContext, payload: string): boolean => { + const socket = ctx.socket; + if (!socket || socket.readyState !== WebSocket.OPEN) return false; + try { + socket.send(payload); + return true; + } catch { + return false; + } + }; + + 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, ...readySession } = ctx.session; + ctx.session = { ...readySession, status: "ready", 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* () { + 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); + if (!frame) return; + yield* Queue.offer(ctx.activitySignals, undefined); + yield* offerRuntimeEvent({ + type: "content.delta", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: ctx.threadId, + ...(ctx.activeTurnId ? { turnId: ctx.activeTurnId } : {}), + payload: { streamKind: "command_output", delta: frame.text }, + }); + }); + + const handleClose = (ctx: AgentRelaySessionContext, code: number, reason: string) => + Effect.gen(function* () { + const liveCtx = sessions.get(ctx.threadId); + if (liveCtx !== ctx || ctx.stopped) return; + ctx.socket = undefined; + // 1000 is a normal close either side can initiate — `stopSession` + // closes with this code, so treat it as a deliberate disconnect + // rather than something to reconnect from. + if (code === 1000) { + yield* stopSessionInternal(ctx); + return; + } + 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 }, + }); + 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(); + let socket: WebSocket; + try { + socket = apiKey + ? new WebSocket(agentRelaySettings.brokerUrl, { + headers: { authorization: `Bearer ${apiKey}` }, + }) + : new WebSocket(agentRelaySettings.brokerUrl); + } 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) => + 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.", + }); + } + + 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, + createdAt: now, + updatedAt: now, + }; + const ctx: AgentRelaySessionContext = { + threadId: input.threadId, + session, + scope, + 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 !== "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.", + }); + } + if (!sendFrame(ctx, encodeSendInputFrame(`${text}\n`))) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "sendInput", + detail: "Failed to send input to the Agent Relay broker socket.", + }); + } + + const isNewTurn = ctx.activeTurnId === undefined; + const turnId = ctx.activeTurnId ?? TurnId.make(yield* randomUUIDv4); + 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); + } + + 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. + sendFrame(ctx, encodeSendInputFrame("\u0003")); + 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, + } satisfies AgentRelayAdapterShape; + }); +} diff --git a/apps/server/src/provider/Layers/AgentRelayProvider.ts b/apps/server/src/provider/Layers/AgentRelayProvider.ts new file mode 100644 index 000000000000..af292817c2fe --- /dev/null +++ b/apps/server/src/provider/Layers/AgentRelayProvider.ts @@ -0,0 +1,144 @@ +/** + * 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: [] }); + +// 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. +const AGENT_RELAY_BUILT_IN_MODELS: ReadonlyArray = [ + { + slug: "relay-agent", + 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 WebSocket URL from the Agent Relay CLI.", + }, + }); + } + + const hasApiKey = settings.apiKey.trim().length > 0; + return buildServerProvider({ + presentation: AGENT_RELAY_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version: null, + status: "ready", + auth: hasApiKey + ? { status: "authenticated", type: "api_key", label: "Agent Relay API key" } + : { status: "unauthenticated" }, + ...(hasApiKey + ? {} + : { message: "No API key configured. The broker may reject the connection." }), + }, + }); + }); +} diff --git a/apps/server/src/provider/Services/AgentRelayAdapter.ts b/apps/server/src/provider/Services/AgentRelayAdapter.ts new file mode 100644 index 000000000000..f5f0b5442518 --- /dev/null +++ b/apps/server/src/provider/Services/AgentRelayAdapter.ts @@ -0,0 +1,16 @@ +/** + * 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"; + +/** + * AgentRelayAdapterShape — per-instance Agent Relay adapter contract. + */ +export interface AgentRelayAdapterShape extends ProviderAdapterShape {} 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 + 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/internals/providers.md b/docs/internals/providers.md index ec40c49810dc..08abdb5512ce 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -35,6 +35,28 @@ 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` frames in, +`sendInput` frames out), so there are no structured turn, tool-call, or approval +events — 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. + ## 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. 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..637a8339e698 --- /dev/null +++ b/docs/user/providers-agentrelay.md @@ -0,0 +1,44 @@ +# 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 an agent that Agent Relay is +already running elsewhere. It streams that agent's terminal into the thread and sends +what you type back as input, the same way Agent Relay's own terminal clients attach. + +## How to start + +Get a broker URL and API key from the Agent Relay CLI for the agent you want to +attach to. In **Settings → Providers**, add an Agent Relay instance and enter: + +- **Broker URL** — the WebSocket URL for the Agent Relay broker, for example + `wss://broker.example.com/ws`. +- **API key** — the attach token for that broker session. + +Enable the instance and start a thread on it. T3 Code connects immediately; there is +nothing to install. + +## Credentials are Agent Relay's, not T3 Code's + +T3 Code does not manage sign-in for the agent Agent Relay is running — no CLI login, +no OAuth flow, no stored account. The broker URL and API key are the only +credentials this provider needs, and they only grant access to attach to that one +already-running agent. 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..05a13b88a4d4 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -762,6 +762,57 @@ 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 — credentials are the broker URL and + * API key the user copies out of the Agent Relay CLI. + */ +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 } }), + ), + brokerUrl: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "Broker URL", + description: + "WebSocket URL for the Agent Relay broker control plane, from the Agent Relay CLI.", + providerSettingsForm: { + placeholder: "wss://broker.example.com/ws", + clearWhenEmpty: "omit", + }, + }), + ), + apiKey: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "API key", + description: + "Attach token for the broker session. Stored in plain text on this environment.", + providerSettingsForm: { + control: "password", + placeholder: "Optional", + clearWhenEmpty: "omit", + }, + }), + ), + customModels: Schema.Array(CustomModelSetting).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + }, + { + order: ["brokerUrl", "apiKey"], + }, +); +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/pnpm-lock.yaml b/pnpm-lock.yaml index 567d72e6da35..f2b0cf80b1a1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -508,6 +508,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 +539,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 From 85f8fc3d77c18e1ab7552f85f87d3642825d4605 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 07:51:36 +0000 Subject: [PATCH 03/16] feat(server): surface externally-started Claude/Codex sessions via lifecycle hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bare `claude` or `codex` invocation in a raw terminal bypasses T3 Code and Agent Relay entirely, leaving no trace anywhere in the app. Add a minimal, deliberately read-only fallback: both CLIs support global lifecycle hooks (Claude Code's SessionStart/SessionEnd, Codex's config.toml hooks) that fire regardless of what launched them and can report a session's existence. - New `POST /api/external-sessions` route (wired into the existing HTTP router alongside the other raw route layers in server.ts) accepts `{provider, pid, cwd, sessionId, event, timestamp}` from a hook script. - On `start`, materializes a settled thread in the project already open for the reported `cwd` (skipped if none exists) carrying an informational `thread.activity.append` entry — reusing existing thread/activity primitives rather than a new session model. No provider session binding, no PTY: there is nothing to attach to or resume. - On `end`, appends a second activity and settles the thread (reverse of the "start" state, per this repo's own reverse-states rule). Both directions are idempotent no-ops for a duplicate start or an end with no prior start. - Docs: docs/user/external-sessions.md has the exact hook configuration for both CLIs; docs/internals/providers.md explains why this stays read-only and separate from AgentSessionImporter's resumable transcript import. Left out of this v1 (noted as future scope, not built): scanning ~/.claude/projects or ~/.codex/sessions for historical transcripts, making external sessions resumable/attachable, and auto-creating a project for a cwd T3 Code has never opened. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01HXsTUKG2oXAfj6dmW838mG --- .../src/project/ExternalSessionHooks.test.ts | 294 ++++++++++++++++++ .../src/project/ExternalSessionHooks.ts | 228 ++++++++++++++ apps/server/src/server.ts | 2 + docs/README.md | 1 + docs/internals/providers.md | 13 + docs/user/external-sessions.md | 81 +++++ 6 files changed, 619 insertions(+) create mode 100644 apps/server/src/project/ExternalSessionHooks.test.ts create mode 100644 apps/server/src/project/ExternalSessionHooks.ts create mode 100644 docs/user/external-sessions.md diff --git a/apps/server/src/project/ExternalSessionHooks.test.ts b/apps/server/src/project/ExternalSessionHooks.test.ts new file mode 100644 index 000000000000..2577ae28a79e --- /dev/null +++ b/apps/server/src/project/ExternalSessionHooks.test.ts @@ -0,0 +1,294 @@ +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 { HttpBody, HttpClient, HttpRouter } from "effect/unstable/http"; + +import { decideOrchestrationCommand } from "../orchestration/decider.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 } 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; + + 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, + ) => + 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), + }; +}); + +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) }), + ); + +it.effect("materializes a read-only marker thread on start and settles it on end", () => + Effect.scoped( + Effect.gen(function* () { + 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* () { + 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))), +); diff --git a/apps/server/src/project/ExternalSessionHooks.ts b/apps/server/src/project/ExternalSessionHooks.ts new file mode 100644 index 000000000000..7fe4eb647b98 --- /dev/null +++ b/apps/server/src/project/ExternalSessionHooks.ts @@ -0,0 +1,228 @@ +/** + * 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 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"; + +export const EXTERNAL_SESSIONS_ROUTE_PATH = "/api/external-sessions"; + +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; + +/** Body a lifecycle hook posts to {@link EXTERNAL_SESSIONS_ROUTE_PATH}. */ +export const ExternalSessionHookPayload = Schema.Struct({ + provider: ExternalSessionHookProvider, + pid: PositiveInt, + cwd: TrimmedNonEmptyString, + sessionId: TrimmedNonEmptyString, + event: ExternalSessionHookEventKind, + timestamp: IsoDateTime, +}); +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:${input.provider}:${input.sessionId}`); +} + +/** + * 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); + + if (payload.event === "end") { + if (Option.isNone(existingThread)) { + return { recorded: false, reason: "unknown-session" } 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)) { + return { recorded: false, reason: "already-recorded" } 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* 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.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, + }); + return { recorded: true, threadId } satisfies ExternalSessionHookResult; + }, +); + +const decodeExternalSessionHookPayload = Schema.decodeUnknownEffect(ExternalSessionHookPayload); + +/** + * `POST /api/external-sessions` — see module docs. Unauthenticated by + * design, same as this server's other loopback-oriented local tooling + * surfaces: the payload only ever produces a read-only informational marker, + * never code execution or a live session, so the worst a forged POST can do + * is add a fake marker thread. + */ +export const externalSessionHooksRouteLayer = HttpRouter.add( + "POST", + EXTERNAL_SESSIONS_ROUTE_PATH, + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const bodyJson = yield* request.json.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 }); + } + 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/server.ts b/apps/server/src/server.ts index 349644966f26..aa131d46a9f8 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -25,6 +25,7 @@ import { guardHttpResponseWriteErrors } from "./httpResponseErrorGuard.ts"; import { fixPath } from "./os-jank.ts"; import { websocketRpcRouteLayer } from "./ws.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; +import { externalSessionHooksRouteLayer } from "./project/ExternalSessionHooks.ts"; import { pullRequestHttpApiLayer } from "./pullRequest/http.ts"; import * as PullRequestProviderRegistry from "./pullRequest/PullRequestProviderRegistry.ts"; import * as PullRequestService from "./pullRequest/PullRequestService.ts"; @@ -549,6 +550,7 @@ export const makeRoutesLayer = Layer.mergeAll( otlpTracesProxyRouteLayer, assetRouteLayer, attachmentUploadRouteLayer, + externalSessionHooksRouteLayer, staticAndDevRouteLayer, websocketRpcRouteLayer, ), diff --git a/docs/README.md b/docs/README.md index 4e6f82bfb826..bbbea9b166b7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -17,6 +17,7 @@ - [Remote access](./user/remote-access.md) - [Running in the background](./user/background-service.md) - [Updating T3 Code](./user/updating.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) --- diff --git a/docs/internals/providers.md b/docs/internals/providers.md index ec40c49810dc..528803d9a030 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -107,3 +107,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. From fc70608f55aa9434a25c7df8edd2755a25d1d21f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 07:51:58 +0000 Subject: [PATCH 04/16] feat(server): auto-discover and spawn Agent Relay agents from a workspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1's Agent Relay adapter only attached to one manually-configured agent (a static brokerUrl+apiKey pair). This adds a "workspace" mode alongside that legacy "single agent" mode: point an Agent Relay instance at a Relaycast workspace key once, and starting a new thread on it spawns a fresh agent through Agent Relay (waiting for it to come online) instead of requiring a pre-existing target; reconnecting to an already-spawned thread reuses its persisted agent name instead of spawning again. New AgentRelayWorkspaceClient(Live) wraps @agent-relay/sdk: listing/ spawning goes through the same workspace-key-scoped thin client Agent Relay's own list_agents/add_agent MCP tools use, and presence uses AgentRelay#addListener("agent.status.*") raced against polling listAgents() (the presence path could not be verified against a live workspace, so polling alone still guarantees correctness). AgentRelaySettings gains mode/workspaceKey/defaultSpawnCli fields (flat struct + selector, matching AntigravitySettings.authMethod) rather than a schema union, keeping the web settings wizard fully schema-driven with no UI code changes needed. AgentRelayAdapter persists the resolved agent name as ProviderSession.resumeCursor (the same mechanism CodexSessionRuntime uses for rollout ids) so restarts reattach correctly. Reading Agent Relay's own source (agent-relay-mcp.ts, local-agent.ts, harness-driver's transport.ts, the SDK's agent-relay.ts) confirmed there is no existing non-interactive way to derive a write-capable broker attach credential from a workspace key — they are two separate credential domains today. Workspace mode therefore still requires both a workspace key (discovery/spawn) and a broker URL/API key (attach), and the actual per-agent attach reuses v1's existing (already-approximate) wire format via a T3-Code-side {name}-substitution convention rather than reimplementing harness-driver's real two-channel ack/keepalive protocol, which is out of scope here for the same reason v1's wire-format guess already was. Both gaps are documented in docs/internals/providers.md with pointers for a follow-up v2 adapter. Automatically materializing threads for already-running agents (as opposed to attaching once a thread already exists) is also left out of scope, pending a product decision on which project should host them — documented with a concrete integration pointer. Tests: extended AgentRelayAdapter.test.ts with workspace-mode spawn/ attach/resume/fallback-URL cases against a real mock WebSocket broker and a fake workspace client, plus a focused unit test for the presence-event parsing helper. `vp run --filter t3 typecheck`, `vp run --filter @t3tools/contracts typecheck`, `vp run --filter @t3tools/web typecheck`, and `vp lint` all clean; targeted tests (12/12) pass. Not verified against a real Agent Relay workspace/broker (none available) — see the providers.md caveats above for what that would need to confirm. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01HXsTUKG2oXAfj6dmW838mG --- apps/server/package.json | 1 + .../src/provider/Drivers/AgentRelayDriver.ts | 12 +- .../provider/Layers/AgentRelayAdapter.test.ts | 130 ++++++++++++ .../src/provider/Layers/AgentRelayAdapter.ts | 158 +++++++++++++- .../AgentRelayWorkspaceClientLive.test.ts | 42 ++++ .../Layers/AgentRelayWorkspaceClientLive.ts | 194 ++++++++++++++++++ .../Services/AgentRelayWorkspaceClient.ts | 46 +++++ docs/internals/providers.md | 77 +++++++ docs/user/providers-agentrelay.md | 62 ++++-- packages/contracts/src/settings.ts | 77 ++++++- pnpm-lock.yaml | 28 +++ 11 files changed, 805 insertions(+), 22 deletions(-) create mode 100644 apps/server/src/provider/Layers/AgentRelayWorkspaceClientLive.test.ts create mode 100644 apps/server/src/provider/Layers/AgentRelayWorkspaceClientLive.ts create mode 100644 apps/server/src/provider/Services/AgentRelayWorkspaceClient.ts diff --git a/apps/server/package.json b/apps/server/package.json index 41c6062fb77a..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:", diff --git a/apps/server/src/provider/Drivers/AgentRelayDriver.ts b/apps/server/src/provider/Drivers/AgentRelayDriver.ts index f1e44b7b8795..76f42a9e23dd 100644 --- a/apps/server/src/provider/Drivers/AgentRelayDriver.ts +++ b/apps/server/src/provider/Drivers/AgentRelayDriver.ts @@ -23,6 +23,7 @@ import { buildInitialAgentRelayProviderSnapshot, checkAgentRelayProviderStatus, } from "../Layers/AgentRelayProvider.ts"; +import { makeAgentRelayWorkspaceClient } from "../Layers/AgentRelayWorkspaceClientLive.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; import { defaultProviderContinuationIdentity, @@ -70,7 +71,16 @@ export const AgentRelayDriver: ProviderDriver(register: (resume: (value: A) => void) => void) { const forkConnectionWait = (server: WebSocketServer) => forkNodeEventWait((resume) => server.once("connection", resume)); +/** Like `forkConnectionWait`, but also captures the upgrade request's URL — + * workspace mode encodes the resolved agent name into it. */ +const forkConnectionWaitWithUrl = (server: WebSocketServer) => + forkNodeEventWait<{ readonly socket: WsSocket; readonly url: string }>((resume) => + server.once("connection", (socket, request) => resume({ socket, url: request.url ?? "" })), + ); + const forkMessageWait = (socket: WsSocket) => forkNodeEventWait((resume) => socket.once("message", (data: Buffer) => resume(data.toString("utf8"))), ); +/** + * 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 = () => + Effect.gen(function* () { + const spawnCalls = yield* Ref.make>([]); + const online = yield* Ref.make>([]); + const shape: AgentRelayWorkspaceClientShape = { + listAgents: () => + Ref.get(online).pipe( + Effect.map((names) => names.map((name) => ({ name, status: "online" as const }))), + ), + spawnAgent: (input) => + Effect.gen(function* () { + yield* Ref.update(spawnCalls, (calls) => [...calls, input.name]); + yield* Ref.update(online, (names) => [...names, input.name]); + return { name: input.name }; + }), + onPresenceChange: () => () => {}, + }; + return { shape, spawnCalls }; + }); + /** * 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 @@ -233,3 +269,97 @@ it.layer(agentRelayAdapterTestLayer)("AgentRelayAdapterLive", (it) => { }), ); }); + +it.layer(agentRelayAdapterTestLayer)("AgentRelayAdapterLive workspace mode", (it) => { + const makeWorkspaceTestAdapter = ( + brokerUrlTemplate: string, + workspaceClient: AgentRelayWorkspaceClientShape | undefined, + ) => + makeAgentRelayAdapter( + decodeAgentRelaySettings({ + enabled: true, + mode: "workspace", + brokerUrl: brokerUrlTemplate, + apiKey: "test-key", + workspaceKey: "rk_live_test", + defaultSpawnCli: "claude", + }), + workspaceClient ? { workspaceClient } : {}, + ); + + it.effect("spawns a new agent, waits for it online, and attaches with its resolved name", () => + Effect.gen(function* () { + const { server, url } = yield* Effect.promise(startMockBroker); + yield* Effect.addFinalizer(() => Effect.sync(() => server.close())); + + const { shape: workspaceClient, spawnCalls } = yield* makeFakeWorkspaceClient(); + const adapter = yield* makeWorkspaceTestAdapter(`${url}/agents/{name}`, workspaceClient); + const connectionFiber = yield* forkConnectionWaitWithUrl(server); + + const threadId = ThreadId.make("agentrelay-workspace-spawn"); + const session = yield* adapter.startSession({ threadId, runtimeMode: "full-access" }); + const connection = yield* Fiber.join(connectionFiber); + + assert.deepEqual(yield* Ref.get(spawnCalls), ["t3code-agentrelay-workspace-spawn"]); + assert.equal(connection.url, "/agents/t3code-agentrelay-workspace-spawn"); + assert.deepEqual(session.resumeCursor, { agentName: "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, url } = yield* Effect.promise(startMockBroker); + yield* Effect.addFinalizer(() => Effect.sync(() => server.close())); + + const { shape: workspaceClient, spawnCalls } = yield* makeFakeWorkspaceClient(); + const adapter = yield* makeWorkspaceTestAdapter(`${url}/agents/{name}`, workspaceClient); + const connectionFiber = yield* forkConnectionWaitWithUrl(server); + + const threadId = ThreadId.make("agentrelay-workspace-resume"); + const session = yield* adapter.startSession({ + threadId, + runtimeMode: "full-access", + resumeCursor: { agentName: "already-running-agent" }, + }); + const connection = yield* Fiber.join(connectionFiber); + + assert.deepEqual(yield* Ref.get(spawnCalls), []); + assert.equal(connection.url, "/agents/already-running-agent"); + assert.deepEqual(session.resumeCursor, { agentName: "already-running-agent" }); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("falls back to a ?agent= query parameter when the URL has no {name} placeholder", () => + Effect.gen(function* () { + const { server, url } = yield* Effect.promise(startMockBroker); + yield* Effect.addFinalizer(() => Effect.sync(() => server.close())); + + const { shape: workspaceClient } = yield* makeFakeWorkspaceClient(); + const adapter = yield* makeWorkspaceTestAdapter(url, workspaceClient); + const connectionFiber = yield* forkConnectionWaitWithUrl(server); + + const threadId = ThreadId.make("agentrelay-workspace-query-fallback"); + yield* adapter.startSession({ threadId, runtimeMode: "full-access" }); + const connection = yield* Fiber.join(connectionFiber); + + assert.equal(connection.url, "/?agent=t3code-agentrelay-workspace-query-fallback"); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("rejects starting a new thread with no workspace client configured", () => + Effect.gen(function* () { + const adapter = yield* makeWorkspaceTestAdapter("ws://127.0.0.1:1/ws", 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"); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/AgentRelayAdapter.ts b/apps/server/src/provider/Layers/AgentRelayAdapter.ts index 170280e08fc9..3125e7fefc52 100644 --- a/apps/server/src/provider/Layers/AgentRelayAdapter.ts +++ b/apps/server/src/provider/Layers/AgentRelayAdapter.ts @@ -18,6 +18,13 @@ * `{ type: "sendInput", data: string }` out). If the real broker's frames * differ, only these two functions need to change. * + * 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 and + * wire-format caveats that come with this. + * * @module AgentRelayAdapterLive */ import { @@ -36,8 +43,10 @@ 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 Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import WebSocket from "ws"; @@ -48,6 +57,7 @@ import { ProviderAdapterValidationError, } from "../Errors.ts"; import { type AgentRelayAdapterShape } from "../Services/AgentRelayAdapter.ts"; +import type { AgentRelayWorkspaceClientShape } from "../Services/AgentRelayWorkspaceClient.ts"; const PROVIDER = ProviderDriverKind.make("agentrelay"); @@ -61,16 +71,108 @@ const RECONNECT_DELAYS_MS = [1_000, 2_000, 5_000, 10_000, 30_000]; // see the "protocol traps" note in docs/internals/providers.md. const TURN_IDLE_COMPLETE_MS = 1_500; +// 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); + +/** 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". */ +const AgentRelayResumeCursorSchema = Schema.Struct({ agentName: Schema.String }); +const isAgentRelayResumeCursor = Schema.is(AgentRelayResumeCursorSchema); + +const AGENT_NAME_PLACEHOLDER = "{name}"; + +/** + * Workspace mode has no real per-agent attach endpoint to build this from + * (see `docs/internals/providers.md`), so this is a T3-Code-side convention + * layered on top of the same placeholder `brokerUrl` setting single mode + * already uses verbatim: substitute a literal `{name}` placeholder when + * present, otherwise append `?agent=`. + */ +function buildWorkspaceAttachUrl(brokerUrlTemplate: string, agentName: string): string { + if (brokerUrlTemplate.includes(AGENT_NAME_PLACEHOLDER)) { + return brokerUrlTemplate.split(AGENT_NAME_PLACEHOLDER).join(encodeURIComponent(agentName)); + } + const separator = brokerUrlTemplate.includes("?") ? "&" : "?"; + return `${brokerUrlTemplate}${separator}agent=${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.`, + }), + ), + ); +} + 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; + /** Resolved connection URL for this thread's session. Equal to + * `agentRelaySettings.brokerUrl` in single mode; in workspace mode, the + * template with the resolved agent name substituted in + * (`buildWorkspaceAttachUrl`). Fixed for the life of the session, so + * reconnects keep attaching to the same agent. */ + readonly wsUrl: string; socket: WebSocket | undefined; reconnectAttempt: number; activeTurnId: TurnId | undefined; @@ -330,10 +432,10 @@ export function makeAgentRelayAdapter( let socket: WebSocket; try { socket = apiKey - ? new WebSocket(agentRelaySettings.brokerUrl, { + ? new WebSocket(ctx.wsUrl, { headers: { authorization: `Bearer ${apiKey}` }, }) - : new WebSocket(agentRelaySettings.brokerUrl); + : new WebSocket(ctx.wsUrl); } catch (cause) { dispatch(handleConnectFailure(ctx, describeError(cause))); return; @@ -390,6 +492,54 @@ export function makeAgentRelayAdapter( }); } + // Workspace mode: resolve which agent this thread attaches to. 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: 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 + // v1 did. + let targetAgentName: string | undefined; + 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; + } + } + const wsUrl = + targetAgentName !== undefined + ? buildWorkspaceAttachUrl(agentRelaySettings.brokerUrl, targetAgentName) + : agentRelaySettings.brokerUrl; + const existing = sessions.get(input.threadId); if (existing) { yield* stopSessionInternal(existing); @@ -405,6 +555,9 @@ export function makeAgentRelayAdapter( runtimeMode: input.runtimeMode, ...(input.cwd ? { cwd: input.cwd } : {}), threadId: input.threadId, + ...(targetAgentName !== undefined + ? { resumeCursor: { agentName: targetAgentName } } + : {}), createdAt: now, updatedAt: now, }; @@ -412,6 +565,7 @@ export function makeAgentRelayAdapter( threadId: input.threadId, session, scope, + wsUrl, socket: undefined, reconnectAttempt: 0, activeTurnId: undefined, 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..aa11467d5344 --- /dev/null +++ b/apps/server/src/provider/Layers/AgentRelayWorkspaceClientLive.test.ts @@ -0,0 +1,42 @@ +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, treating anything but offline as online", () => { + NodeAssert.deepEqual( + readPresenceTransition({ type: "agent.status.offline", agentId: "Worker" }), + { name: "Worker", status: "offline" }, + ); + NodeAssert.deepEqual( + readPresenceTransition({ type: "agent.status.active", agentId: "Worker" }), + { name: "Worker", status: "online" }, + ); + NodeAssert.deepEqual( + readPresenceTransition({ type: "agent.status.idle", agentId: "Worker" }), + { name: "Worker", status: "online" }, + ); + }); + + 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..b5f90c2a533e --- /dev/null +++ b/apps/server/src/provider/Layers/AgentRelayWorkspaceClientLive.ts @@ -0,0 +1,194 @@ +/** + * 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 (typeof type === "string" && type.startsWith("agent.status.")) { + const agentId = record.agentId; + if (typeof agentId !== "string" || !agentId) return undefined; + return { name: agentId, status: type === "agent.status.offline" ? "offline" : "online" }; + } + 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/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/docs/internals/providers.md b/docs/internals/providers.md index 08abdb5512ce..b9b22484dd7c 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -57,6 +57,83 @@ events — turn completion is inferred from the terminal going quiet 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. (Relay's own tracked gap here is + issue #1382, "attach pairs broker URL/key from different sources".) + +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. 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: the real per-agent attach protocol +(`harness-driver/src/transport.ts`) is a **two-channel**, ack/keepalive-based +protocol (`GET/PUT .../delivery-mode`, a shared `/ws?sinceSeq=` event stream +multiplexed by agent `name`, and a separate `/api/input/{name}/stream` for +writes with `pty_input_ready`/`pty_input_ack` flow control) — nothing like the v1 +adapter's single bidirectional `worker_stream`/`sendInput` socket. Reproducing +that protocol is out of scope here for the same reason the v1 wire-format gap +above is: it is a distinct v2 adapter. Workspace mode's `brokerUrl` setting is +therefore a T3-Code-side convention layered on the *existing* v1 guess — a +`{name}` placeholder (or a `?agent=` fallback) substituted by +`buildWorkspaceAttachUrl` — not a real Agent Relay contract. Whoever builds the v2 +adapter should replace both the frame format and this URL convention together +against `harness-driver`'s real transport. + +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. + +### Left out: auto-materializing threads for already-running agents + +`AgentRelayWorkspaceClient` can already list every agent in a workspace and watch +presence, which is the primitive auto-discovery (surfacing an agent nobody started +from T3 Code as a thread) needs. What it does not do is turn that into a thread: +`thread.create` (`apps/server/src/orchestration/decider.ts`) requires a +`projectId`, and every existing thread-creation path is a deliberate user action. +Silently materializing a thread per discovered agent needs a product decision this +change does not make on its own — at minimum, which project houses them and +whether every agent in a workspace should really become a thread unasked. A +follow-up background reactor (shaped like +`apps/server/src/provider/Layers/ProviderSessionReaper.ts`) is the right place to +wire `AgentRelayWorkspaceClient.listAgents`/`onPresenceChange` into `thread.create` +dispatch once that's decided. + ## 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. diff --git a/docs/user/providers-agentrelay.md b/docs/user/providers-agentrelay.md index 637a8339e698..d4a138f34b3c 100644 --- a/docs/user/providers-agentrelay.md +++ b/docs/user/providers-agentrelay.md @@ -1,29 +1,61 @@ # 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 an agent that Agent Relay is -already running elsewhere. It streams that agent's terminal into the thread and sends +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. -## How to start +There are two modes. -Get a broker URL and API key from the Agent Relay CLI for the agent you want to -attach to. In **Settings → Providers**, add an Agent Relay instance and enter: +## Workspace mode (recommended) -- **Broker URL** — the WebSocket URL for the Agent Relay broker, for example - `wss://broker.example.com/ws`. -- **API key** — the attach token for that broker session. +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) — is available to attach to. Starting a **new** +thread on this instance spawns a fresh agent through Agent Relay instead of +requiring one to already exist. -Enable the instance and start a thread on it. T3 Code connects immediately; there is -nothing to install. +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 Agent Relay broker's attach endpoint. In this mode it is a + template: include a literal `{name}` where the agent's name goes (for example + `wss://broker.example.com/agents/{name}`); if you omit it, T3 Code appends + `?agent=` instead. +- **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. + +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. + +### 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** and **API key** for that one agent's +attach session, as printed by the Agent Relay CLI. 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 agent Agent Relay is running — no CLI login, -no OAuth flow, no stored account. The broker URL and API key are the only -credentials this provider needs, and they only grant access to attach to that one -already-running agent. Whatever provider the underlying agent itself uses (Claude, -Codex, or otherwise) is authenticated on Agent Relay's side, not from T3 Code. +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 diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 05a13b88a4d4..6f0b2d8ab9e0 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -766,9 +766,39 @@ 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 — credentials are the broker URL and - * API key the user copies out of the Agent Relay CLI. + * 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: + * + * - `single` (default, legacy v1): the broker URL and API key identify one + * already-running agent directly. Nothing to discover or spawn. + * - `workspace`: `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`. */ +export const AGENT_RELAY_MODES = [ + { value: "single", label: "Single agent (manual)" }, + { value: "workspace", label: "Workspace (auto-discover and spawn)" }, +] 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 @@ -777,12 +807,38 @@ export const AgentRelaySettings = makeProviderSettingsSchema( Schema.withDecodingDefault(Effect.succeed(false)), Schema.annotateKey({ providerSettingsForm: { hidden: true } }), ), + mode: AgentRelayMode.pipe( + Schema.withDecodingDefault(Effect.succeed("single" as const)), + Schema.annotateKey({ + title: "Mode", + description: + "Single agent attaches to the one agent identified below. Workspace discovers every agent in the workspace and spawns new ones for new threads.", + 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: - "WebSocket URL for the Agent Relay broker control plane, from the Agent Relay CLI.", + "WebSocket URL for the Agent Relay broker control plane, from the Agent Relay CLI. In Workspace mode, include a literal {name} placeholder that T3 Code substitutes with the resolved agent name (falls back to appending ?agent= when omitted).", providerSettingsForm: { placeholder: "wss://broker.example.com/ws", clearWhenEmpty: "omit", @@ -802,13 +858,26 @@ export const AgentRelaySettings = makeProviderSettingsSchema( }, }), ), + 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: ["brokerUrl", "apiKey"], + order: ["mode", "workspaceKey", "brokerUrl", "apiKey", "defaultSpawnCli"], }, ); export type AgentRelaySettings = typeof AgentRelaySettings.Type; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f2b0cf80b1a1..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) @@ -1012,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'} @@ -4285,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} @@ -10818,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 @@ -14488,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 From cad581b1b1bd5e5d43d339ee704739ec2ada7a88 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 08:52:20 +0000 Subject: [PATCH 05/16] fix(server): correct Agent Relay wire format to match the real broker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified live against a real agent-relay-broker (built from source, spawned a real `claude --version` under its PTY) plus the broker's own protocol.rs and harness-driver's client — the v1 assumptions were wrong in four ways: - worker_stream events are discriminated by `kind`, not `type`, with the payload in `chunk`, not `data`. - The broker broadcasts every worker on a connection over the same `/ws` socket; the adapter now filters incoming frames by `name` against the session's resolved agent instead of assuming the socket is scoped to one agent. - Auth is an `X-API-Key` header, not `Authorization: Bearer`. - Sending input is `POST /api/input/{name}` with `{data}`, not a message over the `/ws` socket — matches HarnessDriverClient.sendInput exactly. Rewritten with effect/unstable/http's HttpClient per this repo's lint rules instead of global fetch/JSON.stringify. This removes the URL-templating convention workspace mode invented for per-agent attach (`{name}`/`?agent=` in the broker URL) — it's no longer needed now that the connection isn't per-agent. Single mode gains a required "Agent name" setting for the same reason: with one shared connection carrying every worker, T3 Code needs a name to tell them apart. Rewrote AgentRelayAdapter.test.ts's mock broker as a real local HTTP server (for /api/input) plus the existing WebSocketServer on the same port, mirroring the real broker's single-port shape, and added a regression test for the cross-worker frame filtering. Docs updated with what was verified and how, and a doc reference to relay#1382 corrected — it was the wrong issue for the credential-domain gap described there (that's relay#1698). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01HXsTUKG2oXAfj6dmW838mG --- .../src/provider/Drivers/AgentRelayDriver.ts | 2 + .../provider/Layers/AgentRelayAdapter.test.ts | 316 ++++++++++++------ .../src/provider/Layers/AgentRelayAdapter.ts | 204 ++++++----- docs/internals/providers.md | 73 ++-- docs/user/providers-agentrelay.md | 23 +- packages/contracts/src/settings.ts | 20 +- 6 files changed, 412 insertions(+), 226 deletions(-) diff --git a/apps/server/src/provider/Drivers/AgentRelayDriver.ts b/apps/server/src/provider/Drivers/AgentRelayDriver.ts index 76f42a9e23dd..21dbc242ba86 100644 --- a/apps/server/src/provider/Drivers/AgentRelayDriver.ts +++ b/apps/server/src/provider/Drivers/AgentRelayDriver.ts @@ -13,6 +13,7 @@ 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"; @@ -45,6 +46,7 @@ const DRIVER_KIND = ProviderDriverKind.make("agentrelay"); export type AgentRelayDriverEnv = | BackgroundPolicy.BackgroundPolicy | Crypto.Crypto + | HttpClient.HttpClient | ServerSettingsService; export const AgentRelayDriver: ProviderDriver = { diff --git a/apps/server/src/provider/Layers/AgentRelayAdapter.test.ts b/apps/server/src/provider/Layers/AgentRelayAdapter.test.ts index b4018e568621..3b0266636702 100644 --- a/apps/server/src/provider/Layers/AgentRelayAdapter.test.ts +++ b/apps/server/src/provider/Layers/AgentRelayAdapter.test.ts @@ -1,13 +1,17 @@ // @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"; @@ -17,21 +21,67 @@ 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({ - type: Schema.Literal("worker_stream"), - data: Schema.String, + 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)); -const SendInputFrame = Schema.Struct({ type: Schema.Literal("sendInput"), data: Schema.String }); -const decodeSendInputFrame = Schema.decodeUnknownSync(Schema.fromJsonString(SendInputFrame)); +interface RecordedInput { + readonly name: string; + readonly data: string; + readonly apiKeyHeader: string | undefined; +} -function startMockBroker(): Promise<{ readonly server: WebSocketServer; readonly url: string }> { +/** + * 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. + */ +function startMockBroker(): Promise<{ + readonly server: WebSocketServer; + readonly httpServer: NodeHttp.Server; + readonly url: string; + readonly inputs: RecordedInput[]; +}> { + const inputs: RecordedInput[] = []; return new Promise((resolve) => { - const server = new WebSocketServer({ port: 0 }, () => { - const address = server.address(); + const httpServer = NodeHttp.createServer((req, res) => { + const match = /^\/api\/input\/([^/]+)$/.exec(req.url ?? ""); + if (!match || req.method !== "POST") { + res.writeHead(404).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" }); + httpServer.listen(0, "127.0.0.1", () => { + const address = httpServer.address(); const port = typeof address === "object" && address !== null ? address.port : 0; - resolve({ server, url: `ws://127.0.0.1:${port}` }); + resolve({ server, httpServer, url: `http://127.0.0.1:${port}`, inputs }); }); }); } @@ -40,9 +90,9 @@ function startMockBroker(): Promise<{ readonly server: WebSocketServer; readonly * 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 (e.g. `sendFrame` on an - * already-open socket) can fire before the scheduler ever runs the newly - * forked fiber, and the `.once` listener attaches too late to see it. + * 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* () { @@ -60,18 +110,6 @@ function forkNodeEventWait(register: (resume: (value: A) => void) => void) { const forkConnectionWait = (server: WebSocketServer) => forkNodeEventWait((resume) => server.once("connection", resume)); -/** Like `forkConnectionWait`, but also captures the upgrade request's URL — - * workspace mode encodes the resolved agent name into it. */ -const forkConnectionWaitWithUrl = (server: WebSocketServer) => - forkNodeEventWait<{ readonly socket: WsSocket; readonly url: string }>((resume) => - server.once("connection", (socket, request) => resume({ socket, url: request.url ?? "" })), - ); - -const forkMessageWait = (socket: WsSocket) => - forkNodeEventWait((resume) => - socket.once("message", (data: Buffer) => resume(data.toString("utf8"))), - ); - /** * A workspace client whose `spawnAgent` immediately marks the spawned name * "online" for `listAgents`, so `waitForAgentOnline`'s polling fallback @@ -133,18 +171,25 @@ const waitForEvent = ( } }).pipe(Effect.timeout("2 seconds"), TestClock.withLive, Effect.orDie); -const agentRelayAdapterTestLayer = NodeServices.layer; +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, apiKey = "test-key") => - makeAgentRelayAdapter(decodeAgentRelaySettings({ enabled: true, brokerUrl, apiKey })); +const makeTestAdapter = (brokerUrl: string, agentName = "Worker1", apiKey = "test-key") => + makeAgentRelayAdapter(decodeAgentRelaySettings({ enabled: true, brokerUrl, agentName, apiKey })); it.layer(agentRelayAdapterTestLayer)("AgentRelayAdapterLive", (it) => { - it.effect("streams worker_stream frames as content.delta and forwards sendInput", () => + it.effect("streams worker_stream frames as content.delta and posts input over HTTP", () => Effect.gen(function* () { - const { server, url } = yield* Effect.promise(startMockBroker); - yield* Effect.addFinalizer(() => Effect.sync(() => server.close())); + const { server, httpServer, url, inputs } = yield* Effect.promise(startMockBroker); + yield* Effect.addFinalizer(() => Effect.sync(() => httpServer.close())); - const adapter = yield* makeTestAdapter(url); + const adapter = yield* makeTestAdapter(url, "Worker1"); const events = yield* makeEventCollector(adapter.streamEvents); const connectionFiber = yield* forkConnectionWait(server); @@ -158,17 +203,22 @@ it.layer(agentRelayAdapterTestLayer)("AgentRelayAdapterLive", (it) => { 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( - encodeWorkerStreamFrame({ type: "worker_stream", data: "$ echo hi\nhi\n" }), + '{"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"); assert.equal(delta.payload.streamKind, "command_output"); - assert.equal(delta.payload.delta, "$ echo hi\nhi\n"); + assert.equal(delta.payload.delta, "2.1.263 (Claude Code)\r\n[?25h"); - const inboundInputFiber = yield* forkMessageWait(brokerSocket); yield* adapter.sendTurn({ threadId, input: "hello agent" }); - const inbound = yield* Fiber.join(inboundInputFiber); - assert.deepEqual(decodeSendInputFrame(inbound), { type: "sendInput", data: "hello agent\n" }); + 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); @@ -178,10 +228,55 @@ it.layer(agentRelayAdapterTestLayer)("AgentRelayAdapterLive", (it) => { }), ); + 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 goes quiet", () => Effect.gen(function* () { - const { server, url } = yield* Effect.promise(startMockBroker); - yield* Effect.addFinalizer(() => Effect.sync(() => server.close())); + 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); @@ -206,10 +301,10 @@ it.layer(agentRelayAdapterTestLayer)("AgentRelayAdapterLive", (it) => { }), ); - it.effect("interrupting a turn sends Ctrl-C and completes it as cancelled", () => + it.effect("interrupting a turn posts Ctrl-C as input and completes it as cancelled", () => Effect.gen(function* () { - const { server, url } = yield* Effect.promise(startMockBroker); - yield* Effect.addFinalizer(() => Effect.sync(() => server.close())); + 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); @@ -217,18 +312,16 @@ it.layer(agentRelayAdapterTestLayer)("AgentRelayAdapterLive", (it) => { const threadId = ThreadId.make("agentrelay-interrupt"); yield* adapter.startSession({ threadId, runtimeMode: "full-access" }); - const brokerSocket = yield* Fiber.join(connectionFiber); + yield* Fiber.join(connectionFiber); yield* waitForEvent(events, "session.state.changed"); - const firstInputFiber = yield* forkMessageWait(brokerSocket); yield* adapter.sendTurn({ threadId, input: "run forever" }); - yield* Fiber.join(firstInputFiber); + yield* waitUntil(() => inputs.length === 1); - const interruptFiber = yield* forkMessageWait(brokerSocket); yield* adapter.interruptTurn(threadId); - const interruptFrame = yield* Fiber.join(interruptFiber); + yield* waitUntil(() => inputs.length === 2); + assert.equal(inputs[1]!.data, ""); - assert.deepEqual(decodeSendInputFrame(interruptFrame), { type: "sendInput", data: "" }); const completed = yield* waitForEvent(events, "turn.completed"); assert.deepEqual(completed.payload, { state: "cancelled", stopReason: null }); @@ -238,8 +331,8 @@ it.layer(agentRelayAdapterTestLayer)("AgentRelayAdapterLive", (it) => { it.effect("has no session before startSession and none after stopSession", () => Effect.gen(function* () { - const { server, url } = yield* Effect.promise(startMockBroker); - yield* Effect.addFinalizer(() => Effect.sync(() => server.close())); + 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"); @@ -259,7 +352,12 @@ it.layer(agentRelayAdapterTestLayer)("AgentRelayAdapterLive", (it) => { it.effect("rejects starting a session with no broker URL configured", () => Effect.gen(function* () { const adapter = yield* makeAgentRelayAdapter( - decodeAgentRelaySettings({ enabled: true, brokerUrl: "", apiKey: "" }), + decodeAgentRelaySettings({ + enabled: true, + brokerUrl: "", + agentName: "Worker1", + apiKey: "", + }), ); const threadId = ThreadId.make("agentrelay-missing-url"); const failure = yield* adapter @@ -268,18 +366,36 @@ it.layer(agentRelayAdapterTestLayer)("AgentRelayAdapterLive", (it) => { 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, + 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.layer(agentRelayAdapterTestLayer)("AgentRelayAdapterLive workspace mode", (it) => { const makeWorkspaceTestAdapter = ( - brokerUrlTemplate: string, + brokerUrl: string, workspaceClient: AgentRelayWorkspaceClientShape | undefined, ) => makeAgentRelayAdapter( decodeAgentRelaySettings({ enabled: true, mode: "workspace", - brokerUrl: brokerUrlTemplate, + brokerUrl, apiKey: "test-key", workspaceKey: "rk_live_test", defaultSpawnCli: "claude", @@ -287,35 +403,63 @@ it.layer(agentRelayAdapterTestLayer)("AgentRelayAdapterLive workspace mode", (it workspaceClient ? { workspaceClient } : {}, ); - it.effect("spawns a new agent, waits for it online, and attaches with its resolved name", () => - Effect.gen(function* () { - const { server, url } = yield* Effect.promise(startMockBroker); - yield* Effect.addFinalizer(() => Effect.sync(() => server.close())); - - const { shape: workspaceClient, spawnCalls } = yield* makeFakeWorkspaceClient(); - const adapter = yield* makeWorkspaceTestAdapter(`${url}/agents/{name}`, workspaceClient); - const connectionFiber = yield* forkConnectionWaitWithUrl(server); - - const threadId = ThreadId.make("agentrelay-workspace-spawn"); - const session = yield* adapter.startSession({ threadId, runtimeMode: "full-access" }); - const connection = yield* Fiber.join(connectionFiber); - - assert.deepEqual(yield* Ref.get(spawnCalls), ["t3code-agentrelay-workspace-spawn"]); - assert.equal(connection.url, "/agents/t3code-agentrelay-workspace-spawn"); - assert.deepEqual(session.resumeCursor, { agentName: "t3code-agentrelay-workspace-spawn" }); - - yield* adapter.stopSession(threadId); - }), + 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, url } = yield* Effect.promise(startMockBroker); - yield* Effect.addFinalizer(() => Effect.sync(() => server.close())); + 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}/agents/{name}`, workspaceClient); - const connectionFiber = yield* forkConnectionWaitWithUrl(server); + const adapter = yield* makeWorkspaceTestAdapter(url, workspaceClient); + const connectionFiber = yield* forkConnectionWait(server); const threadId = ThreadId.make("agentrelay-workspace-resume"); const session = yield* adapter.startSession({ @@ -323,38 +467,18 @@ it.layer(agentRelayAdapterTestLayer)("AgentRelayAdapterLive workspace mode", (it runtimeMode: "full-access", resumeCursor: { agentName: "already-running-agent" }, }); - const connection = yield* Fiber.join(connectionFiber); + yield* Fiber.join(connectionFiber); assert.deepEqual(yield* Ref.get(spawnCalls), []); - assert.equal(connection.url, "/agents/already-running-agent"); assert.deepEqual(session.resumeCursor, { agentName: "already-running-agent" }); yield* adapter.stopSession(threadId); }), ); - it.effect("falls back to a ?agent= query parameter when the URL has no {name} placeholder", () => - Effect.gen(function* () { - const { server, url } = yield* Effect.promise(startMockBroker); - yield* Effect.addFinalizer(() => Effect.sync(() => server.close())); - - const { shape: workspaceClient } = yield* makeFakeWorkspaceClient(); - const adapter = yield* makeWorkspaceTestAdapter(url, workspaceClient); - const connectionFiber = yield* forkConnectionWaitWithUrl(server); - - const threadId = ThreadId.make("agentrelay-workspace-query-fallback"); - yield* adapter.startSession({ threadId, runtimeMode: "full-access" }); - const connection = yield* Fiber.join(connectionFiber); - - assert.equal(connection.url, "/?agent=t3code-agentrelay-workspace-query-fallback"); - - yield* adapter.stopSession(threadId); - }), - ); - it.effect("rejects starting a new thread with no workspace client configured", () => Effect.gen(function* () { - const adapter = yield* makeWorkspaceTestAdapter("ws://127.0.0.1:1/ws", undefined); + 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" }) diff --git a/apps/server/src/provider/Layers/AgentRelayAdapter.ts b/apps/server/src/provider/Layers/AgentRelayAdapter.ts index 3125e7fefc52..ff8b35374df6 100644 --- a/apps/server/src/provider/Layers/AgentRelayAdapter.ts +++ b/apps/server/src/provider/Layers/AgentRelayAdapter.ts @@ -6,24 +6,38 @@ * 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 and replying with - * `sendInput` keystroke frames over the same socket. See - * `docs/internals/providers.md` for why this is v1 scope and what a v2 - * structured-protocol adapter would add. + * 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 note: the exact JSON shape of `worker_stream` / `sendInput` - * frames is not vendored into this repo, so `parseAgentRelayFrame` and - * `encodeSendInputFrame` below are the single, isolated boundary that - * assumes a shape (`{ type: "worker_stream", data: string }` in, - * `{ type: "sendInput", data: string }` out). If the real broker's frames - * differ, only these two functions need to change. + * 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 and - * wire-format caveats that come with this. + * as v1 built it. See `docs/internals/providers.md` for the credential + * caveat that comes with this. * * @module AgentRelayAdapterLive */ @@ -49,6 +63,7 @@ import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; import WebSocket from "ws"; import { @@ -86,21 +101,14 @@ const AGENT_SPAWN_POLL_INTERVAL = Duration.seconds(2); const AgentRelayResumeCursorSchema = Schema.Struct({ agentName: Schema.String }); const isAgentRelayResumeCursor = Schema.is(AgentRelayResumeCursorSchema); -const AGENT_NAME_PLACEHOLDER = "{name}"; +/** `https://broker.example.com` -> `wss://broker.example.com/ws`. */ +function toWsUrl(brokerBaseUrl: string): string { + return `${brokerBaseUrl.replace(/^http/, "ws")}/ws`; +} -/** - * Workspace mode has no real per-agent attach endpoint to build this from - * (see `docs/internals/providers.md`), so this is a T3-Code-side convention - * layered on top of the same placeholder `brokerUrl` setting single mode - * already uses verbatim: substitute a literal `{name}` placeholder when - * present, otherwise append `?agent=`. - */ -function buildWorkspaceAttachUrl(brokerUrlTemplate: string, agentName: string): string { - if (brokerUrlTemplate.includes(AGENT_NAME_PLACEHOLDER)) { - return brokerUrlTemplate.split(AGENT_NAME_PLACEHOLDER).join(encodeURIComponent(agentName)); - } - const separator = brokerUrlTemplate.includes("?") ? "&" : "?"; - return `${brokerUrlTemplate}${separator}agent=${encodeURIComponent(agentName)}`; +/** `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, @@ -167,12 +175,16 @@ interface AgentRelaySessionContext { readonly threadId: ThreadId; session: ProviderSession; readonly scope: Scope.Closeable; - /** Resolved connection URL for this thread's session. Equal to - * `agentRelaySettings.brokerUrl` in single mode; in workspace mode, the - * template with the resolved agent name substituted in - * (`buildWorkspaceAttachUrl`). Fixed for the life of the session, so - * reconnects keep attaching to the same agent. */ - readonly wsUrl: string; + /** 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; @@ -183,12 +195,17 @@ interface AgentRelaySessionContext { } /** - * Parse one incoming text frame. Returns `undefined` for anything that does - * not match the assumed `worker_stream` shape (non-JSON, a different - * `type`, or a missing text field) so an unrecognized broker message is - * ignored instead of tearing down the session. + * 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 text: string } | undefined { +function parseAgentRelayFrame( + raw: string, +): { readonly name: string; readonly text: string } | undefined { let parsed: unknown; try { parsed = JSON.parse(raw); @@ -197,20 +214,9 @@ function parseAgentRelayFrame(raw: string): { readonly text: string } | undefine } if (typeof parsed !== "object" || parsed === null) return undefined; const record = parsed as Record; - if (record.type !== "worker_stream" && record.type !== "workerStream") return undefined; - const text = - typeof record.data === "string" - ? record.data - : typeof record.chunk === "string" - ? record.chunk - : typeof record.text === "string" - ? record.text - : undefined; - return text !== undefined ? { text } : undefined; -} - -function encodeSendInputFrame(data: string): string { - return JSON.stringify({ type: "sendInput", data }); + 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 { @@ -226,6 +232,7 @@ export function makeAgentRelayAdapter( 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 runtimeEventPubSub = yield* PubSub.unbounded(); @@ -272,16 +279,32 @@ export function makeAgentRelayAdapter( return Effect.succeed(ctx); }; - const sendFrame = (ctx: AgentRelaySessionContext, payload: string): boolean => { - const socket = ctx.socket; - if (!socket || socket.readyState !== WebSocket.OPEN) return false; - try { - socket.send(payload); - return true; - } catch { - return false; - } - }; + /** `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, @@ -361,7 +384,9 @@ export function makeAgentRelayAdapter( const liveCtx = sessions.get(ctx.threadId); if (liveCtx !== ctx || ctx.stopped) return; const frame = parseAgentRelayFrame(raw); - if (!frame) return; + // 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", @@ -429,13 +454,12 @@ export function makeAgentRelayAdapter( 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(ctx.wsUrl, { - headers: { authorization: `Bearer ${apiKey}` }, - }) - : new WebSocket(ctx.wsUrl); + ? new WebSocket(wsUrl, { headers: { "X-API-Key": apiKey } }) + : new WebSocket(wsUrl); } catch (cause) { dispatch(handleConnectFailure(ctx, describeError(cause))); return; @@ -492,14 +516,15 @@ export function makeAgentRelayAdapter( }); } - // Workspace mode: resolve which agent this thread attaches to. 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: spawn a fresh agent and + // 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 - // v1 did. - let targetAgentName: string | undefined; + // single mode does. + let targetAgentName: string; if (agentRelaySettings.mode === "workspace") { if (isAgentRelayResumeCursor(input.resumeCursor)) { targetAgentName = input.resumeCursor.agentName; @@ -534,11 +559,19 @@ export function makeAgentRelayAdapter( 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; } - const wsUrl = - targetAgentName !== undefined - ? buildWorkspaceAttachUrl(agentRelaySettings.brokerUrl, targetAgentName) - : agentRelaySettings.brokerUrl; + const brokerBaseUrl = agentRelaySettings.brokerUrl.trim(); const existing = sessions.get(input.threadId); if (existing) { @@ -555,9 +588,7 @@ export function makeAgentRelayAdapter( runtimeMode: input.runtimeMode, ...(input.cwd ? { cwd: input.cwd } : {}), threadId: input.threadId, - ...(targetAgentName !== undefined - ? { resumeCursor: { agentName: targetAgentName } } - : {}), + resumeCursor: { agentName: targetAgentName }, createdAt: now, updatedAt: now, }; @@ -565,7 +596,8 @@ export function makeAgentRelayAdapter( threadId: input.threadId, session, scope, - wsUrl, + brokerBaseUrl, + agentName: targetAgentName, socket: undefined, reconnectAttempt: 0, activeTurnId: undefined, @@ -614,13 +646,7 @@ export function makeAgentRelayAdapter( "Turn requires non-empty text. Agent Relay's terminal transport cannot carry attachments.", }); } - if (!sendFrame(ctx, encodeSendInputFrame(`${text}\n`))) { - return yield* new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "sendInput", - detail: "Failed to send input to the Agent Relay broker socket.", - }); - } + yield* postInput(ctx, `${text}\n`); const isNewTurn = ctx.activeTurnId === undefined; const turnId = ctx.activeTurnId ?? TurnId.make(yield* randomUUIDv4); @@ -664,7 +690,9 @@ export function makeAgentRelayAdapter( } // Ctrl-C: the terminal-native interrupt signal, matching how a human // attached to the same broker session would cancel a running command. - sendFrame(ctx, encodeSendInputFrame("\u0003")); + // 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"); }); diff --git a/docs/internals/providers.md b/docs/internals/providers.md index 276db81d3225..8ddaa7e68a92 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -49,13 +49,22 @@ directory that the adapter is the only thing writing to the process (e.g. approv 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` frames in, -`sendInput` frames out), so there are no structured turn, tool-call, or approval -events — 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. +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 @@ -85,32 +94,36 @@ separate systems: 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/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. (Relay's own tracked gap here is - issue #1382, "attach pairs broker URL/key from different sources".) - -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. 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: the real per-agent attach protocol -(`harness-driver/src/transport.ts`) is a **two-channel**, ack/keepalive-based -protocol (`GET/PUT .../delivery-mode`, a shared `/ws?sinceSeq=` event stream -multiplexed by agent `name`, and a separate `/api/input/{name}/stream` for -writes with `pty_input_ready`/`pty_input_ack` flow control) — nothing like the v1 -adapter's single bidirectional `worker_stream`/`sendInput` socket. Reproducing -that protocol is out of scope here for the same reason the v1 wire-format gap -above is: it is a distinct v2 adapter. Workspace mode's `brokerUrl` setting is -therefore a T3-Code-side convention layered on the *existing* v1 guess — a -`{name}` placeholder (or a `?agent=` fallback) substituted by -`buildWorkspaceAttachUrl` — not a real Agent Relay contract. Whoever builds the v2 -adapter should replace both the frame format and this URL convention together -against `harness-driver`'s real transport. + `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 diff --git a/docs/user/providers-agentrelay.md b/docs/user/providers-agentrelay.md index d4a138f34b3c..f86d0fd51e86 100644 --- a/docs/user/providers-agentrelay.md +++ b/docs/user/providers-agentrelay.md @@ -20,15 +20,20 @@ In **Settings → Providers**, add an Agent Relay instance, set **Mode** to - **Workspace key** — the Relaycast workspace key (`rk_live_...`) from the Agent Relay CLI (`agent-relay workspace` or wherever you provisioned it). -- **Broker URL** — the Agent Relay broker's attach endpoint. In this mode it is a - template: include a literal `{name}` where the agent's name goes (for example - `wss://broker.example.com/agents/{name}`); if you omit it, T3 Code appends - `?agent=` instead. +- **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. @@ -45,10 +50,12 @@ 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** and **API key** for that one agent's -attach session, as printed by the Agent Relay CLI. 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. +**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 diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 6f0b2d8ab9e0..f1a56f83d48a 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -838,9 +838,9 @@ export const AgentRelaySettings = makeProviderSettingsSchema( Schema.annotateKey({ title: "Broker URL", description: - "WebSocket URL for the Agent Relay broker control plane, from the Agent Relay CLI. In Workspace mode, include a literal {name} placeholder that T3 Code substitutes with the resolved agent name (falls back to appending ?agent= when omitted).", + "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: "wss://broker.example.com/ws", + placeholder: "https://broker.example.com", clearWhenEmpty: "omit", }, }), @@ -850,7 +850,7 @@ export const AgentRelaySettings = makeProviderSettingsSchema( Schema.annotateKey({ title: "API key", description: - "Attach token for the broker session. Stored in plain text on this environment.", + "Sent as the broker's X-API-Key header. Stored in plain text on this environment.", providerSettingsForm: { control: "password", placeholder: "Optional", @@ -858,6 +858,18 @@ export const AgentRelaySettings = makeProviderSettingsSchema( }, }), ), + 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({ @@ -877,7 +889,7 @@ export const AgentRelaySettings = makeProviderSettingsSchema( ), }, { - order: ["mode", "workspaceKey", "brokerUrl", "apiKey", "defaultSpawnCli"], + order: ["mode", "workspaceKey", "brokerUrl", "apiKey", "agentName", "defaultSpawnCli"], }, ); export type AgentRelaySettings = typeof AgentRelaySettings.Type; From db791946a67b41320205e25182be6ab6c2053e1c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 09:04:57 +0000 Subject: [PATCH 06/16] fix(contracts): default Agent Relay to Workspace mode, not Single Single mode requires a manually-typed agent name and only ever sees one agent. Workspace mode needs no agent name and discovers/spawns everything automatically, and the docs already call it "recommended" - the schema default disagreed. A brand-new Agent Relay instance now defaults into the mode that actually delivers on "multi-agent by default." Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01HXsTUKG2oXAfj6dmW838mG --- apps/server/src/provider/Layers/AgentRelayAdapter.test.ts | 6 +++++- packages/contracts/src/settings.ts | 7 +++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/apps/server/src/provider/Layers/AgentRelayAdapter.test.ts b/apps/server/src/provider/Layers/AgentRelayAdapter.test.ts index 3b0266636702..5a98b51b0431 100644 --- a/apps/server/src/provider/Layers/AgentRelayAdapter.test.ts +++ b/apps/server/src/provider/Layers/AgentRelayAdapter.test.ts @@ -181,7 +181,9 @@ const waitUntil = (predicate: () => boolean): Effect.Effect => const agentRelayAdapterTestLayer = Layer.provideMerge(NodeServices.layer, FetchHttpClient.layer); const makeTestAdapter = (brokerUrl: string, agentName = "Worker1", apiKey = "test-key") => - makeAgentRelayAdapter(decodeAgentRelaySettings({ enabled: true, brokerUrl, agentName, apiKey })); + 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", () => @@ -354,6 +356,7 @@ it.layer(agentRelayAdapterTestLayer)("AgentRelayAdapterLive", (it) => { const adapter = yield* makeAgentRelayAdapter( decodeAgentRelaySettings({ enabled: true, + mode: "single", brokerUrl: "", agentName: "Worker1", apiKey: "", @@ -372,6 +375,7 @@ it.layer(agentRelayAdapterTestLayer)("AgentRelayAdapterLive", (it) => { const adapter = yield* makeAgentRelayAdapter( decodeAgentRelaySettings({ enabled: true, + mode: "single", brokerUrl: "http://127.0.0.1:1", agentName: "", apiKey: "", diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index f1a56f83d48a..2d505913a5c1 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -808,11 +808,14 @@ export const AgentRelaySettings = makeProviderSettingsSchema( Schema.annotateKey({ providerSettingsForm: { hidden: true } }), ), mode: AgentRelayMode.pipe( - Schema.withDecodingDefault(Effect.succeed("single" as const)), + // 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: - "Single agent attaches to the one agent identified below. Workspace discovers every agent in the workspace and spawns new ones for new threads.", + "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, From fb97ce64a381f2ba8be64762808320bcdecf1fb4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 09:45:19 +0000 Subject: [PATCH 07/16] feat(server): auto-materialize threads for already-running Agent Relay agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Workspace-mode Agent Relay only surfaced agents T3 Code itself spawned or resumed. An agent started via Agent Relay's own CLI, its MCP tools, or a fleet trigger never showed up in T3 Code at all. Adds AgentRelayThreadDiscoveryReactor, a background reactor shaped like ProviderSessionReaper: it polls each enabled, Workspace-mode Agent Relay instance's listAgents() and materializes a T3 Code thread for every online agent that has no existing thread bound to it. Investigation into workspaceRoot/checkpointing (see the updated docs/internals/providers.md) found no reason to change the recommended design: commandInvariants.ts only string-compares workspaceRoot for uniqueness, and CheckpointReactor.ts's isGitRepository guard already no-ops checkpoint capture on a non-git directory, so a synthetic project only needs a real directory (created via WorkspacePaths.normalizeWorkspaceRoot with createIfMissing), never a git init. One project is created lazily per provider *instance* (not per agent) the first time a sweep finds an unclaimed agent for it, under /agent-relay/. The "attach instead of spawn" signal reuses the exact mechanism AgentSessionImporter.ts already established: install a ProviderSessionDirectory binding with 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, so no new attach-signal plumbing was needed. "Already claimed" is answered by scanning ProviderSessionDirectory.listBindings() for this instance's bindings and reading each resumeCursor.agentName, the same directory ProviderSessionReaper already scans, rather than a second bookkeeping table. An agent that drops out of listAgents() settles its thread (the same thread.settle verb ExternalSessionHooks uses for "session ended") only after it has stayed unconfirmed online for 2 minutes (a comfortable multiple of the 30s sweep interval), since a single missed sweep is not proof the agent is gone — the same presence-reliability caveat already documented for waitForAgentOnline. Sending the settled thread a new message unsettles it automatically via the decider's existing turn-start handling, so nothing needed to reverse this if the agent comes back. Also exposes AgentRelayAdapterShape.listWorkspaceAgents (present only in Workspace mode) so the reactor reuses each instance's already-live AgentRelayWorkspaceClient instead of registering a second presence identity, and exports AgentRelayResumeCursorSchema/isAgentRelayResumeCursor and AGENT_RELAY_DEFAULT_MODEL_SLUG for reuse by the reactor. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01HXsTUKG2oXAfj6dmW838mG --- ...ProviderSessionStartup.integration.test.ts | 4 + .../src/provider/Layers/AgentRelayAdapter.ts | 14 +- .../src/provider/Layers/AgentRelayProvider.ts | 7 +- .../AgentRelayThreadDiscoveryReactor.test.ts | 429 ++++++++++++++++++ .../AgentRelayThreadDiscoveryReactor.ts | 407 +++++++++++++++++ .../provider/Services/AgentRelayAdapter.ts | 16 +- .../AgentRelayThreadDiscoveryReactor.ts | 17 + apps/server/src/server.ts | 2 + apps/server/src/serverRuntimeStartup.ts | 4 + docs/internals/providers.md | 62 ++- docs/user/providers-agentrelay.md | 13 +- 11 files changed, 953 insertions(+), 22 deletions(-) create mode 100644 apps/server/src/provider/Layers/AgentRelayThreadDiscoveryReactor.test.ts create mode 100644 apps/server/src/provider/Layers/AgentRelayThreadDiscoveryReactor.ts create mode 100644 apps/server/src/provider/Services/AgentRelayThreadDiscoveryReactor.ts 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/src/provider/Layers/AgentRelayAdapter.ts b/apps/server/src/provider/Layers/AgentRelayAdapter.ts index ff8b35374df6..f081f739847c 100644 --- a/apps/server/src/provider/Layers/AgentRelayAdapter.ts +++ b/apps/server/src/provider/Layers/AgentRelayAdapter.ts @@ -97,9 +97,14 @@ const AGENT_SPAWN_POLL_INTERVAL = Duration.seconds(2); /** 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". */ -const AgentRelayResumeCursorSchema = Schema.Struct({ agentName: Schema.String }); -const isAgentRelayResumeCursor = Schema.is(AgentRelayResumeCursorSchema); + * 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 { @@ -783,6 +788,9 @@ export function makeAgentRelayAdapter( rollbackThread, stopAll, streamEvents, + ...(options?.workspaceClient + ? { listWorkspaceAgents: options.workspaceClient.listAgents } + : {}), } satisfies AgentRelayAdapterShape; }); } diff --git a/apps/server/src/provider/Layers/AgentRelayProvider.ts b/apps/server/src/provider/Layers/AgentRelayProvider.ts index af292817c2fe..8d2da723a83e 100644 --- a/apps/server/src/provider/Layers/AgentRelayProvider.ts +++ b/apps/server/src/provider/Layers/AgentRelayProvider.ts @@ -34,9 +34,14 @@ const EMPTY_CAPABILITIES = createModelCapabilities({ optionDescriptors: [] }); // 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: "relay-agent", + slug: AGENT_RELAY_DEFAULT_MODEL_SLUG, name: "Relay Agent", isCustom: false, capabilities: EMPTY_CAPABILITIES, 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..bf5d2849fafe --- /dev/null +++ b/apps/server/src/provider/Layers/AgentRelayThreadDiscoveryReactor.test.ts @@ -0,0 +1,429 @@ +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 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; + 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(() => 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; + }, + 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" }, + }, + }); + 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( + "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("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..24e273c5f471 --- /dev/null +++ b/apps/server/src/provider/Layers/AgentRelayThreadDiscoveryReactor.ts @@ -0,0 +1,407 @@ +/** + * 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 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 type { AgentRelayWorkspaceAgentSummary } from "../Services/AgentRelayWorkspaceClient.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. */ + const claimedAgentNamesForInstance = Effect.fn("claimedAgentNamesForInstance")(function* ( + instanceId: ProviderInstanceId, + ) { + const bindings = yield* directory.listBindings(); + const claimed = new Map(); + for (const binding of bindings) { + if (binding.provider !== AGENT_RELAY_DRIVER_KIND) continue; + if (binding.providerInstanceId !== instanceId) continue; + if (!isAgentRelayResumeCursor(binding.resumeCursor)) continue; + claimed.set(binding.resumeCursor.agentName, binding.threadId); + } + 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, + ) { + const agents = yield* instance + .listWorkspaceAgents() + .pipe(Effect.orElseSucceed((): ReadonlyArray => [])); + 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/Services/AgentRelayAdapter.ts b/apps/server/src/provider/Services/AgentRelayAdapter.ts index f5f0b5442518..cbe385cb37ec 100644 --- a/apps/server/src/provider/Services/AgentRelayAdapter.ts +++ b/apps/server/src/provider/Services/AgentRelayAdapter.ts @@ -9,8 +9,22 @@ */ 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 {} +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/server.ts b/apps/server/src/server.ts index aa131d46a9f8..9828d41ef000 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -44,6 +44,7 @@ import { AntigravityInstallation } from "./provider/AntigravityInstallation.ts"; import { ProviderInstanceRegistry } from "./provider/Services/ProviderInstanceRegistry.ts"; import { ProviderRegistry } from "./provider/Services/ProviderRegistry.ts"; import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReaper.ts"; +import { AgentRelayThreadDiscoveryReactorLive } from "./provider/Layers/AgentRelayThreadDiscoveryReactor.ts"; import { ProviderUsageLimitsIngestionLive } from "./provider/Layers/ProviderUsageLimitsIngestion.ts"; import * as OpenCodeRuntime from "./provider/opencodeRuntime.ts"; import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; @@ -420,6 +421,7 @@ const CloudManagedEndpointRuntimeLive = Layer.mergeAll( ); const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe( + Layer.provideMerge(AgentRelayThreadDiscoveryReactorLive), // Subscribes to `account.rate-limits.updated` so usage bars track live // telemetry instead of waiting for the next status probe. Layer.provideMerge(ProviderUsageLimitsIngestionLive), diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 0905439257ff..386f6d7ab030 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -41,6 +41,7 @@ import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import * as ProviderService from "./provider/Services/ProviderService.ts"; import * as ProviderSessionDirectory from "./provider/Services/ProviderSessionDirectory.ts"; import * as ProviderSessionReaper from "./provider/Services/ProviderSessionReaper.ts"; +import * as AgentRelayThreadDiscoveryReactor from "./provider/Services/AgentRelayThreadDiscoveryReactor.ts"; import { forkParked } from "./serverActivation.ts"; import * as ServiceLauncherClient from "./cloud/serviceLauncherClient.ts"; import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; @@ -809,6 +810,8 @@ export const make = (options?: StartupOptions) => 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/docs/internals/providers.md b/docs/internals/providers.md index 8ddaa7e68a92..fcb223094438 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -132,20 +132,54 @@ 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. -### Left out: auto-materializing threads for already-running agents - -`AgentRelayWorkspaceClient` can already list every agent in a workspace and watch -presence, which is the primitive auto-discovery (surfacing an agent nobody started -from T3 Code as a thread) needs. What it does not do is turn that into a thread: -`thread.create` (`apps/server/src/orchestration/decider.ts`) requires a -`projectId`, and every existing thread-creation path is a deliberate user action. -Silently materializing a thread per discovered agent needs a product decision this -change does not make on its own — at minimum, which project houses them and -whether every agent in a workspace should really become a thread unasked. A -follow-up background reactor (shaped like -`apps/server/src/provider/Layers/ProviderSessionReaper.ts`) is the right place to -wire `AgentRelayWorkspaceClient.listAgents`/`onPresenceChange` into `thread.create` -dispatch once that's decided. +### 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 diff --git a/docs/user/providers-agentrelay.md b/docs/user/providers-agentrelay.md index f86d0fd51e86..7213d4a8d510 100644 --- a/docs/user/providers-agentrelay.md +++ b/docs/user/providers-agentrelay.md @@ -11,9 +11,9 @@ There are two modes. 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) — is available to attach to. Starting a **new** -thread on this instance spawns a fresh agent through Agent Relay instead of -requiring one to already exist. +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: @@ -38,6 +38,13 @@ Starting a thread that already has an agent bound to it (including one it spawne 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 From f1a941f5993fdd44435efd4819b70fc711bdcf2e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 11:52:19 +0000 Subject: [PATCH 08/16] fix(contracts): default the Add Provider wizard to Agent Relay Workspace mode The schema-level decoding default already picked Workspace mode, but a fresh, unconfigured instance in the Add Provider wizard still showed Single agent (manual) selected. ProviderSettingsForm's select control defaults to the first entry in `options`, independent of Schema.withDecodingDefault - confirmed live in the browser. Reordering AGENT_RELAY_MODES so Workspace comes first makes the wizard's default match the schema's. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01HXsTUKG2oXAfj6dmW838mG --- packages/contracts/src/settings.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 2d505913a5c1..eec40a2367aa 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -780,9 +780,14 @@ export type OpenCodeSettings = typeof OpenCodeSettings.Type; * Relaycast) that nothing in Agent Relay's MCP/SDK surface currently * derives from a workspace key. See `docs/internals/providers.md`. */ +// 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: "single", label: "Single agent (manual)" }, { 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; From a267f771256c969a828be5072c857eacc7f2e50c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 11:52:29 +0000 Subject: [PATCH 09/16] fix(shared): tolerate EAFNOSUPPORT when probing ::1 availability canListenOnHost only treated EADDRNOTAVAIL as "this address family isn't usable here." A host with no IPv6 stack at all raises EAFNOSUPPORT instead when binding ::1, which canListenOnHost surfaced as "port taken" - every port check then failed and dev-runner exhausted the full port range before ever starting. Confirmed live: this sandboxed environment throws EAFNOSUPPORT for ::1, which blocked `vp run dev` from starting entirely. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01HXsTUKG2oXAfj6dmW838mG --- packages/shared/src/Net.test.ts | 13 +++++++++++++ packages/shared/src/Net.ts | 12 +++++++++--- 2 files changed, 22 insertions(+), 3 deletions(-) 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; } From 8406e68519aea985ffa759a82e79ded9f1996f05 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 11:52:41 +0000 Subject: [PATCH 10/16] fix(server): make Agent Relay's first turn actually reach the broker Live end-to-end testing against a real agent-relay-broker surfaced two bugs that together meant an Agent Relay thread's first message always failed, and even a successful one never rendered anything: - startSession returns as soon as the WebSocket connect is initiated (connect() is deliberately fire-and-forget), so the session is often still "connecting" when orchestration's first sendTurn call lands a moment later - before the handshake finishes. sendTurn now waits for the session to leave "connecting" (bounded, 15s) instead of treating that race as a hard failure. - Incoming worker_stream frames were tagged content.delta with streamKind: "command_output", but ProviderRuntimeIngestion only turns "assistant_text" deltas into visible transcript content and silently drops every other stream kind. "command_output" is for a structured adapter streaming a tool call's output alongside its own separate assistant text; 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. Confirmed live: broker frames arrived and the session reached "ready" while tagged "command_output", but nothing rendered; retagging it fixed that. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01HXsTUKG2oXAfj6dmW838mG --- .../provider/Layers/AgentRelayAdapter.test.ts | 7 ++- .../src/provider/Layers/AgentRelayAdapter.ts | 45 ++++++++++++++++++- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/apps/server/src/provider/Layers/AgentRelayAdapter.test.ts b/apps/server/src/provider/Layers/AgentRelayAdapter.test.ts index 5a98b51b0431..0faf72bbae76 100644 --- a/apps/server/src/provider/Layers/AgentRelayAdapter.test.ts +++ b/apps/server/src/provider/Layers/AgentRelayAdapter.test.ts @@ -211,7 +211,12 @@ it.layer(agentRelayAdapterTestLayer)("AgentRelayAdapterLive", (it) => { '{"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"); - assert.equal(delta.payload.streamKind, "command_output"); + // 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" }); diff --git a/apps/server/src/provider/Layers/AgentRelayAdapter.ts b/apps/server/src/provider/Layers/AgentRelayAdapter.ts index f081f739847c..c10d95877413 100644 --- a/apps/server/src/provider/Layers/AgentRelayAdapter.ts +++ b/apps/server/src/provider/Layers/AgentRelayAdapter.ts @@ -94,6 +94,16 @@ const TURN_IDLE_COMPLETE_MS = 1_500; 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); + /** Durable per-thread continuation state for workspace mode, round-tripped * through `ProviderSession.resumeCursor` / `ProviderSessionDirectory` the * same way `CodexResumeCursorSchema` persists a rollout id. Absent (or @@ -167,6 +177,26 @@ function waitForAgentOnline( ); } +/** + * 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* () { + while (ctx.session.status === "connecting") { + 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`). */ @@ -399,7 +429,17 @@ export function makeAgentRelayAdapter( provider: PROVIDER, threadId: ctx.threadId, ...(ctx.activeTurnId ? { turnId: ctx.activeTurnId } : {}), - payload: { streamKind: "command_output", delta: frame.text }, + // `"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 }, }); }); @@ -635,6 +675,9 @@ export function makeAgentRelayAdapter( 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, From bf8ad8cfa19f449b372be32e75e3a4fe6d190cc7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 13:31:59 +0000 Subject: [PATCH 11/16] fix(server): Agent Relay adapter reconnect, turn-registration, and concurrency bugs PR review feedback (Devin, CodeRabbit, cubic) on the Agent Relay adapter surfaced several real bugs beyond what live testing had already caught: - handleClose treated any close code 1000 as a deliberate local stop, including a remote one (e.g. a broker restart) - that skipped the reconnect/backoff loop entirely on exactly the closes it exists for. ctx.stopped is already the correct "we asked for this" signal (set before stopSessionInternal ever closes the socket), so the special case was both redundant and wrong; removed it. - The idle watchdog could complete a turn, then unconditionally restore session status to "ready" even when handleClose had already marked it "error" with no socket - sendTurn would then accept a next message it could never deliver. completeActiveTurn now preserves "error". - On disconnect mid-turn, nothing aborted the active turn immediately; it sat until the watchdog's own idle timeout fired against a session with no socket. handleClose now aborts it right away. - The idle-quiet timer started immediately after postInput, before any output existed - normal startup latency (broker round-trip, agent cold start) longer than 1.5s got a turn marked "completed" while still working. The watchdog now waits for first activity (bounded) before applying the between-frames idle timer. - sendTurn registered the turn (activeTurnId, turn.started) only after postInput resolved, so output arriving while the POST was in flight had no active turn to attach to. Registration now happens first, with rollback (and a turn.aborted event) if postInput fails. - Two overlapping startSession calls for the same thread could both observe no existing session and race to install their own context, orphaning the loser's socket/spawned agent. startSession is now serialized per thread (matching CursorAdapter's pattern). - A brokerUrl with a trailing slash produced "//ws" and "//api/input/", which the broker's exact-path routing rejects. Normalized once in startSession. - resumeCursor was persisted in Single mode too, so switching an instance from Single to Workspace left the old agent's name behind as a stale cursor. Only set in Workspace mode now. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01HXsTUKG2oXAfj6dmW838mG --- .../provider/Layers/AgentRelayAdapter.test.ts | 373 +++++++++++++++- .../src/provider/Layers/AgentRelayAdapter.ts | 400 ++++++++++++------ 2 files changed, 638 insertions(+), 135 deletions(-) diff --git a/apps/server/src/provider/Layers/AgentRelayAdapter.test.ts b/apps/server/src/provider/Layers/AgentRelayAdapter.test.ts index 0faf72bbae76..c136cbd4c948 100644 --- a/apps/server/src/provider/Layers/AgentRelayAdapter.test.ts +++ b/apps/server/src/provider/Layers/AgentRelayAdapter.test.ts @@ -49,13 +49,22 @@ interface RecordedInput { * 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 ?? ""); @@ -63,6 +72,11 @@ function startMockBroker(): Promise<{ 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", () => { @@ -78,10 +92,18 @@ function startMockBroker(): Promise<{ }); }); 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 }); + resolve({ server, httpServer, url: `http://127.0.0.1:${port}`, inputs, inputFailure }); }); }); } @@ -118,10 +140,12 @@ const forkConnectionWait = (server: WebSocketServer) => * fallback rather than the (unverifiable, see AgentRelayWorkspaceClientLive.ts) * presence push path. */ -const makeFakeWorkspaceClient = () => +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( @@ -129,13 +153,23 @@ const makeFakeWorkspaceClient = () => ), 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 }; + return { shape, spawnCalls, maxConcurrentSpawns }; }); /** @@ -160,12 +194,26 @@ 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( - (event): event is Extract => event.type === eventType, - ); + const found = current.find(predicate); if (found) return found; yield* Effect.sleep(Duration.millis(10)); } @@ -280,7 +328,7 @@ it.layer(agentRelayAdapterTestLayer)("AgentRelayAdapterLive", (it) => { }), ); - it.effect("completes a turn once the broker goes quiet", () => + 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())); @@ -296,9 +344,20 @@ it.layer(agentRelayAdapterTestLayer)("AgentRelayAdapterLive", (it) => { yield* adapter.sendTurn({ threadId, input: "run the tests" }); yield* waitForEvent(events, "turn.started"); - - // No further output arrives: the idle watchdog should complete the - // turn on its own once TURN_IDLE_COMPLETE_MS has elapsed. + // 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"); @@ -308,6 +367,65 @@ it.layer(agentRelayAdapterTestLayer)("AgentRelayAdapterLive", (it) => { }), ); + 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); @@ -336,6 +454,153 @@ it.layer(agentRelayAdapterTestLayer)("AgentRelayAdapterLive", (it) => { }), ); + 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); @@ -393,6 +658,59 @@ it.layer(agentRelayAdapterTestLayer)("AgentRelayAdapterLive", (it) => { 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) => { @@ -495,4 +813,39 @@ it.layer(agentRelayAdapterTestLayer)("AgentRelayAdapterLive workspace mode", (it 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 index c10d95877413..62144ea64b67 100644 --- a/apps/server/src/provider/Layers/AgentRelayAdapter.ts +++ b/apps/server/src/provider/Layers/AgentRelayAdapter.ts @@ -60,9 +60,12 @@ 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"; @@ -86,6 +89,18 @@ const RECONNECT_DELAYS_MS = [1_000, 2_000, 5_000, 10_000, 30_000]; // 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 @@ -104,6 +119,10 @@ const AGENT_SPAWN_POLL_INTERVAL = Duration.seconds(2); 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 @@ -189,7 +208,10 @@ function awaitSessionConnected( ctx: AgentRelaySessionContext, ): Effect.Effect { const pollUntilSettled = Effect.gen(function* () { - while (ctx.session.status === "connecting") { + // `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); } }); @@ -270,6 +292,7 @@ export function makeAgentRelayAdapter( 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); @@ -302,6 +325,28 @@ export function makeAgentRelayAdapter( ); }; + // 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 => { @@ -356,8 +401,15 @@ export function makeAgentRelayAdapter( // the watchdog before it calls this. ctx.turnWatchdogFiber = undefined; const updatedAt = yield* nowIso; - const { activeTurnId: _activeTurnId, ...readySession } = ctx.session; - ctx.session = { ...readySession, status: "ready", updatedAt }; + 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()), @@ -373,6 +425,15 @@ export function makeAgentRelayAdapter( 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( @@ -446,15 +507,20 @@ export function makeAgentRelayAdapter( 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; - // 1000 is a normal close either side can initiate — `stopSession` - // closes with this code, so treat it as a deliberate disconnect - // rather than something to reconnect from. - if (code === 1000) { - yield* stopSessionInternal(ctx); - return; - } const detail = reason.trim() || `Broker connection closed (code ${code}).`; const updatedAt = yield* nowIso; ctx.session = { ...ctx.session, status: "error", updatedAt, lastError: detail }; @@ -465,6 +531,20 @@ export function makeAgentRelayAdapter( 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); }); @@ -545,132 +625,155 @@ export function makeAgentRelayAdapter( }); const startSession: AgentRelayAdapterShape["startSession"] = (input) => - 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.", - }); - } + // 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; + // 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 workspaceClient = options?.workspaceClient; - if (!workspaceClient) { + const configuredName = agentRelaySettings.agentName.trim(); + if (!configuredName) { return yield* new ProviderAdapterValidationError({ provider: PROVIDER, operation: "startSession", issue: - "Agent Relay is in Workspace mode but no workspace key is configured for this instance.", + "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.", }); } - 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; + targetAgentName = configuredName; } - } 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.", - }); + // 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); } - targetAgentName = configuredName; - } - const brokerBaseUrl = agentRelaySettings.brokerUrl.trim(); - 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, - 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); + 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: {}, - }); + 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; - }); + connect(ctx); + return session; + }), + ); const sendTurn: AgentRelayAdapterShape["sendTurn"] = (input) => Effect.gen(function* () { @@ -694,10 +797,20 @@ export function makeAgentRelayAdapter( "Turn requires non-empty text. Agent Relay's terminal transport cannot carry attachments.", }); } - yield* postInput(ctx, `${text}\n`); - + // 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; @@ -721,6 +834,43 @@ export function makeAgentRelayAdapter( 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 }; }); From a7d0b8c38b383f4bade9199b4c5e5d595dfc8de7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 13:32:12 +0000 Subject: [PATCH 12/16] fix(server): Agent Relay discovery reactor - listing failures and phantom bindings Two review findings on the auto-materialize reactor: - A failed listWorkspaceAgents() call was treated as an empty result ([]), running every currently-claimed agent through the same "not online" path a real offline agent takes. An outage lasting past offlineDebounceMs would settle every one of that instance's threads even though nothing actually went offline. The sweep now skips the instance entirely on a listing failure instead. - materializeThread installs the ProviderSessionDirectory binding before dispatching thread.create (deliberately, for ordering - see its comment), so a dispatch failure in between leaves a binding pointing at a thread that was never created. claimedAgentNamesForInstance treated any such binding as "claimed" forever, permanently skipping that agent on every future sweep. It now also checks the thread actually exists before counting a binding as claimed, so a failed attempt gets retried (with a new thread id) instead of orphaning the agent - at the cost of a harmless dead binding row for the failed try. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01HXsTUKG2oXAfj6dmW838mG --- .../AgentRelayThreadDiscoveryReactor.test.ts | 115 +++++++++++++++++- .../AgentRelayThreadDiscoveryReactor.ts | 60 +++++++-- 2 files changed, 163 insertions(+), 12 deletions(-) diff --git a/apps/server/src/provider/Layers/AgentRelayThreadDiscoveryReactor.test.ts b/apps/server/src/provider/Layers/AgentRelayThreadDiscoveryReactor.test.ts index bf5d2849fafe..d7bcb33f465e 100644 --- a/apps/server/src/provider/Layers/AgentRelayThreadDiscoveryReactor.test.ts +++ b/apps/server/src/provider/Layers/AgentRelayThreadDiscoveryReactor.test.ts @@ -21,6 +21,7 @@ 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"; @@ -197,6 +198,7 @@ function makeHarness(input: { readonly seedBinding?: ProviderRuntimeBinding; }) { let agents: ReadonlyArray = input.agents; + let listShouldFail = false; const directory = makeFakeDirectory(); if (input.seedBinding) { directory.seed(input.seedBinding); @@ -240,7 +242,17 @@ function makeHarness(input: { const instance = makeAgentRelayInstance({ ...(input.enabled !== undefined ? { enabled: input.enabled } : {}), - adapter: makeAgentRelayAdapter(() => Effect.sync(() => agents)), + 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( @@ -267,6 +279,9 @@ function makeHarness(input: { setAgents: (next: ReadonlyArray) => { agents = next; }, + setListFailing: (fail: boolean) => { + listShouldFail = fail; + }, dependencies, }; } @@ -331,6 +346,16 @@ it.layer(NodeServices.layer)("AgentRelayThreadDiscoveryReactor", (it) => { 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 @@ -343,6 +368,45 @@ it.layer(NodeServices.layer)("AgentRelayThreadDiscoveryReactor", (it) => { ), ); + 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", () => @@ -397,6 +461,55 @@ it.layer(NodeServices.layer)("AgentRelayThreadDiscoveryReactor", (it) => { ), ); + 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* () { diff --git a/apps/server/src/provider/Layers/AgentRelayThreadDiscoveryReactor.ts b/apps/server/src/provider/Layers/AgentRelayThreadDiscoveryReactor.ts index 24e273c5f471..982ac18d05d4 100644 --- a/apps/server/src/provider/Layers/AgentRelayThreadDiscoveryReactor.ts +++ b/apps/server/src/provider/Layers/AgentRelayThreadDiscoveryReactor.ts @@ -54,6 +54,7 @@ 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"; @@ -63,7 +64,6 @@ 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 type { AgentRelayWorkspaceAgentSummary } from "../Services/AgentRelayWorkspaceClient.ts"; import { AgentRelayThreadDiscoveryReactor, type AgentRelayThreadDiscoveryReactorShape, @@ -195,18 +195,41 @@ const makeAgentRelayThreadDiscoveryReactor = ( /** 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. */ + * 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(); - for (const binding of bindings) { - if (binding.provider !== AGENT_RELAY_DRIVER_KIND) continue; - if (binding.providerInstanceId !== instanceId) continue; - if (!isAgentRelayResumeCursor(binding.resumeCursor)) continue; - claimed.set(binding.resumeCursor.agentName, binding.threadId); - } + 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; }); @@ -321,9 +344,24 @@ const makeAgentRelayThreadDiscoveryReactor = ( const sweepInstance = Effect.fn("sweepInstance")(function* ( instance: AgentRelayWorkspaceInstance, ) { - const agents = yield* instance - .listWorkspaceAgents() - .pipe(Effect.orElseSucceed((): ReadonlyArray => [])); + // 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), ); From 3cd49f2762bd49786047db370c1cc61d624a3963 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 13:32:20 +0000 Subject: [PATCH 13/16] fix(server): whitelist online/offline in Agent Relay presence events readPresenceTransition defaulted any agent.status.* event that wasn't literally "offline" to "online" - an intermediate status like connecting or error (or any future status this module doesn't know about) would resolve waitForAgentOnline for an agent that was never actually attachable. Only agent.status.online/offline now produce a transition; everything else is ignored, matching this module's own stated presence-uncertainty stance - a missed real transition still gets caught by AgentRelayAdapter's polling fallback, but a wrongly-guessed one cannot. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01HXsTUKG2oXAfj6dmW838mG --- .../AgentRelayWorkspaceClientLive.test.ts | 31 ++++++++++++++++--- .../Layers/AgentRelayWorkspaceClientLive.ts | 21 +++++++++---- 2 files changed, 41 insertions(+), 11 deletions(-) diff --git a/apps/server/src/provider/Layers/AgentRelayWorkspaceClientLive.test.ts b/apps/server/src/provider/Layers/AgentRelayWorkspaceClientLive.test.ts index aa11467d5344..4e58f3a6507b 100644 --- a/apps/server/src/provider/Layers/AgentRelayWorkspaceClientLive.test.ts +++ b/apps/server/src/provider/Layers/AgentRelayWorkspaceClientLive.test.ts @@ -16,25 +16,46 @@ describe("readPresenceTransition", () => { ); }); - it("reads the agent.status.* dotted event shape, treating anything but offline as online", () => { + 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.active", agentId: "Worker" }), + readPresenceTransition({ type: "agent.status.online", agentId: "Worker" }), { name: "Worker", status: "online" }, ); - NodeAssert.deepEqual( + }); + + 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" }), - { name: "Worker", status: "online" }, + 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({ 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 index b5f90c2a533e..b0382f3d2e2a 100644 --- a/apps/server/src/provider/Layers/AgentRelayWorkspaceClientLive.ts +++ b/apps/server/src/provider/Layers/AgentRelayWorkspaceClientLive.ts @@ -51,8 +51,7 @@ function describeError(cause: unknown): string { function toAgentSummary(agent: RelayAgent): AgentRelayWorkspaceAgentSummary { return { name: agent.name, - status: - agent.status === "online" || agent.status === "offline" ? agent.status : "unknown", + status: agent.status === "online" || agent.status === "offline" ? agent.status : "unknown", }; } @@ -71,17 +70,26 @@ export function readPresenceTransition( if (type === "agentOnline" || type === "agentOffline") { const agent = record.agent; const name = - typeof agent === "object" && agent !== null && typeof (agent as { name?: unknown }).name === "string" + 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 (typeof type === "string" && type.startsWith("agent.status.")) { + 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.offline" ? "offline" : "online" }; + 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; } @@ -134,7 +142,8 @@ export function makeAgentRelayWorkspaceClient( const listAgents: AgentRelayWorkspaceClientShape["listAgents"] = (filter) => Effect.tryPromise({ - try: () => workspaceClient.agents.list(filter?.status ? { status: filter.status } : undefined), + try: () => + workspaceClient.agents.list(filter?.status ? { status: filter.status } : undefined), catch: (cause) => new ProviderAdapterRequestError({ provider: PROVIDER, From b0e8878f2b4ccf8db6f35293e8205325f9805a24 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 13:32:31 +0000 Subject: [PATCH 14/16] fix(server): Agent Relay health check catches misconfiguration earlier - Workspace mode with a broker URL but no workspace key reported "ready" - every new session on that instance then failed in startSession (no workspaceClient gets built without one). Now reports an error naming the actual gap instead of surfacing it only on the first real thread. - The "no broker URL" message told users to paste a WebSocket URL; the adapter has taken a plain base HTTP(S) URL since the wire-format fix, deriving /ws itself. Message corrected to match. - A configured API key sent to a non-loopback http:// broker now reports a warning (not ready, not a hard error) naming the cleartext exposure. Not a hard requirement for https:// - the primary documented setup is a local, self-hosted broker with no TLS to speak of, and that must keep working. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01HXsTUKG2oXAfj6dmW838mG --- .../Layers/AgentRelayProvider.test.ts | 121 ++++++++++++++++++ .../src/provider/Layers/AgentRelayProvider.ts | 60 ++++++++- 2 files changed, 176 insertions(+), 5 deletions(-) create mode 100644 apps/server/src/provider/Layers/AgentRelayProvider.test.ts 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 index 8d2da723a83e..b183e135da4b 100644 --- a/apps/server/src/provider/Layers/AgentRelayProvider.ts +++ b/apps/server/src/provider/Layers/AgentRelayProvider.ts @@ -31,6 +31,27 @@ const AGENT_RELAY_PRESENTATION = { 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. @@ -122,12 +143,39 @@ export function checkAgentRelayProviderStatus( version: null, status: "error", auth: { status: "unknown" }, - message: "No broker URL configured. Paste the WebSocket URL from the Agent Relay CLI.", + 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, @@ -136,13 +184,15 @@ export function checkAgentRelayProviderStatus( probe: { installed: true, version: null, - status: "ready", + status: cleartextWarning ? "warning" : "ready", auth: hasApiKey ? { status: "authenticated", type: "api_key", label: "Agent Relay API key" } : { status: "unauthenticated" }, - ...(hasApiKey - ? {} - : { message: "No API key configured. The broker may reject the connection." }), + ...(cleartextWarning + ? { message: cleartextWarning } + : hasApiKey + ? {} + : { message: "No API key configured. The broker may reject the connection." }), }, }); }); From 970304f2d50bae7de232670f160dbe87fb3c0074 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 13:32:40 +0000 Subject: [PATCH 15/16] docs(contracts): correct stale Agent Relay defaults doc, link provider guide The AgentRelaySettings docblock still called single mode the default and workspace the opt-in - backwards since an earlier commit flipped the schema default to workspace. Also link the Agent Relay provider guide from docs/README.md's provider index; it existed but wasn't listed there. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01HXsTUKG2oXAfj6dmW838mG --- docs/README.md | 2 +- packages/contracts/src/settings.ts | 18 ++++++++++-------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/docs/README.md b/docs/README.md index bbbea9b166b7..bbe613b9f59a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -18,7 +18,7 @@ - [Running in the background](./user/background-service.md) - [Updating T3 Code](./user/updating.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) +- 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/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index eec40a2367aa..5a807c58b324 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -771,14 +771,16 @@ export type OpenCodeSettings = typeof OpenCodeSettings.Type; * Two modes share this one schema, the same way `AntigravitySettings.authMethod` * keeps every method's fields flat instead of branching the struct: * - * - `single` (default, legacy v1): the broker URL and API key identify one - * already-running agent directly. Nothing to discover or spawn. - * - `workspace`: `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`. + * - `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 From f6256e83dcaa75bdab58ce066dc226158dba67cb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 13:32:59 +0000 Subject: [PATCH 16/16] fix(server): authenticate, bound, and harden external-session hooks PR review (Devin, CodeRabbit, cubic) found the external-session lifecycle hook endpoint had no real guardrails: - Unauthenticated and reachable from anywhere the server is exposed (LAN, Tailscale, T3 Connect) - any network client could fabricate unlimited marker threads. Now restricted to genuine loopback callers, checked against the request's actual socket remoteAddress (never a client-suppliable header). - No body size limit - a reachable caller could send oversized payloads before schema validation ever ran. Now capped via Content-Length pre-check plus a real byte-capped stream read. - cwd/sessionId were unbounded strings, and timestamp (persisted directly as createdAt on the thread and its activities) was never sanity-checked - a far-future or garbage value could skew thread ordering. Both are now bounded/validated, rejecting rather than silently clamping. - A retried "end" hook appended another "ended" activity and re-settled the thread every time. Now checks settledAt first and no-ops if already recorded. - If thread.create succeeded but the follow-up start-activity dispatch failed, the thread was permanently missing its start marker - a retry saw the thread and returned "already-recorded". Now checks for the actual start activity, not just thread existence, and completes it on retry. - historyImport only suppressed checkpoint processing, not actual writes - a marker thread's composer could still start a real provider session. ProviderCommandReactor now refuses to start a turn on a thread minted by these hooks (identified by its own external: id prefix), with a clear activity explaining why. Full client-side UI enforcement (a disabled composer) is a larger, multi-surface follow-up, not done here. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01HXsTUKG2oXAfj6dmW838mG --- .../Layers/ProviderCommandReactor.test.ts | 70 +++++ .../Layers/ProviderCommandReactor.ts | 21 ++ .../src/project/ExternalSessionHooks.test.ts | 254 +++++++++++++++++- .../src/project/ExternalSessionHooks.ts | 221 +++++++++++++-- 4 files changed, 533 insertions(+), 33 deletions(-) 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 index 2577ae28a79e..725847bb3e01 100644 --- a/apps/server/src/project/ExternalSessionHooks.test.ts +++ b/apps/server/src/project/ExternalSessionHooks.test.ts @@ -17,13 +17,19 @@ 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 } from "./ExternalSessionHooks.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"; @@ -62,6 +68,7 @@ const makeInMemoryOrchestration = Effect.fn("makeInMemoryOrchestration")(functio 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* () { @@ -91,8 +98,21 @@ const makeInMemoryOrchestration = Effect.fn("makeInMemoryOrchestration")(functio const dispatch: OrchestrationEngine.OrchestrationEngineShape["dispatch"] = ( command: OrchestrationCommand, - ) => - Effect.gen(function* () { + ) => { + // 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), ); @@ -103,6 +123,7 @@ const makeInMemoryOrchestration = Effect.fn("makeInMemoryOrchestration")(functio } return { sequence }; }).pipe(Effect.orDie); + }; const engine = OrchestrationEngine.OrchestrationEngineService.of({ readEvents: () => Stream.die("unused in this test"), @@ -147,7 +168,9 @@ const makeInMemoryOrchestration = Effect.fn("makeInMemoryOrchestration")(functio 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))), + Effect.sync(() => + Option.fromNullishOr(readModel.threads.find((thread) => thread.id === threadId)), + ), getThreadDetailSnapshot: () => Effect.die("unused in this test"), }); @@ -156,6 +179,9 @@ const makeInMemoryOrchestration = Effect.fn("makeInMemoryOrchestration")(functio snapshots, seedProject, getThread: (threadId: ThreadId) => readModel.threads.find((thread) => thread.id === threadId), + failNextDispatchOf: (type: OrchestrationCommand["type"]) => { + failNextDispatchOfType = type; + }, }; }); @@ -177,9 +203,42 @@ const postHookEvent = (input: { 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, @@ -261,6 +320,7 @@ it.effect("materializes a read-only marker thread on start and settles it on end 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, @@ -292,3 +352,189 @@ it.effect("drops events it cannot attach to a known project or 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 index 7fe4eb647b98..aec42440e3a8 100644 --- a/apps/server/src/project/ExternalSessionHooks.ts +++ b/apps/server/src/project/ExternalSessionHooks.ts @@ -35,6 +35,7 @@ import { 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"; @@ -43,23 +44,76 @@ import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstab 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: TrimmedNonEmptyString, - sessionId: TrimmedNonEmptyString, + cwd: BoundedHookString, + sessionId: BoundedHookString, event: ExternalSessionHookEventKind, - timestamp: IsoDateTime, + timestamp: PlausibleIsoDateTime, }); export type ExternalSessionHookPayload = typeof ExternalSessionHookPayload.Type; @@ -93,7 +147,17 @@ function externalSessionThreadId(input: { readonly provider: ExternalSessionHookProvider; readonly sessionId: string; }): ThreadId { - return ThreadId.make(`external:${input.provider}:${input.sessionId}`); + 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); } /** @@ -110,10 +174,40 @@ export const recordExternalSessionHookEvent = Effect.fn("recordExternalSessionHo 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), @@ -149,7 +243,25 @@ export const recordExternalSessionHookEvent = Effect.fn("recordExternalSessionHo } if (Option.isSome(existingThread)) { - return { recorded: false, reason: "already-recorded" } satisfies ExternalSessionHookResult; + // 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)) { @@ -173,21 +285,7 @@ export const recordExternalSessionHookEvent = Effect.fn("recordExternalSessionHo createdAt: payload.timestamp, historyImport: true, }); - 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.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, - }); + yield* appendStartActivity; return { recorded: true, threadId } satisfies ExternalSessionHookResult; }, ); @@ -195,27 +293,92 @@ export const recordExternalSessionHookEvent = Effect.fn("recordExternalSessionHo const decodeExternalSessionHookPayload = Schema.decodeUnknownEffect(ExternalSessionHookPayload); /** - * `POST /api/external-sessions` — see module docs. Unauthenticated by - * design, same as this server's other loopback-oriented local tooling - * surfaces: the payload only ever produces a read-only informational marker, - * never code execution or a live session, so the worst a forged POST can do - * is add a fake marker thread. + * 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 bodyJson = yield* request.json.pipe(Effect.orElseSucceed(() => null)); + + 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), - ); + : 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(