From 5b8cfdbf05d09f94df36d976ab62a0b2250bef8d Mon Sep 17 00:00:00 2001 From: Ben Davis <45952064+bmdavis419@users.noreply.github.com> Date: Fri, 24 Jul 2026 01:25:44 -0700 Subject: [PATCH 01/68] feat: add Hermes gateway provider --- .../Layers/ProviderCommandReactor.test.ts | 56 ++ .../Layers/ProviderRuntimeIngestion.test.ts | 91 ++ .../Layers/ProviderRuntimeIngestion.ts | 12 +- .../src/provider/Drivers/HermesDriver.ts | 131 +++ .../src/provider/Layers/HermesAdapter.test.ts | 171 ++++ .../src/provider/Layers/HermesAdapter.ts | 562 +++++++++++ .../Layers/HermesGatewayBroker.test.ts | 544 ++++++++++ .../provider/Layers/HermesGatewayBroker.ts | 892 +++++++++++++++++ .../provider/Services/HermesGatewayBroker.ts | 101 ++ apps/server/src/provider/builtInDrivers.ts | 3 + apps/server/src/provider/hermesGatewayHttp.ts | 116 +++ apps/server/src/server.ts | 9 +- apps/server/src/ws.ts | 30 + apps/web/src/components/HermesIcon.tsx | 18 + apps/web/src/components/chat/ChatComposer.tsx | 171 ++-- .../chat/ComposerPrimaryActions.test.ts | 41 +- .../chat/ComposerPrimaryActions.tsx | 55 +- .../src/components/chat/providerIconUtils.ts | 2 + .../AddProviderInstanceDialog.logic.ts | 39 + .../AddProviderInstanceDialog.test.ts | 57 +- .../settings/AddProviderInstanceDialog.tsx | 558 +++++++---- ...HermesGatewayInstanceSection.logic.test.ts | 38 + .../HermesGatewayInstanceSection.logic.ts | 46 + .../settings/HermesGatewayInstanceSection.tsx | 276 +++++ .../settings/ProviderInstanceCard.test.ts | 13 +- .../settings/ProviderInstanceCard.tsx | 152 ++- .../components/settings/providerDriverMeta.ts | 8 + apps/web/src/modelSelection.ts | 2 +- apps/web/src/providerInstances.test.ts | 89 ++ apps/web/src/providerInstances.ts | 94 ++ .../hermes-t3-gateway/COMPATIBILITY.md | 48 + integrations/hermes-t3-gateway/README.md | 71 ++ integrations/hermes-t3-gateway/__init__.py | 77 ++ integrations/hermes-t3-gateway/adapter.py | 940 ++++++++++++++++++ integrations/hermes-t3-gateway/cli.py | 125 +++ integrations/hermes-t3-gateway/connection.py | 247 +++++ integrations/hermes-t3-gateway/plugin.yaml | 24 + integrations/hermes-t3-gateway/protocol.py | 168 ++++ .../hermes-t3-gateway/tests/test_adapter.py | 411 ++++++++ .../tests/test_connection.py | 103 ++ .../hermes-t3-gateway/tests/test_protocol.py | 63 ++ packages/client-runtime/src/state/server.ts | 16 + packages/contracts/src/hermesGateway.test.ts | 414 ++++++++ packages/contracts/src/hermesGateway.ts | 574 +++++++++++ packages/contracts/src/index.ts | 1 + packages/contracts/src/model.ts | 4 + packages/contracts/src/rpc.ts | 50 + packages/contracts/src/settings.ts | 19 + 48 files changed, 7430 insertions(+), 302 deletions(-) create mode 100644 apps/server/src/provider/Drivers/HermesDriver.ts create mode 100644 apps/server/src/provider/Layers/HermesAdapter.test.ts create mode 100644 apps/server/src/provider/Layers/HermesAdapter.ts create mode 100644 apps/server/src/provider/Layers/HermesGatewayBroker.test.ts create mode 100644 apps/server/src/provider/Layers/HermesGatewayBroker.ts create mode 100644 apps/server/src/provider/Services/HermesGatewayBroker.ts create mode 100644 apps/server/src/provider/hermesGatewayHttp.ts create mode 100644 apps/web/src/components/HermesIcon.tsx create mode 100644 apps/web/src/components/settings/HermesGatewayInstanceSection.logic.test.ts create mode 100644 apps/web/src/components/settings/HermesGatewayInstanceSection.logic.ts create mode 100644 apps/web/src/components/settings/HermesGatewayInstanceSection.tsx create mode 100644 integrations/hermes-t3-gateway/COMPATIBILITY.md create mode 100644 integrations/hermes-t3-gateway/README.md create mode 100644 integrations/hermes-t3-gateway/__init__.py create mode 100644 integrations/hermes-t3-gateway/adapter.py create mode 100644 integrations/hermes-t3-gateway/cli.py create mode 100644 integrations/hermes-t3-gateway/connection.py create mode 100644 integrations/hermes-t3-gateway/plugin.yaml create mode 100644 integrations/hermes-t3-gateway/protocol.py create mode 100644 integrations/hermes-t3-gateway/tests/test_adapter.py create mode 100644 integrations/hermes-t3-gateway/tests/test_connection.py create mode 100644 integrations/hermes-t3-gateway/tests/test_protocol.py create mode 100644 packages/contracts/src/hermesGateway.test.ts create mode 100644 packages/contracts/src/hermesGateway.ts diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index c49646b7a4b..a4ba11b9260 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -1181,6 +1181,62 @@ describe("ProviderCommandReactor", () => { expect(harness.stopSession.mock.calls.length).toBe(0); }); + it("reuses a cwd-bookkept Hermes session for active follow-up guidance", async () => { + const harness = await createHarness({ + threadModelSelection: { + instanceId: ProviderInstanceId.make("hermes"), + model: "hermes", + }, + }); + const now = "2026-01-01T00:00:00.000Z"; + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-hermes-guidance-1"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-hermes-guidance-1"), + role: "user", + text: "first", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 1); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ + cwd: "/tmp/provider-project", + provider: ProviderDriverKind.make("hermes"), + providerInstanceId: ProviderInstanceId.make("hermes"), + }); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-hermes-guidance-2"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-hermes-guidance-2"), + role: "user", + text: "follow up", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.sendTurn.mock.calls.length === 2); + expect(harness.startSession).toHaveBeenCalledTimes(1); + expect(harness.stopSession).not.toHaveBeenCalled(); + }); + it("restarts an existing Codex thread on a compatible requested instance", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 74ece50cd31..b9974de5ab0 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -1873,6 +1873,97 @@ describe("ProviderRuntimeIngestion", () => { expect(message?.streaming).toBe(false); }); + it("treats turn.aborted as terminal and finalizes buffered assistant text", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + const turnId = asTurnId("turn-aborted-buffered"); + const itemId = asItemId("item-aborted-buffered"); + + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-turn-started-before-abort"), + provider: ProviderDriverKind.make("hermes"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId, + }); + await waitForThread( + harness.readModel, + (thread) => thread.session?.status === "running" && thread.session.activeTurnId === turnId, + ); + + harness.emit({ + type: "content.delta", + eventId: asEventId("evt-content-before-abort"), + provider: ProviderDriverKind.make("hermes"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId, + itemId, + payload: { + streamKind: "assistant_text", + delta: "partial response", + }, + }); + harness.emit({ + type: "turn.aborted", + eventId: asEventId("evt-turn-aborted"), + provider: ProviderDriverKind.make("hermes"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId, + payload: { + reason: "Interrupted by user.", + }, + }); + + const thread = await waitForThread( + harness.readModel, + (entry) => + entry.session?.status === "ready" && + entry.session.activeTurnId === null && + entry.messages.some( + (message: ProviderRuntimeTestMessage) => + message.id === `assistant:${itemId}` && + message.text === "partial response" && + !message.streaming, + ), + ); + expect(thread.session?.lastError).toBeNull(); + + const nextTurnId = asTurnId("turn-after-abort"); + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-turn-started-after-abort"), + provider: ProviderDriverKind.make("hermes"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: nextTurnId, + }); + await waitForThread( + harness.readModel, + (entry) => entry.session?.status === "running" && entry.session.activeTurnId === nextTurnId, + ); + harness.emit({ + type: "turn.aborted", + eventId: asEventId("evt-stale-turn-aborted"), + provider: ProviderDriverKind.make("hermes"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId, + payload: { + reason: "Late abort for the previous turn.", + }, + }); + await harness.drain(); + const afterStaleAbort = await harness.readModel(); + const activeThread = afterStaleAbort.threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + ); + expect(activeThread?.session?.status).toBe("running"); + expect(activeThread?.session?.activeTurnId).toBe(nextTurnId); + }); + it("flushes and completes buffered assistant text when an approval request opens", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index a8a51b30260..6750c9bb77e 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -1343,6 +1343,7 @@ const make = Effect.gen(function* () { case "turn.started": return !conflictsWithActiveTurn || conflictingTurnStartIsPendingTurnStart; case "turn.completed": + case "turn.aborted": if (conflictsWithActiveTurn || missingTurnForActiveTurn) { return false; } @@ -1367,7 +1368,8 @@ const make = Effect.gen(function* () { event.type === "session.exited" || event.type === "thread.started" || event.type === "turn.started" || - event.type === "turn.completed" + event.type === "turn.completed" || + event.type === "turn.aborted" ) { const status = (() => { switch (event.type) { @@ -1383,6 +1385,8 @@ const make = Effect.gen(function* () { return normalizeRuntimeTurnState(event.payload.state) === "failed" ? "error" : "ready"; + case "turn.aborted": + return "ready"; case "session.started": case "thread.started": // Provider thread/session start notifications can arrive during an @@ -1393,7 +1397,9 @@ const make = Effect.gen(function* () { const nextActiveTurnId = event.type === "turn.started" ? (eventTurnId ?? null) - : event.type === "turn.completed" || event.type === "session.exited" + : event.type === "turn.completed" || + event.type === "turn.aborted" || + event.type === "session.exited" ? null : event.type === "session.state.changed" && !sessionStatusAllowsActiveTurn( @@ -1634,7 +1640,7 @@ const make = Effect.gen(function* () { }); } - if (event.type === "turn.completed") { + if (event.type === "turn.completed" || event.type === "turn.aborted") { const detailedThread = yield* getLoadedThreadDetail(); const messages = detailedThread?.messages ?? []; const proposedPlans = detailedThread?.proposedPlans ?? []; diff --git a/apps/server/src/provider/Drivers/HermesDriver.ts b/apps/server/src/provider/Drivers/HermesDriver.ts new file mode 100644 index 00000000000..cdaacaa8792 --- /dev/null +++ b/apps/server/src/provider/Drivers/HermesDriver.ts @@ -0,0 +1,131 @@ +import { + DEFAULT_HERMES_MODEL, + HERMES_DRIVER_KIND, + HermesSettings, + TextGenerationError, + type ServerProvider, +} from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; + +import type { TextGenerationShape } from "../../textGeneration/TextGeneration.ts"; +import { makeHermesAdapter } from "../Layers/HermesAdapter.ts"; +import { + defaultProviderContinuationIdentity, + type ProviderDriver, + type ProviderInstance, +} from "../ProviderDriver.ts"; +import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; +import { HermesGatewayBroker } from "../Services/HermesGatewayBroker.ts"; + +const decodeHermesSettings = Schema.decodeSync(HermesSettings); + +export type HermesDriverEnv = Crypto.Crypto; + +const unsupportedTextGeneration = ( + operation: + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle", +) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Hermes gateway instances do not support utility text generation.", + }), + ); + +const makeTextGeneration = (): TextGenerationShape => ({ + generateCommitMessage: () => unsupportedTextGeneration("generateCommitMessage"), + generatePrContent: () => unsupportedTextGeneration("generatePrContent"), + generateBranchName: () => unsupportedTextGeneration("generateBranchName"), + generateThreadTitle: () => unsupportedTextGeneration("generateThreadTitle"), +}); + +export const HermesDriver: ProviderDriver = { + driverKind: HERMES_DRIVER_KIND, + metadata: { + displayName: "Hermes", + supportsMultipleInstances: true, + }, + configSchema: HermesSettings, + defaultConfig: () => decodeHermesSettings({}), + create: ({ instanceId, displayName, accentColor, enabled }) => + Effect.gen(function* () { + const broker = yield* HermesGatewayBroker; + const adapter = yield* makeHermesAdapter({ instanceId }); + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: HERMES_DRIVER_KIND, + instanceId, + }); + const maintenanceCapabilities = makeManualOnlyProviderMaintenanceCapabilities({ + provider: HERMES_DRIVER_KIND, + packageName: null, + }); + const getSnapshot = Effect.gen(function* () { + const connected = yield* broker.isConnected(instanceId); + const status = yield* broker + .getInstanceStatus(instanceId) + .pipe(Effect.catchTag("HermesGatewayManagementError", () => Effect.succeed(undefined))); + return { + instanceId, + driver: HERMES_DRIVER_KIND, + ...(displayName ? { displayName } : {}), + ...(accentColor ? { accentColor } : {}), + continuation: { groupKey: continuationIdentity.continuationKey }, + showInteractionModeToggle: false, + requiresNewThreadForModelChange: true, + enabled, + installed: true, + version: status?.hermesVersion ?? null, + status: !enabled ? "disabled" : connected ? "ready" : "warning", + auth: { + status: connected ? "authenticated" : "unauthenticated", + type: "gateway", + label: status?.nickname ?? displayName ?? "Hermes", + }, + checkedAt: DateTime.formatIso(DateTime.nowUnsafe()), + ...(!connected && enabled + ? { message: "Hermes is offline. Reconnect its T3 Code gateway plugin." } + : {}), + availability: "available", + models: [ + { + slug: DEFAULT_HERMES_MODEL, + name: "Hermes", + isCustom: false, + isDefault: true, + capabilities: null, + }, + ], + slashCommands: [], + skills: [], + } satisfies ServerProvider; + }); + const snapshot = { + maintenanceCapabilities, + getSnapshot, + refresh: getSnapshot, + streamChanges: broker.streamStatuses.pipe( + Stream.filter((status) => status.instanceId === instanceId), + Stream.mapEffect(() => getSnapshot), + ), + }; + + return { + instanceId, + driverKind: HERMES_DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot, + adapter, + textGeneration: makeTextGeneration(), + } satisfies ProviderInstance; + }), +}; diff --git a/apps/server/src/provider/Layers/HermesAdapter.test.ts b/apps/server/src/provider/Layers/HermesAdapter.test.ts new file mode 100644 index 00000000000..265d6765622 --- /dev/null +++ b/apps/server/src/provider/Layers/HermesAdapter.test.ts @@ -0,0 +1,171 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { + HERMES_GATEWAY_PROTOCOL_VERSION, + HermesGatewayRequestId, + HermesGatewaySessionId, + ProviderInstanceId, + ThreadId, + type HermesGatewayT3ToPluginMessage, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Option from "effect/Option"; +import * as PubSub from "effect/PubSub"; +import * as Stream from "effect/Stream"; + +import { + HermesGatewayBroker, + type HermesGatewayBrokerShape, + type HermesGatewayEnvelope, +} from "../Services/HermesGatewayBroker.ts"; +import { + makeHermesAdapter, + sanitizeHermesItemData, + sanitizeHermesRequestArgs, + shouldProjectHermesTurnStarted, +} from "./HermesAdapter.ts"; + +it("sanitizes persisted gateway payloads and suppresses steering acknowledgements", () => { + assert.deepEqual( + sanitizeHermesItemData("command_execution", { + command: "git status", + cwd: "/workspace", + arbitrarySecret: "drop-me", + result: { unbounded: true }, + }), + { command: "git status", cwd: "/workspace" }, + ); + assert.deepEqual( + sanitizeHermesRequestArgs("command_execution_approval", { + command: "git push", + arbitrarySecret: "drop-me", + }), + { command: "git push" }, + ); + const steerRequestId = HermesGatewayRequestId.make("steer-request"); + const startRequestId = HermesGatewayRequestId.make("start-request"); + const pendingSteerRequestIds = new Set([steerRequestId]); + assert.isFalse(shouldProjectHermesTurnStarted(pendingSteerRequestIds, steerRequestId)); + assert.isTrue(shouldProjectHermesTurnStarted(pendingSteerRequestIds, startRequestId)); + assert.isFalse(pendingSteerRequestIds.has(steerRequestId)); +}); + +it.effect("keeps cwd local while forwarding turn text byte-for-byte and steering follow-ups", () => + Effect.gen(function* () { + const instanceId = ProviderInstanceId.make("hermes_remote"); + const threadId = ThreadId.make("thread-1"); + const sessionId = HermesGatewaySessionId.make("session-1"); + const sent: Array = []; + const brokerEvents = yield* PubSub.unbounded(); + const broker: HermesGatewayBrokerShape = { + createEnrollment: () => Effect.die(new Error("unused")), + getInstanceStatus: () => Effect.die(new Error("unused")), + listInstances: Effect.succeed([]), + revokeInstance: () => Effect.die(new Error("unused")), + registerConnection: () => Effect.die(new Error("unused")), + receive: () => Effect.void, + disconnect: () => Effect.void, + request: (_instanceId, message) => { + sent.push(message); + if (message.type === "session.ensure") { + return Effect.succeed({ + type: "session.ready", + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + requestId: message.requestId, + threadId: message.threadId, + sessionId, + resumed: false, + }); + } + if (message.type === "turn.start" || message.type === "turn.steer") { + return Effect.succeed({ + type: "turn.started", + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + requestId: message.requestId, + threadId: message.threadId, + sessionId: message.sessionId, + turnId: message.turnId, + }); + } + return Effect.die(new Error(`unexpected request ${message.type}`)); + }, + send: (_instanceId, message) => Effect.sync(() => sent.push(message)).pipe(Effect.asVoid), + isConnected: () => Effect.succeed(true), + stream: Stream.fromPubSub(brokerEvents), + streamStatuses: Stream.empty, + }; + const adapter = yield* makeHermesAdapter({ instanceId }).pipe( + Effect.provideService(HermesGatewayBroker, broker), + ); + yield* Effect.yieldNow; + const session = yield* adapter.startSession({ + threadId, + providerInstanceId: instanceId, + cwd: "/must/not/leak", + runtimeMode: "full-access", + }); + assert.equal(session.cwd, "/must/not/leak"); + const sessionEnsure = sent.find((message) => message.type === "session.ensure"); + assert.isFalse(sessionEnsure !== undefined && "cwd" in sessionEnsure); + + const original = " /help keep all whitespace \n"; + yield* adapter.sendTurn({ threadId, input: original }); + const turnStart = sent.find((message) => message.type === "turn.start"); + assert.equal(turnStart?.type === "turn.start" ? turnStart.text : undefined, original); + + if (!turnStart || turnStart.type !== "turn.start") { + return yield* Effect.die(new Error("turn.start was not sent")); + } + const startEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* PubSub.publish(brokerEvents, { + instanceId, + message: { + type: "turn.started", + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + requestId: turnStart.requestId, + threadId, + sessionId, + turnId: turnStart.turnId, + }, + }); + const startEvent = Option.getOrUndefined(yield* Fiber.join(startEventFiber)); + assert.equal(startEvent?.type, "turn.started"); + + yield* adapter.sendTurn({ threadId, input: "follow up" }); + const turnSteer = sent.find((message) => message.type === "turn.steer"); + if (!turnSteer || turnSteer.type !== "turn.steer") { + return yield* Effect.die(new Error("turn.steer was not sent")); + } + const nextEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* PubSub.publish(brokerEvents, { + instanceId, + message: { + type: "turn.started", + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + requestId: turnSteer.requestId, + threadId, + sessionId, + turnId: turnSteer.turnId, + }, + }); + yield* PubSub.publish(brokerEvents, { + instanceId, + message: { + type: "content.delta", + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + threadId, + sessionId, + turnId: turnSteer.turnId, + streamKind: "assistant_text", + delta: "continued", + }, + }); + const nextEvent = Option.getOrUndefined(yield* Fiber.join(nextEventFiber)); + assert.equal(nextEvent?.type, "content.delta"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/provider/Layers/HermesAdapter.ts b/apps/server/src/provider/Layers/HermesAdapter.ts new file mode 100644 index 00000000000..4720bb91d4b --- /dev/null +++ b/apps/server/src/provider/Layers/HermesAdapter.ts @@ -0,0 +1,562 @@ +import { + EventId, + HERMES_GATEWAY_PROTOCOL_VERSION, + HermesGatewayRequestId, + HermesGatewayResumeCursor, + HermesGatewaySessionId, + ProviderDriverKind, + RuntimeItemId, + RuntimeRequestId, + ThreadId, + TurnId, + type HermesGatewayPluginToT3Message, + type ProviderInstanceId, + type ProviderRuntimeEvent, + type ProviderSession, +} from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as PubSub from "effect/PubSub"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; + +import { + ProviderAdapterRequestError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, +} from "../Errors.ts"; +import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; +import { HermesGatewayBroker } from "../Services/HermesGatewayBroker.ts"; + +const PROVIDER = ProviderDriverKind.make("hermes"); +const isResumeCursor = Schema.is(HermesGatewayResumeCursor); + +type HermesAdapterShape = ProviderAdapterShape< + ProviderAdapterRequestError | ProviderAdapterSessionNotFoundError | ProviderAdapterValidationError +>; + +interface SessionContext { + readonly hermesSessionId: HermesGatewaySessionId; + readonly turns: Array<{ readonly id: TurnId; readonly items: Array }>; + session: ProviderSession; +} + +type PluginMessage = Exclude; + +const nowIso = () => DateTime.formatIso(DateTime.nowUnsafe()); +const MAX_PERSISTED_GATEWAY_FIELD_CHARS = 4_096; + +const boundedString = (value: unknown) => + typeof value === "string" && value.length <= MAX_PERSISTED_GATEWAY_FIELD_CHARS + ? value + : undefined; + +const asRecord = (value: unknown) => + typeof value === "object" && value !== null + ? (value as Readonly>) + : undefined; + +export const sanitizeHermesItemData = ( + itemType: Extract["itemType"], + value: unknown, +) => { + const record = asRecord(value); + if (!record) return undefined; + const fields = + itemType === "command_execution" + ? ["command", "cwd"] + : itemType === "file_change" + ? ["path"] + : itemType === "web_search" + ? ["query"] + : itemType === "image_view" + ? ["path"] + : itemType === "mcp_tool_call" + ? ["server", "operation"] + : []; + const sanitized = Object.fromEntries( + fields.flatMap((field) => { + const value = boundedString(record[field]); + return value === undefined ? [] : [[field, value]]; + }), + ); + return Object.keys(sanitized).length > 0 ? sanitized : undefined; +}; + +export const sanitizeHermesRequestArgs = ( + requestType: Extract["requestType"], + value: unknown, +) => { + const record = asRecord(value); + if (!record) return undefined; + const fields = + requestType === "command_execution_approval" || requestType === "exec_command_approval" + ? ["command", "cwd"] + : requestType === "file_read_approval" || + requestType === "file_change_approval" || + requestType === "apply_patch_approval" + ? ["path"] + : []; + const sanitized = Object.fromEntries( + fields.flatMap((field) => { + const value = boundedString(record[field]); + return value === undefined ? [] : [[field, value]]; + }), + ); + return Object.keys(sanitized).length > 0 ? sanitized : undefined; +}; + +export const shouldProjectHermesTurnStarted = ( + pendingSteerRequestIds: Set, + incomingRequestId: HermesGatewayRequestId, +) => !pendingSteerRequestIds.delete(incomingRequestId); + +export const makeHermesAdapter = Effect.fn("makeHermesAdapter")(function* (input: { + readonly instanceId: ProviderInstanceId; +}) { + const crypto = yield* Crypto.Crypto; + const broker = yield* HermesGatewayBroker; + const events = yield* PubSub.unbounded(); + const sessions = new Map(); + const pendingSteerRequestIds = new Set(); + + const randomId = crypto.randomUUIDv4.pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "crypto/randomUUIDv4", + detail: "Failed to generate a Hermes runtime identifier.", + cause, + }), + ), + ); + const requestId = randomId.pipe( + Effect.map((value) => HermesGatewayRequestId.make(`t3-${value}`)), + ); + const eventBase = (message: { + readonly threadId: ThreadId; + readonly turnId?: string | undefined; + readonly itemId?: string | undefined; + readonly requestId?: string | undefined; + }) => + randomId.pipe( + Effect.map((value) => ({ + eventId: EventId.make(value), + provider: PROVIDER, + providerInstanceId: input.instanceId, + threadId: message.threadId, + createdAt: nowIso(), + ...(message.turnId ? { turnId: TurnId.make(message.turnId) } : {}), + ...(message.itemId ? { itemId: RuntimeItemId.make(message.itemId) } : {}), + ...(message.requestId ? { requestId: RuntimeRequestId.make(message.requestId) } : {}), + })), + ); + + const emit = (event: ProviderRuntimeEvent) => PubSub.publish(events, event).pipe(Effect.asVoid); + + const findContext = (threadId: ThreadId) => + Effect.gen(function* (): Effect.fn.Return { + const context = sessions.get(threadId); + if (!context) { + return yield* new ProviderAdapterSessionNotFoundError({ + provider: PROVIDER, + threadId, + }); + } + return context; + }); + + const updateSession = (context: SessionContext, patch: Partial) => { + context.session = { + ...context.session, + ...patch, + updatedAt: nowIso(), + }; + }; + + const toRuntimeEvent = (message: PluginMessage) => + Effect.gen(function* (): Effect.fn.Return< + ProviderRuntimeEvent | undefined, + ProviderAdapterRequestError + > { + if (!("threadId" in message)) return undefined; + const context = sessions.get(message.threadId); + const base = yield* eventBase(message); + switch (message.type) { + case "session.ready": + return undefined; + case "turn.started": { + const isSteeringAcknowledgement = !shouldProjectHermesTurnStarted( + pendingSteerRequestIds, + message.requestId, + ); + if (context) { + updateSession(context, { + status: "running", + activeTurnId: TurnId.make(message.turnId), + }); + if (!context.turns.some((turn) => turn.id === message.turnId)) { + context.turns.push({ id: TurnId.make(message.turnId), items: [] }); + } + } + if (isSteeringAcknowledgement) return undefined; + return { + ...base, + type: "turn.started", + payload: {}, + }; + } + case "content.delta": + return { + ...base, + type: "content.delta", + payload: { + streamKind: message.streamKind, + delta: message.delta, + ...(message.contentIndex !== undefined ? { contentIndex: message.contentIndex } : {}), + }, + }; + case "item.started": + case "item.updated": + case "item.completed": { + const data = sanitizeHermesItemData(message.itemType, message.data); + const payload = { + itemType: message.itemType, + ...(message.status ? { status: message.status } : {}), + ...(message.title ? { title: message.title } : {}), + ...(message.detail ? { detail: message.detail } : {}), + ...(data ? { data } : {}), + }; + const turn = context?.turns.find((entry) => entry.id === message.turnId); + if (turn && message.type === "item.completed") turn.items.push(payload); + return { ...base, type: message.type, payload }; + } + case "request.opened": { + const args = sanitizeHermesRequestArgs(message.requestType, message.args); + return { + ...base, + type: "request.opened", + payload: { + requestType: message.requestType, + ...(message.detail ? { detail: message.detail } : {}), + ...(args ? { args } : {}), + }, + }; + } + case "request.resolved": + return { + ...base, + type: "request.resolved", + payload: { + requestType: message.requestType, + ...(message.decision ? { decision: message.decision } : {}), + }, + }; + case "user-input.requested": + return { + ...base, + type: "user-input.requested", + payload: { questions: message.questions }, + }; + case "user-input.resolved": + return { + ...base, + type: "user-input.resolved", + payload: { answers: message.answers }, + }; + case "turn.completed": + if (context) { + updateSession(context, { status: message.state === "failed" ? "error" : "ready" }); + const { activeTurnId: _activeTurnId, ...session } = context.session; + context.session = session; + } + return { + ...base, + type: "turn.completed", + payload: { + state: message.state, + ...(message.stopReason !== undefined ? { stopReason: message.stopReason } : {}), + ...(message.errorMessage ? { errorMessage: message.errorMessage } : {}), + }, + }; + case "turn.aborted": + if (context) { + const { activeTurnId: _activeTurnId, ...session } = context.session; + context.session = { ...session, status: "ready", updatedAt: nowIso() }; + } + return { + ...base, + type: "turn.aborted", + payload: { reason: message.reason }, + }; + case "session.exited": + if (context) updateSession(context, { status: message.recoverable ? "error" : "closed" }); + return { + ...base, + type: "session.exited", + payload: { + ...(message.reason ? { reason: message.reason } : {}), + recoverable: message.recoverable, + exitKind: message.recoverable ? "error" : "graceful", + }, + }; + default: + return undefined; + } + }); + + yield* broker.stream.pipe( + Stream.filter((envelope) => envelope.instanceId === input.instanceId), + Stream.runForEach((envelope) => + toRuntimeEvent(envelope.message).pipe( + Effect.flatMap((event) => (event ? emit(event) : Effect.void)), + ), + ), + Effect.forkScoped, + ); + + const startSession: HermesAdapterShape["startSession"] = Effect.fn("startSession")( + function* (sessionInput) { + const connected = yield* broker.isConnected(input.instanceId); + if (!connected) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.ensure", + detail: `Hermes gateway instance '${input.instanceId}' is offline.`, + }); + } + if (sessionInput.providerInstanceId && sessionInput.providerInstanceId !== input.instanceId) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: `Session targets instance '${sessionInput.providerInstanceId}', expected '${input.instanceId}'.`, + }); + } + const resume = + sessionInput.resumeCursor === undefined + ? undefined + : isResumeCursor(sessionInput.resumeCursor) + ? sessionInput.resumeCursor + : undefined; + if (sessionInput.resumeCursor !== undefined && !resume) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: "The Hermes resume cursor is invalid or from an unsupported protocol version.", + }); + } + const response = yield* broker.request(input.instanceId, { + type: "session.ensure", + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + requestId: yield* requestId, + threadId: sessionInput.threadId, + ...(resume ? { resumeSessionId: resume.sessionId } : {}), + }); + if (response.type !== "session.ready" || response.threadId !== sessionInput.threadId) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.ensure", + detail: + response.type === "protocol.error" + ? response.message + : `Expected session.ready, received '${response.type}'.`, + }); + } + const createdAt = nowIso(); + const session = { + provider: PROVIDER, + providerInstanceId: input.instanceId, + status: "ready", + runtimeMode: sessionInput.runtimeMode, + ...(sessionInput.cwd !== undefined ? { cwd: sessionInput.cwd } : {}), + ...(sessionInput.modelSelection?.model ? { model: sessionInput.modelSelection.model } : {}), + threadId: sessionInput.threadId, + resumeCursor: { + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + sessionId: response.sessionId, + } satisfies HermesGatewayResumeCursor, + createdAt, + updatedAt: createdAt, + } satisfies ProviderSession; + sessions.set(sessionInput.threadId, { + session, + hermesSessionId: response.sessionId, + turns: [], + }); + yield* emit({ + ...(yield* eventBase({ threadId: sessionInput.threadId })), + type: "session.started", + payload: { + message: response.resumed ? "Hermes session resumed" : "Hermes session started", + resume: session.resumeCursor, + }, + }); + yield* emit({ + ...(yield* eventBase({ threadId: sessionInput.threadId })), + type: "thread.started", + payload: { providerThreadId: response.sessionId }, + }); + return session; + }, + ); + + const sendTurn: HermesAdapterShape["sendTurn"] = Effect.fn("sendTurn")(function* (turnInput) { + const context = yield* findContext(turnInput.threadId); + if (turnInput.attachments && turnInput.attachments.length > 0) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "Hermes gateway attachments are not supported yet.", + }); + } + const text = turnInput.input; + if (!text || text.trim().length === 0) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "Hermes turns require text input.", + }); + } + const activeTurnId = context.session.activeTurnId; + const turnId = activeTurnId ?? TurnId.make(`hermes-turn-${yield* randomId}`); + const outboundRequestId = yield* requestId; + if (activeTurnId) pendingSteerRequestIds.add(outboundRequestId); + const response = yield* broker + .request(input.instanceId, { + type: activeTurnId ? "turn.steer" : "turn.start", + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + requestId: outboundRequestId, + threadId: turnInput.threadId, + sessionId: context.hermesSessionId, + turnId, + text, + }) + .pipe( + Effect.tapError(() => Effect.sync(() => pendingSteerRequestIds.delete(outboundRequestId))), + ); + if (response.type !== "turn.started") { + pendingSteerRequestIds.delete(outboundRequestId); + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: activeTurnId ? "turn.steer" : "turn.start", + detail: + response.type === "protocol.error" + ? response.message + : `Expected turn.started, received '${response.type}'.`, + }); + } + updateSession(context, { status: "running", activeTurnId: turnId }); + return { + threadId: turnInput.threadId, + turnId, + resumeCursor: context.session.resumeCursor, + }; + }); + + const adapter: HermesAdapterShape = { + provider: PROVIDER, + capabilities: { sessionModelSwitch: "unsupported" }, + startSession, + sendTurn, + interruptTurn: (threadId, selectedTurnId) => + Effect.gen(function* () { + const context = yield* findContext(threadId); + const turnId = selectedTurnId ?? context.session.activeTurnId; + if (!turnId) return; + yield* broker.send(input.instanceId, { + type: "turn.interrupt", + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + requestId: yield* requestId, + threadId, + sessionId: context.hermesSessionId, + turnId, + }); + }), + respondToRequest: (threadId, selectedRequestId, decision) => + Effect.gen(function* () { + const context = yield* findContext(threadId); + if (!context.session.activeTurnId) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "respondToRequest", + issue: "The Hermes session has no active turn.", + }); + } + yield* broker.send(input.instanceId, { + type: "approval.respond", + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + requestId: HermesGatewayRequestId.make(selectedRequestId), + threadId, + sessionId: context.hermesSessionId, + turnId: context.session.activeTurnId, + decision, + }); + }), + respondToUserInput: (threadId, selectedRequestId, answers) => + Effect.gen(function* () { + const context = yield* findContext(threadId); + if (!context.session.activeTurnId) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "respondToUserInput", + issue: "The Hermes session has no active turn.", + }); + } + yield* broker.send(input.instanceId, { + type: "user-input.respond", + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + requestId: HermesGatewayRequestId.make(selectedRequestId), + threadId, + sessionId: context.hermesSessionId, + turnId: context.session.activeTurnId, + answers, + }); + }), + stopSession: (threadId) => + Effect.gen(function* () { + const context = yield* findContext(threadId); + if (yield* broker.isConnected(input.instanceId)) { + yield* broker.send(input.instanceId, { + type: "session.stop", + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + requestId: yield* requestId, + threadId, + sessionId: context.hermesSessionId, + }); + } + updateSession(context, { status: "closed" }); + sessions.delete(threadId); + }), + listSessions: () => Effect.sync(() => Array.from(sessions.values(), ({ session }) => session)), + hasSession: (threadId) => Effect.sync(() => sessions.has(threadId)), + readThread: (threadId) => + findContext(threadId).pipe( + Effect.map((context) => ({ + threadId, + turns: context.turns, + })), + ), + rollbackThread: (threadId) => + findContext(threadId).pipe( + Effect.flatMap(() => + Effect.fail( + new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "rollbackThread", + issue: "Hermes gateway thread rollback is not supported.", + }), + ), + ), + ), + stopAll: () => + Effect.forEach(Array.from(sessions.keys()), (threadId) => adapter.stopSession(threadId), { + discard: true, + }), + streamEvents: Stream.fromPubSub(events), + }; + + yield* Effect.addFinalizer(() => adapter.stopAll().pipe(Effect.ignore)); + return adapter; +}); diff --git a/apps/server/src/provider/Layers/HermesGatewayBroker.test.ts b/apps/server/src/provider/Layers/HermesGatewayBroker.test.ts new file mode 100644 index 00000000000..de1ebff5451 --- /dev/null +++ b/apps/server/src/provider/Layers/HermesGatewayBroker.test.ts @@ -0,0 +1,544 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { + HERMES_GATEWAY_PROTOCOL_VERSION, + HermesGatewayCredential, + HermesGatewayRequestId, + HermesGatewaySessionId, + ProviderInstanceId, + ThreadId, + type HermesGatewayConnectionHello, + type HermesGatewayT3ToPluginMessage, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Context from "effect/Context"; +import * as Deferred from "effect/Deferred"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; + +import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; +import * as ServerSettings from "../../serverSettings.ts"; +import { HermesDriver } from "../Drivers/HermesDriver.ts"; +import type { ProviderInstance } from "../ProviderDriver.ts"; +import { + HermesGatewayBroker, + type HermesGatewayTransport, +} from "../Services/HermesGatewayBroker.ts"; +import { HermesGatewayBrokerLive, makeHermesGatewayBroker } from "./HermesGatewayBroker.ts"; + +const instanceId = ProviderInstanceId.make("hermes_remote"); +const otherInstanceId = ProviderInstanceId.make("hermes_other"); +class HermesTestInstance extends Context.Service()( + "t3/provider/Layers/HermesGatewayBroker.test/HermesTestInstance", +) {} + +const capabilities = { + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + streaming: true, + activity: true, + approvals: true, + userInput: true, + attachments: false, +} as const; + +const makeSecretStore = () => { + const values = new Map(); + const service: ServerSecretStore.ServerSecretStore["Service"] = { + get: (name) => Effect.succeed(Option.fromUndefinedOr(values.get(name))), + set: (name, value) => Effect.sync(() => values.set(name, value)).pipe(Effect.asVoid), + create: (name, value) => Effect.sync(() => values.set(name, value)).pipe(Effect.asVoid), + getOrCreateRandom: (_name, bytes) => Effect.succeed(new Uint8Array(bytes)), + remove: (name) => Effect.sync(() => values.delete(name)).pipe(Effect.asVoid), + }; + return service; +}; + +const makeBroker = (secrets: ServerSecretStore.ServerSecretStore["Service"]) => + makeHermesGatewayBroker.pipe( + Effect.provide( + ServerSettings.layerTest({ + providerInstances: { + [instanceId]: { driver: "hermes", displayName: "Remote", config: {} }, + [otherInstanceId]: { driver: "hermes", displayName: "Other", config: {} }, + }, + }), + ), + Effect.provideService(ServerSecretStore.ServerSecretStore, secrets), + Effect.provide(NodeServices.layer), + ); + +const hello = ( + authentication: HermesGatewayConnectionHello["authentication"], + protocolVersion: number = HERMES_GATEWAY_PROTOCOL_VERSION, +): HermesGatewayConnectionHello => ({ + type: "connection.hello", + requestId: HermesGatewayRequestId.make(`hello-${protocolVersion}`), + protocolVersion, + pluginVersion: "0.1.0", + hermesVersion: "1.0.0", + capabilities: { ...capabilities, protocolVersion }, + authentication, +}); + +it.effect("authenticates before applying incompatible connection state", () => + Effect.gen(function* () { + const secrets = makeSecretStore(); + const broker = yield* makeBroker(secrets); + yield* broker.createEnrollment({ + instanceId: ProviderInstanceId.make("hermes"), + nickname: "Default Hermes", + connectorUrl: "https://t3.example.test", + }); + const enrollment = yield* broker.createEnrollment({ + instanceId, + nickname: "Remote Hermes", + connectorUrl: "https://t3.example.test", + }); + + const firstCloses: Array = []; + const firstTransport: HermesGatewayTransport = { + send: () => Effect.void, + close: (code) => Effect.sync(() => firstCloses.push(code)).pipe(Effect.asVoid), + }; + const first = yield* broker.registerConnection( + hello({ type: "enrollment-token", token: enrollment.oneTimeToken }), + firstTransport, + ); + assert.isTrue(first.accepted.credential !== undefined); + assert.equal((yield* broker.getInstanceStatus(instanceId)).status, "connected"); + + const secondCloses: Array = []; + const sent: Array = []; + const secondTransport: HermesGatewayTransport = { + send: (message) => Effect.sync(() => sent.push(message)).pipe(Effect.asVoid), + close: (code) => Effect.sync(() => secondCloses.push(code)).pipe(Effect.asVoid), + }; + const second = yield* broker.registerConnection( + hello({ + type: "instance-credential", + instanceId, + credential: first.accepted.credential!, + }), + secondTransport, + ); + assert.deepEqual(firstCloses, [4001]); + + const threadId = ThreadId.make("pending-thread"); + const pendingRequestId = HermesGatewayRequestId.make("pending-session"); + const pendingRequest = yield* broker + .request(instanceId, { + type: "session.ensure", + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + requestId: pendingRequestId, + threadId, + }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.yieldNow; + assert.equal(sent.at(-1)?.type, "session.ensure"); + + const malicious = yield* Effect.flip( + broker.registerConnection( + hello( + { + type: "instance-credential", + instanceId, + credential: HermesGatewayCredential.make("not-the-real-credential"), + }, + 2, + ), + secondTransport, + ), + ); + assert.equal(malicious.code, "invalid-authentication"); + assert.deepEqual(secondCloses, []); + assert.equal((yield* broker.getInstanceStatus(instanceId)).status, "connected"); + assert.isUndefined(pendingRequest.pollUnsafe()); + + yield* broker.receive(second, { + type: "session.ready", + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + requestId: pendingRequestId, + threadId, + sessionId: HermesGatewaySessionId.make("pending-session-id"), + resumed: false, + }); + assert.equal((yield* Fiber.join(pendingRequest)).type, "session.ready"); + + const incompatible = yield* Effect.flip( + broker.registerConnection( + hello( + { + type: "instance-credential", + instanceId, + credential: first.accepted.credential!, + }, + 2, + ), + secondTransport, + ), + ); + assert.equal(incompatible.code, "version-incompatible"); + assert.deepEqual(secondCloses, [4004]); + assert.equal((yield* broker.getInstanceStatus(instanceId)).status, "upgrade-required"); + + const otherEnrollment = yield* broker.createEnrollment({ + instanceId: otherInstanceId, + nickname: "Other Hermes", + connectorUrl: "https://t3.example.test", + }); + const incompatibleEnrollment = yield* Effect.flip( + broker.registerConnection( + hello({ type: "enrollment-token", token: otherEnrollment.oneTimeToken }, 2), + secondTransport, + ), + ); + assert.equal(incompatibleEnrollment.code, "version-incompatible"); + const enrolledAfterUpgrade = yield* broker.registerConnection( + hello({ type: "enrollment-token", token: otherEnrollment.oneTimeToken }), + secondTransport, + ); + assert.equal(enrolledAfterUpgrade.instanceId, otherInstanceId); + + const revoked = yield* broker.revokeInstance(instanceId); + assert.equal(revoked.status, "revoked"); + }), +); + +it.effect("invalidates the previous credential when replacement enrollment begins", () => + Effect.gen(function* () { + const broker = yield* makeBroker(makeSecretStore()); + const enrollment = yield* broker.createEnrollment({ + instanceId, + nickname: "Remote Hermes", + connectorUrl: "https://t3.example.test", + }); + const closes: Array = []; + const transport: HermesGatewayTransport = { + send: () => Effect.void, + close: (code) => Effect.sync(() => closes.push(code)).pipe(Effect.asVoid), + }; + const first = yield* broker.registerConnection( + hello({ type: "enrollment-token", token: enrollment.oneTimeToken }), + transport, + ); + const replacement = yield* broker.createEnrollment({ + instanceId, + nickname: "Remote Hermes", + connectorUrl: "https://t3.example.test", + }); + assert.deepEqual(closes, [4001]); + + const staleCredential = yield* Effect.flip( + broker.registerConnection( + hello({ + type: "instance-credential", + instanceId, + credential: first.accepted.credential!, + }), + transport, + ), + ); + assert.equal(staleCredential.code, "invalid-authentication"); + const replacementConnection = yield* broker.registerConnection( + hello({ type: "enrollment-token", token: replacement.oneTimeToken }), + transport, + ); + assert.equal(replacementConnection.instanceId, instanceId); + }), +); + +it.effect("accepts only the newest unconsumed enrollment token for an instance", () => + Effect.gen(function* () { + const broker = yield* makeBroker(makeSecretStore()); + const older = yield* broker.createEnrollment({ + instanceId, + nickname: "Remote Hermes", + connectorUrl: "https://t3.example.test", + }); + const newest = yield* broker.createEnrollment({ + instanceId, + nickname: "Remote Hermes", + connectorUrl: "https://t3.example.test", + }); + const transport: HermesGatewayTransport = { + send: () => Effect.void, + close: () => Effect.void, + }; + + const olderTokenError = yield* Effect.flip( + broker.registerConnection( + hello({ type: "enrollment-token", token: older.oneTimeToken }), + transport, + ), + ); + assert.equal(olderTokenError.code, "enrollment-expired"); + const newestConnection = yield* broker.registerConnection( + hello({ type: "enrollment-token", token: newest.oneTimeToken }), + transport, + ); + assert.equal(newestConnection.instanceId, instanceId); + }), +); + +it.effect("atomically reserves normalized nicknames across concurrent enrollments", () => + Effect.gen(function* () { + const storedSecrets = makeSecretStore(); + const firstPersistStarted = yield* Deferred.make(); + const releaseFirstPersist = yield* Deferred.make(); + let metadataPersistCount = 0; + const secrets: ServerSecretStore.ServerSecretStore["Service"] = { + ...storedSecrets, + set: (name, value) => + name.startsWith("hermes-gateway-metadata-") + ? Effect.gen(function* () { + metadataPersistCount += 1; + if (metadataPersistCount === 1) { + yield* Deferred.succeed(firstPersistStarted, undefined); + yield* Deferred.await(releaseFirstPersist); + } + yield* storedSecrets.set(name, value); + }) + : storedSecrets.set(name, value), + }; + const broker = yield* makeBroker(secrets); + const firstEnrollment = yield* broker + .createEnrollment({ + instanceId, + nickname: "Shared Hermes", + connectorUrl: "https://t3.example.test", + }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(firstPersistStarted); + const secondEnrollment = yield* broker + .createEnrollment({ + instanceId: otherInstanceId, + nickname: " Shared Hermes ", + connectorUrl: "https://t3.example.test", + }) + .pipe(Effect.forkChild({ startImmediately: true })); + for (let index = 0; index < 10; index += 1) { + yield* Effect.yieldNow; + } + assert.equal(metadataPersistCount, 1); + + yield* Deferred.succeed(releaseFirstPersist, undefined); + yield* Fiber.join(firstEnrollment); + const conflict = yield* Effect.flip(Fiber.join(secondEnrollment)); + assert.equal(conflict.code, "nickname-conflict"); + assert.equal(metadataPersistCount, 1); + }), +); + +it.effect("keeps a revoked instance nickname reserved", () => + Effect.gen(function* () { + const broker = yield* makeBroker(makeSecretStore()); + yield* broker.createEnrollment({ + instanceId, + nickname: "Reserved Hermes", + connectorUrl: "https://t3.example.test", + }); + assert.equal((yield* broker.revokeInstance(instanceId)).status, "revoked"); + + const conflict = yield* Effect.flip( + broker.createEnrollment({ + instanceId: otherInstanceId, + nickname: " Reserved Hermes ", + connectorUrl: "https://t3.example.test", + }), + ); + assert.equal(conflict.code, "nickname-conflict"); + }), +); + +it.effect("finalizes revocation when credential deletion fails", () => + Effect.gen(function* () { + const storedSecrets = makeSecretStore(); + let failRemovals = false; + const secrets: ServerSecretStore.ServerSecretStore["Service"] = { + ...storedSecrets, + remove: (name) => + failRemovals + ? Effect.fail( + new ServerSecretStore.SecretStoreRemoveError({ + resource: `secret ${name}`, + cause: new Error("forced credential deletion failure"), + }), + ) + : storedSecrets.remove(name), + }; + const broker = yield* makeBroker(secrets); + const enrollment = yield* broker.createEnrollment({ + instanceId, + nickname: "Remote Hermes", + connectorUrl: "https://t3.example.test", + }); + const closes: Array = []; + const sent: Array = []; + const registration = yield* broker.registerConnection( + hello({ type: "enrollment-token", token: enrollment.oneTimeToken }), + { + send: (message) => Effect.sync(() => sent.push(message)).pipe(Effect.asVoid), + close: (code) => Effect.sync(() => closes.push(code)).pipe(Effect.asVoid), + }, + ); + const credential = registration.accepted.credential; + if (!credential) { + return yield* Effect.die(new Error("enrollment did not issue a credential")); + } + + const threadId = ThreadId.make("revoke-pending-thread"); + const pendingRequestId = HermesGatewayRequestId.make("revoke-pending-request"); + const pendingRequest = yield* broker + .request(instanceId, { + type: "session.ensure", + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + requestId: pendingRequestId, + threadId, + }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.yieldNow; + assert.equal(sent.at(-1)?.type, "session.ensure"); + const statusEvent = yield* Stream.runHead(broker.streamStatuses).pipe( + Effect.forkChild({ startImmediately: true }), + ); + + failRemovals = true; + const revokeError = yield* Effect.flip(broker.revokeInstance(instanceId)); + assert.equal(revokeError.code, "persistence-failed"); + assert.deepEqual(closes, [4003]); + assert.equal((yield* broker.getInstanceStatus(instanceId)).status, "revoked"); + assert.equal(Option.getOrUndefined(yield* Fiber.join(statusEvent))?.status, "revoked"); + const pendingError = yield* Effect.flip(Fiber.join(pendingRequest)); + assert.include(pendingError.detail, "revoked"); + + const staleReconnect = yield* Effect.flip( + broker.registerConnection(hello({ type: "instance-credential", instanceId, credential }), { + send: () => Effect.void, + close: () => Effect.void, + }), + ); + assert.equal(staleReconnect.code, "instance-revoked"); + + const restartedBroker = yield* makeBroker(secrets); + const staleReconnectAfterRestart = yield* Effect.flip( + restartedBroker.registerConnection( + hello({ type: "instance-credential", instanceId, credential }), + { + send: () => Effect.void, + close: () => Effect.void, + }, + ), + ); + assert.equal(staleReconnectAfterRestart.code, "instance-revoked"); + }), +); + +it.effect("shares one live broker between the gateway route and Hermes provider instance", () => { + const secrets = makeSecretStore(); + const providerLayer = Layer.effect( + HermesTestInstance, + HermesDriver.create({ + instanceId, + displayName: "Remote Hermes", + environment: [], + enabled: true, + config: HermesDriver.defaultConfig(), + }), + ).pipe( + Layer.provideMerge(HermesGatewayBrokerLive), + Layer.provide( + ServerSettings.layerTest({ + providerInstances: { + [instanceId]: { driver: "hermes", displayName: "Remote", config: {} }, + }, + }), + ), + Layer.provide(Layer.succeed(ServerSecretStore.ServerSecretStore, secrets)), + Layer.provide(NodeServices.layer), + ); + + return Effect.gen(function* () { + const broker = yield* HermesGatewayBroker; + const provider = yield* HermesTestInstance; + const enrollment = yield* broker.createEnrollment({ + instanceId, + nickname: "Remote Hermes", + connectorUrl: "https://t3.example.test", + }); + const sent: Array = []; + const registration = yield* broker.registerConnection( + hello({ type: "enrollment-token", token: enrollment.oneTimeToken }), + { + send: (message) => Effect.sync(() => sent.push(message)).pipe(Effect.asVoid), + close: () => Effect.void, + }, + ); + assert.equal((yield* provider.snapshot.getSnapshot).status, "ready"); + + const threadId = ThreadId.make("shared-broker-thread"); + const startSession = yield* provider.adapter + .startSession({ + threadId, + providerInstanceId: instanceId, + runtimeMode: "full-access", + }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.yieldNow; + const ensure = sent.at(-1); + if (!ensure || ensure.type !== "session.ensure") { + return yield* Effect.die(new Error("session.ensure did not reach the gateway transport")); + } + const sessionId = HermesGatewaySessionId.make("shared-broker-session"); + yield* broker.receive(registration, { + type: "session.ready", + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + requestId: ensure.requestId, + threadId, + sessionId, + resumed: false, + }); + yield* Fiber.join(startSession); + + const sendTurn = yield* provider.adapter + .sendTurn({ threadId, input: "hello through the shared broker" }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.yieldNow; + const turnStart = sent.at(-1); + if (!turnStart || turnStart.type !== "turn.start") { + return yield* Effect.die(new Error("turn.start did not reach the gateway transport")); + } + yield* broker.receive(registration, { + type: "turn.started", + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + requestId: turnStart.requestId, + threadId, + sessionId, + turnId: turnStart.turnId, + }); + assert.equal((yield* Fiber.join(sendTurn)).turnId, turnStart.turnId); + }).pipe(Effect.provide(providerLayer), Effect.scoped); +}); + +it.effect("checks nickname uniqueness from persisted metadata after restart", () => + Effect.gen(function* () { + const secrets = makeSecretStore(); + const firstBroker = yield* makeBroker(secrets); + yield* firstBroker.createEnrollment({ + instanceId, + nickname: "Persistent Nickname", + connectorUrl: "https://t3.example.test", + }); + + const restartedBroker = yield* makeBroker(secrets); + const error = yield* Effect.flip( + restartedBroker.createEnrollment({ + instanceId: otherInstanceId, + nickname: "Persistent Nickname", + connectorUrl: "https://t3.example.test", + }), + ); + assert.equal(error.code, "nickname-conflict"); + }), +); diff --git a/apps/server/src/provider/Layers/HermesGatewayBroker.ts b/apps/server/src/provider/Layers/HermesGatewayBroker.ts new file mode 100644 index 00000000000..e5be1243a81 --- /dev/null +++ b/apps/server/src/provider/Layers/HermesGatewayBroker.ts @@ -0,0 +1,892 @@ +import * as NodeCrypto from "node:crypto"; +import { + HERMES_GATEWAY_PROTOCOL_VERSION, + HERMES_DRIVER_KIND, + defaultInstanceIdForDriver, + HermesGatewayCredential, + HermesGatewayEnrollmentToken, + HermesGatewayCapabilities, + HermesGatewayManagementError, + type HermesGatewayConnectionHello, + type HermesGatewayCreateEnrollmentInput, + type HermesGatewayEnrollmentResult, + type HermesGatewayInstanceStatus, + type HermesGatewayPluginToT3Message, + type HermesGatewayRevokeInstanceResult, + type HermesGatewayT3ToPluginMessage, + type ProviderInstanceId, +} from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as Clock from "effect/Clock"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Encoding from "effect/Encoding"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as PubSub from "effect/PubSub"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; + +import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { ProviderAdapterRequestError } from "../Errors.ts"; +import { + HermesGatewayBroker, + type HermesGatewayConnectionRegistration, + type HermesGatewayEnvelope, + type HermesGatewayTransport, + type HermesGatewayBrokerShape, +} from "../Services/HermesGatewayBroker.ts"; + +const ENROLLMENT_TTL = Duration.minutes(10); +const REQUEST_TIMEOUT = Duration.seconds(30); +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); +const isStrictCapabilities = Schema.is(HermesGatewayCapabilities); + +interface PendingEnrollment { + readonly input: HermesGatewayCreateEnrollmentInput; + readonly expiresAtMillis: number; +} + +interface InstanceMetadata { + readonly nickname: string; + readonly connectorUrl: string; + readonly revoked: boolean; + readonly lastSeen?: { + readonly pluginVersion: string; + readonly hermesVersion: string; + readonly capabilities: NonNullable; + readonly connectedAt: string; + readonly activeSessionCount: number; + }; +} + +interface ActiveConnection { + readonly generation: number; + readonly transport: HermesGatewayTransport; + readonly pluginVersion: string; + readonly hermesVersion: string; + readonly capabilities: NonNullable; + readonly connectedAt: string; + readonly activeSessionCount: number; +} + +interface InstanceState { + readonly metadata: InstanceMetadata; + readonly connection?: ActiveConnection; + readonly lastSeen?: Omit & { + readonly activeSessionCount: number; + }; + readonly upgradeRequired?: { + readonly pluginVersion: string; + readonly hermesVersion: string; + readonly protocolVersion: number; + }; +} + +type PluginMessage = Exclude; + +const credentialSecretName = (instanceId: ProviderInstanceId) => + `hermes-gateway-credential-${Buffer.from(instanceId, "utf8").toString("base64url")}`; + +const metadataSecretName = (instanceId: ProviderInstanceId) => + `hermes-gateway-metadata-${Buffer.from(instanceId, "utf8").toString("base64url")}`; + +const managementError = ( + operation: HermesGatewayManagementError["operation"], + code: HermesGatewayManagementError["code"], + message: string, + instanceId?: ProviderInstanceId, +) => + new HermesGatewayManagementError({ + operation, + code, + message, + ...(instanceId ? { instanceId } : {}), + }); + +const rejection = ( + requestId: HermesGatewayConnectionHello["requestId"], + code: Extract["code"], + message: string, +): Extract => ({ + type: "connection.rejected", + requestId, + code, + message, + expectedProtocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, +}); + +const shellQuote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`; + +const credentialsEqual = (left: Uint8Array, right: string) => { + const rightBytes = textEncoder.encode(right); + return left.byteLength === rightBytes.byteLength && NodeCrypto.timingSafeEqual(left, rightBytes); +}; + +const statusFromState = ( + instanceId: ProviderInstanceId, + state: InstanceState, +): HermesGatewayInstanceStatus => ({ + ...(() => { + const observed = state.connection ?? state.lastSeen ?? state.metadata.lastSeen; + return { + lastConnectedAt: observed?.connectedAt ?? null, + pluginVersion: observed?.pluginVersion ?? state.upgradeRequired?.pluginVersion ?? null, + hermesVersion: observed?.hermesVersion ?? state.upgradeRequired?.hermesVersion ?? null, + activeSessionCount: state.connection?.activeSessionCount ?? 0, + protocolVersion: + observed?.capabilities.protocolVersion ?? state.upgradeRequired?.protocolVersion ?? null, + capabilities: observed?.capabilities ?? null, + }; + })(), + instanceId, + nickname: state.metadata.nickname, + status: state.metadata.revoked + ? "revoked" + : state.upgradeRequired + ? "upgrade-required" + : state.connection + ? "connected" + : "offline", + connectorUrl: state.metadata.connectorUrl, +}); + +export const makeHermesGatewayBroker = Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const secretStore = yield* ServerSecretStore.ServerSecretStore; + const settings = yield* ServerSettingsService; + const states = yield* Ref.make(new Map()); + const enrollments = yield* Ref.make(new Map()); + const pendingRequests = yield* Ref.make( + new Map< + string, + { + readonly instanceId: ProviderInstanceId; + readonly deferred: Deferred.Deferred; + } + >(), + ); + const events = yield* PubSub.unbounded(); + const statusEvents = yield* PubSub.unbounded(); + const generation = yield* Ref.make(0); + const enrollmentSemaphore = yield* Semaphore.make(1); + + const publishStatus = (instanceId: ProviderInstanceId, state: InstanceState) => + PubSub.publish(statusEvents, statusFromState(instanceId, state)).pipe(Effect.asVoid); + + const failPendingRequests = (instanceId: ProviderInstanceId, detail: string) => + Effect.gen(function* () { + const affected = yield* Ref.modify(pendingRequests, (current) => { + const next = new Map(current); + const found = Array.from(current.entries()).filter( + ([, pending]) => pending.instanceId === instanceId, + ); + for (const [requestId] of found) next.delete(requestId); + return [found.map(([, pending]) => pending.deferred), next] as const; + }); + yield* Effect.forEach( + affected, + (deferred) => + Deferred.fail( + deferred, + new ProviderAdapterRequestError({ + provider: HERMES_DRIVER_KIND, + method: "connection", + detail, + }), + ), + { discard: true }, + ); + }); + + const persistMetadata = (instanceId: ProviderInstanceId, metadata: InstanceMetadata) => + secretStore + .set(metadataSecretName(instanceId), textEncoder.encode(JSON.stringify(metadata))) + .pipe( + Effect.mapError(() => + managementError( + "create-enrollment", + "persistence-failed", + "Failed to persist Hermes gateway metadata.", + instanceId, + ), + ), + ); + + const readMetadata = (instanceId: ProviderInstanceId) => + secretStore.get(metadataSecretName(instanceId)).pipe( + Effect.map((stored) => { + if (Option.isNone(stored)) return undefined; + try { + const parsed: unknown = JSON.parse(textDecoder.decode(stored.value)); + if ( + typeof parsed === "object" && + parsed !== null && + "nickname" in parsed && + typeof parsed.nickname === "string" && + "connectorUrl" in parsed && + typeof parsed.connectorUrl === "string" && + "revoked" in parsed && + typeof parsed.revoked === "boolean" + ) { + return { + nickname: parsed.nickname, + connectorUrl: parsed.connectorUrl, + revoked: parsed.revoked, + ...("lastSeen" in parsed && + typeof parsed.lastSeen === "object" && + parsed.lastSeen !== null && + "pluginVersion" in parsed.lastSeen && + typeof parsed.lastSeen.pluginVersion === "string" && + "hermesVersion" in parsed.lastSeen && + typeof parsed.lastSeen.hermesVersion === "string" && + "connectedAt" in parsed.lastSeen && + typeof parsed.lastSeen.connectedAt === "string" && + "activeSessionCount" in parsed.lastSeen && + typeof parsed.lastSeen.activeSessionCount === "number" && + "capabilities" in parsed.lastSeen && + isStrictCapabilities(parsed.lastSeen.capabilities) + ? { + lastSeen: { + pluginVersion: parsed.lastSeen.pluginVersion, + hermesVersion: parsed.lastSeen.hermesVersion, + connectedAt: parsed.lastSeen.connectedAt, + activeSessionCount: parsed.lastSeen.activeSessionCount, + capabilities: parsed.lastSeen.capabilities, + }, + } + : {}), + } satisfies InstanceMetadata; + } + } catch { + return undefined; + } + return undefined; + }), + Effect.orElseSucceed(() => undefined), + ); + + const getState = (instanceId: ProviderInstanceId) => + Ref.get(states).pipe( + Effect.map((current) => current.get(instanceId)), + Effect.flatMap((state) => { + if (state) return Effect.succeed(state); + return readMetadata(instanceId).pipe( + Effect.flatMap((metadata) => { + if (!metadata) { + return Effect.fail( + managementError( + "get-status", + "instance-not-found", + `Hermes gateway instance '${instanceId}' has not been enrolled.`, + instanceId, + ), + ); + } + const loaded: InstanceState = { metadata }; + return Ref.update(states, (current) => new Map(current).set(instanceId, loaded)).pipe( + Effect.as(loaded), + ); + }), + ); + }), + ); + + const createEnrollment = (input: HermesGatewayCreateEnrollmentInput) => + enrollmentSemaphore.withPermits(1)( + Effect.gen(function* () { + const normalizedNickname = input.nickname.trim(); + const normalizedInput = { + ...input, + nickname: normalizedNickname, + } satisfies HermesGatewayCreateEnrollmentInput; + const currentSettings = yield* settings.getSettings.pipe( + Effect.mapError(() => + managementError( + "create-enrollment", + "internal-error", + "Failed to read server settings.", + input.instanceId, + ), + ), + ); + const configured = currentSettings.providerInstances[input.instanceId]; + const isDefaultHermesInstance = + input.instanceId === defaultInstanceIdForDriver(HERMES_DRIVER_KIND); + if ((!configured || configured.driver !== HERMES_DRIVER_KIND) && !isDefaultHermesInstance) { + return yield* managementError( + "create-enrollment", + "instance-not-found", + `Provider instance '${input.instanceId}' is not configured with the Hermes driver.`, + input.instanceId, + ); + } + + const configuredHermesIds = [ + defaultInstanceIdForDriver(HERMES_DRIVER_KIND), + ...Object.entries(currentSettings.providerInstances) + .filter(([, config]) => config.driver === HERMES_DRIVER_KIND) + .map(([instanceId]) => instanceId as ProviderInstanceId), + ]; + const existingStates = yield* Ref.get(states); + for (const instanceId of new Set(configuredHermesIds)) { + const persistedMetadata = existingStates.has(instanceId) + ? undefined + : yield* readMetadata(instanceId); + const state = + existingStates.get(instanceId) ?? + (persistedMetadata ? { metadata: persistedMetadata } : undefined); + if (!state) continue; + if (instanceId !== input.instanceId && state.metadata.nickname === normalizedNickname) { + return yield* managementError( + "create-enrollment", + "nickname-conflict", + `A Hermes gateway named '${normalizedNickname}' already exists.`, + input.instanceId, + ); + } + } + + const token = HermesGatewayEnrollmentToken.make( + Encoding.encodeBase64Url( + yield* crypto + .randomBytes(32) + .pipe( + Effect.mapError(() => + managementError( + "create-enrollment", + "internal-error", + "Failed to generate an enrollment token.", + input.instanceId, + ), + ), + ), + ), + ); + const expiresAtMillis = + (yield* Clock.currentTimeMillis) + Duration.toMillis(ENROLLMENT_TTL); + const previousState = existingStates.get(input.instanceId); + const metadata = { + nickname: normalizedNickname, + connectorUrl: input.connectorUrl, + revoked: false, + ...(previousState?.metadata.lastSeen + ? { lastSeen: previousState.metadata.lastSeen } + : {}), + } satisfies InstanceMetadata; + yield* secretStore + .remove(credentialSecretName(input.instanceId)) + .pipe( + Effect.mapError(() => + managementError( + "create-enrollment", + "persistence-failed", + "Failed to invalidate the previous Hermes gateway credential.", + input.instanceId, + ), + ), + ); + if (previousState?.connection) { + yield* failPendingRequests( + input.instanceId, + "A new enrollment replaced the active Hermes gateway credential.", + ); + yield* previousState.connection.transport.close( + 4001, + "A new enrollment was created for this instance", + ); + } + yield* persistMetadata(input.instanceId, metadata); + const enrolledState: InstanceState = { + metadata, + ...(previousState?.lastSeen ? { lastSeen: previousState.lastSeen } : {}), + }; + yield* Ref.update(states, (current) => + new Map(current).set(input.instanceId, enrolledState), + ); + yield* publishStatus(input.instanceId, enrolledState); + yield* Ref.update(enrollments, (current) => { + const next = new Map(current); + for (const [pendingToken, pending] of current) { + if (pending.input.instanceId === input.instanceId) { + next.delete(pendingToken); + } + } + return next.set(token, { input: normalizedInput, expiresAtMillis }); + }); + + return { + instanceId: input.instanceId, + expiresAt: DateTime.formatIso(DateTime.makeUnsafe(expiresAtMillis)), + connectorUrl: input.connectorUrl, + command: `hermes t3 connect --url ${shellQuote(input.connectorUrl)} --token ${shellQuote(token)}`, + oneTimeToken: token, + } satisfies HermesGatewayEnrollmentResult; + }), + ); + + const getInstanceStatus = (instanceId: ProviderInstanceId) => + getState(instanceId).pipe(Effect.map((state) => statusFromState(instanceId, state))); + + const listInstances = Effect.gen(function* () { + const currentSettings = yield* settings.getSettings.pipe( + Effect.mapError(() => + managementError("list-instances", "internal-error", "Failed to read server settings."), + ), + ); + const ids = Array.from( + new Set([ + defaultInstanceIdForDriver(HERMES_DRIVER_KIND), + ...Object.entries(currentSettings.providerInstances) + .filter(([, config]) => config.driver === HERMES_DRIVER_KIND) + .map(([id]) => id as ProviderInstanceId), + ]), + ); + return yield* Effect.forEach( + ids, + (instanceId) => + getInstanceStatus(instanceId).pipe( + Effect.catchTag("HermesGatewayManagementError", () => Effect.succeed(undefined)), + ), + { concurrency: "unbounded" }, + ).pipe(Effect.map((values) => values.filter((value) => value !== undefined))); + }); + + const revokeInstance = (instanceId: ProviderInstanceId) => + Effect.gen(function* () { + const state = yield* getState(instanceId); + const metadata: InstanceMetadata = { ...state.metadata, revoked: true }; + yield* persistMetadata(instanceId, metadata).pipe( + Effect.mapError((error) => + managementError("revoke-instance", error.code, error.message, instanceId), + ), + ); + const next: InstanceState = { + metadata, + ...(state.lastSeen ? { lastSeen: state.lastSeen } : {}), + }; + yield* Ref.update(states, (current) => new Map(current).set(instanceId, next)); + if (state.connection) { + yield* failPendingRequests(instanceId, "The Hermes gateway credential was revoked."); + yield* state.connection.transport.close(4003, "Hermes gateway credential revoked"); + } + yield* publishStatus(instanceId, next); + yield* secretStore + .remove(credentialSecretName(instanceId)) + .pipe( + Effect.mapError(() => + managementError( + "revoke-instance", + "persistence-failed", + "Failed to remove the Hermes gateway credential.", + instanceId, + ), + ), + ); + return statusFromState(instanceId, next) satisfies HermesGatewayRevokeInstanceResult; + }); + + const registerConnectionEffect = ( + hello: HermesGatewayConnectionHello, + transport: HermesGatewayTransport, + ) => + Effect.gen(function* () { + const strictCapabilities = isStrictCapabilities(hello.capabilities) + ? hello.capabilities + : undefined; + + let instanceId: ProviderInstanceId; + let credential: HermesGatewayCredential | undefined; + let authenticatedEnrollment: PendingEnrollment | undefined; + let state: InstanceState; + if (hello.authentication.type === "enrollment-token") { + const authentication = hello.authentication; + const enrollment = (yield* Ref.get(enrollments)).get(authentication.token); + if (!enrollment || enrollment.expiresAtMillis < (yield* Clock.currentTimeMillis)) { + return yield* Effect.fail( + rejection( + hello.requestId, + "enrollment-expired", + "The enrollment token is invalid, expired, or already used.", + ), + ); + } + instanceId = enrollment.input.instanceId; + authenticatedEnrollment = enrollment; + state = yield* getState(instanceId).pipe( + Effect.mapError(() => + rejection( + hello.requestId, + "invalid-authentication", + "Unknown Hermes gateway instance.", + ), + ), + ); + } else { + const authentication = hello.authentication; + instanceId = authentication.instanceId; + state = yield* getState(instanceId).pipe( + Effect.mapError(() => + rejection( + hello.requestId, + "invalid-authentication", + "Unknown Hermes gateway instance.", + ), + ), + ); + if (state.metadata.revoked) { + return yield* Effect.fail( + rejection( + hello.requestId, + "instance-revoked", + "This Hermes gateway instance has been revoked.", + ), + ); + } + const stored = yield* secretStore + .get(credentialSecretName(instanceId)) + .pipe( + Effect.mapError(() => + rejection( + hello.requestId, + "internal-error", + "Failed to read the gateway credential.", + ), + ), + ); + if (Option.isNone(stored) || !credentialsEqual(stored.value, authentication.credential)) { + return yield* Effect.fail( + rejection( + hello.requestId, + "invalid-authentication", + "The Hermes gateway credential is invalid.", + ), + ); + } + } + + if (state.metadata.revoked) { + return yield* Effect.fail( + rejection( + hello.requestId, + "instance-revoked", + "This Hermes gateway instance has been revoked.", + ), + ); + } + + if ( + hello.protocolVersion !== HERMES_GATEWAY_PROTOCOL_VERSION || + strictCapabilities === undefined + ) { + if (state.connection) { + yield* failPendingRequests( + instanceId, + "The Hermes gateway plugin requires a protocol upgrade.", + ); + yield* state.connection.transport.close( + 4004, + "Hermes gateway protocol version is incompatible", + ); + } + const upgradeState: InstanceState = { + metadata: state.metadata, + ...(state.lastSeen ? { lastSeen: state.lastSeen } : {}), + upgradeRequired: { + pluginVersion: hello.pluginVersion, + hermesVersion: hello.hermesVersion, + protocolVersion: hello.protocolVersion, + }, + }; + yield* Ref.update(states, (current) => new Map(current).set(instanceId, upgradeState)); + yield* publishStatus(instanceId, upgradeState); + return yield* Effect.fail( + rejection( + hello.requestId, + "version-incompatible", + `Expected protocol version ${HERMES_GATEWAY_PROTOCOL_VERSION}.`, + ), + ); + } + + if (hello.authentication.type === "enrollment-token" && authenticatedEnrollment) { + const authentication = hello.authentication; + const enrollment = yield* Ref.modify(enrollments, (current) => { + const found = current.get(authentication.token); + const next = new Map(current); + if (found === authenticatedEnrollment) next.delete(authentication.token); + return [found, next] as const; + }); + if ( + enrollment !== authenticatedEnrollment || + enrollment.expiresAtMillis < (yield* Clock.currentTimeMillis) + ) { + return yield* Effect.fail( + rejection( + hello.requestId, + "enrollment-expired", + "The enrollment token is invalid, expired, or already used.", + ), + ); + } + instanceId = enrollment.input.instanceId; + credential = HermesGatewayCredential.make( + Encoding.encodeBase64Url( + yield* crypto + .randomBytes(32) + .pipe( + Effect.mapError(() => + rejection( + hello.requestId, + "internal-error", + "Failed to generate the Hermes gateway credential.", + ), + ), + ), + ), + ); + yield* secretStore + .set(credentialSecretName(instanceId), textEncoder.encode(credential)) + .pipe( + Effect.mapError(() => + rejection( + hello.requestId, + "internal-error", + "Failed to persist the Hermes gateway credential.", + ), + ), + ); + } + + const nextGeneration = yield* Ref.getAndUpdate(generation, (value) => value + 1); + const connectedAt = DateTime.formatIso(yield* DateTime.now); + const connection = { + generation: nextGeneration, + transport, + pluginVersion: hello.pluginVersion, + hermesVersion: hello.hermesVersion, + capabilities: strictCapabilities, + connectedAt, + activeSessionCount: 0, + } satisfies ActiveConnection; + const lastSeen = { + pluginVersion: connection.pluginVersion, + hermesVersion: connection.hermesVersion, + capabilities: connection.capabilities, + connectedAt: connection.connectedAt, + activeSessionCount: connection.activeSessionCount, + }; + const connectedMetadata: InstanceMetadata = { + ...state.metadata, + lastSeen, + }; + yield* persistMetadata(instanceId, connectedMetadata).pipe( + Effect.mapError(() => + rejection( + hello.requestId, + "internal-error", + "Failed to persist Hermes gateway connection metadata.", + ), + ), + ); + if (state.connection) { + yield* failPendingRequests( + instanceId, + "The Hermes gateway connection was replaced by a newer connection.", + ); + yield* state.connection.transport.close(4001, "Replaced by a newer connection"); + } + yield* Ref.update(states, (current) => + new Map(current).set(instanceId, { + metadata: connectedMetadata, + connection, + lastSeen, + }), + ); + yield* publishStatus(instanceId, { + metadata: connectedMetadata, + connection, + lastSeen, + }); + + const accepted = { + type: "connection.accepted", + requestId: hello.requestId, + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + instanceId, + nickname: state.metadata.nickname, + ...(credential ? { credential } : {}), + } as const; + return { + instanceId, + generation: nextGeneration, + accepted, + } satisfies HermesGatewayConnectionRegistration; + }); + + const registerConnection = ( + hello: HermesGatewayConnectionHello, + transport: HermesGatewayTransport, + ) => + hello.authentication.type === "enrollment-token" + ? enrollmentSemaphore.withPermits(1)(registerConnectionEffect(hello, transport)) + : registerConnectionEffect(hello, transport); + + const receive = (registration: HermesGatewayConnectionRegistration, message: PluginMessage) => + Effect.gen(function* () { + const state = (yield* Ref.get(states)).get(registration.instanceId); + if (state?.connection?.generation !== registration.generation) return; + if (message.type === "connection.status") { + yield* Ref.update(states, (current) => { + const found = current.get(registration.instanceId); + if (found?.connection?.generation !== registration.generation) return current; + return new Map(current).set(registration.instanceId, { + ...found, + connection: { + ...found.connection, + activeSessionCount: message.activeSessionCount, + }, + }); + }); + const updated = (yield* Ref.get(states)).get(registration.instanceId); + if (updated) yield* publishStatus(registration.instanceId, updated); + } + if ("requestId" in message && message.requestId) { + const correlatedRequestId = message.requestId; + const pending = yield* Ref.modify(pendingRequests, (current) => { + const found = current.get(correlatedRequestId); + if (!found) return [undefined, current] as const; + const next = new Map(current); + next.delete(correlatedRequestId); + return [found, next] as const; + }); + if (pending) { + yield* Deferred.succeed(pending.deferred, message); + } + } + yield* PubSub.publish(events, { + instanceId: registration.instanceId, + message, + }); + }); + + const disconnect = (registration: HermesGatewayConnectionRegistration) => + Effect.gen(function* () { + let disconnected: InstanceState | undefined; + yield* Ref.update(states, (current) => { + const found = current.get(registration.instanceId); + if (found?.connection?.generation !== registration.generation) return current; + const next = new Map(current); + const lastSeen = { + pluginVersion: found.connection.pluginVersion, + hermesVersion: found.connection.hermesVersion, + capabilities: found.connection.capabilities, + connectedAt: found.connection.connectedAt, + activeSessionCount: found.connection.activeSessionCount, + }; + disconnected = { + metadata: { ...found.metadata, lastSeen }, + lastSeen, + }; + next.set(registration.instanceId, disconnected); + return next; + }); + if (disconnected) { + yield* persistMetadata(registration.instanceId, disconnected.metadata).pipe(Effect.ignore); + } + if (disconnected) yield* publishStatus(registration.instanceId, disconnected); + if (disconnected) { + yield* failPendingRequests( + registration.instanceId, + "The Hermes gateway connection disconnected.", + ); + } + }); + + const send = (instanceId: ProviderInstanceId, message: HermesGatewayT3ToPluginMessage) => + Effect.gen(function* () { + const state = (yield* Ref.get(states)).get(instanceId); + if (!state?.connection) { + return yield* new ProviderAdapterRequestError({ + provider: "hermes", + method: message.type, + detail: `Hermes gateway instance '${instanceId}' is offline.`, + }); + } + yield* state.connection.transport.send(message); + }); + + const request = (instanceId: ProviderInstanceId, message: HermesGatewayT3ToPluginMessage) => + Effect.gen(function* () { + if (!("requestId" in message) || !message.requestId) { + return yield* new ProviderAdapterRequestError({ + provider: "hermes", + method: message.type, + detail: "A correlated Hermes gateway request requires a request id.", + }); + } + const deferred = yield* Deferred.make(); + yield* Ref.update(pendingRequests, (current) => + new Map(current).set(message.requestId as string, { + instanceId, + deferred, + }), + ); + yield* send(instanceId, message).pipe( + Effect.tapError(() => + Ref.update(pendingRequests, (current) => { + const next = new Map(current); + next.delete(message.requestId as string); + return next; + }), + ), + ); + return yield* Deferred.await(deferred).pipe( + Effect.timeout(REQUEST_TIMEOUT), + Effect.mapError((error) => + error._tag === "TimeoutError" + ? new ProviderAdapterRequestError({ + provider: "hermes", + method: message.type, + detail: "Hermes gateway request timed out.", + }) + : error, + ), + Effect.ensuring( + Ref.update(pendingRequests, (current) => { + const next = new Map(current); + next.delete(message.requestId as string); + return next; + }), + ), + ); + }); + + const isConnected = (instanceId: ProviderInstanceId) => + Ref.get(states).pipe( + Effect.map((current) => current.get(instanceId)?.connection !== undefined), + ); + + return { + createEnrollment, + getInstanceStatus, + listInstances, + revokeInstance, + registerConnection, + receive, + disconnect, + request, + send, + isConnected, + stream: Stream.fromPubSub(events), + streamStatuses: Stream.fromPubSub(statusEvents), + } satisfies HermesGatewayBrokerShape; +}); + +export const HermesGatewayBrokerLive = Layer.effect(HermesGatewayBroker, makeHermesGatewayBroker); diff --git a/apps/server/src/provider/Services/HermesGatewayBroker.ts b/apps/server/src/provider/Services/HermesGatewayBroker.ts new file mode 100644 index 00000000000..959c746d754 --- /dev/null +++ b/apps/server/src/provider/Services/HermesGatewayBroker.ts @@ -0,0 +1,101 @@ +import type { + HermesGatewayConnectionHello, + HermesGatewayCreateEnrollmentInput, + HermesGatewayEnrollmentResult, + HermesGatewayInstanceStatus, + HermesGatewayPluginToT3Message, + HermesGatewayRevokeInstanceResult, + HermesGatewayT3ToPluginMessage, + ProviderInstanceId, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Stream from "effect/Stream"; + +import type { HermesGatewayManagementError } from "@t3tools/contracts"; +import type { ProviderAdapterRequestError } from "../Errors.ts"; + +export interface HermesGatewayTransport { + readonly send: ( + message: HermesGatewayT3ToPluginMessage, + ) => Effect.Effect; + readonly close: (code: number, reason: string) => Effect.Effect; +} + +export interface HermesGatewayConnectionRegistration { + readonly instanceId: ProviderInstanceId; + readonly generation: number; + readonly accepted: Extract< + HermesGatewayT3ToPluginMessage, + { readonly type: "connection.accepted" } + >; +} + +export interface HermesGatewayEnvelope { + readonly instanceId: ProviderInstanceId; + readonly message: Exclude; +} + +export interface HermesGatewayBrokerShape { + readonly createEnrollment: ( + input: HermesGatewayCreateEnrollmentInput, + ) => Effect.Effect; + readonly getInstanceStatus: ( + instanceId: ProviderInstanceId, + ) => Effect.Effect; + readonly listInstances: Effect.Effect< + ReadonlyArray, + HermesGatewayManagementError + >; + readonly revokeInstance: ( + instanceId: ProviderInstanceId, + ) => Effect.Effect; + readonly registerConnection: ( + hello: HermesGatewayConnectionHello, + transport: HermesGatewayTransport, + ) => Effect.Effect< + HermesGatewayConnectionRegistration, + Extract + >; + readonly receive: ( + registration: HermesGatewayConnectionRegistration, + message: Exclude, + ) => Effect.Effect; + readonly disconnect: (registration: HermesGatewayConnectionRegistration) => Effect.Effect; + readonly request: ( + instanceId: ProviderInstanceId, + message: HermesGatewayT3ToPluginMessage, + ) => Effect.Effect< + Exclude, + ProviderAdapterRequestError + >; + readonly send: ( + instanceId: ProviderInstanceId, + message: HermesGatewayT3ToPluginMessage, + ) => Effect.Effect; + readonly isConnected: (instanceId: ProviderInstanceId) => Effect.Effect; + readonly stream: Stream.Stream; + readonly streamStatuses: Stream.Stream; +} + +const unavailable = () => Effect.die(new Error("HermesGatewayBroker live layer is not installed")); + +export const HermesGatewayBroker = Context.Reference( + "t3/provider/Services/HermesGatewayBroker", + { + defaultValue: () => ({ + createEnrollment: unavailable, + getInstanceStatus: unavailable, + listInstances: unavailable(), + revokeInstance: unavailable, + registerConnection: unavailable, + receive: unavailable, + disconnect: unavailable, + request: unavailable, + send: unavailable, + isConnected: () => Effect.succeed(false), + stream: Stream.empty, + streamStatuses: Stream.empty, + }), + }, +); diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 791a96e1da3..58822279797 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -24,6 +24,7 @@ import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts"; +import { HermesDriver, type HermesDriverEnv } from "./Drivers/HermesDriver.ts"; import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts"; import type { AnyProviderDriver } from "./ProviderDriver.ts"; @@ -37,6 +38,7 @@ export type BuiltInDriversEnv = | CodexDriverEnv | CursorDriverEnv | GrokDriverEnv + | HermesDriverEnv | OpenCodeDriverEnv; /** @@ -50,4 +52,5 @@ export const BUILT_IN_DRIVERS: ReadonlyArray>( + Option.none(), + ); + const transport = { + send: (message: HermesGatewayT3ToPluginMessage) => + write(encodeServerFrame(message)).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: "hermes", + method: message.type, + detail: "Failed to write a Hermes gateway WebSocket frame.", + cause, + }), + ), + ), + close: (code: number, reason: string) => + write(new Socket.CloseEvent(code, reason)).pipe(Effect.ignore), + }; + + yield* socket + .runString((frame) => + Effect.gen(function* () { + const message = yield* decodePluginFrame(frame); + const current = yield* Ref.get(registration); + + if (Option.isNone(current)) { + if (!isConnectionHello(message)) { + yield* transport.close(4002, "First message must be connection.hello"); + return; + } + const registered = yield* broker + .registerConnection(message, transport) + .pipe( + Effect.tapError((rejected) => + transport + .send(rejected) + .pipe(Effect.andThen(transport.close(4003, rejected.message))), + ), + ); + yield* Ref.set(registration, Option.some(registered)); + yield* transport.send(registered.accepted); + return; + } + + if (isConnectionHello(message)) { + yield* transport.close(4002, "connection.hello may only be sent once"); + return; + } + yield* broker.receive(current.value, message); + }).pipe( + Effect.catch((cause) => + Effect.logWarning("Rejected Hermes gateway WebSocket frame", { cause }).pipe( + Effect.andThen(transport.close(4002, "Invalid Hermes gateway message")), + ), + ), + ), + ) + .pipe( + Effect.catch((cause) => + Effect.logDebug("Hermes gateway WebSocket disconnected", { cause }), + ), + Effect.ensuring( + Ref.get(registration).pipe( + Effect.flatMap( + Option.match({ + onNone: () => Effect.void, + onSome: broker.disconnect, + }), + ), + ), + ), + ); + + return HttpServerResponse.empty(); + }), + ); + }), +); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 66b9823afb3..7e173481b0f 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -21,6 +21,8 @@ import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory.ts"; import * as ProviderSessionRuntime from "./persistence/ProviderSessionRuntime.ts"; import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry.ts"; +import { HermesGatewayBrokerLive } from "./provider/Layers/HermesGatewayBroker.ts"; +import { hermesGatewayWebSocketRouteLayer } from "./provider/hermesGatewayHttp.ts"; import * as ProviderEventLoggers from "./provider/Layers/ProviderEventLoggers.ts"; import { ProviderServiceLive } from "./provider/Layers/ProviderService.ts"; import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReaper.ts"; @@ -287,6 +289,10 @@ const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe( Layer.provideMerge(OrchestrationLayerLive), ); +const HermesProviderRuntimeLive = ProviderInstanceRegistryHydrationLive.pipe( + Layer.provideMerge(HermesGatewayBrokerLive), +); + const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( // Core Services Layer.provideMerge(CheckpointingLayerLive), @@ -303,7 +309,7 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( // through this layer. Built-in drivers come from `BUILT_IN_DRIVERS`; // `providerInstances` hydration merges `settings.providers.` // with explicit `providerInstances` entries on boot. - Layer.provideMerge(ProviderInstanceRegistryHydrationLive), + Layer.provideMerge(HermesProviderRuntimeLive), // Shared native/canonical NDJSON writers used by both the per-instance // drivers (native stream, written from inside each `Adapter`) and // `ProviderService` (canonical stream, written after event normalization). @@ -362,6 +368,7 @@ export const makeRoutesLayer = Layer.mergeAll( assetRouteLayer, staticAndDevRouteLayer, websocketRpcRouteLayer, + hermesGatewayWebSocketRouteLayer, ), McpHttpServer.layer.pipe(Layer.provide(McpSessionRegistry.layer)), ).pipe( diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index f6f46d1e76e..123259decf8 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -78,6 +78,7 @@ import { } from "./observability/RpcInstrumentation.ts"; import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; +import { HermesGatewayBroker } from "./provider/Services/HermesGatewayBroker.ts"; import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; @@ -307,6 +308,10 @@ const RPC_REQUIRED_SCOPE = new Map([ [WS_METHODS.serverGetProcessDiagnostics, AuthOrchestrationReadScope], [WS_METHODS.serverGetProcessResourceHistory, AuthOrchestrationReadScope], [WS_METHODS.serverSignalProcess, AuthOrchestrationOperateScope], + [WS_METHODS.hermesGatewayCreateEnrollment, AuthOrchestrationOperateScope], + [WS_METHODS.hermesGatewayGetInstanceStatus, AuthOrchestrationReadScope], + [WS_METHODS.hermesGatewayListInstances, AuthOrchestrationReadScope], + [WS_METHODS.hermesGatewayRevokeInstance, AuthOrchestrationOperateScope], [WS_METHODS.cloudGetRelayClientStatus, AuthRelayWriteScope], [WS_METHODS.cloudInstallRelayClient, AuthRelayWriteScope], [WS_METHODS.sourceControlLookupRepository, AuthOrchestrationReadScope], @@ -420,6 +425,7 @@ const makeWsRpcLayer = ( const portDiscovery = yield* PortScanner.PortDiscovery; const providerRegistry = yield* ProviderRegistry.ProviderRegistry; const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner; + const hermesGatewayBroker = yield* HermesGatewayBroker; const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate; const config = yield* ServerConfig.ServerConfig; const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents; @@ -1521,6 +1527,30 @@ const makeWsRpcLayer = ( "rpc.aggregate": "server", }, ), + [WS_METHODS.hermesGatewayCreateEnrollment]: (input) => + observeRpcEffect( + WS_METHODS.hermesGatewayCreateEnrollment, + hermesGatewayBroker.createEnrollment(input), + { "rpc.aggregate": "hermes-gateway" }, + ), + [WS_METHODS.hermesGatewayGetInstanceStatus]: ({ instanceId }) => + observeRpcEffect( + WS_METHODS.hermesGatewayGetInstanceStatus, + hermesGatewayBroker.getInstanceStatus(instanceId), + { "rpc.aggregate": "hermes-gateway" }, + ), + [WS_METHODS.hermesGatewayListInstances]: (_input) => + observeRpcEffect( + WS_METHODS.hermesGatewayListInstances, + hermesGatewayBroker.listInstances, + { "rpc.aggregate": "hermes-gateway" }, + ), + [WS_METHODS.hermesGatewayRevokeInstance]: ({ instanceId }) => + observeRpcEffect( + WS_METHODS.hermesGatewayRevokeInstance, + hermesGatewayBroker.revokeInstance(instanceId), + { "rpc.aggregate": "hermes-gateway" }, + ), [WS_METHODS.serverDiscoverSourceControl]: (_input) => observeRpcEffect( WS_METHODS.serverDiscoverSourceControl, diff --git a/apps/web/src/components/HermesIcon.tsx b/apps/web/src/components/HermesIcon.tsx new file mode 100644 index 00000000000..91fbae89ecd --- /dev/null +++ b/apps/web/src/components/HermesIcon.tsx @@ -0,0 +1,18 @@ +import type { Icon } from "./Icons"; + +/** + * Compact local mark for Hermes provider surfaces. The paired wings suggest + * Hermes' caduceus without depending on a remote image asset. + */ +export const HermesIcon: Icon = (props) => ( + + + + +); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index b427037bdf7..7367e21ceb9 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -75,7 +75,10 @@ import { ProviderModelPicker } from "./ProviderModelPicker"; import { type ComposerCommandItem, ComposerCommandMenu } from "./ComposerCommandMenu"; import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions"; import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu"; -import { ComposerPrimaryActions } from "./ComposerPrimaryActions"; +import { + ComposerPrimaryActions, + shouldUseCollapsedHermesRunningActions, +} from "./ComposerPrimaryActions"; import { ComposerPendingApprovalPanel } from "./ComposerPendingApprovalPanel"; import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel"; import { ComposerPlanFollowUpBanner } from "./ComposerPlanFollowUpBanner"; @@ -171,9 +174,10 @@ import { getProviderDisplayName, getProviderInteractionModeToggle } from "../../ import { applyProviderInstanceSettings, deriveProviderInstanceEntries, - NO_PROVIDER_MODEL_SELECTION, + getProviderInstanceComposerUnavailableMessage, + isProviderInstanceComposerAvailable, + resolveComposerProviderInstanceId, resolveProviderDriverKindForInstanceSelection, - resolveSelectableProviderInstanceEntry, sortProviderInstanceEntries, type ProviderInstanceEntry, } from "../../providerInstances"; @@ -412,6 +416,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( isComplete: boolean; } | null; isRunning: boolean; + allowSendWhileRunning: boolean; showPlanFollowUpPrompt: boolean; promptHasText: boolean; isSendBusy: boolean; @@ -438,6 +443,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( compact={props.compact} pendingAction={props.pendingAction} isRunning={props.isRunning} + allowSendWhileRunning={props.allowSendWhileRunning} showPlanFollowUpPrompt={props.showPlanFollowUpPrompt} promptHasText={props.promptHasText} isSendBusy={props.isSendBusy} @@ -776,52 +782,28 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // Resolve which configured instance the composer is currently targeting. // Priority: - // 1. The composer draft's `activeProvider` — the user's unsaved pick + // 1. A started Hermes thread's persisted instance binding. Hermes + // sessions cannot be reassigned, including when that instance is + // currently missing or disabled. + // 2. The composer draft's `activeProvider` — the user's unsaved pick // from the model picker (must win, otherwise the UI appears to // ignore picker selections). - // 2. Thread's persisted instance id (server-side saved selection). - // 3. Project default's instance id. - // 4. First enabled entry matching the current driver kind. - // 5. First enabled entry overall / default instance for the kind. + // 3. Thread's persisted instance id (server-side saved selection). + // 4. Project default's instance id. + // 5. First enabled entry matching the current driver kind. + // 6. First enabled entry overall / default instance for the kind. // const selectedInstanceId = useMemo(() => { - const candidates: Array = [ - composerDraft.activeProvider, - activeThread?.session?.providerInstanceId, - activeThreadModelSelection?.instanceId, - activeProjectDefaultModelSelection?.instanceId, - ]; - for (const candidate of candidates) { - if (!candidate) continue; - const match = providerInstanceEntries.find( - (entry) => entry.instanceId === candidate && entry.enabled && entry.isAvailable, - ); - if (match) { - // When locked to a specific driver kind, ignore persisted instance - // ids from a different kind or continuation group. - if (lockedProvider && match.driverKind !== lockedProvider) continue; - if ( - lockedContinuationGroupKey && - match.continuationGroupKey !== lockedContinuationGroupKey - ) { - continue; - } - return match.instanceId; - } - } - const compatibleEntries = providerInstanceEntries.filter( - (entry) => - (!lockedProvider || entry.driverKind === lockedProvider) && - (!lockedContinuationGroupKey || entry.continuationGroupKey === lockedContinuationGroupKey), - ); - const requestedDriverEntries = compatibleEntries.filter( - (entry) => entry.driverKind === requestedDriverKind, - ); - return ( - resolveSelectableProviderInstanceEntry(requestedDriverEntries, undefined)?.instanceId ?? - resolveSelectableProviderInstanceEntry(compatibleEntries, undefined)?.instanceId ?? - NO_PROVIDER_MODEL_SELECTION.instanceId - ); + return resolveComposerProviderInstanceId({ + entries: providerInstanceEntries, + draftInstanceId: composerDraft.activeProvider ?? null, + threadInstanceId: activeThread?.session?.providerInstanceId, + threadModelInstanceId: activeThreadModelSelection?.instanceId, + projectInstanceId: activeProjectDefaultModelSelection?.instanceId, + requestedDriverKind, + lockedProvider, + lockedContinuationGroupKey, + }); }, [ activeProjectDefaultModelSelection?.instanceId, activeThread?.session?.providerInstanceId, @@ -840,12 +822,28 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) () => providerInstanceEntries.find((entry) => entry.instanceId === selectedInstanceId), [providerInstanceEntries, selectedInstanceId], ); - const noProviderAvailable = selectedProviderEntry === undefined; + const noProviderAvailable = + selectedProviderEntry === undefined || + !isProviderInstanceComposerAvailable(selectedProviderEntry); + const missingBoundHermesInstanceId = + selectedProviderEntry === undefined && lockedProvider === "hermes" + ? (activeThread?.session?.providerInstanceId ?? activeThreadModelSelection?.instanceId) + : undefined; + const providerUnavailableMessage = getProviderInstanceComposerUnavailableMessage( + selectedProviderEntry, + missingBoundHermesInstanceId + ? { + driverKind: ProviderDriverKind.make("hermes"), + instanceId: missingBoundHermesInstanceId, + } + : undefined, + ); // The driver kind follows the instance that will actually run the turn, // which can differ from the persisted selection when that selection is // disabled. const selectedProvider: ProviderDriverKind = selectedProviderEntry?.driverKind ?? requestedDriverKind; + const allowHermesSteering = phase === "running" && selectedProvider === "hermes"; const { modelOptions: composerModelOptions, selectedModel } = useEffectiveComposerModelState({ threadRef: composerDraftTarget, @@ -1216,7 +1214,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) [activePendingIsResponding, activePendingProgress, activePendingResolvedAnswers], ); const collapsedComposerPrimaryActionDisabled = - phase === "running" || + (phase === "running" && !allowHermesSteering) || isSendBusy || isConnecting || noProviderAvailable || @@ -1765,7 +1763,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isConnecting || noProviderAvailable || environmentUnavailable !== null || - phase === "running" + (phase === "running" && !allowHermesSteering) ) { return false; } @@ -1783,6 +1781,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isSendBusy, noProviderAvailable, phase, + allowHermesSteering, showPlanFollowUpPrompt, ]); @@ -2368,29 +2367,56 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ? activePendingProgress.customAnswer || "Type your own answer, or leave this blank to use the selected option" : prompt.trim() || - (noProviderAvailable ? "Enable a provider in Settings" : "Ask anything...")} - - + {shouldUseCollapsedHermesRunningActions({ + isRunning: phase === "running", + allowSendWhileRunning: allowHermesSteering, + }) ? ( + 0} + isSendBusy={isSendBusy} + isConnecting={isConnecting} + isEnvironmentUnavailable={ + environmentUnavailable !== null || + noProviderAvailable || + projectSelectionRequired + } + isPreparingWorktree={false} + hasSendableContent={composerSendState.hasSendableContent} + preserveComposerFocusOnPointerDown + onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion} + onInterrupt={handleInterruptPrimaryAction} + onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction} + /> + ) : ( + + )} ) : null} @@ -2575,7 +2601,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) : projectSelectionRequired ? "Choose a project above to start a thread" : noProviderAvailable - ? "Enable a provider in Settings to send a message" + ? providerUnavailableMessage : phase === "disconnected" ? "Ask for follow-up changes or attach images" : "Ask anything, @tag files/folders, $use skills, or / for commands" @@ -2643,7 +2669,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) className="shrink-0 gap-2 px-2 text-muted-foreground/70 sm:px-3" > - No provider available + {providerUnavailableMessage} ) : ( 0} isSendBusy={isSendBusy} diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.test.ts b/apps/web/src/components/chat/ComposerPrimaryActions.test.ts index b7624db0a8f..97a2674034b 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.test.ts +++ b/apps/web/src/components/chat/ComposerPrimaryActions.test.ts @@ -1,6 +1,45 @@ import { describe, expect, it } from "vite-plus/test"; -import { formatPendingPrimaryActionLabel } from "./ComposerPrimaryActions"; +import { + formatPendingPrimaryActionLabel, + resolveRunningPrimaryActionMode, + shouldUseCollapsedHermesRunningActions, +} from "./ComposerPrimaryActions"; + +describe("resolveRunningPrimaryActionMode", () => { + it("keeps stop-only behavior for ordinary running providers", () => { + expect(resolveRunningPrimaryActionMode({ isRunning: true, allowSendWhileRunning: false })).toBe( + "stop", + ); + }); + + it("shows steer and stop actions for a running Hermes provider", () => { + expect(resolveRunningPrimaryActionMode({ isRunning: true, allowSendWhileRunning: true })).toBe( + "steer", + ); + expect( + shouldUseCollapsedHermesRunningActions({ + isRunning: true, + allowSendWhileRunning: true, + }), + ).toBe(true); + }); + + it("does not replace collapsed actions for idle or ordinary providers", () => { + expect( + shouldUseCollapsedHermesRunningActions({ + isRunning: false, + allowSendWhileRunning: true, + }), + ).toBe(false); + expect( + shouldUseCollapsedHermesRunningActions({ + isRunning: true, + allowSendWhileRunning: false, + }), + ).toBe(false); + }); +}); describe("formatPendingPrimaryActionLabel", () => { it("returns 'Submitting...' while responding", () => { diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.tsx index 3e664e226b7..f96b00d886c 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.tsx @@ -18,6 +18,7 @@ interface ComposerPrimaryActionsProps { compact: boolean; pendingAction: PendingActionState | null; isRunning: boolean; + allowSendWhileRunning?: boolean; showPlanFollowUpPrompt: boolean; promptHasText: boolean; isSendBusy: boolean; @@ -49,6 +50,21 @@ export const formatPendingPrimaryActionLabel = (input: { return input.questionIndex > 0 ? "Submit answers" : "Submit answer"; }; +export function resolveRunningPrimaryActionMode(input: { + readonly isRunning: boolean; + readonly allowSendWhileRunning: boolean; +}): "idle" | "stop" | "steer" { + if (!input.isRunning) return "idle"; + return input.allowSendWhileRunning ? "steer" : "stop"; +} + +export function shouldUseCollapsedHermesRunningActions(input: { + readonly isRunning: boolean; + readonly allowSendWhileRunning: boolean; +}): boolean { + return resolveRunningPrimaryActionMode(input) === "steer"; +} + const preventPointerFocus: PointerEventHandler = (event) => { event.preventDefault(); }; @@ -57,6 +73,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ compact, pendingAction, isRunning, + allowSendWhileRunning = false, showPlanFollowUpPrompt, promptHasText, isSendBusy, @@ -125,7 +142,8 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ ); } - if (isRunning) { + const runningMode = resolveRunningPrimaryActionMode({ isRunning, allowSendWhileRunning }); + if (runningMode === "stop") { return ( + + + ); + } + if (showPlanFollowUpPrompt) { if (promptHasText) { return ( diff --git a/apps/web/src/components/chat/providerIconUtils.ts b/apps/web/src/components/chat/providerIconUtils.ts index f9e7a700716..00dc726e70d 100644 --- a/apps/web/src/components/chat/providerIconUtils.ts +++ b/apps/web/src/components/chat/providerIconUtils.ts @@ -1,6 +1,7 @@ import { ProviderDriverKind } from "@t3tools/contracts"; import { ClaudeAI, CursorIcon, GrokIcon, Icon, OpenAI, OpenCodeIcon } from "../Icons"; import { PROVIDER_OPTIONS } from "../../session-logic"; +import { HermesIcon } from "../HermesIcon"; export const PROVIDER_ICON_BY_PROVIDER: Partial> = { [ProviderDriverKind.make("codex")]: OpenAI, @@ -8,6 +9,7 @@ export const PROVIDER_ICON_BY_PROVIDER: Partial [ProviderDriverKind.make("opencode")]: OpenCodeIcon, [ProviderDriverKind.make("cursor")]: CursorIcon, [ProviderDriverKind.make("grok")]: GrokIcon, + [ProviderDriverKind.make("hermes")]: HermesIcon, }; function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is { diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.logic.ts b/apps/web/src/components/settings/AddProviderInstanceDialog.logic.ts index fdffa9a190e..43f3c30e670 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.logic.ts +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.logic.ts @@ -1,11 +1,50 @@ +import type { ProviderDriverKind } from "@t3tools/contracts"; + export type WizardNavigation = | { readonly kind: "navigate"; readonly step: number } | { readonly kind: "blocked"; readonly step: number; readonly error: string }; const IDENTITY_STEP = 1; +const INSTANCE_ID_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/; export const ADD_PROVIDER_WIZARD_STEPS = ["Driver", "Identity", "Config"] as const; +export function isOwnedHermesEnrollmentRetry(input: { + readonly driver: ProviderDriverKind; + readonly instanceId: string; + readonly createdHermesInstanceId: string | null; +}): boolean { + return ( + input.driver === "hermes" && + input.createdHermesInstanceId !== null && + input.createdHermesInstanceId === input.instanceId + ); +} + +export function validateProviderInstanceIdForWizard(input: { + readonly driver: ProviderDriverKind; + readonly instanceId: string; + readonly existingIds: ReadonlySet; + readonly createdHermesInstanceId: string | null; +}): string | null { + if (input.instanceId.length === 0) return "Instance ID is required."; + if (input.instanceId.length > 64) return "Instance ID must be 64 characters or fewer."; + if (!INSTANCE_ID_PATTERN.test(input.instanceId)) { + return "Instance ID must start with a letter and use only letters, digits, '-', or '_'."; + } + if ( + input.existingIds.has(input.instanceId) && + !isOwnedHermesEnrollmentRetry({ + driver: input.driver, + instanceId: input.instanceId, + createdHermesInstanceId: input.createdHermesInstanceId, + }) + ) { + return `An instance named '${input.instanceId}' already exists.`; + } + return null; +} + /** * Resolve navigation within the add-provider wizard. * diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.test.ts b/apps/web/src/components/settings/AddProviderInstanceDialog.test.ts index 594d2e4537e..ae5d613560f 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.test.ts +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from "vite-plus/test"; -import { resolveWizardNavigation } from "./AddProviderInstanceDialog.logic"; +import { ProviderDriverKind } from "@t3tools/contracts"; + +import { + isOwnedHermesEnrollmentRetry, + resolveWizardNavigation, + validateProviderInstanceIdForWizard, +} from "./AddProviderInstanceDialog.logic"; describe("resolveWizardNavigation", () => { const invalidId = { instanceIdError: "Instance ID is required." }; @@ -42,3 +48,52 @@ describe("resolveWizardNavigation", () => { expect(resolveWizardNavigation(0, -1, 3, invalidId)).toEqual({ kind: "navigate", step: 0 }); }); }); + +describe("Hermes enrollment retry ownership", () => { + const existingIds = new Set(["hermes-research", "hermes-existing"]); + + it("allows retrying enrollment for the exact Hermes instance created by this wizard", () => { + expect( + isOwnedHermesEnrollmentRetry({ + driver: ProviderDriverKind.make("hermes"), + instanceId: "hermes-research", + createdHermesInstanceId: "hermes-research", + }), + ).toBe(true); + expect( + validateProviderInstanceIdForWizard({ + driver: ProviderDriverKind.make("hermes"), + instanceId: "hermes-research", + existingIds, + createdHermesInstanceId: "hermes-research", + }), + ).toBeNull(); + }); + + it("still rejects arbitrary existing instances and non-Hermes reuse", () => { + expect( + validateProviderInstanceIdForWizard({ + driver: ProviderDriverKind.make("hermes"), + instanceId: "hermes-existing", + existingIds, + createdHermesInstanceId: "hermes-research", + }), + ).toContain("already exists"); + expect( + validateProviderInstanceIdForWizard({ + driver: ProviderDriverKind.make("codex"), + instanceId: "hermes-research", + existingIds, + createdHermesInstanceId: "hermes-research", + }), + ).toContain("already exists"); + expect( + validateProviderInstanceIdForWizard({ + driver: ProviderDriverKind.make("hermes"), + instanceId: "hermes-research", + existingIds, + createdHermesInstanceId: null, + }), + ).toContain("already exists"); + }); +}); diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx index a6da37c1551..9d57bf533ab 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx @@ -1,14 +1,17 @@ "use client"; import { Radio as RadioPrimitive } from "@base-ui/react/radio"; -import { CheckIcon } from "lucide-react"; -import { useMemo, useState } from "react"; +import { CheckIcon, CopyIcon, LoaderIcon } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; import { + type HermesGatewayEnrollmentResult, ProviderInstanceId, ProviderDriverKind, type ProviderInstanceConfig, } from "@t3tools/contracts"; +import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings"; import { cn } from "../../lib/utils"; import { normalizeProviderAccentColor } from "../../providerInstances"; @@ -31,10 +34,20 @@ import { ProviderSettingsForm, deriveProviderSettingsFields } from "./ProviderSe import { AnimatedHeight } from "../AnimatedHeight"; import { ADD_PROVIDER_WIZARD_STEPS, + isOwnedHermesEnrollmentRetry, resolveWizardNavigation, type WizardNavigation, + validateProviderInstanceIdForWizard, } from "./AddProviderInstanceDialog.logic"; import { AddProviderInstanceWizardSteps } from "./AddProviderInstanceWizardSteps"; +import { usePrimaryEnvironment } from "../../state/environments"; +import { serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { + defaultHermesConnectorUrl, + formatHermesLastConnected, + messageFromUnknownError, +} from "./HermesGatewayInstanceSection.logic"; const PROVIDER_ACCENT_SWATCHES = [ "#2563eb", @@ -63,10 +76,12 @@ function slugifyLabel(value: string): string { function deriveInstanceId(driver: ProviderDriverKind, label: string): string { const slug = slugifyLabel(label); + if (driver === "hermes") { + return slug ? `hermes-${slug.replaceAll("_", "-")}` : ""; + } return slug ? `${driver}_${slug}` : ""; } -const INSTANCE_ID_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/; const DEFAULT_DRIVER_KIND = ProviderDriverKind.make("codex"); const DEFAULT_DRIVER_OPTION = DRIVER_OPTIONS[0]!; const EMPTY_CONFIG_DRAFT: Record = {}; @@ -99,21 +114,6 @@ const COMING_SOON_DRIVER_OPTIONS: readonly ComingSoonDriverOption[] = [ }, ]; -/** - * Validate an instance id against the same slug rules the server applies in - * `ProviderInstanceId` (see `packages/contracts/src/providerInstance.ts`). - * Returns a user-facing error string, or `null` if valid. - */ -function validateInstanceId(id: string, existing: ReadonlySet): string | null { - if (id.length === 0) return "Instance ID is required."; - if (id.length > 64) return "Instance ID must be 64 characters or fewer."; - if (!INSTANCE_ID_PATTERN.test(id)) { - return "Instance ID must start with a letter and use only letters, digits, '-', or '_'."; - } - if (existing.has(id)) return `An instance named '${id}' already exists.`; - return null; -} - interface AddProviderInstanceDialogProps { open: boolean; onOpenChange: (open: boolean) => void; @@ -122,6 +122,13 @@ interface AddProviderInstanceDialogProps { export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderInstanceDialogProps) { const settings = usePrimarySettings(); const updateSettings = useUpdatePrimarySettings(); + const environmentId = usePrimaryEnvironment()?.environmentId ?? null; + const updateServerSettings = useAtomCommand(serverEnvironment.updateSettings, { + reportFailure: false, + }); + const createHermesEnrollment = useAtomCommand(serverEnvironment.hermesGatewayCreateEnrollment, { + reportFailure: false, + }); const [wizardStep, setWizardStep] = useState(0); const [driver, setDriver] = useState(DEFAULT_DRIVER_KIND); @@ -134,6 +141,37 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns // Errors are suppressed until the user has tried to submit once. After that // they update live so fixing the problem clears the message in place. const [hasAttemptedSubmit, setHasAttemptedSubmit] = useState(false); + const [hermesConnectorUrl, setHermesConnectorUrl] = useState(() => + typeof window === "undefined" + ? "http://localhost/api/hermes-gateway/ws" + : defaultHermesConnectorUrl(window.location.origin), + ); + const [hermesEnrollment, setHermesEnrollment] = useState( + null, + ); + const [saveError, setSaveError] = useState(null); + const [isSaving, setIsSaving] = useState(false); + const [createdHermesIdentity, setCreatedHermesIdentity] = useState<{ + readonly instanceId: string; + readonly nickname: string; + } | null>(null); + const { copyToClipboard, isCopied } = useCopyToClipboard({ + target: "Hermes enrollment command", + onCopy: () => + toastManager.add({ + type: "success", + title: "Hermes command copied", + description: "Run it in the terminal where Hermes is installed.", + }), + }); + + useEffect(() => { + if (open) return; + setHermesEnrollment(null); + setSaveError(null); + setIsSaving(false); + setCreatedHermesIdentity(null); + }, [open]); const existingIds = useMemo( () => new Set(Object.keys(settings.providerInstances ?? {})), @@ -146,7 +184,15 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns () => deriveProviderSettingsFields(driverOption), [driverOption], ); - const instanceIdError = validateInstanceId(instanceId, existingIds); + const instanceIdError = + driver === "hermes" && label.trim().length === 0 + ? "Hermes nickname is required." + : validateProviderInstanceIdForWizard({ + driver, + instanceId, + existingIds, + createdHermesInstanceId: createdHermesIdentity?.instanceId ?? null, + }); const showInstanceIdError = hasAttemptedSubmit && instanceIdError !== null; const previewLabel = label.trim() || `${driverOption.label} Workspace`; const wizardStepSummaries = [driverOption.label, previewLabel, null] as const; @@ -179,7 +225,7 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns ); }; - const handleSave = () => { + const handleSave = async () => { setHasAttemptedSubmit(true); if (instanceIdError !== null) return; @@ -195,15 +241,60 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns ...(hasConfig ? { config } : {}), }; // `ProviderInstanceId.make` revalidates the slug; we've already checked - // it via `validateInstanceId`, but going through the brand constructor + // it via `validateProviderInstanceIdForWizard`, but going through the brand constructor // keeps the type boundary honest and guards against any future drift in // the slug rules. const brandedId = ProviderInstanceId.make(instanceId); + const isOwnedHermesRetry = isOwnedHermesEnrollmentRetry({ + driver, + instanceId, + createdHermesInstanceId: createdHermesIdentity?.instanceId ?? null, + }); const nextMap = { ...settings.providerInstances, [brandedId]: nextInstance, }; try { + setIsSaving(true); + setSaveError(null); + if (driver === "hermes") { + if (environmentId === null) { + throw new Error("Connect this browser to a T3 server before pairing Hermes."); + } + if (!isOwnedHermesRetry) { + const settingsResult = await updateServerSettings({ + environmentId, + input: { patch: { providerInstances: nextMap } }, + }); + if (settingsResult._tag === "Failure") { + throw squashAtomCommandFailure(settingsResult); + } + setCreatedHermesIdentity({ + instanceId, + nickname: label.trim(), + }); + } + const enrollmentResult = await createHermesEnrollment({ + environmentId, + input: { + instanceId: brandedId, + nickname: createdHermesIdentity?.nickname ?? label.trim(), + connectorUrl: hermesConnectorUrl, + }, + }); + if (enrollmentResult._tag === "Failure") { + throw squashAtomCommandFailure(enrollmentResult); + } + setHermesEnrollment(enrollmentResult.value); + toastManager.add({ + type: "success", + title: "Hermes instance added", + description: "Run the one-time command to connect it.", + }); + setIsSaving(false); + return; + } + updateSettings({ providerInstances: nextMap }); toastManager.add({ type: "success", @@ -212,11 +303,15 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns }); onOpenChange(false); } catch (error) { + const message = messageFromUnknownError(error); + setSaveError(message); toastManager.add({ type: "error", title: "Could not add provider instance", - description: error instanceof Error ? error.message : "Update failed.", + description: message, }); + } finally { + setIsSaving(false); } }; @@ -230,203 +325,280 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns Configure an additional provider instance — for example, a second Codex install pointed at a different workspace. - + {hermesEnrollment === null && createdHermesIdentity === null ? ( + + ) : null}
- -
-
- Driver + {hermesEnrollment ? ( +
+
+

Connect Hermes

+

+ This one-time command expires{" "} + {formatHermesLastConnected(hermesEnrollment.expiresAt)}. +

- setDriver(ProviderDriverKind.make(value))} - aria-labelledby="add-instance-driver-label" - className="grid grid-cols-1 gap-2 sm:grid-cols-2" - > - {DRIVER_OPTIONS.map((option) => { - const IconComponent = option.icon; - return ( - - - - {option.label} - - + + {hermesEnrollment.command} + + +
+

+ The persistent gateway credential is delivered directly to the plugin and is never + shown in T3 Code. +

+
+ ) : ( + +
+
+ Driver +
+ setDriver(ProviderDriverKind.make(value))} + aria-labelledby="add-instance-driver-label" + className="grid grid-cols-1 gap-2 sm:grid-cols-2" + > + {DRIVER_OPTIONS.map((option) => { + const IconComponent = option.icon; + return ( + - - - {option.badgeLabel ? ( + + + {option.label} + + + + + {option.badgeLabel ? ( + + {option.badgeLabel} + + ) : null} + + ); + })} + {COMING_SOON_DRIVER_OPTIONS.map((option) => { + const IconComponent = option.icon; + return ( + + + + {option.label} + - {option.badgeLabel} + Coming Soon - ) : null} - - ); - })} - {COMING_SOON_DRIVER_OPTIONS.map((option) => { - const IconComponent = option.icon; - return ( - - - - {option.label} - - - Coming Soon - - - ); - })} - -
- - + + ); + })} + +
- -
- Accent color -
- setAccentColor(event.target.value)} - aria-label="Provider instance accent color" - className="h-8 w-10 cursor-pointer rounded-xl border border-input bg-background p-0.5" +
- {accentColor ? ( - - ) : null} + + Optional marker shown in the picker. +
- - Optional marker shown in the picker. - -
- {driverSettingsFields.length > 0 ? ( -
- -
- ) : wizardStep === 2 ? ( -
-

- This driver has no required configuration. You can add the instance now. + {driver === "hermes" && wizardStep === 2 ? ( + + ) : driverSettingsFields.length > 0 ? ( +

+ +
+ ) : wizardStep === 2 ? ( +
+

+ This driver has no required configuration. You can add the instance now. +

+
+ ) : null} + {saveError ?

{saveError}

: null} + {createdHermesIdentity ? ( +

+ The Hermes instance was added. Retry enrollment for this exact instance, or + close this dialog and finish pairing from its Settings card.

-
- ) : null} - + ) : null} + + )} - - {wizardStep < ADD_PROVIDER_WIZARD_STEPS.length - 1 ? ( + {hermesEnrollment ? ( + + ) : ( + + )} + {hermesEnrollment ? null : wizardStep < ADD_PROVIDER_WIZARD_STEPS.length - 1 ? ( ) : ( - )} diff --git a/apps/web/src/components/settings/HermesGatewayInstanceSection.logic.test.ts b/apps/web/src/components/settings/HermesGatewayInstanceSection.logic.test.ts new file mode 100644 index 00000000000..4e4b6f4279f --- /dev/null +++ b/apps/web/src/components/settings/HermesGatewayInstanceSection.logic.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + defaultHermesConnectorUrl, + formatHermesLastConnected, + hermesGatewayStatusLabel, + messageFromUnknownError, + shouldApplyHermesConnectorStatusUrl, +} from "./HermesGatewayInstanceSection.logic"; + +describe("Hermes gateway settings logic", () => { + it("uses the current origin with the gateway WebSocket route", () => { + expect(defaultHermesConnectorUrl("https://siva.davis7.space:3774/settings")).toBe( + "https://siva.davis7.space:3774/api/hermes-gateway/ws", + ); + }); + + it("provides useful labels for every connection state", () => { + expect( + ["offline", "connecting", "connected", "upgrade-required", "revoked"].map((status) => + hermesGatewayStatusLabel(status as never), + ), + ).toEqual(["Offline", "Connecting", "Connected", "Upgrade required", "Revoked"]); + }); + + it("keeps invalid server timestamps readable and extracts structured messages", () => { + expect(formatHermesLastConnected(null)).toBe("Never"); + expect(formatHermesLastConnected("not-a-date")).toBe("not-a-date"); + expect(messageFromUnknownError({ message: "Nickname is already used." })).toBe( + "Nickname is already used.", + ); + }); + + it("does not replace an in-progress connector URL edit during status polling", () => { + expect(shouldApplyHermesConnectorStatusUrl(false)).toBe(true); + expect(shouldApplyHermesConnectorStatusUrl(true)).toBe(false); + }); +}); diff --git a/apps/web/src/components/settings/HermesGatewayInstanceSection.logic.ts b/apps/web/src/components/settings/HermesGatewayInstanceSection.logic.ts new file mode 100644 index 00000000000..65c84358639 --- /dev/null +++ b/apps/web/src/components/settings/HermesGatewayInstanceSection.logic.ts @@ -0,0 +1,46 @@ +import type { HermesGatewayConnectionState } from "@t3tools/contracts"; + +export const HERMES_GATEWAY_SOCKET_PATH = "/api/hermes-gateway/ws"; + +export function defaultHermesConnectorUrl(origin: string): string { + return new URL(HERMES_GATEWAY_SOCKET_PATH, origin).toString(); +} + +export function shouldApplyHermesConnectorStatusUrl(hasLocalEdits: boolean): boolean { + return !hasLocalEdits; +} + +export function hermesGatewayStatusLabel(status: HermesGatewayConnectionState): string { + switch (status) { + case "connected": + return "Connected"; + case "connecting": + return "Connecting"; + case "upgrade-required": + return "Upgrade required"; + case "revoked": + return "Revoked"; + case "offline": + return "Offline"; + } +} + +export function formatHermesLastConnected(value: string | null): string { + if (value === null) return "Never"; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? value : date.toLocaleString(); +} + +export function messageFromUnknownError(error: unknown): string { + if (error instanceof Error && error.message.trim()) return error.message; + if ( + typeof error === "object" && + error !== null && + "message" in error && + typeof error.message === "string" && + error.message.trim() + ) { + return error.message; + } + return "The Hermes gateway request failed."; +} diff --git a/apps/web/src/components/settings/HermesGatewayInstanceSection.tsx b/apps/web/src/components/settings/HermesGatewayInstanceSection.tsx new file mode 100644 index 00000000000..30480d2ef82 --- /dev/null +++ b/apps/web/src/components/settings/HermesGatewayInstanceSection.tsx @@ -0,0 +1,276 @@ +"use client"; + +import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import { + type HermesGatewayConnectionState, + type HermesGatewayEnrollmentResult, + type HermesGatewayInstanceStatus, + type ProviderInstanceId, +} from "@t3tools/contracts"; +import { CheckIcon, CopyIcon, LoaderIcon, RefreshCwIcon, UnplugIcon } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; + +import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; +import { usePrimaryEnvironment } from "../../state/environments"; +import { serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { Badge } from "../ui/badge"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; +import { toastManager } from "../ui/toast"; +import { + defaultHermesConnectorUrl, + formatHermesLastConnected, + hermesGatewayStatusLabel, + messageFromUnknownError, + shouldApplyHermesConnectorStatusUrl, +} from "./HermesGatewayInstanceSection.logic"; + +const badgeVariantByStatus: Record< + HermesGatewayConnectionState, + "success" | "warning" | "error" | "secondary" +> = { + connected: "success", + connecting: "warning", + offline: "secondary", + "upgrade-required": "warning", + revoked: "error", +}; + +function browserDefaultConnectorUrl() { + if (typeof window === "undefined") return "http://localhost/api/hermes-gateway/ws"; + return defaultHermesConnectorUrl(window.location.origin); +} + +export function HermesGatewayInstanceSection(props: { + readonly instanceId: ProviderInstanceId; + readonly nickname: string; +}) { + const environmentId = usePrimaryEnvironment()?.environmentId ?? null; + const getStatus = useAtomCommand(serverEnvironment.hermesGatewayGetInstanceStatus, { + reportFailure: false, + }); + const createEnrollment = useAtomCommand(serverEnvironment.hermesGatewayCreateEnrollment, { + reportFailure: false, + }); + const revokeInstance = useAtomCommand(serverEnvironment.hermesGatewayRevokeInstance, { + reportFailure: false, + }); + const [connectorUrl, setConnectorUrl] = useState(browserDefaultConnectorUrl); + const [status, setStatus] = useState(null); + const [enrollment, setEnrollment] = useState(null); + const [error, setError] = useState(null); + const [pendingAction, setPendingAction] = useState<"status" | "enroll" | "revoke" | null>(null); + const connectorUrlHasLocalEditsRef = useRef(false); + const { copyToClipboard, isCopied } = useCopyToClipboard({ + target: "Hermes enrollment command", + onCopy: () => { + toastManager.add({ + type: "success", + title: "Hermes command copied", + description: "Run it in the terminal where Hermes is installed.", + }); + }, + }); + + const refreshStatus = useCallback( + async (quiet = false) => { + if (environmentId === null) return; + if (!quiet) setPendingAction("status"); + const result = await getStatus({ + environmentId, + input: { instanceId: props.instanceId }, + }); + if (result._tag === "Success") { + setStatus(result.value); + if (shouldApplyHermesConnectorStatusUrl(connectorUrlHasLocalEditsRef.current)) { + setConnectorUrl(result.value.connectorUrl); + } + setError(null); + } else if (!quiet) { + setError(messageFromUnknownError(squashAtomCommandFailure(result))); + } + if (!quiet) setPendingAction(null); + }, + [environmentId, getStatus, props.instanceId], + ); + + useEffect(() => { + void refreshStatus(); + const interval = window.setInterval(() => void refreshStatus(true), 5_000); + return () => window.clearInterval(interval); + }, [refreshStatus]); + + const handleCreateEnrollment = async () => { + if (environmentId === null) { + setError("Connect this browser to a T3 server before pairing Hermes."); + return; + } + setPendingAction("enroll"); + setError(null); + const result = await createEnrollment({ + environmentId, + input: { + instanceId: props.instanceId, + nickname: props.nickname, + connectorUrl, + }, + }); + if (result._tag === "Success") { + connectorUrlHasLocalEditsRef.current = false; + setConnectorUrl(result.value.connectorUrl); + setEnrollment(result.value); + setStatus((current) => + current + ? { ...current, connectorUrl: result.value.connectorUrl, status: "offline" } + : current, + ); + } else { + setError(messageFromUnknownError(squashAtomCommandFailure(result))); + } + setPendingAction(null); + }; + + const handleRevoke = async () => { + if (environmentId === null) return; + setPendingAction("revoke"); + setError(null); + const result = await revokeInstance({ + environmentId, + input: { instanceId: props.instanceId }, + }); + if (result._tag === "Success") { + setStatus(result.value); + setEnrollment(null); + } else { + setError(messageFromUnknownError(squashAtomCommandFailure(result))); + } + setPendingAction(null); + }; + + const connectionState = status?.status ?? "offline"; + const isBusy = pendingAction !== null; + + return ( +
+
+
+

Hermes gateway

+

+ Pair an already-running Hermes process over any reachable network. +

+
+
+ + {hermesGatewayStatusLabel(connectionState)} + + +
+
+ + + + {status ? ( +
+
+
Last connected
+
{formatHermesLastConnected(status.lastConnectedAt)}
+
+
+
Active sessions
+
{status.activeSessionCount}
+
+
+
Protocol
+
{status.protocolVersion ?? "Unknown"}
+
+
+
Plugin
+
{status.pluginVersion ?? "Unknown"}
+
+
+
Hermes
+
{status.hermesVersion ?? "Unknown"}
+
+
+ ) : null} + + {enrollment ? ( +
+
+

One-time enrollment command

+

+ Expires {formatHermesLastConnected(enrollment.expiresAt)}. The long-lived credential + is issued directly to the plugin and is never shown here. +

+
+
+ + {enrollment.command} + + +
+
+ ) : null} + + {error ?

{error}

: null} + +
+ {connectionState !== "revoked" ? ( + + ) : null} + +
+
+ ); +} diff --git a/apps/web/src/components/settings/ProviderInstanceCard.test.ts b/apps/web/src/components/settings/ProviderInstanceCard.test.ts index 051045b030c..87b6017cd54 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.test.ts +++ b/apps/web/src/components/settings/ProviderInstanceCard.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from "vite-plus/test"; import type { ServerProviderModel } from "@t3tools/contracts"; -import { deriveProviderModelsForDisplay } from "./ProviderInstanceCard"; +import { + deriveProviderModelsForDisplay, + isProviderInstanceDisplayNameEditable, +} from "./ProviderInstanceCard"; describe("deriveProviderModelsForDisplay", () => { it("uses current config custom models instead of stale live custom rows", () => { @@ -34,3 +37,11 @@ describe("deriveProviderModelsForDisplay", () => { ).toEqual(["server-model", "kept-custom"]); }); }); + +describe("isProviderInstanceDisplayNameEditable", () => { + it("keeps Hermes nicknames immutable without changing other providers", () => { + expect(isProviderInstanceDisplayNameEditable("hermes")).toBe(false); + expect(isProviderInstanceDisplayNameEditable("codex")).toBe(true); + expect(isProviderInstanceDisplayNameEditable("claudeAgent")).toBe(true); + }); +}); diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index 2a691943df4..b24c16b0ff7 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -13,6 +13,7 @@ import { import * as Arr from "effect/Array"; import * as Result from "effect/Result"; import { useState, type ReactNode } from "react"; +import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { isProviderDriverKind, type ProviderInstanceConfig, @@ -26,6 +27,9 @@ import { import { cn } from "../../lib/utils"; import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; import { normalizeProviderAccentColor } from "../../providerInstances"; +import { usePrimaryEnvironment } from "../../state/environments"; +import { serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; import { Checkbox } from "../ui/checkbox"; @@ -43,6 +47,8 @@ import { ProviderModelsSection } from "./ProviderModelsSection"; import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; import { ProviderAccentColorPicker } from "./ProviderAccentColorPicker"; import { RedactedSensitiveText } from "./RedactedSensitiveText"; +import { HermesGatewayInstanceSection } from "./HermesGatewayInstanceSection"; +import { messageFromUnknownError } from "./HermesGatewayInstanceSection.logic"; import { getProviderVersionAdvisoryPresentation, PROVIDER_STATUS_STYLES, @@ -131,6 +137,10 @@ export function deriveProviderModelsForDisplay(input: { return [...serverModels, ...customModels]; } +export function isProviderInstanceDisplayNameEditable(driver: string): boolean { + return driver !== "hermes"; +} + function ProviderAuthEmail(props: { readonly email: string | undefined; readonly prefix?: string; @@ -394,6 +404,11 @@ export function ProviderInstanceCard({ onRunUpdate, isUpdating = false, }: ProviderInstanceCardProps) { + const environmentId = usePrimaryEnvironment()?.environmentId ?? null; + const revokeHermesInstance = useAtomCommand(serverEnvironment.hermesGatewayRevokeInstance, { + reportFailure: false, + }); + const [isDeleting, setIsDeleting] = useState(false); const enabled = instance.enabled ?? true; // The server-reported status wins when present; otherwise fall back to // "disabled"/"warning" based on the local `enabled` flag so the dot @@ -413,8 +428,21 @@ export function ProviderInstanceCard({ const versionAdvisory = getProviderVersionAdvisoryPresentation(liveProvider?.versionAdvisory); const updateCommand = versionAdvisory?.updateCommand ?? null; const FallbackIconComponent = driverOption?.icon; - const displayName = - instance.displayName?.trim() || driverOption?.label || String(instance.driver); + const configuredDisplayName = instance.displayName?.trim(); + const isHermes = instance.driver === "hermes"; + const hermesNickname = + configuredDisplayName?.replace(/^Hermes\s*[·:–—-]?\s*/u, "").trim() || + (instanceId === "hermes" + ? "Hermes" + : String(instanceId) + .replace(/^hermes[-_]?/u, "") + .replace(/[-_]+/g, " ") + .trim()); + const displayName = isHermes + ? instanceId === "hermes" || hermesNickname === "Hermes" + ? "Hermes" + : `Hermes · ${hermesNickname}` + : configuredDisplayName || driverOption?.label || String(instance.driver); const accentColor = normalizeProviderAccentColor(instance.accentColor); const { copyToClipboard } = useCopyToClipboard<{ providerName: string }>({ onCopy: ({ providerName }) => { @@ -501,6 +529,49 @@ export function ProviderInstanceCard({ ); }; + const handleDelete = async () => { + if (!onDelete || isDeleting) return; + if (!isHermes) { + onDelete(); + return; + } + if (environmentId === null) { + toastManager.add({ + type: "error", + title: "Could not delete Hermes instance", + description: "Reconnect this browser to the T3 server so its gateway can be revoked first.", + }); + return; + } + setIsDeleting(true); + const result = await revokeHermesInstance({ + environmentId, + input: { instanceId }, + }); + if (result._tag === "Success") { + onDelete(); + setIsDeleting(false); + return; + } + const failure = squashAtomCommandFailure(result); + const wasNeverEnrolled = + typeof failure === "object" && + failure !== null && + "code" in failure && + failure.code === "instance-not-found"; + if (wasNeverEnrolled) { + onDelete(); + setIsDeleting(false); + return; + } + toastManager.add({ + type: "error", + title: "Could not delete Hermes instance", + description: `${messageFromUnknownError(failure)} The instance was kept so a live gateway credential is not orphaned.`, + }); + setIsDeleting(false); + }; + const titleIconNode = driverKind ? ( void handleDelete()} aria-label={`Delete provider instance ${instanceId}`} > - + {isDeleting ? ( + + ) : ( + + )} } /> @@ -730,22 +806,38 @@ export function ProviderInstanceCard({
-
-
+ )}
-
- -
+ {isHermes ? ( + + ) : ( +
+ +
+ )} - {driverOption ? ( + {driverOption && !isHermes ? ( ) : null} - {driverOption !== undefined ? ( + {driverOption !== undefined && !isHermes ? ( - ) : ( + ) : driverOption === undefined ? (

This instance uses a driver ( @@ -797,7 +893,7 @@ export function ProviderInstanceCard({ edited from this surface.

- )} + ) : null}
diff --git a/apps/web/src/components/settings/providerDriverMeta.ts b/apps/web/src/components/settings/providerDriverMeta.ts index bfee6a8d680..5c174c5c37f 100644 --- a/apps/web/src/components/settings/providerDriverMeta.ts +++ b/apps/web/src/components/settings/providerDriverMeta.ts @@ -3,11 +3,13 @@ import { CodexSettings, CursorSettings, GrokSettings, + HermesSettings, OpenCodeSettings, ProviderDriverKind, } from "@t3tools/contracts"; import type * as Schema from "effect/Schema"; import { ClaudeAI, CursorIcon, GrokIcon, type Icon, OpenAI, OpenCodeIcon } from "../Icons"; +import { HermesIcon } from "../HermesIcon"; type ProviderSettingsSchema = { readonly fields: Readonly>; @@ -67,6 +69,12 @@ export const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = icon: OpenCodeIcon, settingsSchema: OpenCodeSettings, }, + { + value: ProviderDriverKind.make("hermes"), + label: "Hermes", + icon: HermesIcon, + settingsSchema: HermesSettings, + }, ]; export const PROVIDER_CLIENT_DEFINITION_BY_VALUE: Partial< diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index ec089d766cf..87b63fe97ed 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -64,7 +64,7 @@ function readInstanceCustomModels( } const legacyProviders = settings.providers as Record< string, - { readonly customModels: ReadonlyArray } | undefined + { readonly customModels?: ReadonlyArray } | undefined >; return legacyProviders[driverKind]?.customModels ?? []; } diff --git a/apps/web/src/providerInstances.test.ts b/apps/web/src/providerInstances.test.ts index 2d0d7bb0213..2ccc64dcab3 100644 --- a/apps/web/src/providerInstances.test.ts +++ b/apps/web/src/providerInstances.test.ts @@ -5,7 +5,10 @@ import { deriveProviderInstanceEntries, getDefaultProviderInstanceModel, isProviderInstancePickerReady, + isProviderInstanceComposerAvailable, + getProviderInstanceComposerUnavailableMessage, isProviderInstancePickerVisible, + resolveComposerProviderInstanceId, resolveDefaultProviderModelSelection, resolveSelectableProviderInstance, resolveProviderDriverKindForInstanceSelection, @@ -68,6 +71,66 @@ describe("isProviderInstancePickerReady", () => { }); }); +describe("Hermes composer availability", () => { + it("keeps an offline Hermes instance selected but marks it unsendable", () => { + const [entry] = deriveProviderInstanceEntries([ + provider({ + provider: ProviderDriverKind.make("hermes"), + instanceId: "hermes-research", + displayName: "Research", + status: "error", + }), + ]); + + expect(entry && isProviderInstanceComposerAvailable(entry)).toBe(false); + expect(getProviderInstanceComposerUnavailableMessage(entry)).toContain( + "Hermes · Research is offline", + ); + }); + + it("does not tighten legacy composer availability for other providers", () => { + const [entry] = deriveProviderInstanceEntries([ + provider({ + provider: ProviderDriverKind.make("codex"), + instanceId: "codex", + status: "error", + }), + ]); + + expect(entry && isProviderInstanceComposerAvailable(entry)).toBe(true); + }); + + it("preserves a missing bound Hermes instance instead of rebinding to another gateway", () => { + const missing = ProviderInstanceId.make("hermes-research"); + const connected = ProviderInstanceId.make("hermes-lab"); + const entries = deriveProviderInstanceEntries([ + provider({ + provider: ProviderDriverKind.make("hermes"), + instanceId: connected, + }), + ]); + + expect( + resolveComposerProviderInstanceId({ + entries, + draftInstanceId: null, + threadInstanceId: missing, + threadModelInstanceId: missing, + projectInstanceId: connected, + requestedDriverKind: ProviderDriverKind.make("hermes"), + lockedProvider: ProviderDriverKind.make("hermes"), + lockedContinuationGroupKey: null, + }), + ).toBe(missing); + expect( + getProviderInstanceComposerUnavailableMessage(undefined, { + driverKind: ProviderDriverKind.make("hermes"), + instanceId: missing, + }), + ).toContain("hermes-research"); + }); +}); + describe("isProviderInstancePickerVisible", () => { it("keeps enabled instances in the rail and removes disabled instances", () => { const [enabledEntry, disabledEntry] = deriveProviderInstanceEntries([ @@ -130,6 +193,32 @@ describe("deriveProviderInstanceEntries", () => { expect(entry?.driverKind).toBe("codex"); expect(entry?.isDefault).toBe(false); }); + + it("formats default and custom Hermes instance labels consistently", () => { + const entries = deriveProviderInstanceEntries([ + provider({ + provider: ProviderDriverKind.make("hermes"), + instanceId: "hermes", + displayName: "Hermes", + }), + provider({ + provider: ProviderDriverKind.make("hermes"), + instanceId: "hermes-research", + displayName: "Research", + }), + provider({ + provider: ProviderDriverKind.make("hermes"), + instanceId: "hermes-lab", + displayName: "Hermes · Lab", + }), + ]); + + expect(entries.map((entry) => entry.displayName)).toEqual([ + "Hermes", + "Hermes · Research", + "Hermes · Lab", + ]); + }); }); describe("resolveSelectableProviderInstance", () => { diff --git a/apps/web/src/providerInstances.ts b/apps/web/src/providerInstances.ts index 337e68d44d0..23457ed17bd 100644 --- a/apps/web/src/providerInstances.ts +++ b/apps/web/src/providerInstances.ts @@ -74,6 +74,31 @@ export function isProviderInstancePickerReady(entry: ProviderInstanceEntry): boo return entry.enabled && entry.isAvailable && entry.status === "ready"; } +/** + * Existing providers retain their historical composer behavior while Hermes + * requires a live gateway connection. This keeps an offline Hermes thread + * selected and readable without allowing a turn to be routed elsewhere. + */ +export function isProviderInstanceComposerAvailable(entry: ProviderInstanceEntry): boolean { + return entry.driverKind !== "hermes" || isProviderInstancePickerReady(entry); +} + +export function getProviderInstanceComposerUnavailableMessage( + entry: ProviderInstanceEntry | undefined, + missingSelection?: { + readonly driverKind: ProviderDriverKind; + readonly instanceId: ProviderInstanceId; + }, +): string { + if (entry?.driverKind === "hermes") { + return `${entry.displayName} is offline. Reconnect it in Settings to continue this thread.`; + } + if (missingSelection?.driverKind === "hermes") { + return `Hermes instance '${missingSelection.instanceId}' is unavailable. Restore it in Settings to continue this thread.`; + } + return "Enable a provider in Settings to send a message."; +} + /** Picker rails contain configured, enabled instances only. */ export function isProviderInstancePickerVisible(entry: ProviderInstanceEntry): boolean { return entry.enabled; @@ -139,6 +164,15 @@ function resolveInstanceDisplayName( ): string { const trimmedSnapshotName = snapshot.displayName?.trim(); const kindLabel = driverKindLabel(driverKind); + if (driverKind === "hermes") { + if (isDefault) return "Hermes"; + const candidate = + trimmedSnapshotName && trimmedSnapshotName !== kindLabel + ? trimmedSnapshotName + : humanizeInstanceId(instanceId).replace(/^Hermes(?:\s+|$)/u, ""); + const nickname = candidate.replace(/^Hermes\s*[·:–—-]\s*/u, "").trim(); + return nickname ? `Hermes · ${nickname}` : "Hermes"; + } if (trimmedSnapshotName && trimmedSnapshotName !== kindLabel) { return trimmedSnapshotName; } @@ -312,6 +346,66 @@ export function resolveSelectableProviderInstanceEntry( ); } +/** + * Resolve the instance used by the composer while preserving an existing + * Hermes thread's immutable gateway binding. Hermes sessions cannot be moved + * to another instance, so a missing or disabled bound instance remains the + * exact selection and lets the composer surface its unavailable state instead + * of silently routing the next message to another connected Hermes process. + */ +export function resolveComposerProviderInstanceId(input: { + readonly entries: ReadonlyArray; + readonly draftInstanceId: ProviderInstanceId | null; + readonly threadInstanceId: ProviderInstanceId | undefined; + readonly threadModelInstanceId: ProviderInstanceId | undefined; + readonly projectInstanceId: ProviderInstanceId | undefined; + readonly requestedDriverKind: ProviderDriverKind; + readonly lockedProvider: ProviderDriverKind | null; + readonly lockedContinuationGroupKey: string | null; +}): ProviderInstanceId { + const boundThreadInstanceId = input.threadInstanceId ?? input.threadModelInstanceId; + if (input.lockedProvider === "hermes" && boundThreadInstanceId) { + return boundThreadInstanceId; + } + + const candidates = [ + input.draftInstanceId, + input.threadInstanceId, + input.threadModelInstanceId, + input.projectInstanceId, + ]; + for (const candidate of candidates) { + if (!candidate) continue; + const match = input.entries.find( + (entry) => entry.instanceId === candidate && entry.enabled && entry.isAvailable, + ); + if (!match) continue; + if (input.lockedProvider && match.driverKind !== input.lockedProvider) continue; + if ( + input.lockedContinuationGroupKey && + match.continuationGroupKey !== input.lockedContinuationGroupKey + ) { + continue; + } + return match.instanceId; + } + + const compatibleEntries = input.entries.filter( + (entry) => + (!input.lockedProvider || entry.driverKind === input.lockedProvider) && + (!input.lockedContinuationGroupKey || + entry.continuationGroupKey === input.lockedContinuationGroupKey), + ); + const requestedDriverEntries = compatibleEntries.filter( + (entry) => entry.driverKind === input.requestedDriverKind, + ); + return ( + resolveSelectableProviderInstanceEntry(requestedDriverEntries, undefined)?.instanceId ?? + resolveSelectableProviderInstanceEntry(compatibleEntries, undefined)?.instanceId ?? + NO_PROVIDER_MODEL_SELECTION.instanceId + ); +} + /** * Resolve the routing key for a selection that may reference an instance * id that no longer exists (e.g. a persisted thread selection after the diff --git a/integrations/hermes-t3-gateway/COMPATIBILITY.md b/integrations/hermes-t3-gateway/COMPATIBILITY.md new file mode 100644 index 00000000000..c4a7db7ace3 --- /dev/null +++ b/integrations/hermes-t3-gateway/COMPATIBILITY.md @@ -0,0 +1,48 @@ +# Hermes event compatibility inventory + +The plugin deliberately uses only the public Hermes plugin and platform-adapter +surfaces audited at Hermes Agent commit +`3910ab28c0892fcf846fc61318d2fd15689eddf1`. + +## Mapped in V1 + +| Hermes surface | T3 gateway event | +| ----------------------------------------- | ---------------------------------------------- | +| Cumulative `send` / `edit_message` output | `content.delta` | +| Final stream edit | `item.completed`, `turn.completed` | +| `pre_tool_call` / `post_tool_call` hooks | Typed `item.started` / `item.completed` | +| Live adapter status text | Generic `unknown` activity item | +| `send_exec_approval` | `request.opened` / `request.resolved` | +| `send_clarify` | `user-input.requested` / `user-input.resolved` | +| `/steer` gateway command | `turn.steer` | +| Adapter interrupt event | `turn.interrupt` | + +## Known limitations + +- The platform adapter receives cumulative rendered text, not the underlying + token stream category. V1 maps it to `assistant_text`; Hermes reasoning, + plan, and command-output stream categories are not publicly exposed here. +- The delta contract has no replacement operation. Rare edits that revise + already-emitted text become a meaningful generic activity instead of + corrupting the T3 transcript. +- Hermes' documented tool hook surface exposes a `task_id`, tool name, + arguments, string result, and duration. The audited runtime additionally + supplies `session_id` and `tool_call_id`; V1 uses them when present and falls + back to the documented IDs for older versions. It projects only canonical, + whitelisted fields (command/cwd, file path, search query, image path, or MCP + server/operation); arbitrary arguments and raw results never cross the wire. +- Approval resolution is session-FIFO in Hermes. T3 request IDs identify the UI + prompt, then resolve the oldest matching Hermes approval for that session. +- The public `clarify` hook is a single question. The wire protocol supports an + array so richer structured input can be added without a protocol break. +- Hermes session completion has no dedicated platform-adapter callback. The + plugin uses the stream consumer's required `finalize=True` edit as the + authoritative completion boundary. +- Active `/steer` dispatch returns a textual Hermes control acknowledgement + through the normal platform `send(..., notify=True)` path. The plugin + captures that response in the originating steering request's async context, + suppresses it from the transcript, and acknowledges T3 only when the audited + Hermes success response begins with `⏩ Steer queued`. Unknown future response + shapes fail closed with `protocol.error` rather than completing the turn. +- Attachments are not accepted. They are the first planned post-stability + feature; the capability is reserved and fixed to `false` in protocol V1. diff --git a/integrations/hermes-t3-gateway/README.md b/integrations/hermes-t3-gateway/README.md new file mode 100644 index 00000000000..f2b9557c341 --- /dev/null +++ b/integrations/hermes-t3-gateway/README.md @@ -0,0 +1,71 @@ +# Hermes T3 Code Gateway + +Experimental Hermes platform plugin for connecting one already-running Hermes +process to T3 Code. The plugin makes an outbound WebSocket connection; Hermes +does not need to listen on a public port. + +Each T3 thread maps deterministically to one Hermes gateway session. A new T3 +thread creates a new session identity, later messages use the same identity, +active-turn messages use Hermes' native `/steer` path, and stopping a turn does +not delete its transcript. + +## Install from this repository + +Copy or symlink this directory into the active Hermes profile: + +```bash +mkdir -p ~/.hermes/plugins +ln -s /path/to/t3code/integrations/hermes-t3-gateway \ + ~/.hermes/plugins/hermes-t3-gateway +hermes plugins enable hermes-t3-gateway +``` + +In T3 Code, open the Hermes instance settings, choose **Add Hermes**, enter a +unique nickname, and copy the generated enrollment command. It has this shape: + +```bash +hermes t3 connect \ + --url https://siva.davis7.space: \ + --token +``` + +`--url` accepts an HTTP(S) browser origin or an explicit WS(S) URL. The command +normalizes it to `/api/hermes-gateway/ws`, enrolls over the first authenticated +`connection.hello` frame, and saves these values with Hermes' +profile-aware `save_env_value` helper: + +- `HERMES_T3_GATEWAY_URL` +- `HERMES_T3_GATEWAY_INSTANCE_ID` +- `HERMES_T3_GATEWAY_CREDENTIAL` +- `HERMES_T3_GATEWAY_NICKNAME` + +The long-lived credential is never printed. Restart `hermes gateway` after +enrollment. `hermes t3 status` reports the local enrollment without revealing +the credential. + +## V1 scope + +- Text input and live assistant streaming +- Multiple concurrent Hermes sessions in one process +- Active-turn steering and interrupt +- Dangerous-command approvals +- Structured `clarify` questions +- Tool lifecycle activity through Hermes plugin hooks +- Reconnect with bounded backoff +- Version-incompatible and revoked credentials fail closed + +Attachments intentionally advertise `false`. Adding bounded image/file input is +the first post-stability feature and should not reuse arbitrary raw payloads. + +See [COMPATIBILITY.md](./COMPATIBILITY.md) for public Hermes extension-surface +limitations. + +## Tests + +The pure protocol and transport tests do not require a live Hermes or T3 server: + +```bash +python -m unittest discover \ + integrations/hermes-t3-gateway/tests \ + -p 'test_*.py' +``` diff --git a/integrations/hermes-t3-gateway/__init__.py b/integrations/hermes-t3-gateway/__init__.py new file mode 100644 index 00000000000..b0acafb2c30 --- /dev/null +++ b/integrations/hermes-t3-gateway/__init__.py @@ -0,0 +1,77 @@ +"""T3 Code gateway plugin registration for Hermes Agent.""" +# ruff: noqa: N999 - Hermes loads hyphenated plugin directories dynamically. + +from __future__ import annotations + +from .adapter import ( + T3PlatformAdapter, + check_requirements, + env_enablement, + validate_config, +) +from .cli import register_cli, t3_command + + +def _pre_tool_call( + tool_name: str, + args: dict, + task_id: str, + **kwargs, +) -> None: + session_id = str(kwargs.get("session_id") or task_id) + tool_call_id = str(kwargs.get("tool_call_id") or "") + T3PlatformAdapter.route_tool_started(tool_name, args, session_id, tool_call_id) + + +def _post_tool_call( + tool_name: str, + args: dict, + result: str, + task_id: str, + duration_ms: int | None = None, + **kwargs, +) -> None: + del args + session_id = str(kwargs.get("session_id") or task_id) + tool_call_id = str(kwargs.get("tool_call_id") or "") + status = str(kwargs.get("status") or "") + T3PlatformAdapter.route_tool_completed( + tool_name, result, session_id, duration_ms, tool_call_id, status + ) + + +def register(ctx) -> None: + ctx.register_platform( + name="t3", + label="T3 Code", + adapter_factory=lambda config: T3PlatformAdapter(config), + check_fn=check_requirements, + validate_config=validate_config, + required_env=[ + "HERMES_T3_GATEWAY_URL", + "HERMES_T3_GATEWAY_INSTANCE_ID", + "HERMES_T3_GATEWAY_CREDENTIAL", + ], + env_enablement_fn=env_enablement, + max_message_length=120_000, + emoji="🔺", + pii_safe=True, + platform_hint=( + "You are chatting through T3 Code. Preserve normal Hermes behavior; " + "T3 renders streamed text, tool activity, approvals, and questions." + ), + ) + ctx.register_cli_command( + name="t3", + help="Pair and inspect the T3 Code gateway", + setup_fn=register_cli, + handler_fn=t3_command, + description=( + "Connect this Hermes process to a named T3 Code provider instance." + ), + ) + ctx.register_hook("pre_tool_call", _pre_tool_call) + ctx.register_hook("post_tool_call", _post_tool_call) + + +__all__ = ["register"] diff --git a/integrations/hermes-t3-gateway/adapter.py b/integrations/hermes-t3-gateway/adapter.py new file mode 100644 index 00000000000..42ad56fe0d5 --- /dev/null +++ b/integrations/hermes-t3-gateway/adapter.py @@ -0,0 +1,940 @@ +"""Hermes platform adapter that treats each T3 thread as one Hermes session.""" + +from __future__ import annotations + +import asyncio +import contextvars +import logging +import os +import threading +import uuid +import weakref +from dataclasses import dataclass, field +from typing import Any + +from gateway.config import Platform, PlatformConfig +from gateway.platforms.base import ( + BasePlatformAdapter, + MessageEvent, + MessageType, + SendResult, +) +from gateway.session import build_session_key + +from .cli import CREDENTIAL_ENV, INSTANCE_ID_ENV, NICKNAME_ENV, URL_ENV +from .connection import T3GatewayConnection, dependency_available +from .protocol import ( + canonical_tool_data, + canonical_tool_item_type, + frame, + iso_now, + item_id, + protocol_error, + validate_server_frame, +) + +logger = logging.getLogger(__name__) + + +def _hermes_version() -> str: + try: + from hermes_cli import __version__ + + return str(__version__) + except Exception: # noqa: BLE001 - version discovery must not block loading + return "unknown" + + +@dataclass +class _TurnState: + thread_id: str + session_id: str + turn_id: str + request_id: str + message_id: str = field(default_factory=lambda: str(uuid.uuid4())) + visible_text: str = "" + assistant_started: bool = False + tool_items: dict[str, str] = field(default_factory=dict) + generic_activity_id: str | None = None + generic_activity_detail: str | None = None + + +@dataclass +class _SteerControlResponse: + thread_id: str + request_id: str + messages: list[str] = field(default_factory=list) + + +_steer_control_response = contextvars.ContextVar[_SteerControlResponse | None]( + "hermes_t3_steer_control_response", + default=None, +) + + +class T3PlatformAdapter(BasePlatformAdapter): + """One process-level T3 connection serving many isolated thread sessions.""" + + supports_code_blocks = True + supports_status_text = True + REQUIRES_EDIT_FINALIZE = True + MAX_MESSAGE_LENGTH = 120_000 + _instances: weakref.WeakSet[T3PlatformAdapter] = weakref.WeakSet() + + def __init__(self, config: PlatformConfig): + super().__init__(config, Platform("t3")) + extra = config.extra or {} + self._url = str(extra.get("url") or os.environ.get(URL_ENV, "")).strip() + self._instance_id = str( + extra.get("instance_id") or os.environ.get(INSTANCE_ID_ENV, "") + ).strip() + self._credential = str( + extra.get("credential") or os.environ.get(CREDENTIAL_ENV, "") + ).strip() + self._nickname = str( + extra.get("nickname") or os.environ.get(NICKNAME_ENV, "") or "Hermes" + ).strip() + self._connection: T3GatewayConnection | None = None + self._event_loop: asyncio.AbstractEventLoop | None = None + self._sessions: dict[str, str] = {} + self._active_session_threads: set[str] = set() + self._thread_by_session: dict[str, str] = {} + self._active_turns: dict[str, _TurnState] = {} + self._approval_requests: dict[str, tuple[str, str]] = {} + self._user_input_requests: dict[str, tuple[str, str]] = {} + type(self)._instances.add(self) + + @property + def name(self) -> str: + return f"T3 Code ({self._nickname})" + + @property + def authorization_is_upstream(self) -> bool: + # The only source of inbound messages is T3's instance-authenticated + # socket. There is no separate Hermes-side user allowlist. + return True + + async def connect(self, *, is_reconnect: bool = False) -> bool: + del is_reconnect + if not (self._url and self._instance_id and self._credential): + self._set_fatal_error( + "t3_not_enrolled", + "Run `hermes t3 connect --url --token ` first.", + retryable=False, + ) + return False + self._event_loop = asyncio.get_running_loop() + self._connection = T3GatewayConnection( + url=self._url, + instance_id=self._instance_id, + credential=self._credential, + hermes_version=_hermes_version(), + on_message=self._handle_server_frame, + on_state=self._handle_connection_state, + ) + try: + connected = await self._connection.connect() + except Exception as exc: # noqa: BLE001 - transport supplies typed rejection details + self._set_fatal_error("t3_connection_rejected", str(exc), retryable=False) + return False + if connected: + self._mark_connected() + await self._send_status() + return connected + + async def disconnect(self) -> None: + if self._connection is not None: + await self._connection.disconnect() + self._connection = None + self._mark_disconnected() + + async def send( + self, + chat_id: str, + content: str, + reply_to: str | None = None, + metadata: dict[str, Any] | None = None, + ) -> SendResult: + del reply_to + captured = self._capture_steer_control_response(chat_id, content) + if captured is not None: + return captured + turn = self._active_turns.get(str(chat_id)) + if turn is None: + return SendResult(success=False, error="no active T3 turn") + try: + await self._emit_assistant_content(turn, content) + if bool((metadata or {}).get("notify")): + await self._complete_turn(turn) + return SendResult(success=True, message_id=turn.message_id) + except Exception as exc: # noqa: BLE001 - adapter send must return SendResult + return SendResult(success=False, error=str(exc)) + + async def edit_message( + self, + chat_id: str, + message_id: str, + content: str, + *, + finalize: bool = False, + metadata: dict[str, Any] | None = None, + ) -> SendResult: + del metadata + captured = self._capture_steer_control_response(chat_id, content) + if captured is not None: + return captured + turn = self._active_turns.get(str(chat_id)) + if turn is None: + return SendResult(success=False, error="no active T3 turn") + try: + await self._emit_assistant_content(turn, content) + if finalize: + await self._complete_turn(turn) + return SendResult(success=True, message_id=message_id) + except Exception as exc: # noqa: BLE001 - adapter edit must return SendResult + return SendResult(success=False, error=str(exc)) + + async def get_chat_info(self, chat_id: str) -> dict[str, Any]: + return {"name": f"T3 thread {chat_id}", "type": "dm"} + + async def send_typing( + self, chat_id: str, metadata: dict[str, Any] | None = None + ) -> None: + del metadata + turn = self._active_turns.get(str(chat_id)) + if turn is None: + return + status = getattr(self, "_status_text", {}).get(str(chat_id)) + if status: + await self._emit_generic_activity(turn, status) + + def set_status_text(self, chat_id: str, text: str | None) -> None: + super().set_status_text(chat_id, text) + if not text: + return + turn = self._active_turns.get(str(chat_id)) + if turn is not None: + self._schedule(self._emit_generic_activity(turn, text)) + + async def send_exec_approval( + self, + chat_id: str, + command: str, + session_key: str, + description: str, + metadata: dict[str, Any] | None = None, + **kwargs: Any, + ) -> SendResult: + del metadata, kwargs + turn = self._active_turns.get(str(chat_id)) + if turn is None: + return SendResult(success=False, error="no active T3 turn") + approval_id = str(uuid.uuid4()) + self._approval_requests[approval_id] = (session_key, turn.turn_id) + await self._send_frame( + frame( + "request.opened", + threadId=turn.thread_id, + sessionId=turn.session_id, + turnId=turn.turn_id, + requestId=approval_id, + requestType="command_execution_approval", + detail=description or "Hermes requests permission to run a command", + args={"command": command}, + ) + ) + return SendResult(success=True, message_id=approval_id) + + async def send_clarify( + self, + chat_id: str, + question: str, + choices: list[Any] | None, + clarify_id: str, + session_key: str, + metadata: dict[str, Any] | None = None, + ) -> SendResult: + del metadata + turn = self._active_turns.get(str(chat_id)) + if turn is None: + return SendResult(success=False, error="no active T3 turn") + options = [] + for choice in choices or []: + label = str(choice.get("label") if isinstance(choice, dict) else choice) + description = ( + str(choice.get("description") or label) + if isinstance(choice, dict) + else label + ) + options.append({"label": label, "description": description}) + self._user_input_requests[clarify_id] = (session_key, turn.turn_id) + await self._send_frame( + frame( + "user-input.requested", + threadId=turn.thread_id, + sessionId=turn.session_id, + turnId=turn.turn_id, + requestId=clarify_id, + questions=[ + { + "id": clarify_id, + "header": "Hermes", + "question": question, + "options": options, + "multiSelect": False, + } + ], + ) + ) + return SendResult(success=True, message_id=clarify_id) + + async def _handle_server_frame(self, raw: dict[str, Any]) -> None: + request = raw.get("requestId") + try: + message = validate_server_frame(raw) + frame_type = message["type"] + if frame_type == "session.ensure": + await self._ensure_session(message) + elif frame_type == "turn.start": + await self._start_turn(message) + elif frame_type == "turn.steer": + await self._steer_turn(message) + elif frame_type == "turn.interrupt": + await self._interrupt_turn(message) + elif frame_type == "approval.respond": + await self._resolve_approval(message) + elif frame_type == "user-input.respond": + await self._resolve_user_input(message) + elif frame_type == "session.stop": + await self._stop_session(message) + elif frame_type == "ping": + await self._send_frame( + frame( + "pong", + requestId=message["requestId"], + sentAt=message.get("sentAt") or iso_now(), + ) + ) + except ValueError as exc: + await self._send_frame( + protocol_error( + "unsupported-message", + str(exc), + recoverable=True, + related_request_id=str(request) if request else None, + ) + ) + except Exception as exc: + logger.exception("T3 gateway command failed") + await self._send_frame( + protocol_error( + "internal-error", + str(exc) or type(exc).__name__, + recoverable=True, + related_request_id=str(request) if request else None, + ) + ) + + async def _ensure_session(self, message: dict[str, Any]) -> None: + thread_id = str(message["threadId"]) + source = self._source(thread_id, str(message["requestId"])) + session_id = build_session_key(source) + resume_id = str(message.get("resumeSessionId") or "") + self._sessions[thread_id] = session_id + self._active_session_threads.add(thread_id) + self._thread_by_session[session_id] = thread_id + await self._send_frame( + frame( + "session.ready", + requestId=message["requestId"], + threadId=thread_id, + sessionId=session_id, + resumed=bool(resume_id and resume_id == session_id), + ) + ) + await self._send_status() + + async def _start_turn(self, message: dict[str, Any]) -> None: + thread_id = str(message["threadId"]) + session_id = self._sessions.get(thread_id) + if not session_id or session_id != str(message["sessionId"]): + await self._send_frame( + protocol_error( + "session-not-found", + "Call session.ensure before starting a turn.", + recoverable=True, + related_request_id=str(message["requestId"]), + ) + ) + return + if thread_id in self._active_turns: + await self._send_frame( + protocol_error( + "invalid-message", + "This Hermes session already has an active turn; use turn.steer.", + recoverable=True, + related_request_id=str(message["requestId"]), + ) + ) + return + turn = _TurnState( + thread_id=thread_id, + session_id=session_id, + turn_id=str(message["turnId"]), + request_id=str(message["requestId"]), + ) + self._active_turns[thread_id] = turn + await self._send_frame( + frame( + "turn.started", + requestId=turn.request_id, + threadId=thread_id, + sessionId=session_id, + turnId=turn.turn_id, + ) + ) + await self._send_status() + await self.handle_message( + MessageEvent( + text=str(message["text"]), + message_type=( + MessageType.COMMAND + if str(message["text"]).lstrip().startswith("/") + else MessageType.TEXT + ), + source=self._source(thread_id, turn.request_id), + message_id=turn.request_id, + metadata={"t3_turn_id": turn.turn_id}, + ) + ) + + async def _steer_turn(self, message: dict[str, Any]) -> None: + turn = self._active_turns.get(str(message["threadId"])) + if turn is None or turn.turn_id != str(message["turnId"]): + await self._send_frame( + protocol_error( + "turn-not-active", + "The requested Hermes turn is no longer active.", + recoverable=True, + related_request_id=str(message["requestId"]), + ) + ) + return + # `/steer` is Hermes' official active-run injection surface. The base + # adapter dispatches active slash commands inline, then sends the + # command's textual acknowledgement back through this adapter with + # `notify=True`. Capture that one command response by request context: + # it is control traffic, not assistant output and not a turn boundary. + control = _SteerControlResponse( + thread_id=turn.thread_id, + request_id=str(message["requestId"]), + ) + context_token = _steer_control_response.set(control) + command_error: Exception | None = None + try: + await self.handle_message( + MessageEvent( + text=f"/steer {message['text']}", + message_type=MessageType.COMMAND, + source=self._source(turn.thread_id, control.request_id), + message_id=control.request_id, + metadata={"t3_turn_id": turn.turn_id, "t3_steer": True}, + ) + ) + except Exception as exc: # noqa: BLE001 - translate command failures to the wire + command_error = exc + finally: + _steer_control_response.reset(context_token) + + if command_error is not None: + await self._send_frame( + protocol_error( + "internal-error", + str(command_error) or "Hermes steering failed.", + recoverable=True, + related_request_id=control.request_id, + ) + ) + return + + response = control.messages[-1] if control.messages else "" + if not response.startswith("⏩ Steer queued"): + if response.startswith(("Agent still starting", "No active agent")): + error_code = "turn-not-active" + elif response.startswith("⚠️ Steer failed"): + error_code = "internal-error" + else: + error_code = "invalid-message" + await self._send_frame( + protocol_error( + error_code, + response or "Hermes did not acknowledge the steering request.", + recoverable=True, + related_request_id=control.request_id, + ) + ) + return + + # Correlated command acknowledgement. It intentionally reuses the + # existing turnId: this is not a second runtime turn. T3 consumes the + # steering requestId as its broker acknowledgement and suppresses the + # duplicate turn-start lifecycle projection. + await self._send_frame( + frame( + "turn.started", + requestId=control.request_id, + threadId=turn.thread_id, + sessionId=turn.session_id, + turnId=turn.turn_id, + ) + ) + + def _capture_steer_control_response( + self, + chat_id: str, + content: str, + ) -> SendResult | None: + control = _steer_control_response.get() + if control is None or control.thread_id != str(chat_id): + return None + control.messages.append(str(content)) + return SendResult( + success=True, + message_id=f"t3-steer-control-{control.request_id}", + ) + + async def _interrupt_turn(self, message: dict[str, Any]) -> None: + thread_id = str(message["threadId"]) + turn = self._active_turns.get(thread_id) + if turn is None or turn.turn_id != str(message["turnId"]): + await self._send_frame( + protocol_error( + "turn-not-active", + "The requested Hermes turn is no longer active.", + recoverable=True, + related_request_id=str(message["requestId"]), + ) + ) + return + await self.interrupt_session_activity(turn.session_id, thread_id) + await self._send_frame( + frame( + "turn.aborted", + threadId=thread_id, + sessionId=turn.session_id, + turnId=turn.turn_id, + reason="Interrupted by T3 Code", + ) + ) + self._active_turns.pop(thread_id, None) + await self._send_status() + + async def _resolve_approval(self, message: dict[str, Any]) -> None: + request_id = str(message["requestId"]) + pending = self._approval_requests.pop(request_id, None) + if pending is None: + await self._send_frame( + protocol_error( + "request-not-found", + "The Hermes approval request is no longer pending.", + recoverable=True, + related_request_id=request_id, + ) + ) + return + session_key, _turn_id = pending + decision = str(message["decision"]) + choice = { + "accept": "once", + "acceptForSession": "session", + "decline": "deny", + "cancel": "deny", + }.get(decision, "deny") + from tools.approval import resolve_gateway_approval + + resolved = resolve_gateway_approval(session_key, choice) + await self._send_frame( + frame( + "request.resolved", + threadId=message["threadId"], + sessionId=message["sessionId"], + turnId=message["turnId"], + requestId=request_id, + requestType="command_execution_approval", + decision=decision, + resolution={"resolvedCount": resolved}, + ) + ) + + async def _resolve_user_input(self, message: dict[str, Any]) -> None: + request_id = str(message["requestId"]) + pending = self._user_input_requests.pop(request_id, None) + if pending is None: + await self._send_frame( + protocol_error( + "request-not-found", + "The Hermes user-input request is no longer pending.", + recoverable=True, + related_request_id=request_id, + ) + ) + return + answers = message.get("answers") or {} + answer = answers.get(request_id) if isinstance(answers, dict) else None + if answer is None and isinstance(answers, dict) and answers: + answer = next(iter(answers.values())) + if isinstance(answer, list): + response = ", ".join(str(value) for value in answer) + else: + response = str(answer or "") + from tools.clarify_gateway import resolve_gateway_clarify + + resolved = resolve_gateway_clarify(request_id, response) + await self._send_frame( + frame( + "user-input.resolved", + threadId=message["threadId"], + sessionId=message["sessionId"], + turnId=message["turnId"], + requestId=request_id, + answers=answers, + ) + ) + if not resolved: + logger.warning( + "Hermes clarify request %s was no longer pending", request_id + ) + + async def _stop_session(self, message: dict[str, Any]) -> None: + thread_id = str(message["threadId"]) + session_id = self._sessions.get(thread_id) + if session_id is None: + await self._send_frame( + protocol_error( + "session-not-found", + "The requested Hermes session is not active in this connection.", + recoverable=True, + related_request_id=str(message["requestId"]), + ) + ) + return + turn = self._active_turns.pop(thread_id, None) + if turn is not None: + await self.interrupt_session_activity(session_id, thread_id) + await self._send_frame( + frame( + "turn.aborted", + threadId=thread_id, + sessionId=session_id, + turnId=turn.turn_id, + reason="Hermes session stopped by T3 Code", + ) + ) + await self._send_frame( + frame( + "session.exited", + threadId=thread_id, + sessionId=session_id, + reason="Stopped by T3 Code", + recoverable=True, + ) + ) + # Deliberately retain the deterministic mapping and Hermes transcript. + # A later session.ensure resumes this same thread/session identity. + self._active_session_threads.discard(thread_id) + await self._send_status() + + async def _emit_assistant_content(self, turn: _TurnState, content: str) -> None: + visible = str(content or "").replace(" ▉", "").replace("▉", "") + if not turn.assistant_started: + turn.assistant_started = True + await self._send_frame( + frame( + "item.started", + threadId=turn.thread_id, + sessionId=turn.session_id, + turnId=turn.turn_id, + itemId=turn.message_id, + itemType="assistant_message", + status="inProgress", + title="Hermes response", + ) + ) + if visible.startswith(turn.visible_text): + delta = visible[len(turn.visible_text) :] + turn.visible_text = visible + if delta: + await self._send_frame( + frame( + "content.delta", + threadId=turn.thread_id, + sessionId=turn.session_id, + turnId=turn.turn_id, + itemId=turn.message_id, + streamKind="assistant_text", + delta=delta, + contentIndex=0, + ) + ) + elif visible != turn.visible_text: + # Hermes' public platform edit hook exposes cumulative rendered text, + # but the v1 T3 delta contract has no replacement operation. Preserve + # the valid prefix and report the rare rewrite as generic activity. + await self._emit_generic_activity( + turn, "Hermes revised already-streamed text; replacement is deferred." + ) + + async def _complete_turn(self, turn: _TurnState) -> None: + if self._active_turns.get(turn.thread_id) is not turn: + return + if turn.assistant_started: + await self._send_frame( + frame( + "item.completed", + threadId=turn.thread_id, + sessionId=turn.session_id, + turnId=turn.turn_id, + itemId=turn.message_id, + itemType="assistant_message", + status="completed", + title="Hermes response", + ) + ) + if turn.generic_activity_id is not None: + await self._send_frame( + frame( + "item.completed", + threadId=turn.thread_id, + sessionId=turn.session_id, + turnId=turn.turn_id, + itemId=turn.generic_activity_id, + itemType="unknown", + status="completed", + title="Hermes activity", + **( + {"detail": turn.generic_activity_detail} + if turn.generic_activity_detail + else {} + ), + ) + ) + await self._send_frame( + frame( + "turn.completed", + threadId=turn.thread_id, + sessionId=turn.session_id, + turnId=turn.turn_id, + state="completed", + stopReason=None, + ) + ) + self._active_turns.pop(turn.thread_id, None) + await self._send_status() + + async def _emit_generic_activity(self, turn: _TurnState, detail: str) -> None: + if not detail: + return + normalized_detail = str(detail)[:2_000] + if turn.generic_activity_detail == normalized_detail: + return + turn.generic_activity_detail = normalized_detail + if turn.generic_activity_id is None: + turn.generic_activity_id = item_id() + event_type = "item.started" + else: + event_type = "item.updated" + await self._send_frame( + frame( + event_type, + threadId=turn.thread_id, + sessionId=turn.session_id, + turnId=turn.turn_id, + itemId=turn.generic_activity_id, + itemType="unknown", + status="inProgress", + title="Hermes activity", + detail=normalized_detail, + ) + ) + + def emit_tool_started( + self, + session_id: str, + tool_name: str, + args: dict[str, Any], + tool_call_id: str = "", + ) -> None: + thread_id = self._thread_by_session.get(str(session_id)) + turn = self._active_turns.get(thread_id or "") + if turn is None: + return + tool_item_id = item_id() + correlation_key = tool_call_id or tool_name + turn.tool_items[correlation_key] = tool_item_id + data = canonical_tool_data(tool_name, args) + payload: dict[str, Any] = { + "threadId": turn.thread_id, + "sessionId": turn.session_id, + "turnId": turn.turn_id, + "itemId": tool_item_id, + "itemType": canonical_tool_item_type(tool_name), + "status": "inProgress", + "title": tool_name, + } + if data is not None: + payload["data"] = data + self._schedule( + self._send_frame( + frame( + "item.started", + **payload, + ) + ) + ) + + def emit_tool_completed( + self, + session_id: str, + tool_name: str, + result: str, + duration_ms: int | None, + tool_call_id: str = "", + status: str = "", + ) -> None: + thread_id = self._thread_by_session.get(str(session_id)) + turn = self._active_turns.get(thread_id or "") + if turn is None: + return + correlation_key = tool_call_id or tool_name + tool_item_id = turn.tool_items.pop(correlation_key, None) or item_id() + del result + payload: dict[str, Any] = { + "threadId": turn.thread_id, + "sessionId": turn.session_id, + "turnId": turn.turn_id, + "itemId": tool_item_id, + "itemType": canonical_tool_item_type(tool_name), + "status": "failed" if status == "error" else "completed", + "title": tool_name, + } + if duration_ms is not None: + payload["detail"] = f"Completed in {duration_ms} ms" + payload["data"] = {"durationMs": duration_ms} + self._schedule( + self._send_frame( + frame( + "item.completed", + **payload, + ) + ) + ) + + @classmethod + def route_tool_started( + cls, + tool_name: str, + args: dict[str, Any], + session_id: str, + tool_call_id: str = "", + ) -> None: + for instance in list(cls._instances): + instance.emit_tool_started(session_id, tool_name, args, tool_call_id) + + @classmethod + def route_tool_completed( + cls, + tool_name: str, + result: str, + session_id: str, + duration_ms: int | None, + tool_call_id: str = "", + status: str = "", + ) -> None: + for instance in list(cls._instances): + instance.emit_tool_completed( + session_id, + tool_name, + result, + duration_ms, + tool_call_id, + status, + ) + + def _source(self, thread_id: str, message_id: str): + return self.build_source( + chat_id=thread_id, + chat_name=f"T3 thread {thread_id}", + chat_type="dm", + user_id="t3-code", + user_name="T3 Code", + message_id=message_id, + ) + + async def _send_frame(self, message: dict[str, Any]) -> None: + connection = self._connection + if connection is None: + raise ConnectionError("T3 Code gateway is offline") + await connection.send(message) + + async def _send_status(self) -> None: + if self._connection is None or not self._connection.connected: + return + await self._send_frame( + frame( + "connection.status", + activeSessionCount=len(self._active_session_threads), + ) + ) + + async def _handle_connection_state( + self, connected: bool, reason: str | None + ) -> None: + if connected: + self._mark_connected() + await self._send_status() + return + self._mark_disconnected() + if reason: + logger.warning("T3 gateway offline: %s", reason) + + def _schedule(self, coroutine) -> None: + loop = self._event_loop + if loop is None or loop.is_closed(): + coroutine.close() + return + if threading.current_thread() is threading.main_thread(): + try: + asyncio.get_running_loop() + except RuntimeError: + asyncio.run_coroutine_threadsafe(coroutine, loop) + else: + loop.create_task(coroutine) + else: + asyncio.run_coroutine_threadsafe(coroutine, loop) + + +def check_requirements() -> bool: + return dependency_available() + + +def validate_config(config: PlatformConfig) -> bool: + extra = getattr(config, "extra", {}) or {} + return ( + bool(extra.get("url") or os.environ.get(URL_ENV, "")) + and bool(extra.get("instance_id") or os.environ.get(INSTANCE_ID_ENV, "")) + and bool(extra.get("credential") or os.environ.get(CREDENTIAL_ENV, "")) + ) + + +def env_enablement() -> dict[str, str] | None: + url = os.environ.get(URL_ENV, "").strip() + instance_id = os.environ.get(INSTANCE_ID_ENV, "").strip() + credential = os.environ.get(CREDENTIAL_ENV, "").strip() + if not (url and instance_id and credential): + return None + return { + "url": url, + "instance_id": instance_id, + "credential": credential, + "nickname": os.environ.get(NICKNAME_ENV, "").strip() or "Hermes", + } diff --git a/integrations/hermes-t3-gateway/cli.py b/integrations/hermes-t3-gateway/cli.py new file mode 100644 index 00000000000..383f8dbbe96 --- /dev/null +++ b/integrations/hermes-t3-gateway/cli.py @@ -0,0 +1,125 @@ +"""`hermes t3 connect` enrollment command.""" + +from __future__ import annotations + +import argparse +import asyncio +import os +import sys + +from .connection import ConnectionRejected, enroll_once, websocket_url + +URL_ENV = "HERMES_T3_GATEWAY_URL" +INSTANCE_ID_ENV = "HERMES_T3_GATEWAY_INSTANCE_ID" +CREDENTIAL_ENV = "HERMES_T3_GATEWAY_CREDENTIAL" +NICKNAME_ENV = "HERMES_T3_GATEWAY_NICKNAME" + + +def _hermes_version() -> str: + try: + from hermes_cli import __version__ + + return str(__version__) + except Exception: # noqa: BLE001 - version discovery must not block enrollment + return "unknown" + + +def register_cli(parser: argparse.ArgumentParser) -> None: + commands = parser.add_subparsers(dest="t3_command") + connect = commands.add_parser( + "connect", + help="Pair this Hermes process with a named T3 Code provider instance", + ) + connect.add_argument( + "--url", + required=True, + help="T3 browser origin or explicit ws(s) gateway URL", + ) + connect.add_argument( + "--token", + required=True, + help="Short-lived, one-time enrollment token generated by T3 Code", + ) + connect.set_defaults(func=t3_command) + + status = commands.add_parser("status", help="Show local T3 enrollment state") + status.set_defaults(func=t3_command) + + +def t3_command(args) -> None: + command = getattr(args, "t3_command", None) + if command == "status": + _print_status() + return + if command != "connect": + print("Usage: hermes t3 connect --url --token ") + return + + url = str(getattr(args, "url", "") or "").strip() + token = str(getattr(args, "token", "") or "").strip() + try: + normalized_url = websocket_url(url) + accepted = asyncio.run( + enroll_once( + url=normalized_url, + token=token, + hermes_version=_hermes_version(), + ) + ) + except ConnectionRejected as exc: + print(f"✗ T3 enrollment rejected ({exc.code}): {exc}") + raise SystemExit(1) from exc + except Exception as exc: + print(f"✗ Could not enroll with T3 Code: {exc}") + raise SystemExit(1) from exc + + values = { + URL_ENV: normalized_url, + INSTANCE_ID_ENV: str(accepted["instanceId"]), + CREDENTIAL_ENV: str(accepted["credential"]), + NICKNAME_ENV: str(accepted.get("nickname") or "Hermes"), + } + try: + from hermes_cli.config import get_env_path, save_env_value + + for key, value in values.items(): + save_env_value(key, value) + env_path = get_env_path() + except Exception as exc: + print(f"✗ Enrollment succeeded, but credentials could not be saved: {exc}") + print(" The credential was not printed. Revoke and re-enroll this instance.") + raise SystemExit(1) from exc + + # Mirror the newly written values into this process for status output and + # tests. A running gateway still needs a restart to construct the adapter. + os.environ.update(values) + print(f'✓ Connected Hermes to T3 Code as "{values[NICKNAME_ENV]}"') + print(f" Instance: {values[INSTANCE_ID_ENV]}") + print(f" Gateway: {normalized_url}") + print(f" Saved the credential securely in {env_path} (value hidden).") + print(" Restart `hermes gateway` to activate the connection.") + + +def _print_status() -> None: + url = os.environ.get(URL_ENV, "").strip() + instance_id = os.environ.get(INSTANCE_ID_ENV, "").strip() + credential = os.environ.get(CREDENTIAL_ENV, "").strip() + nickname = os.environ.get(NICKNAME_ENV, "").strip() or "Hermes" + if not (url and instance_id and credential): + print("T3 Code: not enrolled") + print("Run: hermes t3 connect --url --token ") + return + print(f"T3 Code: enrolled as {nickname}") + print(f" Instance: {instance_id}") + print(f" Gateway: {url}") + print(" Credential: configured (hidden)") + + +if __name__ == "__main__": # pragma: no cover - executable fallback + parser = argparse.ArgumentParser(prog="python -m hermes_t3_gateway.cli") + register_cli(parser) + parsed = parser.parse_args() + if not hasattr(parsed, "func"): + parser.print_help() + sys.exit(2) + parsed.func(parsed) diff --git a/integrations/hermes-t3-gateway/connection.py b/integrations/hermes-t3-gateway/connection.py new file mode 100644 index 00000000000..b2bce92296a --- /dev/null +++ b/integrations/hermes-t3-gateway/connection.py @@ -0,0 +1,247 @@ +"""Outbound authenticated WebSocket connection to a T3 Code server.""" + +from __future__ import annotations + +import asyncio +import json +import logging +from collections.abc import Awaitable, Callable +from contextlib import suppress +from typing import Any +from urllib.parse import urlsplit, urlunsplit + +from .protocol import WEBSOCKET_PATH, connection_hello + +logger = logging.getLogger(__name__) + +try: + import websockets +except ImportError: # pragma: no cover - Hermes currently installs websockets + websockets = None + +MessageHandler = Callable[[dict[str, Any]], Awaitable[None]] +StateHandler = Callable[[bool, str | None], Awaitable[None] | None] + + +class ConnectionRejected(RuntimeError): + def __init__(self, code: str, message: str): + super().__init__(message) + self.code = code + + +def websocket_url(url: str) -> str: + """Normalize an HTTP(S) browser origin or WS(S) URL to the gateway route.""" + raw = (url or "").strip() + parsed = urlsplit(raw) + scheme = parsed.scheme.lower() + if scheme == "https": + scheme = "wss" + elif scheme == "http": + scheme = "ws" + if scheme not in {"ws", "wss"} or not parsed.netloc: + raise ValueError("URL must use http://, https://, ws://, or wss://") + path = parsed.path.rstrip("/") + if path != WEBSOCKET_PATH: + path = WEBSOCKET_PATH + return urlunsplit((scheme, parsed.netloc, path, "", "")) + + +def dependency_available() -> bool: + return websockets is not None + + +async def _open_socket(url: str): + if websockets is None: + raise RuntimeError( + "The `websockets` package is unavailable. Install the standard " + "Hermes Agent dependencies and retry." + ) + return await websockets.connect( # type: ignore[union-attr] + websocket_url(url), + open_timeout=20, + ping_interval=20, + ping_timeout=20, + close_timeout=5, + max_size=2 * 1024 * 1024, + ) + + +async def authenticate_socket( + socket: Any, + *, + authentication: dict[str, str], + hermes_version: str, + timeout: float = 20, +) -> dict[str, Any]: + hello = connection_hello( + hermes_version=hermes_version, + authentication=authentication, + ) + await socket.send(json.dumps(hello, separators=(",", ":"), ensure_ascii=False)) + raw = await asyncio.wait_for(socket.recv(), timeout=timeout) + message = json.loads(raw) + if not isinstance(message, dict): + raise TypeError("T3 returned a non-object handshake frame") + if message.get("requestId") != hello["requestId"]: + raise RuntimeError("T3 returned a handshake with an unexpected requestId") + if message.get("type") == "connection.rejected": + raise ConnectionRejected( + str(message.get("code") or "internal-error"), + str(message.get("message") or "T3 rejected the gateway connection"), + ) + if message.get("type") != "connection.accepted": + raise RuntimeError( + f"expected connection.accepted, received {message.get('type')!r}" + ) + if message.get("protocolVersion") != 1: + raise RuntimeError("T3 accepted the connection with an incompatible version") + return message + + +async def enroll_once( + *, + url: str, + token: str, + hermes_version: str, +) -> dict[str, Any]: + socket = await _open_socket(url) + try: + accepted = await authenticate_socket( + socket, + authentication={"type": "enrollment-token", "token": token}, + hermes_version=hermes_version, + ) + if not accepted.get("instanceId") or not accepted.get("credential"): + raise RuntimeError( + "T3 accepted enrollment without returning an instance credential" + ) + return accepted + finally: + await socket.close() + + +class T3GatewayConnection: + """Reconnectable runtime connection authenticated by an instance credential.""" + + def __init__( + self, + *, + url: str, + instance_id: str, + credential: str, + hermes_version: str, + on_message: MessageHandler, + on_state: StateHandler | None = None, + ): + self.url = websocket_url(url) + self.instance_id = instance_id + self.credential = credential + self.hermes_version = hermes_version + self._on_message = on_message + self._on_state = on_state + self._socket: Any = None + self._supervisor: asyncio.Task[None] | None = None + self._send_lock = asyncio.Lock() + self._connected = asyncio.Event() + self._first_result: asyncio.Future[bool] | None = None + self._stopping = False + + @property + def connected(self) -> bool: + return self._connected.is_set() + + async def connect(self, timeout: float = 30) -> bool: + if self._supervisor is not None and not self._supervisor.done(): + return self.connected + self._stopping = False + self._first_result = asyncio.get_running_loop().create_future() + self._supervisor = asyncio.create_task( + self._supervise(), name="hermes-t3-gateway" + ) + try: + return await asyncio.wait_for(asyncio.shield(self._first_result), timeout) + except TimeoutError: + await self.disconnect() + return False + + async def disconnect(self) -> None: + self._stopping = True + self._connected.clear() + if self._socket is not None: + with suppress(Exception): + await self._socket.close() + self._socket = None + if self._supervisor is not None: + self._supervisor.cancel() + with suppress(asyncio.CancelledError): + await self._supervisor + self._supervisor = None + await self._notify_state(False, None) + + async def send(self, message: dict[str, Any]) -> None: + if not self.connected or self._socket is None: + raise ConnectionError("T3 Code gateway is offline") + encoded = json.dumps(message, separators=(",", ":"), ensure_ascii=False) + async with self._send_lock: + await self._socket.send(encoded) + + async def _supervise(self) -> None: + delay = 1.0 + while not self._stopping: + reason: str | None = None + try: + socket = await _open_socket(self.url) + self._socket = socket + await authenticate_socket( + socket, + authentication={ + "type": "instance-credential", + "instanceId": self.instance_id, + "credential": self.credential, + }, + hermes_version=self.hermes_version, + ) + self._connected.set() + if self._first_result is not None and not self._first_result.done(): + self._first_result.set_result(True) + await self._notify_state(True, None) + delay = 1.0 + async for raw in socket: + message = json.loads(raw) + if isinstance(message, dict): + await self._on_message(message) + except asyncio.CancelledError: + raise + except ConnectionRejected as exc: + reason = f"{exc.code}: {exc}" + if self._first_result is not None and not self._first_result.done(): + self._first_result.set_exception(exc) + # Revoked credentials and version mismatches need operator + # action; reconnecting the same secret can never recover. + if exc.code in { + "instance-revoked", + "invalid-authentication", + "version-incompatible", + }: + self._stopping = True + except Exception as exc: # noqa: BLE001 - reconnect every transient transport failure + reason = str(exc) + logger.warning("T3 gateway connection dropped: %s", exc) + finally: + self._connected.clear() + self._socket = None + await self._notify_state(False, reason) + if self._stopping: + break + await asyncio.sleep(delay) + delay = min(delay * 2, 30.0) + + async def _notify_state(self, connected: bool, reason: str | None) -> None: + if self._on_state is None: + return + try: + result = self._on_state(connected, reason) + if asyncio.iscoroutine(result): + await result + except Exception: + logger.debug("T3 connection state callback failed", exc_info=True) diff --git a/integrations/hermes-t3-gateway/plugin.yaml b/integrations/hermes-t3-gateway/plugin.yaml new file mode 100644 index 00000000000..02d7c6f7a81 --- /dev/null +++ b/integrations/hermes-t3-gateway/plugin.yaml @@ -0,0 +1,24 @@ +name: hermes-t3-gateway +label: T3 Code +kind: platform +version: 0.1.0 +description: > + Experimental outbound gateway that exposes one Hermes Agent process as a + named T3 Code provider instance over an authenticated WebSocket. +author: T3 Tools +provides_hooks: + - pre_tool_call + - post_tool_call +optional_env: + - name: HERMES_T3_GATEWAY_URL + description: "T3 Code server URL used by the outbound gateway connection" + prompt: "T3 Code URL" + password: false + - name: HERMES_T3_GATEWAY_INSTANCE_ID + description: "Opaque T3 Code Hermes instance identifier issued at enrollment" + prompt: "T3 Code Hermes instance ID" + password: false + - name: HERMES_T3_GATEWAY_CREDENTIAL + description: "Long-lived per-instance credential issued at enrollment" + prompt: "T3 Code Hermes credential" + password: true diff --git a/integrations/hermes-t3-gateway/protocol.py b/integrations/hermes-t3-gateway/protocol.py new file mode 100644 index 00000000000..24e752681b8 --- /dev/null +++ b/integrations/hermes-t3-gateway/protocol.py @@ -0,0 +1,168 @@ +"""Pure-Python helpers for the T3 Code ↔ Hermes gateway wire contract.""" + +from __future__ import annotations + +import uuid +from datetime import datetime, timezone +from typing import Any + +PROTOCOL_VERSION = 1 +PLUGIN_VERSION = "0.1.0" +WEBSOCKET_PATH = "/api/hermes-gateway/ws" + +CAPABILITIES = { + "protocolVersion": PROTOCOL_VERSION, + "streaming": True, + "activity": True, + "approvals": True, + "userInput": True, + # First post-stability feature: advertise false until binary/media framing + # and bounded attachment transfer are implemented end to end. + "attachments": False, +} + +SERVER_COMMANDS = frozenset( + { + "session.ensure", + "turn.start", + "turn.steer", + "turn.interrupt", + "approval.respond", + "user-input.respond", + "session.stop", + "ping", + } +) + + +def request_id() -> str: + return str(uuid.uuid4()) + + +def item_id() -> str: + return str(uuid.uuid4()) + + +def iso_now() -> str: + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + +def frame(frame_type: str, **payload: Any) -> dict[str, Any]: + return { + "type": frame_type, + "protocolVersion": PROTOCOL_VERSION, + **payload, + } + + +def connection_hello( + *, + hermes_version: str, + authentication: dict[str, str], + hello_request_id: str | None = None, +) -> dict[str, Any]: + return { + "type": "connection.hello", + "requestId": hello_request_id or request_id(), + "protocolVersion": PROTOCOL_VERSION, + "pluginVersion": PLUGIN_VERSION, + "hermesVersion": hermes_version, + "capabilities": dict(CAPABILITIES), + "authentication": authentication, + } + + +def protocol_error( + code: str, + message: str, + *, + recoverable: bool, + related_request_id: str | None = None, +) -> dict[str, Any]: + payload: dict[str, Any] = { + "code": code, + "message": message, + "recoverable": recoverable, + } + if related_request_id: + payload["requestId"] = related_request_id + return frame("protocol.error", **payload) + + +def validate_server_frame(message: Any) -> dict[str, Any]: + if not isinstance(message, dict): + raise TypeError("gateway frame must be a JSON object") + frame_type = message.get("type") + if frame_type not in SERVER_COMMANDS: + raise ValueError(f"unsupported T3 gateway frame: {frame_type!r}") + if message.get("protocolVersion") != PROTOCOL_VERSION: + raise ValueError( + f"unsupported protocol version: {message.get('protocolVersion')!r}" + ) + return message + + +def canonical_tool_item_type(tool_name: str) -> str: + normalized = (tool_name or "").strip().lower() + if normalized in {"terminal", "execute_code", "shell", "bash"}: + return "command_execution" + if normalized in { + "apply_patch", + "write_file", + "edit_file", + "delete_file", + "move_file", + }: + return "file_change" + if normalized.startswith(("mcp", "mcp__")): + return "mcp_tool_call" + if normalized in {"delegate_task", "spawn_agent", "send_message"}: + return "collab_agent_tool_call" + if "search" in normalized or normalized in {"web_fetch", "fetch_url"}: + return "web_search" + if normalized in {"view_image", "open_image"}: + return "image_view" + return "dynamic_tool_call" + + +def canonical_tool_data(tool_name: str, args: Any) -> dict[str, Any] | None: + """Project known-safe, canonical fields; never forward arbitrary tool args.""" + if not isinstance(args, dict): + return None + item_type = canonical_tool_item_type(tool_name) + if item_type == "command_execution": + command = args.get("command") + cwd = args.get("cwd") or args.get("workdir") + projected = {} + if isinstance(command, str) and command.strip(): + projected["command"] = command[:4_000] + if isinstance(cwd, str) and cwd.strip(): + projected["cwd"] = cwd[:1_000] + return projected or None + if item_type == "file_change": + path = args.get("path") or args.get("file_path") or args.get("filename") + return ( + {"path": path[:1_000]} if isinstance(path, str) and path.strip() else None + ) + if item_type == "web_search": + query = args.get("query") or args.get("q") or args.get("url") + return ( + {"query": query[:2_000]} + if isinstance(query, str) and query.strip() + else None + ) + if item_type == "image_view": + path = args.get("path") or args.get("image_path") + return ( + {"path": path[:1_000]} if isinstance(path, str) and path.strip() else None + ) + if item_type == "mcp_tool_call": + server = args.get("server") + operation = args.get("tool") or args.get("operation") + projected = {} + if isinstance(server, str) and server.strip(): + projected["server"] = server[:200] + if isinstance(operation, str) and operation.strip(): + projected["operation"] = operation[:200] + return projected or None + return None diff --git a/integrations/hermes-t3-gateway/tests/test_adapter.py b/integrations/hermes-t3-gateway/tests/test_adapter.py new file mode 100644 index 00000000000..da2d7a6822f --- /dev/null +++ b/integrations/hermes-t3-gateway/tests/test_adapter.py @@ -0,0 +1,411 @@ +from __future__ import annotations + +import dataclasses +import enum +import importlib.util +import pathlib +import sys +import types +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +PACKAGE = "hermes_t3_gateway_adapter_test" + + +class Platform(str, enum.Enum): + T3 = "t3" + + @classmethod + def _missing_(cls, value): + if value == "t3": + return cls.T3 + return None + + +@dataclasses.dataclass +class PlatformConfig: + enabled: bool = True + extra: dict = dataclasses.field(default_factory=dict) + + +class MessageType(enum.Enum): + TEXT = "text" + COMMAND = "command" + + +@dataclasses.dataclass +class MessageEvent: + text: str + message_type: MessageType + source: object + message_id: str + metadata: dict + + +@dataclasses.dataclass +class SendResult: + success: bool + message_id: str | None = None + error: str | None = None + + +@dataclasses.dataclass +class Source: + platform: Platform + chat_id: str + message_id: str + + +class BasePlatformAdapter: + def __init__(self, config, platform): + self.config = config + self.platform = platform + self._status_text = {} + self.messages = [] + self._running = False + self._message_handler = None + + def build_source(self, *, chat_id, message_id, **kwargs): + return Source(self.platform, str(chat_id), str(message_id)) + + async def handle_message(self, event): + self.messages.append(event) + if ( + self._message_handler is not None + and event.message_type == MessageType.COMMAND + and event.text.startswith("/steer ") + ): + # Faithful model of Hermes BasePlatformAdapter's active-command + # path: the gateway handler returns a control acknowledgement, + # then BasePlatformAdapter sends it through the platform adapter + # with notify=True. + response = await self._message_handler(event) + if response: + await self.send( + event.source.chat_id, + response, + metadata={"notify": True}, + ) + + async def interrupt_session_activity(self, session_key, chat_id): + self.interrupted = (session_key, chat_id) + + def set_status_text(self, chat_id, text): + if text: + self._status_text[str(chat_id)] = text + else: + self._status_text.pop(str(chat_id), None) + + def _mark_connected(self): + self._running = True + + def _mark_disconnected(self): + self._running = False + + def _set_fatal_error(self, *args, **kwargs): + self.fatal_error = (args, kwargs) + + +def build_session_key(source): + return f"agent:main:t3:dm:{source.chat_id}" + + +def install_fake_hermes_modules(): + gateway = types.ModuleType("gateway") + config = types.ModuleType("gateway.config") + config.Platform = Platform + config.PlatformConfig = PlatformConfig + platforms = types.ModuleType("gateway.platforms") + base = types.ModuleType("gateway.platforms.base") + base.BasePlatformAdapter = BasePlatformAdapter + base.MessageEvent = MessageEvent + base.MessageType = MessageType + base.SendResult = SendResult + session = types.ModuleType("gateway.session") + session.build_session_key = build_session_key + sys.modules.update( + { + "gateway": gateway, + "gateway.config": config, + "gateway.platforms": platforms, + "gateway.platforms.base": base, + "gateway.session": session, + } + ) + + +def load_plugin_modules(): + install_fake_hermes_modules() + package = types.ModuleType(PACKAGE) + package.__path__ = [str(ROOT)] + sys.modules[PACKAGE] = package + for name in ("protocol", "connection", "cli", "adapter"): + spec = importlib.util.spec_from_file_location( + f"{PACKAGE}.{name}", ROOT / f"{name}.py" + ) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + sys.modules[f"{PACKAGE}.{name}"] = module + spec.loader.exec_module(module) + return sys.modules[f"{PACKAGE}.adapter"] + + +adapter_module = load_plugin_modules() + + +class FakeConnection: + def __init__(self): + self.connected = True + self.messages = [] + + async def send(self, message): + self.messages.append(message) + + +class AdapterTests(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.adapter = adapter_module.T3PlatformAdapter( + PlatformConfig( + extra={ + "url": "wss://t3.example/api/hermes-gateway/ws", + "instance_id": "instance", + "credential": "credential", + } + ) + ) + self.connection = FakeConnection() + self.adapter._connection = self.connection + + async def test_thread_ensure_start_stream_and_complete(self): + await self.adapter._handle_server_frame( + { + "type": "session.ensure", + "protocolVersion": 1, + "requestId": "ensure-1", + "threadId": "thread-1", + } + ) + ready = self.connection.messages[-2] + self.assertEqual(ready["type"], "session.ready") + self.assertEqual(ready["sessionId"], "agent:main:t3:dm:thread-1") + self.assertEqual(self.connection.messages[-1]["activeSessionCount"], 1) + + await self.adapter._handle_server_frame( + { + "type": "turn.start", + "protocolVersion": 1, + "requestId": "start-1", + "threadId": "thread-1", + "sessionId": ready["sessionId"], + "turnId": "turn-1", + "text": "Hello Hermes", + } + ) + self.assertEqual(self.adapter.messages[-1].text, "Hello Hermes") + await self.adapter.send("thread-1", "Hello", metadata={"expect_edits": True}) + await self.adapter.edit_message( + "thread-1", "message", "Hello world", finalize=True + ) + types_seen = [message["type"] for message in self.connection.messages] + self.assertIn("content.delta", types_seen) + self.assertIn("turn.completed", types_seen) + deltas = [ + message["delta"] + for message in self.connection.messages + if message["type"] == "content.delta" + ] + self.assertEqual(deltas, ["Hello", " world"]) + + async def test_steer_uses_official_hermes_command(self): + await self.adapter._handle_server_frame( + { + "type": "session.ensure", + "protocolVersion": 1, + "requestId": "ensure-2", + "threadId": "thread-2", + } + ) + session_id = self.adapter._sessions["thread-2"] + await self.adapter._handle_server_frame( + { + "type": "turn.start", + "protocolVersion": 1, + "requestId": "start-2", + "threadId": "thread-2", + "sessionId": session_id, + "turnId": "turn-2", + "text": "Start", + } + ) + messages_before_steer = len(self.connection.messages) + + async def accept_steer(_event): + return ( + "⏩ Steer queued — arrives after the next tool call: 'Focus on tests'" + ) + + self.adapter._message_handler = accept_steer + await self.adapter._handle_server_frame( + { + "type": "turn.steer", + "protocolVersion": 1, + "requestId": "steer-2", + "threadId": "thread-2", + "sessionId": session_id, + "turnId": "turn-2", + "text": "Focus on tests", + } + ) + self.assertEqual(self.adapter.messages[-1].text, "/steer Focus on tests") + self.assertEqual(self.adapter.messages[-1].message_type, MessageType.COMMAND) + steer_messages = self.connection.messages[messages_before_steer:] + self.assertEqual( + [message["type"] for message in steer_messages], ["turn.started"] + ) + self.assertEqual(steer_messages[0]["requestId"], "steer-2") + self.assertIn("thread-2", self.adapter._active_turns) + + await self.adapter.edit_message( + "thread-2", + "message", + "Actual response after steering", + finalize=True, + ) + deltas = [ + message["delta"] + for message in self.connection.messages + if message["type"] == "content.delta" + ] + self.assertEqual(deltas, ["Actual response after steering"]) + self.assertNotIn("thread-2", self.adapter._active_turns) + + async def test_rejected_steer_emits_error_without_completing_active_turn(self): + await self.adapter._handle_server_frame( + { + "type": "session.ensure", + "protocolVersion": 1, + "requestId": "ensure-rejected-steer", + "threadId": "thread-rejected-steer", + } + ) + session_id = self.adapter._sessions["thread-rejected-steer"] + await self.adapter._handle_server_frame( + { + "type": "turn.start", + "protocolVersion": 1, + "requestId": "start-rejected-steer", + "threadId": "thread-rejected-steer", + "sessionId": session_id, + "turnId": "turn-rejected-steer", + "text": "Start", + } + ) + messages_before_steer = len(self.connection.messages) + + async def reject_steer(_event): + return "Steer rejected (empty payload)." + + self.adapter._message_handler = reject_steer + await self.adapter._handle_server_frame( + { + "type": "turn.steer", + "protocolVersion": 1, + "requestId": "steer-rejected", + "threadId": "thread-rejected-steer", + "sessionId": session_id, + "turnId": "turn-rejected-steer", + "text": "Focus on tests", + } + ) + + steer_messages = self.connection.messages[messages_before_steer:] + self.assertEqual( + [message["type"] for message in steer_messages], ["protocol.error"] + ) + self.assertEqual(steer_messages[0]["requestId"], "steer-rejected") + self.assertEqual(steer_messages[0]["code"], "invalid-message") + self.assertIn("thread-rejected-steer", self.adapter._active_turns) + + await self.adapter.edit_message( + "thread-rejected-steer", + "message", + "Actual response after rejected steering", + finalize=True, + ) + self.assertEqual(self.connection.messages[-1]["type"], "connection.status") + self.assertNotIn("thread-rejected-steer", self.adapter._active_turns) + + async def test_failed_steer_emits_correlated_internal_error(self): + await self.adapter._handle_server_frame( + { + "type": "session.ensure", + "protocolVersion": 1, + "requestId": "ensure-failed-steer", + "threadId": "thread-failed-steer", + } + ) + session_id = self.adapter._sessions["thread-failed-steer"] + await self.adapter._handle_server_frame( + { + "type": "turn.start", + "protocolVersion": 1, + "requestId": "start-failed-steer", + "threadId": "thread-failed-steer", + "sessionId": session_id, + "turnId": "turn-failed-steer", + "text": "Start", + } + ) + messages_before_steer = len(self.connection.messages) + + async def fail_steer(_event): + raise RuntimeError("running agent rejected steering") + + self.adapter._message_handler = fail_steer + await self.adapter._handle_server_frame( + { + "type": "turn.steer", + "protocolVersion": 1, + "requestId": "steer-failed", + "threadId": "thread-failed-steer", + "sessionId": session_id, + "turnId": "turn-failed-steer", + "text": "Focus on tests", + } + ) + + steer_messages = self.connection.messages[messages_before_steer:] + self.assertEqual( + [message["type"] for message in steer_messages], ["protocol.error"] + ) + self.assertEqual(steer_messages[0]["requestId"], "steer-failed") + self.assertEqual(steer_messages[0]["code"], "internal-error") + self.assertIn("thread-failed-steer", self.adapter._active_turns) + + async def test_session_status_counts_ready_sessions_and_stop_decrements(self): + await self.adapter._handle_server_frame( + { + "type": "session.ensure", + "protocolVersion": 1, + "requestId": "ensure-3", + "threadId": "thread-3", + } + ) + session_id = self.adapter._sessions["thread-3"] + self.assertEqual(self.connection.messages[-1]["activeSessionCount"], 1) + await self.adapter._handle_server_frame( + { + "type": "session.stop", + "protocolVersion": 1, + "requestId": "stop-3", + "threadId": "thread-3", + "sessionId": session_id, + } + ) + self.assertEqual(self.connection.messages[-1]["type"], "connection.status") + self.assertEqual(self.connection.messages[-1]["activeSessionCount"], 0) + self.assertEqual(self.adapter._sessions["thread-3"], session_id) + + +if __name__ == "__main__": + unittest.main() diff --git a/integrations/hermes-t3-gateway/tests/test_connection.py b/integrations/hermes-t3-gateway/tests/test_connection.py new file mode 100644 index 00000000000..8f26110aab0 --- /dev/null +++ b/integrations/hermes-t3-gateway/tests/test_connection.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +import importlib.util +import json +import pathlib +import sys +import types +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +PACKAGE = "hermes_t3_gateway_test" + +package = types.ModuleType(PACKAGE) +package.__path__ = [str(ROOT)] +sys.modules.setdefault(PACKAGE, package) + +for name in ("protocol", "connection"): + spec = importlib.util.spec_from_file_location( + f"{PACKAGE}.{name}", ROOT / f"{name}.py" + ) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + sys.modules[f"{PACKAGE}.{name}"] = module + spec.loader.exec_module(module) + +connection = sys.modules[f"{PACKAGE}.connection"] + + +class FakeSocket: + def __init__(self, response): + self.response = response + self.sent = [] + self.closed = False + + async def send(self, value): + self.sent.append(json.loads(value)) + + async def recv(self): + request_id = self.sent[0]["requestId"] + return json.dumps({**self.response, "requestId": request_id}) + + async def close(self): + self.closed = True + + +class ConnectionTests(unittest.IsolatedAsyncioTestCase): + def test_url_normalization(self): + self.assertEqual( + connection.websocket_url("https://t3.example"), + "wss://t3.example/api/hermes-gateway/ws", + ) + self.assertEqual( + connection.websocket_url("http://siva.davis7.space:8484/"), + "ws://siva.davis7.space:8484/api/hermes-gateway/ws", + ) + with self.assertRaises(ValueError): + connection.websocket_url("ftp://invalid.example") + + async def test_enrollment_handshake_returns_credential(self): + socket = FakeSocket( + { + "type": "connection.accepted", + "protocolVersion": 1, + "instanceId": "provider-instance", + "nickname": "Research", + "credential": "persistent-secret", + } + ) + accepted = await connection.authenticate_socket( + socket, + authentication={"type": "enrollment-token", "token": "once"}, + hermes_version="0.19.0", + ) + self.assertEqual(accepted["credential"], "persistent-secret") + self.assertEqual( + socket.sent[0]["authentication"], + {"type": "enrollment-token", "token": "once"}, + ) + + async def test_rejected_handshake_fails_closed(self): + socket = FakeSocket( + { + "type": "connection.rejected", + "code": "version-incompatible", + "message": "upgrade required", + "expectedProtocolVersion": 1, + } + ) + with self.assertRaises(connection.ConnectionRejected) as raised: + await connection.authenticate_socket( + socket, + authentication={ + "type": "instance-credential", + "instanceId": "provider-instance", + "credential": "secret", + }, + hermes_version="0.19.0", + ) + self.assertEqual(raised.exception.code, "version-incompatible") + + +if __name__ == "__main__": + unittest.main() diff --git a/integrations/hermes-t3-gateway/tests/test_protocol.py b/integrations/hermes-t3-gateway/tests/test_protocol.py new file mode 100644 index 00000000000..4064214b343 --- /dev/null +++ b/integrations/hermes-t3-gateway/tests/test_protocol.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import importlib.util +import pathlib +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location( + "t3_gateway_protocol", ROOT / "protocol.py" +) +assert SPEC and SPEC.loader +protocol = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(protocol) + + +class ProtocolTests(unittest.TestCase): + def test_hello_matches_v1_contract(self): + hello = protocol.connection_hello( + hermes_version="0.19.0", + authentication={"type": "enrollment-token", "token": "once"}, + hello_request_id="request-1", + ) + self.assertEqual(hello["type"], "connection.hello") + self.assertEqual(hello["requestId"], "request-1") + self.assertEqual(hello["protocolVersion"], 1) + self.assertFalse(hello["capabilities"]["attachments"]) + self.assertTrue(hello["capabilities"]["streaming"]) + + def test_server_frame_validation_is_closed(self): + with self.assertRaisesRegex(ValueError, "unsupported"): + protocol.validate_server_frame({"type": "made.up", "protocolVersion": 1}) + with self.assertRaisesRegex(ValueError, "version"): + protocol.validate_server_frame({"type": "ping", "protocolVersion": 2}) + + def test_tool_types_map_to_canonical_items(self): + self.assertEqual( + protocol.canonical_tool_item_type("terminal"), "command_execution" + ) + self.assertEqual( + protocol.canonical_tool_item_type("apply_patch"), "file_change" + ) + self.assertEqual( + protocol.canonical_tool_item_type("custom_vendor_tool"), + "dynamic_tool_call", + ) + + def test_tool_data_never_forwards_arbitrary_args(self): + self.assertEqual( + protocol.canonical_tool_data( + "terminal", + {"command": "pytest", "cwd": "/repo", "credential": "secret"}, + ), + {"command": "pytest", "cwd": "/repo"}, + ) + self.assertIsNone( + protocol.canonical_tool_data( + "custom_vendor_tool", {"credential": "must-not-cross"} + ) + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index ea7f5fb6d75..d5fd8ed624d 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -315,6 +315,22 @@ export function createServerEnvironmentAtoms( key: ({ environmentId }) => environmentId, }, }), + hermesGatewayCreateEnrollment: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:hermes-gateway-create-enrollment", + tag: WS_METHODS.hermesGatewayCreateEnrollment, + }), + hermesGatewayGetInstanceStatus: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:hermes-gateway-get-instance-status", + tag: WS_METHODS.hermesGatewayGetInstanceStatus, + }), + hermesGatewayListInstances: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:hermes-gateway-list-instances", + tag: WS_METHODS.hermesGatewayListInstances, + }), + hermesGatewayRevokeInstance: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:hermes-gateway-revoke-instance", + tag: WS_METHODS.hermesGatewayRevokeInstance, + }), updateProvider: createEnvironmentRpcCommand(runtime, { label: "environment-data:server:update-provider", tag: WS_METHODS.serverUpdateProvider, diff --git a/packages/contracts/src/hermesGateway.test.ts b/packages/contracts/src/hermesGateway.test.ts new file mode 100644 index 00000000000..0cfef7b7994 --- /dev/null +++ b/packages/contracts/src/hermesGateway.test.ts @@ -0,0 +1,414 @@ +import * as Schema from "effect/Schema"; +import { describe, expect, it } from "vite-plus/test"; + +import { + DEFAULT_HERMES_MODEL, + DEFAULT_MODEL_BY_PROVIDER, + HERMES_DRIVER_KIND, + PROVIDER_DISPLAY_NAMES, +} from "./model.ts"; +import { + HermesGatewayCapabilities, + HermesGatewayConnectionHello, + HermesGatewayCreateEnrollmentInput, + HermesGatewayInstanceStatus, + HermesGatewayPluginToT3Message, + HermesGatewayResumeCursor, + HermesGatewayT3ToPluginMessage, +} from "./hermesGateway.ts"; +import { WS_METHODS } from "./rpc.ts"; +import { DEFAULT_SERVER_SETTINGS, HermesSettings } from "./settings.ts"; + +const decodeCreateEnrollment = Schema.decodeUnknownSync(HermesGatewayCreateEnrollmentInput); +const decodeCapabilities = Schema.decodeUnknownSync(HermesGatewayCapabilities); +const decodeInstanceStatus = Schema.decodeUnknownSync(HermesGatewayInstanceStatus); +const decodeHello = Schema.decodeUnknownSync(HermesGatewayConnectionHello); +const decodeResumeCursor = Schema.decodeUnknownSync(HermesGatewayResumeCursor); +const decodeT3Message = Schema.decodeUnknownSync(HermesGatewayT3ToPluginMessage); +const decodePluginMessage = Schema.decodeUnknownSync(HermesGatewayPluginToT3Message); +const decodeHermesSettings = Schema.decodeUnknownSync(HermesSettings); + +describe("Hermes gateway management contracts", () => { + it("decodes an enrollment request without deriving identity from the nickname", () => { + expect( + decodeCreateEnrollment({ + instanceId: "hermes-research", + nickname: " Research ", + connectorUrl: " https://siva.davis7.space:3774/hermes ", + }), + ).toEqual({ + instanceId: "hermes-research", + nickname: "Research", + connectorUrl: "https://siva.davis7.space:3774/hermes", + }); + }); + + it("rejects invalid provider ids and non-connector URL schemes", () => { + expect(() => + decodeCreateEnrollment({ + instanceId: "1-hermes", + nickname: "Research", + connectorUrl: "wss://siva.davis7.space/hermes", + }), + ).toThrow(); + expect(() => + decodeCreateEnrollment({ + instanceId: "hermes-research", + nickname: "Research", + connectorUrl: "ftp://siva.davis7.space/hermes", + }), + ).toThrow(); + }); + + it("represents connected and upgrade-required instances for the web UI", () => { + const connected = decodeInstanceStatus({ + instanceId: "hermes-research", + nickname: "Research", + status: "connected", + connectorUrl: "wss://siva.davis7.space/hermes", + lastConnectedAt: "2026-07-23T12:00:00.000Z", + pluginVersion: "0.1.0", + hermesVersion: "1.2.3", + activeSessionCount: 2, + protocolVersion: 1, + capabilities: { + protocolVersion: 1, + streaming: true, + activity: true, + approvals: true, + userInput: true, + attachments: false, + }, + }); + expect(connected.status).toBe("connected"); + expect(connected.activeSessionCount).toBe(2); + + const upgradeRequired = decodeInstanceStatus({ + ...connected, + status: "upgrade-required", + protocolVersion: 2, + capabilities: null, + }); + expect(upgradeRequired.protocolVersion).toBe(2); + expect(upgradeRequired.capabilities).toBeNull(); + }); +}); + +describe("Hermes gateway handshake", () => { + it("accepts one-time enrollment authentication", () => { + const hello = decodeHello({ + type: "connection.hello", + requestId: "hello-1", + protocolVersion: 1, + pluginVersion: "0.1.0", + hermesVersion: "1.2.3", + capabilities: { + protocolVersion: 1, + streaming: true, + activity: true, + approvals: true, + userInput: true, + attachments: false, + }, + authentication: { + type: "enrollment-token", + token: "enroll-secret", + }, + }); + + expect(hello.authentication.type).toBe("enrollment-token"); + }); + + it("accepts persistent instance authentication", () => { + const hello = decodeHello({ + type: "connection.hello", + requestId: "hello-2", + protocolVersion: 1, + pluginVersion: "0.1.0", + hermesVersion: "1.2.3", + capabilities: { + protocolVersion: 1, + streaming: true, + activity: true, + approvals: true, + userInput: true, + attachments: false, + }, + authentication: { + type: "instance-credential", + instanceId: "hermes-research", + credential: "persistent-secret", + }, + }); + + expect(hello.authentication.type).toBe("instance-credential"); + }); + + it("decodes a future-version hello so the broker can reject it explicitly", () => { + const hello = decodeHello({ + type: "connection.hello", + requestId: "hello-future", + protocolVersion: 2, + pluginVersion: "0.2.0", + hermesVersion: "2.0.0", + capabilities: { + protocolVersion: 2, + streaming: true, + activity: true, + approvals: true, + userInput: true, + attachments: true, + }, + authentication: { + type: "enrollment-token", + token: "enroll-secret", + }, + }); + + expect(hello.protocolVersion).toBe(2); + expect(hello.capabilities.protocolVersion).toBe(2); + }); + + it("reserves attachments for a future protocol version", () => { + expect(() => + decodeCapabilities({ + protocolVersion: 1, + streaming: true, + activity: true, + approvals: true, + userInput: true, + attachments: true, + }), + ).toThrow(); + }); +}); + +describe("T3 to Hermes v1 messages", () => { + it("decodes session creation and opaque resume cursors", () => { + expect( + decodeT3Message({ + type: "session.ensure", + protocolVersion: 1, + requestId: "ensure-1", + threadId: "thread-1", + resumeSessionId: "opaque/hermes/session/value", + }).type, + ).toBe("session.ensure"); + + expect( + decodeResumeCursor({ + protocolVersion: 1, + sessionId: "opaque/hermes/session/value", + }).sessionId, + ).toBe("opaque/hermes/session/value"); + }); + + it("decodes start and steering as distinct turn operations", () => { + const context = { + protocolVersion: 1, + requestId: "turn-command-1", + threadId: "thread-1", + sessionId: "session-1", + turnId: "turn-1", + text: "Keep the current turn running, but use this guidance.", + }; + + expect(decodeT3Message({ type: "turn.start", ...context }).type).toBe("turn.start"); + expect(decodeT3Message({ type: "turn.steer", ...context }).type).toBe("turn.steer"); + }); + + it("decodes interrupt, approval, structured input, stop, and ping", () => { + const turnContext = { + protocolVersion: 1, + threadId: "thread-1", + sessionId: "session-1", + turnId: "turn-1", + }; + + expect( + decodeT3Message({ + type: "turn.interrupt", + requestId: "interrupt-1", + ...turnContext, + }).type, + ).toBe("turn.interrupt"); + expect( + decodeT3Message({ + type: "approval.respond", + requestId: "approval-1", + decision: "acceptForSession", + ...turnContext, + }).type, + ).toBe("approval.respond"); + expect( + decodeT3Message({ + type: "user-input.respond", + requestId: "question-1", + answers: { environment: "production" }, + ...turnContext, + }).type, + ).toBe("user-input.respond"); + expect( + decodeT3Message({ + type: "session.stop", + protocolVersion: 1, + requestId: "stop-1", + threadId: "thread-1", + sessionId: "session-1", + }).type, + ).toBe("session.stop"); + expect( + decodeT3Message({ + type: "ping", + protocolVersion: 1, + requestId: "ping-1", + sentAt: "2026-07-23T12:00:00.000Z", + }).type, + ).toBe("ping"); + }); + + it("rejects post-handshake frames from another protocol version", () => { + expect(() => + decodeT3Message({ + type: "ping", + protocolVersion: 2, + requestId: "ping-1", + sentAt: "2026-07-23T12:00:00.000Z", + }), + ).toThrow(); + }); +}); + +describe("Hermes to T3 v1 events", () => { + const turnContext = { + protocolVersion: 1, + threadId: "thread-1", + sessionId: "session-1", + turnId: "turn-1", + }; + + it("decodes session readiness, turn start, streaming text, and completion", () => { + expect( + decodePluginMessage({ + type: "session.ready", + protocolVersion: 1, + requestId: "ensure-1", + threadId: "thread-1", + sessionId: "session-1", + resumed: false, + }).type, + ).toBe("session.ready"); + expect( + decodePluginMessage({ + type: "turn.started", + requestId: "turn-command-1", + ...turnContext, + }).type, + ).toBe("turn.started"); + expect( + decodePluginMessage({ + type: "content.delta", + streamKind: "assistant_text", + delta: "Hello", + ...turnContext, + }).type, + ).toBe("content.delta"); + expect( + decodePluginMessage({ + type: "turn.completed", + state: "completed", + ...turnContext, + }).type, + ).toBe("turn.completed"); + }); + + it("decodes activity lifecycle events with normalized and generic data", () => { + for (const type of ["item.started", "item.updated", "item.completed"] as const) { + expect( + decodePluginMessage({ + type, + itemId: "tool-1", + itemType: "mcp_tool_call", + status: type === "item.completed" ? "completed" : "inProgress", + title: "Search", + detail: "Looking up the requested information", + data: { providerKind: "hermes-native-event" }, + ...turnContext, + }).type, + ).toBe(type); + } + }); + + it("decodes approvals and structured user-input lifecycle events", () => { + expect( + decodePluginMessage({ + type: "request.opened", + requestId: "approval-1", + requestType: "command_execution_approval", + detail: "Run the command?", + args: { command: "git status" }, + ...turnContext, + }).type, + ).toBe("request.opened"); + expect( + decodePluginMessage({ + type: "request.resolved", + requestId: "approval-1", + requestType: "command_execution_approval", + decision: "accept", + ...turnContext, + }).type, + ).toBe("request.resolved"); + expect( + decodePluginMessage({ + type: "user-input.requested", + requestId: "question-1", + questions: [ + { + id: "environment", + header: "Target", + question: "Which environment?", + options: [ + { + label: "Staging", + description: "Deploy to the staging environment.", + }, + ], + }, + ], + ...turnContext, + }).type, + ).toBe("user-input.requested"); + expect( + decodePluginMessage({ + type: "user-input.resolved", + requestId: "question-1", + answers: { environment: "Staging" }, + ...turnContext, + }).type, + ).toBe("user-input.resolved"); + }); +}); + +describe("Hermes provider integration constants", () => { + it("exposes Hermes as a single opaque model in the normal provider picker", () => { + expect(DEFAULT_MODEL_BY_PROVIDER[HERMES_DRIVER_KIND]).toBe(DEFAULT_HERMES_MODEL); + expect(PROVIDER_DISPLAY_NAMES[HERMES_DRIVER_KIND]).toBe("Hermes"); + }); + + it("keeps Hermes server settings remote-only", () => { + expect(decodeHermesSettings({})).toEqual({ + enabled: true, + }); + expect(DEFAULT_SERVER_SETTINGS.providers.hermes).toEqual({ + enabled: true, + }); + }); + + it("registers the web-management RPC method names", () => { + expect(WS_METHODS.hermesGatewayCreateEnrollment).toBe("hermesGateway.createEnrollment"); + expect(WS_METHODS.hermesGatewayGetInstanceStatus).toBe("hermesGateway.getInstanceStatus"); + expect(WS_METHODS.hermesGatewayListInstances).toBe("hermesGateway.listInstances"); + expect(WS_METHODS.hermesGatewayRevokeInstance).toBe("hermesGateway.revokeInstance"); + }); +}); diff --git a/packages/contracts/src/hermesGateway.ts b/packages/contracts/src/hermesGateway.ts new file mode 100644 index 00000000000..4afdf40b822 --- /dev/null +++ b/packages/contracts/src/hermesGateway.ts @@ -0,0 +1,574 @@ +/** + * Versioned contracts for the T3 Code gateway plugin hosted by Hermes. + * + * The web-management schemas are intentionally separate from the plugin wire + * protocol. Browser clients may receive one-time enrollment tokens, but never + * the persistent credential issued directly to the plugin after enrollment. + * + * @module hermesGateway + */ +import * as Schema from "effect/Schema"; + +import { + IsoDateTime, + NonNegativeInt, + PositiveInt, + ThreadId, + TrimmedNonEmptyString, + TurnId, +} from "./baseSchemas.ts"; +import { ProviderApprovalDecision, ProviderUserInputAnswers } from "./orchestration.ts"; +import { ProviderInstanceId } from "./providerInstance.ts"; +import { CanonicalItemType, CanonicalRequestType, UserInputQuestion } from "./providerRuntime.ts"; + +export const HERMES_GATEWAY_PROTOCOL_VERSION = 1 as const; + +export const HermesGatewayProtocolVersion = Schema.Literal(HERMES_GATEWAY_PROTOCOL_VERSION); +export type HermesGatewayProtocolVersion = typeof HermesGatewayProtocolVersion.Type; + +export const HermesGatewayRequestId = TrimmedNonEmptyString.pipe( + Schema.brand("HermesGatewayRequestId"), +); +export type HermesGatewayRequestId = typeof HermesGatewayRequestId.Type; + +/** + * An opaque identifier owned entirely by Hermes. T3 persists and echoes it, + * but must not derive routing or other semantics from its contents. + */ +export const HermesGatewaySessionId = TrimmedNonEmptyString.pipe( + Schema.brand("HermesGatewaySessionId"), +); +export type HermesGatewaySessionId = typeof HermesGatewaySessionId.Type; + +export const HermesGatewayResumeCursor = Schema.Struct({ + protocolVersion: HermesGatewayProtocolVersion, + sessionId: HermesGatewaySessionId, +}); +export type HermesGatewayResumeCursor = typeof HermesGatewayResumeCursor.Type; + +export const HermesGatewayItemId = TrimmedNonEmptyString.pipe(Schema.brand("HermesGatewayItemId")); +export type HermesGatewayItemId = typeof HermesGatewayItemId.Type; + +export const HermesGatewayEnrollmentToken = TrimmedNonEmptyString.pipe( + Schema.brand("HermesGatewayEnrollmentToken"), +); +export type HermesGatewayEnrollmentToken = typeof HermesGatewayEnrollmentToken.Type; + +export const HermesGatewayCredential = TrimmedNonEmptyString.pipe( + Schema.brand("HermesGatewayCredential"), +); +export type HermesGatewayCredential = typeof HermesGatewayCredential.Type; + +export const HermesGatewayNickname = TrimmedNonEmptyString.check(Schema.isMaxLength(64)); +export type HermesGatewayNickname = typeof HermesGatewayNickname.Type; + +/** + * T3 accepts ordinary HTTP(S) URLs because the plugin command may normalize + * them to WebSocket URLs, as well as explicit WS(S) connector URLs. + */ +export const HermesGatewayConnectorUrl = TrimmedNonEmptyString.check( + Schema.isMaxLength(2_048), + Schema.isPattern(/^(?:https?|wss?):\/\/\S+$/i), +); +export type HermesGatewayConnectorUrl = typeof HermesGatewayConnectorUrl.Type; + +export const HermesGatewayCapabilities = Schema.Struct({ + protocolVersion: HermesGatewayProtocolVersion, + streaming: Schema.Boolean, + activity: Schema.Boolean, + approvals: Schema.Boolean, + userInput: Schema.Boolean, + attachments: Schema.Literal(false), +}); +export type HermesGatewayCapabilities = typeof HermesGatewayCapabilities.Type; + +/** + * Capability advertisement accepted at the initial handshake boundary. + * + * This deliberately permits capability shapes from a newer protocol so T3 can + * return a structured `version-incompatible` rejection instead of failing the + * WebSocket frame decoder. Accepted v1 connections must subsequently validate + * this advertisement with `HermesGatewayCapabilities`. + */ +export const HermesGatewayHelloCapabilities = Schema.Struct({ + protocolVersion: PositiveInt, + streaming: Schema.Boolean, + activity: Schema.Boolean, + approvals: Schema.Boolean, + userInput: Schema.Boolean, + attachments: Schema.Boolean, +}); +export type HermesGatewayHelloCapabilities = typeof HermesGatewayHelloCapabilities.Type; + +export const HermesGatewayConnectionState = Schema.Literals([ + "offline", + "connecting", + "connected", + "upgrade-required", + "revoked", +]); +export type HermesGatewayConnectionState = typeof HermesGatewayConnectionState.Type; + +/** + * Public instance state used by settings and provider-picker surfaces. + * + * `protocolVersion` is not restricted to v1 here so the UI can report the + * unsupported version observed from a plugin that needs an upgrade. + */ +export const HermesGatewayInstanceStatus = Schema.Struct({ + instanceId: ProviderInstanceId, + nickname: HermesGatewayNickname, + status: HermesGatewayConnectionState, + connectorUrl: HermesGatewayConnectorUrl, + lastConnectedAt: Schema.NullOr(IsoDateTime), + pluginVersion: Schema.NullOr(TrimmedNonEmptyString), + hermesVersion: Schema.NullOr(TrimmedNonEmptyString), + activeSessionCount: NonNegativeInt, + protocolVersion: Schema.NullOr(PositiveInt), + capabilities: Schema.NullOr(HermesGatewayCapabilities), +}); +export type HermesGatewayInstanceStatus = typeof HermesGatewayInstanceStatus.Type; + +export const HermesGatewayCreateEnrollmentInput = Schema.Struct({ + instanceId: ProviderInstanceId, + nickname: HermesGatewayNickname, + connectorUrl: HermesGatewayConnectorUrl, +}); +export type HermesGatewayCreateEnrollmentInput = typeof HermesGatewayCreateEnrollmentInput.Type; + +/** + * Returned exactly once to the web client. The long-lived plugin credential + * is intentionally absent and is delivered only over the authenticated + * enrollment socket. + */ +export const HermesGatewayEnrollmentResult = Schema.Struct({ + instanceId: ProviderInstanceId, + expiresAt: IsoDateTime, + connectorUrl: HermesGatewayConnectorUrl, + command: TrimmedNonEmptyString, + oneTimeToken: HermesGatewayEnrollmentToken, +}); +export type HermesGatewayEnrollmentResult = typeof HermesGatewayEnrollmentResult.Type; + +export const HermesGatewayListInstancesResult = Schema.Array(HermesGatewayInstanceStatus); +export type HermesGatewayListInstancesResult = typeof HermesGatewayListInstancesResult.Type; + +export const HermesGatewayGetInstanceStatusInput = Schema.Struct({ + instanceId: ProviderInstanceId, +}); +export type HermesGatewayGetInstanceStatusInput = typeof HermesGatewayGetInstanceStatusInput.Type; + +export const HermesGatewayRenameInstanceInput = Schema.Struct({ + instanceId: ProviderInstanceId, + nickname: HermesGatewayNickname, +}); +export type HermesGatewayRenameInstanceInput = typeof HermesGatewayRenameInstanceInput.Type; + +export const HermesGatewayRevokeInstanceInput = Schema.Struct({ + instanceId: ProviderInstanceId, +}); +export type HermesGatewayRevokeInstanceInput = typeof HermesGatewayRevokeInstanceInput.Type; + +export const HermesGatewayRevokeInstanceResult = HermesGatewayInstanceStatus; +export type HermesGatewayRevokeInstanceResult = typeof HermesGatewayRevokeInstanceResult.Type; + +export const HermesGatewayManagementOperation = Schema.Literals([ + "create-enrollment", + "get-status", + "list-instances", + "revoke-instance", +]); +export type HermesGatewayManagementOperation = typeof HermesGatewayManagementOperation.Type; + +export const HermesGatewayManagementErrorCode = Schema.Literals([ + "instance-not-found", + "nickname-conflict", + "invalid-connector-url", + "instance-revoked", + "persistence-failed", + "internal-error", +]); +export type HermesGatewayManagementErrorCode = typeof HermesGatewayManagementErrorCode.Type; + +export class HermesGatewayManagementError extends Schema.TaggedErrorClass()( + "HermesGatewayManagementError", + { + operation: HermesGatewayManagementOperation, + code: HermesGatewayManagementErrorCode, + message: TrimmedNonEmptyString, + instanceId: Schema.optional(ProviderInstanceId), + }, +) {} + +const HermesGatewayEnrollmentAuthentication = Schema.Struct({ + type: Schema.Literal("enrollment-token"), + token: HermesGatewayEnrollmentToken, +}); +export type HermesGatewayEnrollmentAuthentication = + typeof HermesGatewayEnrollmentAuthentication.Type; + +const HermesGatewayCredentialAuthentication = Schema.Struct({ + type: Schema.Literal("instance-credential"), + instanceId: ProviderInstanceId, + credential: HermesGatewayCredential, +}); +export type HermesGatewayCredentialAuthentication = + typeof HermesGatewayCredentialAuthentication.Type; + +export const HermesGatewayAuthentication = Schema.Union([ + HermesGatewayEnrollmentAuthentication, + HermesGatewayCredentialAuthentication, +]); +export type HermesGatewayAuthentication = typeof HermesGatewayAuthentication.Type; + +/** + * `protocolVersion` accepts any positive integer at the initial boundary so + * T3 can reject incompatible plugins with a structured upgrade response. + * Once accepted, all remaining v1 frames use the literal v1 schema. + */ +export const HermesGatewayConnectionHello = Schema.Struct({ + type: Schema.Literal("connection.hello"), + requestId: HermesGatewayRequestId, + protocolVersion: PositiveInt, + pluginVersion: TrimmedNonEmptyString, + hermesVersion: TrimmedNonEmptyString, + capabilities: HermesGatewayHelloCapabilities, + authentication: HermesGatewayAuthentication, +}); +export type HermesGatewayConnectionHello = typeof HermesGatewayConnectionHello.Type; + +export const HermesGatewayConnectionAccepted = Schema.Struct({ + type: Schema.Literal("connection.accepted"), + requestId: HermesGatewayRequestId, + protocolVersion: HermesGatewayProtocolVersion, + instanceId: ProviderInstanceId, + nickname: HermesGatewayNickname, + credential: Schema.optional(HermesGatewayCredential), +}); +export type HermesGatewayConnectionAccepted = typeof HermesGatewayConnectionAccepted.Type; + +export const HermesGatewayConnectionRejectionCode = Schema.Literals([ + "invalid-authentication", + "enrollment-expired", + "instance-revoked", + "version-incompatible", + "internal-error", +]); +export type HermesGatewayConnectionRejectionCode = typeof HermesGatewayConnectionRejectionCode.Type; + +export const HermesGatewayConnectionRejected = Schema.Struct({ + type: Schema.Literal("connection.rejected"), + requestId: HermesGatewayRequestId, + code: HermesGatewayConnectionRejectionCode, + message: TrimmedNonEmptyString, + expectedProtocolVersion: HermesGatewayProtocolVersion, +}); +export type HermesGatewayConnectionRejected = typeof HermesGatewayConnectionRejected.Type; + +export const HermesGatewayConnectionStatus = Schema.Struct({ + type: Schema.Literal("connection.status"), + protocolVersion: HermesGatewayProtocolVersion, + activeSessionCount: NonNegativeInt, +}); +export type HermesGatewayConnectionStatus = typeof HermesGatewayConnectionStatus.Type; + +const HermesGatewaySessionContext = Schema.Struct({ + threadId: ThreadId, + sessionId: HermesGatewaySessionId, +}); + +const HermesGatewayTurnContext = Schema.Struct({ + ...HermesGatewaySessionContext.fields, + turnId: TurnId, +}); + +const HermesGatewayTurnText = Schema.String.check( + Schema.isMinLength(1), + Schema.isMaxLength(120_000), +); + +export const HermesGatewaySessionEnsure = Schema.Struct({ + type: Schema.Literal("session.ensure"), + protocolVersion: HermesGatewayProtocolVersion, + requestId: HermesGatewayRequestId, + threadId: ThreadId, + resumeSessionId: Schema.optional(HermesGatewaySessionId), +}); +export type HermesGatewaySessionEnsure = typeof HermesGatewaySessionEnsure.Type; + +export const HermesGatewayTurnStart = Schema.Struct({ + type: Schema.Literal("turn.start"), + protocolVersion: HermesGatewayProtocolVersion, + requestId: HermesGatewayRequestId, + ...HermesGatewayTurnContext.fields, + text: HermesGatewayTurnText, +}); +export type HermesGatewayTurnStart = typeof HermesGatewayTurnStart.Type; + +export const HermesGatewayTurnSteer = Schema.Struct({ + type: Schema.Literal("turn.steer"), + protocolVersion: HermesGatewayProtocolVersion, + requestId: HermesGatewayRequestId, + ...HermesGatewayTurnContext.fields, + text: HermesGatewayTurnText, +}); +export type HermesGatewayTurnSteer = typeof HermesGatewayTurnSteer.Type; + +export const HermesGatewayTurnInterrupt = Schema.Struct({ + type: Schema.Literal("turn.interrupt"), + protocolVersion: HermesGatewayProtocolVersion, + requestId: HermesGatewayRequestId, + ...HermesGatewayTurnContext.fields, +}); +export type HermesGatewayTurnInterrupt = typeof HermesGatewayTurnInterrupt.Type; + +export const HermesGatewayApprovalResponse = Schema.Struct({ + type: Schema.Literal("approval.respond"), + protocolVersion: HermesGatewayProtocolVersion, + ...HermesGatewayTurnContext.fields, + requestId: HermesGatewayRequestId, + decision: ProviderApprovalDecision, +}); +export type HermesGatewayApprovalResponse = typeof HermesGatewayApprovalResponse.Type; + +export const HermesGatewayUserInputResponse = Schema.Struct({ + type: Schema.Literal("user-input.respond"), + protocolVersion: HermesGatewayProtocolVersion, + ...HermesGatewayTurnContext.fields, + requestId: HermesGatewayRequestId, + answers: ProviderUserInputAnswers, +}); +export type HermesGatewayUserInputResponse = typeof HermesGatewayUserInputResponse.Type; + +export const HermesGatewaySessionStop = Schema.Struct({ + type: Schema.Literal("session.stop"), + protocolVersion: HermesGatewayProtocolVersion, + requestId: HermesGatewayRequestId, + ...HermesGatewaySessionContext.fields, +}); +export type HermesGatewaySessionStop = typeof HermesGatewaySessionStop.Type; + +export const HermesGatewayPing = Schema.Struct({ + type: Schema.Literal("ping"), + protocolVersion: HermesGatewayProtocolVersion, + requestId: HermesGatewayRequestId, + sentAt: IsoDateTime, +}); +export type HermesGatewayPing = typeof HermesGatewayPing.Type; + +export const HermesGatewaySessionReady = Schema.Struct({ + type: Schema.Literal("session.ready"), + protocolVersion: HermesGatewayProtocolVersion, + requestId: HermesGatewayRequestId, + threadId: ThreadId, + sessionId: HermesGatewaySessionId, + resumed: Schema.Boolean, +}); +export type HermesGatewaySessionReady = typeof HermesGatewaySessionReady.Type; + +export const HermesGatewayTurnStarted = Schema.Struct({ + type: Schema.Literal("turn.started"), + protocolVersion: HermesGatewayProtocolVersion, + requestId: HermesGatewayRequestId, + ...HermesGatewayTurnContext.fields, +}); +export type HermesGatewayTurnStarted = typeof HermesGatewayTurnStarted.Type; + +export const HermesGatewayContentStreamKind = Schema.Literals([ + "assistant_text", + "reasoning_text", + "reasoning_summary_text", + "plan_text", + "command_output", + "unknown", +]); +export type HermesGatewayContentStreamKind = typeof HermesGatewayContentStreamKind.Type; + +export const HermesGatewayContentDelta = Schema.Struct({ + type: Schema.Literal("content.delta"), + protocolVersion: HermesGatewayProtocolVersion, + ...HermesGatewayTurnContext.fields, + itemId: Schema.optional(HermesGatewayItemId), + streamKind: HermesGatewayContentStreamKind, + delta: Schema.String, + contentIndex: Schema.optional(NonNegativeInt), +}); +export type HermesGatewayContentDelta = typeof HermesGatewayContentDelta.Type; + +export const HermesGatewayItemStatus = Schema.Literals([ + "inProgress", + "completed", + "failed", + "declined", +]); +export type HermesGatewayItemStatus = typeof HermesGatewayItemStatus.Type; + +const HermesGatewayItemLifecycleFields = { + protocolVersion: HermesGatewayProtocolVersion, + ...HermesGatewayTurnContext.fields, + itemId: HermesGatewayItemId, + itemType: CanonicalItemType, + status: Schema.optional(HermesGatewayItemStatus), + title: Schema.optional(TrimmedNonEmptyString), + detail: Schema.optional(TrimmedNonEmptyString), + data: Schema.optional(Schema.Unknown), +}; + +export const HermesGatewayItemStarted = Schema.Struct({ + type: Schema.Literal("item.started"), + ...HermesGatewayItemLifecycleFields, +}); +export type HermesGatewayItemStarted = typeof HermesGatewayItemStarted.Type; + +export const HermesGatewayItemUpdated = Schema.Struct({ + type: Schema.Literal("item.updated"), + ...HermesGatewayItemLifecycleFields, +}); +export type HermesGatewayItemUpdated = typeof HermesGatewayItemUpdated.Type; + +export const HermesGatewayItemCompleted = Schema.Struct({ + type: Schema.Literal("item.completed"), + ...HermesGatewayItemLifecycleFields, +}); +export type HermesGatewayItemCompleted = typeof HermesGatewayItemCompleted.Type; + +const HermesGatewayInteractionContext = Schema.Struct({ + ...HermesGatewayTurnContext.fields, + requestId: HermesGatewayRequestId, +}); + +export const HermesGatewayRequestOpened = Schema.Struct({ + type: Schema.Literal("request.opened"), + protocolVersion: HermesGatewayProtocolVersion, + ...HermesGatewayInteractionContext.fields, + requestType: CanonicalRequestType, + detail: Schema.optional(TrimmedNonEmptyString), + args: Schema.optional(Schema.Unknown), +}); +export type HermesGatewayRequestOpened = typeof HermesGatewayRequestOpened.Type; + +export const HermesGatewayRequestResolved = Schema.Struct({ + type: Schema.Literal("request.resolved"), + protocolVersion: HermesGatewayProtocolVersion, + ...HermesGatewayInteractionContext.fields, + requestType: CanonicalRequestType, + decision: Schema.optional(TrimmedNonEmptyString), + resolution: Schema.optional(Schema.Unknown), +}); +export type HermesGatewayRequestResolved = typeof HermesGatewayRequestResolved.Type; + +export const HermesGatewayUserInputRequested = Schema.Struct({ + type: Schema.Literal("user-input.requested"), + protocolVersion: HermesGatewayProtocolVersion, + ...HermesGatewayInteractionContext.fields, + questions: Schema.Array(UserInputQuestion), +}); +export type HermesGatewayUserInputRequested = typeof HermesGatewayUserInputRequested.Type; + +export const HermesGatewayUserInputResolved = Schema.Struct({ + type: Schema.Literal("user-input.resolved"), + protocolVersion: HermesGatewayProtocolVersion, + ...HermesGatewayInteractionContext.fields, + answers: ProviderUserInputAnswers, +}); +export type HermesGatewayUserInputResolved = typeof HermesGatewayUserInputResolved.Type; + +export const HermesGatewayTurnCompletionState = Schema.Literals(["completed", "failed"]); +export type HermesGatewayTurnCompletionState = typeof HermesGatewayTurnCompletionState.Type; + +export const HermesGatewayTurnCompleted = Schema.Struct({ + type: Schema.Literal("turn.completed"), + protocolVersion: HermesGatewayProtocolVersion, + ...HermesGatewayTurnContext.fields, + state: HermesGatewayTurnCompletionState, + stopReason: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + usage: Schema.optional(Schema.Unknown), + errorMessage: Schema.optional(TrimmedNonEmptyString), +}); +export type HermesGatewayTurnCompleted = typeof HermesGatewayTurnCompleted.Type; + +export const HermesGatewayTurnAborted = Schema.Struct({ + type: Schema.Literal("turn.aborted"), + protocolVersion: HermesGatewayProtocolVersion, + ...HermesGatewayTurnContext.fields, + reason: TrimmedNonEmptyString, +}); +export type HermesGatewayTurnAborted = typeof HermesGatewayTurnAborted.Type; + +export const HermesGatewaySessionExited = Schema.Struct({ + type: Schema.Literal("session.exited"), + protocolVersion: HermesGatewayProtocolVersion, + ...HermesGatewaySessionContext.fields, + reason: Schema.optional(TrimmedNonEmptyString), + recoverable: Schema.Boolean, +}); +export type HermesGatewaySessionExited = typeof HermesGatewaySessionExited.Type; + +export const HermesGatewayPong = Schema.Struct({ + type: Schema.Literal("pong"), + protocolVersion: HermesGatewayProtocolVersion, + requestId: HermesGatewayRequestId, + sentAt: IsoDateTime, +}); +export type HermesGatewayPong = typeof HermesGatewayPong.Type; + +export const HermesGatewayProtocolErrorCode = Schema.Literals([ + "invalid-message", + "unsupported-message", + "session-not-found", + "turn-not-active", + "request-not-found", + "internal-error", +]); +export type HermesGatewayProtocolErrorCode = typeof HermesGatewayProtocolErrorCode.Type; + +export const HermesGatewayProtocolError = Schema.Struct({ + type: Schema.Literal("protocol.error"), + protocolVersion: HermesGatewayProtocolVersion, + requestId: Schema.optional(HermesGatewayRequestId), + code: HermesGatewayProtocolErrorCode, + message: TrimmedNonEmptyString, + recoverable: Schema.Boolean, +}); +export type HermesGatewayProtocolError = typeof HermesGatewayProtocolError.Type; + +export const HermesGatewayT3ToPluginMessage = Schema.Union([ + HermesGatewayConnectionAccepted, + HermesGatewayConnectionRejected, + HermesGatewaySessionEnsure, + HermesGatewayTurnStart, + HermesGatewayTurnSteer, + HermesGatewayTurnInterrupt, + HermesGatewayApprovalResponse, + HermesGatewayUserInputResponse, + HermesGatewaySessionStop, + HermesGatewayPing, +]); +export type HermesGatewayT3ToPluginMessage = typeof HermesGatewayT3ToPluginMessage.Type; + +export const HermesGatewayPluginToT3Message = Schema.Union([ + HermesGatewayConnectionHello, + HermesGatewayConnectionStatus, + HermesGatewaySessionReady, + HermesGatewayTurnStarted, + HermesGatewayContentDelta, + HermesGatewayItemStarted, + HermesGatewayItemUpdated, + HermesGatewayItemCompleted, + HermesGatewayRequestOpened, + HermesGatewayRequestResolved, + HermesGatewayUserInputRequested, + HermesGatewayUserInputResolved, + HermesGatewayTurnCompleted, + HermesGatewayTurnAborted, + HermesGatewaySessionExited, + HermesGatewayPong, + HermesGatewayProtocolError, +]); +export type HermesGatewayPluginToT3Message = typeof HermesGatewayPluginToT3Message.Type; + +export const HermesGatewayWireMessage = Schema.Union([ + HermesGatewayT3ToPluginMessage, + HermesGatewayPluginToT3Message, +]); +export type HermesGatewayWireMessage = typeof HermesGatewayWireMessage.Type; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 5936cb0fc17..feb9401b997 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -27,3 +27,4 @@ export * from "./review.ts"; export * from "./preview.ts"; export * from "./previewAutomation.ts"; export * from "./rpc.ts"; +export * from "./hermesGateway.ts"; diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 8c74c13b89b..9660034675b 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -132,8 +132,10 @@ const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor"); const GROK_DRIVER_KIND = ProviderDriverKind.make("grok"); const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode"); +export const HERMES_DRIVER_KIND = ProviderDriverKind.make("hermes"); export const DEFAULT_MODEL = "gpt-5.6-sol"; +export const DEFAULT_HERMES_MODEL = "hermes"; /** * Codex default-model preference, most preferred first. The provider snapshot @@ -152,6 +154,7 @@ export const DEFAULT_MODEL_BY_PROVIDER: Partial> [CURSOR_DRIVER_KIND]: "Cursor", [GROK_DRIVER_KIND]: "Grok", [OPENCODE_DRIVER_KIND]: "OpenCode", + [HERMES_DRIVER_KIND]: "Hermes", }; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index fa2d23b8ef2..f7b331d03ce 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -59,6 +59,16 @@ import { OrchestrationRpcSchemas, } from "./orchestration.ts"; import { ProviderInstanceId } from "./providerInstance.ts"; +import { + HermesGatewayCreateEnrollmentInput, + HermesGatewayEnrollmentResult, + HermesGatewayGetInstanceStatusInput, + HermesGatewayInstanceStatus, + HermesGatewayListInstancesResult, + HermesGatewayManagementError, + HermesGatewayRevokeInstanceInput, + HermesGatewayRevokeInstanceResult, +} from "./hermesGateway.ts"; import { RelayClientInstallFailedError, RelayClientInstallProgressEventSchema, @@ -219,6 +229,12 @@ export const WS_METHODS = { serverGetProcessResourceHistory: "server.getProcessResourceHistory", serverSignalProcess: "server.signalProcess", + // Hermes gateway instance management + hermesGatewayCreateEnrollment: "hermesGateway.createEnrollment", + hermesGatewayGetInstanceStatus: "hermesGateway.getInstanceStatus", + hermesGatewayListInstances: "hermesGateway.listInstances", + hermesGatewayRevokeInstance: "hermesGateway.revokeInstance", + // Cloud environment methods cloudGetRelayClientStatus: "cloud.getRelayClientStatus", cloudInstallRelayClient: "cloud.installRelayClient", @@ -334,6 +350,36 @@ export const WsServerSignalProcessRpc = Rpc.make(WS_METHODS.serverSignalProcess, error: EnvironmentAuthorizationError, }); +export const WsHermesGatewayCreateEnrollmentRpc = Rpc.make( + WS_METHODS.hermesGatewayCreateEnrollment, + { + payload: HermesGatewayCreateEnrollmentInput, + success: HermesGatewayEnrollmentResult, + error: Schema.Union([HermesGatewayManagementError, EnvironmentAuthorizationError]), + }, +); + +export const WsHermesGatewayGetInstanceStatusRpc = Rpc.make( + WS_METHODS.hermesGatewayGetInstanceStatus, + { + payload: HermesGatewayGetInstanceStatusInput, + success: HermesGatewayInstanceStatus, + error: Schema.Union([HermesGatewayManagementError, EnvironmentAuthorizationError]), + }, +); + +export const WsHermesGatewayListInstancesRpc = Rpc.make(WS_METHODS.hermesGatewayListInstances, { + payload: Schema.Struct({}), + success: HermesGatewayListInstancesResult, + error: Schema.Union([HermesGatewayManagementError, EnvironmentAuthorizationError]), +}); + +export const WsHermesGatewayRevokeInstanceRpc = Rpc.make(WS_METHODS.hermesGatewayRevokeInstance, { + payload: HermesGatewayRevokeInstanceInput, + success: HermesGatewayRevokeInstanceResult, + error: Schema.Union([HermesGatewayManagementError, EnvironmentAuthorizationError]), +}); + export const WsCloudGetRelayClientStatusRpc = Rpc.make(WS_METHODS.cloudGetRelayClientStatus, { payload: Schema.Struct({}), success: RelayClientStatusSchema, @@ -713,6 +759,10 @@ export const WsRpcGroup = RpcGroup.make( WsServerGetProcessDiagnosticsRpc, WsServerGetProcessResourceHistoryRpc, WsServerSignalProcessRpc, + WsHermesGatewayCreateEnrollmentRpc, + WsHermesGatewayGetInstanceStatusRpc, + WsHermesGatewayListInstancesRpc, + WsHermesGatewayRevokeInstanceRpc, WsCloudGetRelayClientStatusRpc, WsCloudInstallRelayClientRpc, WsSourceControlLookupRepositoryRpc, diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 06f7de3db67..8aeed24e981 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -389,6 +389,19 @@ export const OpenCodeSettings = makeProviderSettingsSchema( ); export type OpenCodeSettings = typeof OpenCodeSettings.Type; +/** + * Hermes is a remotely managed runtime. Its model, process, and session + * configuration remain on the paired Hermes instance, so T3 exposes no + * binary, home, working-directory, or model settings. + */ +export const HermesSettings = makeProviderSettingsSchema({ + enabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(true)), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), +}); +export type HermesSettings = typeof HermesSettings.Type; + export const ObservabilitySettings = Schema.Struct({ otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), otlpMetricsUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), @@ -433,6 +446,7 @@ export const ServerSettings = Schema.Struct({ cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), grok: GrokSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), + hermes: HermesSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), }).pipe(Schema.withDecodingDefault(Effect.succeed({}))), // New driver-agnostic instance map. Keyed by `ProviderInstanceId`; values // are `ProviderInstanceConfig` envelopes. The driver-specific config blob @@ -536,6 +550,10 @@ const OpenCodeSettingsPatch = Schema.Struct({ customModels: Schema.optionalKey(Schema.Array(Schema.String)), }); +const HermesSettingsPatch = Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), +}); + export const ServerSettingsPatch = Schema.Struct({ // Server settings enableAssistantStreaming: Schema.optionalKey(Schema.Boolean), @@ -558,6 +576,7 @@ export const ServerSettingsPatch = Schema.Struct({ cursor: Schema.optionalKey(CursorSettingsPatch), grok: Schema.optionalKey(GrokSettingsPatch), opencode: Schema.optionalKey(OpenCodeSettingsPatch), + hermes: Schema.optionalKey(HermesSettingsPatch), }), ), // Whole-map replacement for the new instance config. Patching individual From 19d1329ac55d53d90f86acc2184b966c4ea0c8e8 Mon Sep 17 00:00:00 2001 From: Ben Davis <45952064+bmdavis419@users.noreply.github.com> Date: Fri, 24 Jul 2026 02:49:18 -0700 Subject: [PATCH 02/68] fix: harden Hermes instance lifecycle --- .../src/provider/Layers/HermesAdapter.test.ts | 2 + .../Layers/HermesGatewayBroker.test.ts | 584 ++++++++++++++++- .../provider/Layers/HermesGatewayBroker.ts | 592 ++++++++++++++---- .../ProviderInstanceRegistryHydration.test.ts | 37 ++ .../ProviderInstanceRegistryHydration.ts | 17 +- .../provider/Layers/ProviderRegistry.test.ts | 22 +- .../provider/Services/HermesGatewayBroker.ts | 11 + apps/server/src/server.test.ts | 1 + apps/server/src/serverSettings.test.ts | 49 ++ apps/server/src/serverSettings.ts | 75 ++- apps/server/src/ws.ts | 14 + .../AddProviderInstanceDialog.logic.ts | 31 + .../AddProviderInstanceDialog.test.ts | 34 + .../settings/AddProviderInstanceDialog.tsx | 45 +- ...HermesGatewayInstanceSection.logic.test.ts | 28 + .../HermesGatewayInstanceSection.logic.ts | 21 + .../settings/HermesGatewayInstanceSection.tsx | 100 ++- .../settings/ProviderInstanceCard.test.ts | 4 +- .../settings/ProviderInstanceCard.tsx | 129 ++-- .../settings/SettingsPanels.logic.test.ts | 8 + .../settings/SettingsPanels.logic.ts | 9 + .../components/settings/SettingsPanels.tsx | 20 +- apps/web/src/providerInstances.test.ts | 13 + apps/web/src/providerInstances.ts | 1 - packages/client-runtime/src/state/server.ts | 8 + packages/contracts/src/hermesGateway.ts | 17 + packages/contracts/src/rpc.ts | 20 + 27 files changed, 1636 insertions(+), 256 deletions(-) create mode 100644 apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.test.ts diff --git a/apps/server/src/provider/Layers/HermesAdapter.test.ts b/apps/server/src/provider/Layers/HermesAdapter.test.ts index 265d6765622..f32f8adecc4 100644 --- a/apps/server/src/provider/Layers/HermesAdapter.test.ts +++ b/apps/server/src/provider/Layers/HermesAdapter.test.ts @@ -62,7 +62,9 @@ it.effect("keeps cwd local while forwarding turn text byte-for-byte and steering createEnrollment: () => Effect.die(new Error("unused")), getInstanceStatus: () => Effect.die(new Error("unused")), listInstances: Effect.succeed([]), + renameInstance: () => Effect.die(new Error("unused")), revokeInstance: () => Effect.die(new Error("unused")), + removeInstance: () => Effect.die(new Error("unused")), registerConnection: () => Effect.die(new Error("unused")), receive: () => Effect.void, disconnect: () => Effect.void, diff --git a/apps/server/src/provider/Layers/HermesGatewayBroker.test.ts b/apps/server/src/provider/Layers/HermesGatewayBroker.test.ts index de1ebff5451..b3b5791ba55 100644 --- a/apps/server/src/provider/Layers/HermesGatewayBroker.test.ts +++ b/apps/server/src/provider/Layers/HermesGatewayBroker.test.ts @@ -2,9 +2,11 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { HERMES_GATEWAY_PROTOCOL_VERSION, + HERMES_DRIVER_KIND, HermesGatewayCredential, HermesGatewayRequestId, HermesGatewaySessionId, + ProviderDriverKind, ProviderInstanceId, ThreadId, type HermesGatewayConnectionHello, @@ -19,6 +21,7 @@ import * as Option from "effect/Option"; import * as Stream from "effect/Stream"; import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; +import * as ServerConfig from "../../config.ts"; import * as ServerSettings from "../../serverSettings.ts"; import { HermesDriver } from "../Drivers/HermesDriver.ts"; import type { ProviderInstance } from "../ProviderDriver.ts"; @@ -30,6 +33,9 @@ import { HermesGatewayBrokerLive, makeHermesGatewayBroker } from "./HermesGatewa const instanceId = ProviderInstanceId.make("hermes_remote"); const otherInstanceId = ProviderInstanceId.make("hermes_other"); +const defaultHermesInstanceId = ProviderInstanceId.make("hermes"); +const metadataSecretNameForTest = (id: ProviderInstanceId) => + `hermes-gateway-metadata-${Buffer.from(id, "utf8").toString("base64url")}`; class HermesTestInstance extends Context.Service()( "t3/provider/Layers/HermesGatewayBroker.test/HermesTestInstance", ) {} @@ -60,6 +66,11 @@ const makeBroker = (secrets: ServerSecretStore.ServerSecretStore["Service"]) => Effect.provide( ServerSettings.layerTest({ providerInstances: { + [defaultHermesInstanceId]: { + driver: "hermes", + displayName: "Hermes", + config: {}, + }, [instanceId]: { driver: "hermes", displayName: "Remote", config: {} }, [otherInstanceId]: { driver: "hermes", displayName: "Other", config: {} }, }, @@ -87,7 +98,7 @@ it.effect("authenticates before applying incompatible connection state", () => const secrets = makeSecretStore(); const broker = yield* makeBroker(secrets); yield* broker.createEnrollment({ - instanceId: ProviderInstanceId.make("hermes"), + instanceId: defaultHermesInstanceId, nickname: "Default Hermes", connectorUrl: "https://t3.example.test", }); @@ -314,7 +325,7 @@ it.effect("atomically reserves normalized nicknames across concurrent enrollment const secondEnrollment = yield* broker .createEnrollment({ instanceId: otherInstanceId, - nickname: " Shared Hermes ", + nickname: " SHARED HERMES ", connectorUrl: "https://t3.example.test", }) .pipe(Effect.forkChild({ startImmediately: true })); @@ -542,3 +553,572 @@ it.effect("checks nickname uniqueness from persisted metadata after restart", () assert.equal(error.code, "nickname-conflict"); }), ); + +it.effect( + "renames the display label while preserving instance identity and normalized uniqueness", + () => + Effect.gen(function* () { + const broker = yield* makeHermesGatewayBroker; + const settings = yield* ServerSettings.ServerSettingsService; + yield* broker.createEnrollment({ + instanceId, + nickname: "Remote Hermes", + connectorUrl: "https://t3.example.test", + }); + yield* broker.createEnrollment({ + instanceId: otherInstanceId, + nickname: "Other Hermes", + connectorUrl: "https://t3.example.test", + }); + + const renamed = yield* broker.renameInstance({ + instanceId, + nickname: "Research Hermes", + }); + assert.equal(renamed.instanceId, instanceId); + assert.equal(renamed.nickname, "Research Hermes"); + assert.equal( + (yield* settings.getSettings).providerInstances[instanceId]?.displayName, + "Research Hermes", + ); + + const conflict = yield* Effect.flip( + broker.renameInstance({ + instanceId: otherInstanceId, + nickname: " RESEARCH HERMES ", + }), + ); + assert.equal(conflict.operation, "rename-instance"); + assert.equal(conflict.code, "nickname-conflict"); + assert.equal((yield* broker.getInstanceStatus(otherInstanceId)).nickname, "Other Hermes"); + }).pipe( + Effect.provide( + Layer.mergeAll( + ServerSettings.layerTest({ + providerInstances: { + [instanceId]: { driver: "hermes", displayName: "Remote", config: {} }, + [otherInstanceId]: { driver: "hermes", displayName: "Other", config: {} }, + }, + }), + Layer.succeed(ServerSecretStore.ServerSecretStore, makeSecretStore()), + NodeServices.layer, + ), + ), + ), +); + +it.effect("preserves concurrent provider settings edits during rename and removal", () => + Effect.gen(function* () { + const storedSecrets = makeSecretStore(); + let metadataGate: + | { + readonly started: Deferred.Deferred; + readonly release: Deferred.Deferred; + } + | undefined; + const secrets: ServerSecretStore.ServerSecretStore["Service"] = { + ...storedSecrets, + set: (name, value) => { + const gate = metadataGate; + if (!gate || !name.startsWith("hermes-gateway-metadata-")) { + return storedSecrets.set(name, value); + } + metadataGate = undefined; + return Effect.gen(function* () { + yield* Deferred.succeed(gate.started, undefined); + yield* Deferred.await(gate.release); + yield* storedSecrets.set(name, value); + }); + }, + }; + const broker = yield* makeHermesGatewayBroker.pipe( + Effect.provideService(ServerSecretStore.ServerSecretStore, secrets), + ); + const settings = yield* ServerSettings.ServerSettingsService; + yield* broker.createEnrollment({ + instanceId, + nickname: "Concurrent Hermes", + connectorUrl: "https://t3.example.test", + }); + + const renameStarted = yield* Deferred.make(); + const releaseRename = yield* Deferred.make(); + metadataGate = { started: renameStarted, release: releaseRename }; + const rename = yield* broker + .renameInstance({ instanceId, nickname: "Concurrent Research" }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(renameStarted); + yield* settings.updateSettingsWith((current) => ({ + providerInstances: { + ...current.providerInstances, + codex_concurrent: { driver: "codex", displayName: "Concurrent Codex", config: {} }, + }, + })); + yield* Deferred.succeed(releaseRename, undefined); + yield* Fiber.join(rename); + assert.equal( + (yield* settings.getSettings).providerInstances[ProviderInstanceId.make("codex_concurrent")] + ?.displayName, + "Concurrent Codex", + ); + + yield* broker.revokeInstance(instanceId); + const removeStarted = yield* Deferred.make(); + const releaseRemove = yield* Deferred.make(); + metadataGate = { started: removeStarted, release: releaseRemove }; + const remove = yield* broker + .removeInstance(instanceId) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(removeStarted); + yield* settings.updateSettingsWith((current) => ({ + providerInstances: { + ...current.providerInstances, + claude_concurrent: { + driver: "claudeAgent", + displayName: "Concurrent Claude", + config: {}, + }, + }, + })); + yield* Deferred.succeed(releaseRemove, undefined); + yield* Fiber.join(remove); + const finalSettings = yield* settings.getSettings; + assert.equal( + finalSettings.providerInstances[ProviderInstanceId.make("codex_concurrent")]?.displayName, + "Concurrent Codex", + ); + assert.equal( + finalSettings.providerInstances[ProviderInstanceId.make("claude_concurrent")]?.displayName, + "Concurrent Claude", + ); + assert.isUndefined(finalSettings.providerInstances[instanceId]); + }).pipe( + Effect.provide( + Layer.mergeAll( + ServerSettings.layerTest({ + providerInstances: { + [instanceId]: { driver: "hermes", displayName: "Remote", config: {} }, + [otherInstanceId]: { driver: "hermes", displayName: "Other", config: {} }, + }, + }), + NodeServices.layer, + ), + ), + ), +); + +it.effect("tombstones removed instance ids while freeing their nickname", () => + Effect.gen(function* () { + const broker = yield* makeHermesGatewayBroker; + const settings = yield* ServerSettings.ServerSettingsService; + const enrollment = yield* broker.createEnrollment({ + instanceId, + nickname: "Disposable Hermes", + connectorUrl: "https://t3.example.test", + }); + const transport: HermesGatewayTransport = { + send: () => Effect.void, + close: () => Effect.void, + }; + const registration = yield* broker.registerConnection( + hello({ type: "enrollment-token", token: enrollment.oneTimeToken }), + transport, + ); + const staleCredential = registration.accepted.credential; + if (!staleCredential) { + return yield* Effect.die(new Error("enrollment did not issue a credential")); + } + + const liveError = yield* Effect.flip(broker.removeInstance(instanceId)); + assert.equal(liveError.operation, "remove-instance"); + assert.equal(liveError.code, "instance-not-revoked"); + + yield* broker.revokeInstance(instanceId); + assert.deepEqual(yield* broker.removeInstance(instanceId), { instanceId }); + assert.isUndefined((yield* settings.getSettings).providerInstances[instanceId]); + assert.isFalse( + (yield* broker.listInstances).some((status) => status.instanceId === instanceId), + ); + const missing = yield* Effect.flip(broker.getInstanceStatus(instanceId)); + assert.equal(missing.code, "instance-not-found"); + + yield* settings.updateSettingsWith((current) => ({ + providerInstances: { + ...current.providerInstances, + [instanceId]: { driver: "hermes", displayName: "Reused Hermes", config: {} }, + }, + })); + const tombstoneError = yield* Effect.flip( + broker.createEnrollment({ + instanceId, + nickname: "Reused Hermes", + connectorUrl: "https://t3.example.test", + }), + ); + assert.equal(tombstoneError.code, "instance-removed"); + assert.isUndefined((yield* settings.getSettings).providerInstances[instanceId]); + assert.isFalse( + (yield* broker.listInstances).some((status) => status.instanceId === instanceId), + ); + const staleConnection = yield* Effect.flip( + broker.registerConnection( + hello({ + type: "instance-credential", + instanceId, + credential: staleCredential, + }), + transport, + ), + ); + assert.equal(staleConnection.code, "invalid-authentication"); + + yield* settings.updateSettingsWith((current) => ({ + providerInstances: { + ...current.providerInstances, + [instanceId]: { driver: "hermes", displayName: "Reused Again", config: {} }, + }, + })); + const restartedBroker = yield* makeHermesGatewayBroker; + const restartedError = yield* Effect.flip( + restartedBroker.createEnrollment({ + instanceId, + nickname: "Reused Hermes", + connectorUrl: "https://t3.example.test", + }), + ); + assert.equal(restartedError.code, "instance-removed"); + assert.isUndefined((yield* settings.getSettings).providerInstances[instanceId]); + + const replacement = yield* broker.createEnrollment({ + instanceId: otherInstanceId, + nickname: "disposable hermes", + connectorUrl: "https://t3.example.test", + }); + assert.equal(replacement.instanceId, otherInstanceId); + }).pipe( + Effect.provide( + Layer.mergeAll( + ServerSettings.layerTest({ + providerInstances: { + [instanceId]: { driver: "hermes", displayName: "Remote", config: {} }, + [otherInstanceId]: { driver: "hermes", displayName: "Other", config: {} }, + }, + }), + Layer.succeed(ServerSecretStore.ServerSecretStore, makeSecretStore()), + NodeServices.layer, + ), + ), + ), +); + +it.effect("keeps the in-memory tombstone when credential cleanup fails", () => + Effect.gen(function* () { + const storedSecrets = makeSecretStore(); + let failCredentialCleanup = false; + const secrets: ServerSecretStore.ServerSecretStore["Service"] = { + ...storedSecrets, + remove: (name) => + failCredentialCleanup && name.startsWith("hermes-gateway-credential-") + ? Effect.fail( + new ServerSecretStore.SecretStoreRemoveError({ + resource: `secret ${name}`, + cause: new Error("forced credential cleanup failure"), + }), + ) + : storedSecrets.remove(name), + }; + const broker = yield* makeHermesGatewayBroker.pipe( + Effect.provideService(ServerSecretStore.ServerSecretStore, secrets), + ); + const settings = yield* ServerSettings.ServerSettingsService; + yield* broker.createEnrollment({ + instanceId, + nickname: "Cleanup Failure Hermes", + connectorUrl: "https://t3.example.test", + }); + yield* broker.revokeInstance(instanceId); + + failCredentialCleanup = true; + assert.deepEqual(yield* broker.removeInstance(instanceId), { instanceId }); + yield* settings.updateSettingsWith((current) => ({ + providerInstances: { + ...current.providerInstances, + [instanceId]: { driver: "hermes", displayName: "Forbidden Reuse", config: {} }, + }, + })); + const error = yield* Effect.flip( + broker.createEnrollment({ + instanceId, + nickname: "Forbidden Reuse", + connectorUrl: "https://t3.example.test", + }), + ); + assert.equal(error.code, "instance-removed"); + assert.isUndefined((yield* settings.getSettings).providerInstances[instanceId]); + }).pipe( + Effect.provide( + Layer.mergeAll( + ServerSettings.layerTest({ + providerInstances: { + [instanceId]: { driver: "hermes", displayName: "Cleanup Failure Hermes", config: {} }, + }, + }), + NodeServices.layer, + ), + ), + ), +); + +it.effect("keeps the tombstone when settings post-commit materialization degrades", () => { + const storedSecrets = makeSecretStore(); + const sensitiveInstanceId = ProviderInstanceId.make("codex_sensitive"); + let providerEnvironmentReadsBeforeFailure = Number.POSITIVE_INFINITY; + const secrets: ServerSecretStore.ServerSecretStore["Service"] = { + ...storedSecrets, + get: (name) => { + if (!name.startsWith("provider-env-")) return storedSecrets.get(name); + if (providerEnvironmentReadsBeforeFailure <= 0) { + return Effect.fail( + new ServerSecretStore.SecretStoreReadError({ + resource: `secret ${name}`, + cause: new Error("forced post-commit materialization failure"), + }), + ); + } + providerEnvironmentReadsBeforeFailure -= 1; + return storedSecrets.get(name); + }, + }; + const secretLayer = Layer.succeed(ServerSecretStore.ServerSecretStore, secrets); + const configLayer = Layer.fresh( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-hermes-post-commit-settings-test-", + }), + ).pipe(Layer.provide(NodeServices.layer)); + const settingsLayer = ServerSettings.layer.pipe( + Layer.provide(secretLayer), + Layer.provideMerge(configLayer), + Layer.provide(NodeServices.layer), + ); + + return Effect.gen(function* () { + const settings = yield* ServerSettings.ServerSettingsService; + yield* settings.updateSettings({ + providerInstances: { + [instanceId]: { + driver: HERMES_DRIVER_KIND, + displayName: "Post Commit Hermes", + config: {}, + }, + [sensitiveInstanceId]: { + driver: ProviderDriverKind.make("codex"), + environment: [ + { + name: "OPENROUTER_API_KEY", + value: "secret", + sensitive: true, + }, + ], + config: {}, + }, + }, + }); + const broker = yield* makeHermesGatewayBroker; + yield* broker.createEnrollment({ + instanceId, + nickname: "Post Commit Hermes", + connectorUrl: "https://t3.example.test", + }); + yield* broker.revokeInstance(instanceId); + providerEnvironmentReadsBeforeFailure = 1; + assert.deepEqual(yield* broker.removeInstance(instanceId), { instanceId }); + + providerEnvironmentReadsBeforeFailure = Number.POSITIVE_INFINITY; + yield* settings.updateSettingsWith((current) => ({ + providerInstances: { + ...current.providerInstances, + [instanceId]: { + driver: HERMES_DRIVER_KIND, + displayName: "Forbidden Reuse", + config: {}, + }, + }, + })); + const error = yield* Effect.flip( + broker.createEnrollment({ + instanceId, + nickname: "Forbidden Reuse", + connectorUrl: "https://t3.example.test", + }), + ); + assert.equal(error.code, "instance-removed"); + }).pipe( + Effect.provide(Layer.mergeAll(settingsLayer, secretLayer, configLayer, NodeServices.layer)), + ); +}); + +it.effect("migrates legacy default metadata into an explicit visible provider instance", () => + Effect.gen(function* () { + const broker = yield* makeHermesGatewayBroker; + const settings = yield* ServerSettings.ServerSettingsService; + yield* broker.createEnrollment({ + instanceId: defaultHermesInstanceId, + nickname: "Legacy Hermes", + connectorUrl: "https://t3.example.test", + }); + yield* settings.updateSettingsWith((current) => { + const providerInstances = { ...current.providerInstances }; + delete providerInstances[defaultHermesInstanceId]; + return { providerInstances }; + }); + assert.isUndefined((yield* settings.getSettings).providerInstances[defaultHermesInstanceId]); + + yield* makeHermesGatewayBroker; + assert.deepEqual((yield* settings.getSettings).providerInstances[defaultHermesInstanceId], { + driver: HERMES_DRIVER_KIND, + displayName: "Legacy Hermes", + enabled: true, + config: {}, + }); + }).pipe( + Effect.provide( + Layer.mergeAll( + ServerSettings.layerTest({ + providerInstances: { + [defaultHermesInstanceId]: { + driver: "hermes", + displayName: "Hermes", + enabled: true, + config: {}, + }, + }, + }), + Layer.succeed(ServerSecretStore.ServerSecretStore, makeSecretStore()), + NodeServices.layer, + ), + ), + ), +); + +it.effect("removes an explicitly configured Hermes instance that was never enrolled", () => + Effect.gen(function* () { + const broker = yield* makeHermesGatewayBroker; + const settings = yield* ServerSettings.ServerSettingsService; + + assert.deepEqual(yield* broker.removeInstance(instanceId), { instanceId }); + assert.isUndefined((yield* settings.getSettings).providerInstances[instanceId]); + assert.equal( + (yield* Effect.flip(broker.getInstanceStatus(instanceId))).code, + "instance-not-found", + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + ServerSettings.layerTest({ + providerInstances: { + [instanceId]: { driver: "hermes", displayName: "Never Enrolled", config: {} }, + }, + }), + Layer.succeed(ServerSecretStore.ServerSecretStore, makeSecretStore()), + NodeServices.layer, + ), + ), + ), +); + +it.effect("fails closed when tombstone metadata cannot be read", () => + Effect.gen(function* () { + const storedSecrets = makeSecretStore(); + let failMetadataReads = false; + const secrets: ServerSecretStore.ServerSecretStore["Service"] = { + ...storedSecrets, + get: (name) => + failMetadataReads && name === metadataSecretNameForTest(instanceId) + ? Effect.fail( + new ServerSecretStore.SecretStoreReadError({ + resource: `secret ${name}`, + cause: new Error("forced metadata read failure"), + }), + ) + : storedSecrets.get(name), + }; + const broker = yield* makeHermesGatewayBroker.pipe( + Effect.provideService(ServerSecretStore.ServerSecretStore, secrets), + ); + const settings = yield* ServerSettings.ServerSettingsService; + yield* broker.createEnrollment({ + instanceId, + nickname: "Read Failure Hermes", + connectorUrl: "https://t3.example.test", + }); + yield* broker.revokeInstance(instanceId); + yield* broker.removeInstance(instanceId); + yield* settings.updateSettingsWith((current) => ({ + providerInstances: { + ...current.providerInstances, + [instanceId]: { driver: "hermes", displayName: "Reused Hermes", config: {} }, + }, + })); + + failMetadataReads = true; + const restarted = yield* makeHermesGatewayBroker.pipe( + Effect.provideService(ServerSecretStore.ServerSecretStore, secrets), + ); + const error = yield* Effect.flip( + restarted.createEnrollment({ + instanceId, + nickname: "Reused Hermes", + connectorUrl: "https://t3.example.test", + }), + ); + assert.equal(error.code, "persistence-failed"); + assert.equal( + (yield* settings.getSettings).providerInstances[instanceId]?.displayName, + "Reused Hermes", + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + ServerSettings.layerTest({ + providerInstances: { + [instanceId]: { driver: "hermes", displayName: "Original Hermes", config: {} }, + }, + }), + NodeServices.layer, + ), + ), + ), +); + +it.effect("fails closed when present Hermes metadata is malformed", () => + Effect.gen(function* () { + const secrets = makeSecretStore(); + yield* secrets.set( + metadataSecretNameForTest(instanceId), + new TextEncoder().encode("{not-valid-json"), + ); + const broker = yield* makeHermesGatewayBroker.pipe( + Effect.provideService(ServerSecretStore.ServerSecretStore, secrets), + ); + + const error = yield* Effect.flip( + broker.createEnrollment({ + instanceId, + nickname: "Malformed Hermes", + connectorUrl: "https://t3.example.test", + }), + ); + assert.equal(error.code, "persistence-failed"); + }).pipe( + Effect.provide( + Layer.mergeAll( + ServerSettings.layerTest({ + providerInstances: { + [instanceId]: { driver: "hermes", displayName: "Malformed Hermes", config: {} }, + }, + }), + NodeServices.layer, + ), + ), + ), +); diff --git a/apps/server/src/provider/Layers/HermesGatewayBroker.ts b/apps/server/src/provider/Layers/HermesGatewayBroker.ts index e5be1243a81..53902e237a0 100644 --- a/apps/server/src/provider/Layers/HermesGatewayBroker.ts +++ b/apps/server/src/provider/Layers/HermesGatewayBroker.ts @@ -12,6 +12,9 @@ import { type HermesGatewayEnrollmentResult, type HermesGatewayInstanceStatus, type HermesGatewayPluginToT3Message, + type HermesGatewayRemoveInstanceResult, + type HermesGatewayRenameInstanceInput, + type HermesGatewayRenameInstanceResult, type HermesGatewayRevokeInstanceResult, type HermesGatewayT3ToPluginMessage, type ProviderInstanceId, @@ -53,18 +56,23 @@ interface PendingEnrollment { readonly expiresAtMillis: number; } -interface InstanceMetadata { - readonly nickname: string; - readonly connectorUrl: string; - readonly revoked: boolean; - readonly lastSeen?: { - readonly pluginVersion: string; - readonly hermesVersion: string; - readonly capabilities: NonNullable; - readonly connectedAt: string; - readonly activeSessionCount: number; - }; -} +const InstanceMetadata = Schema.Struct({ + nickname: Schema.String, + connectorUrl: Schema.String, + revoked: Schema.Boolean, + removed: Schema.optionalKey(Schema.Boolean), + lastSeen: Schema.optionalKey( + Schema.Struct({ + pluginVersion: Schema.String, + hermesVersion: Schema.String, + capabilities: HermesGatewayCapabilities, + connectedAt: Schema.String, + activeSessionCount: Schema.Number, + }), + ), +}); +type InstanceMetadata = typeof InstanceMetadata.Type; +const decodeInstanceMetadata = Schema.decodeUnknownEffect(Schema.fromJsonString(InstanceMetadata)); interface ActiveConnection { readonly generation: number; @@ -129,6 +137,11 @@ const credentialsEqual = (left: Uint8Array, right: string) => { return left.byteLength === rightBytes.byteLength && NodeCrypto.timingSafeEqual(left, rightBytes); }; +const normalizeNickname = (nickname: string) => nickname.trim(); + +const nicknameComparisonKey = (nickname: string) => + normalizeNickname(nickname).normalize("NFKC").toLocaleLowerCase("en-US"); + const statusFromState = ( instanceId: ProviderInstanceId, state: InstanceState, @@ -221,65 +234,47 @@ export const makeHermesGatewayBroker = Effect.gen(function* () { const readMetadata = (instanceId: ProviderInstanceId) => secretStore.get(metadataSecretName(instanceId)).pipe( - Effect.map((stored) => { - if (Option.isNone(stored)) return undefined; - try { - const parsed: unknown = JSON.parse(textDecoder.decode(stored.value)); - if ( - typeof parsed === "object" && - parsed !== null && - "nickname" in parsed && - typeof parsed.nickname === "string" && - "connectorUrl" in parsed && - typeof parsed.connectorUrl === "string" && - "revoked" in parsed && - typeof parsed.revoked === "boolean" - ) { - return { - nickname: parsed.nickname, - connectorUrl: parsed.connectorUrl, - revoked: parsed.revoked, - ...("lastSeen" in parsed && - typeof parsed.lastSeen === "object" && - parsed.lastSeen !== null && - "pluginVersion" in parsed.lastSeen && - typeof parsed.lastSeen.pluginVersion === "string" && - "hermesVersion" in parsed.lastSeen && - typeof parsed.lastSeen.hermesVersion === "string" && - "connectedAt" in parsed.lastSeen && - typeof parsed.lastSeen.connectedAt === "string" && - "activeSessionCount" in parsed.lastSeen && - typeof parsed.lastSeen.activeSessionCount === "number" && - "capabilities" in parsed.lastSeen && - isStrictCapabilities(parsed.lastSeen.capabilities) - ? { - lastSeen: { - pluginVersion: parsed.lastSeen.pluginVersion, - hermesVersion: parsed.lastSeen.hermesVersion, - connectedAt: parsed.lastSeen.connectedAt, - activeSessionCount: parsed.lastSeen.activeSessionCount, - capabilities: parsed.lastSeen.capabilities, - }, - } - : {}), - } satisfies InstanceMetadata; - } - } catch { - return undefined; - } - return undefined; + Effect.flatMap((stored) => { + if (Option.isNone(stored)) return Effect.succeed(undefined); + return decodeInstanceMetadata(textDecoder.decode(stored.value)).pipe( + Effect.mapError( + (cause) => + new ServerSecretStore.SecretStoreDecodeError({ + resource: `Hermes gateway metadata for ${instanceId}`, + cause, + }), + ), + ); }), - Effect.orElseSucceed(() => undefined), ); const getState = (instanceId: ProviderInstanceId) => Ref.get(states).pipe( Effect.map((current) => current.get(instanceId)), Effect.flatMap((state) => { - if (state) return Effect.succeed(state); + if (state) { + return state.metadata.removed + ? Effect.fail( + managementError( + "get-status", + "instance-not-found", + `Hermes gateway instance '${instanceId}' has been removed.`, + instanceId, + ), + ) + : Effect.succeed(state); + } return readMetadata(instanceId).pipe( + Effect.mapError(() => + managementError( + "get-status", + "persistence-failed", + "Failed to read Hermes gateway metadata.", + instanceId, + ), + ), Effect.flatMap((metadata) => { - if (!metadata) { + if (!metadata || metadata.removed) { return Effect.fail( managementError( "get-status", @@ -301,7 +296,7 @@ export const makeHermesGatewayBroker = Effect.gen(function* () { const createEnrollment = (input: HermesGatewayCreateEnrollmentInput) => enrollmentSemaphore.withPermits(1)( Effect.gen(function* () { - const normalizedNickname = input.nickname.trim(); + const normalizedNickname = normalizeNickname(input.nickname); const normalizedInput = { ...input, nickname: normalizedNickname, @@ -317,9 +312,7 @@ export const makeHermesGatewayBroker = Effect.gen(function* () { ), ); const configured = currentSettings.providerInstances[input.instanceId]; - const isDefaultHermesInstance = - input.instanceId === defaultInstanceIdForDriver(HERMES_DRIVER_KIND); - if ((!configured || configured.driver !== HERMES_DRIVER_KIND) && !isDefaultHermesInstance) { + if (!configured || configured.driver !== HERMES_DRIVER_KIND) { return yield* managementError( "create-enrollment", "instance-not-found", @@ -328,22 +321,77 @@ export const makeHermesGatewayBroker = Effect.gen(function* () { ); } - const configuredHermesIds = [ - defaultInstanceIdForDriver(HERMES_DRIVER_KIND), - ...Object.entries(currentSettings.providerInstances) - .filter(([, config]) => config.driver === HERMES_DRIVER_KIND) - .map(([instanceId]) => instanceId as ProviderInstanceId), - ]; + const existingInputState = + (yield* Ref.get(states)).get(input.instanceId) ?? + (yield* readMetadata(input.instanceId).pipe( + Effect.map((metadata) => (metadata ? { metadata } : undefined)), + Effect.mapError(() => + managementError( + "create-enrollment", + "persistence-failed", + "Failed to read Hermes gateway metadata.", + input.instanceId, + ), + ), + )); + if (existingInputState?.metadata.removed) { + yield* settings + .updateSettingsWith((latest) => { + if (latest.providerInstances[input.instanceId]?.driver !== HERMES_DRIVER_KIND) { + return {}; + } + const providerInstances = { ...latest.providerInstances }; + delete providerInstances[input.instanceId]; + return { providerInstances }; + }) + .pipe( + Effect.mapError(() => + managementError( + "create-enrollment", + "persistence-failed", + `Failed to clean up the reconfigured tombstoned Hermes instance '${input.instanceId}'.`, + input.instanceId, + ), + ), + ); + return yield* managementError( + "create-enrollment", + "instance-removed", + `Hermes gateway instance id '${input.instanceId}' was permanently removed. Create a new instance with a fresh id.`, + input.instanceId, + ); + } + + const configuredHermesIds = Object.entries(currentSettings.providerInstances) + .filter(([, config]) => config.driver === HERMES_DRIVER_KIND) + .map(([instanceId]) => instanceId as ProviderInstanceId); const existingStates = yield* Ref.get(states); for (const instanceId of new Set(configuredHermesIds)) { const persistedMetadata = existingStates.has(instanceId) ? undefined - : yield* readMetadata(instanceId); + : yield* readMetadata(instanceId).pipe( + Effect.mapError(() => + managementError( + "create-enrollment", + "persistence-failed", + "Failed to read Hermes gateway metadata.", + input.instanceId, + ), + ), + ); const state = existingStates.get(instanceId) ?? (persistedMetadata ? { metadata: persistedMetadata } : undefined); - if (!state) continue; - if (instanceId !== input.instanceId && state.metadata.nickname === normalizedNickname) { + const candidateNickname = + state?.metadata.removed === true + ? undefined + : (state?.metadata.nickname ?? + currentSettings.providerInstances[instanceId]?.displayName); + if ( + instanceId !== input.instanceId && + candidateNickname !== undefined && + nicknameComparisonKey(candidateNickname) === nicknameComparisonKey(normalizedNickname) + ) { return yield* managementError( "create-enrollment", "nickname-conflict", @@ -440,14 +488,9 @@ export const makeHermesGatewayBroker = Effect.gen(function* () { managementError("list-instances", "internal-error", "Failed to read server settings."), ), ); - const ids = Array.from( - new Set([ - defaultInstanceIdForDriver(HERMES_DRIVER_KIND), - ...Object.entries(currentSettings.providerInstances) - .filter(([, config]) => config.driver === HERMES_DRIVER_KIND) - .map(([id]) => id as ProviderInstanceId), - ]), - ); + const ids = Object.entries(currentSettings.providerInstances) + .filter(([, config]) => config.driver === HERMES_DRIVER_KIND) + .map(([id]) => id as ProviderInstanceId); return yield* Effect.forEach( ids, (instanceId) => @@ -458,39 +501,318 @@ export const makeHermesGatewayBroker = Effect.gen(function* () { ).pipe(Effect.map((values) => values.filter((value) => value !== undefined))); }); + const renameInstance = (input: HermesGatewayRenameInstanceInput) => + enrollmentSemaphore.withPermits(1)( + Effect.gen(function* () { + const state = yield* getState(input.instanceId).pipe( + Effect.mapError((error) => + managementError("rename-instance", error.code, error.message, input.instanceId), + ), + ); + const normalizedNickname = normalizeNickname(input.nickname); + const currentSettings = yield* settings.getSettings.pipe( + Effect.mapError(() => + managementError( + "rename-instance", + "internal-error", + "Failed to read server settings.", + input.instanceId, + ), + ), + ); + const configured = currentSettings.providerInstances[input.instanceId]; + if (!configured || configured.driver !== HERMES_DRIVER_KIND) { + return yield* managementError( + "rename-instance", + "instance-not-found", + `Provider instance '${input.instanceId}' is not configured with the Hermes driver.`, + input.instanceId, + ); + } + + const existingStates = yield* Ref.get(states); + for (const [candidateId, candidateConfig] of Object.entries( + currentSettings.providerInstances, + )) { + if (candidateId === input.instanceId || candidateConfig.driver !== HERMES_DRIVER_KIND) { + continue; + } + const candidateInstanceId = candidateId as ProviderInstanceId; + const persistedMetadata = existingStates.has(candidateInstanceId) + ? undefined + : yield* readMetadata(candidateInstanceId).pipe( + Effect.mapError(() => + managementError( + "rename-instance", + "persistence-failed", + "Failed to read Hermes gateway metadata.", + input.instanceId, + ), + ), + ); + const candidateState = + existingStates.get(candidateInstanceId) ?? + (persistedMetadata ? { metadata: persistedMetadata } : undefined); + const candidateNickname = + candidateState?.metadata.removed === true + ? undefined + : (candidateState?.metadata.nickname ?? candidateConfig.displayName); + if ( + candidateNickname !== undefined && + nicknameComparisonKey(candidateNickname) === nicknameComparisonKey(normalizedNickname) + ) { + return yield* managementError( + "rename-instance", + "nickname-conflict", + `A Hermes gateway named '${normalizedNickname}' already exists.`, + input.instanceId, + ); + } + } + + const metadata: InstanceMetadata = { + ...state.metadata, + nickname: normalizedNickname, + }; + yield* persistMetadata(input.instanceId, metadata).pipe( + Effect.mapError((error) => + managementError("rename-instance", error.code, error.message, input.instanceId), + ), + ); + let settingsUpdate: "pending" | "updated" | "instance-not-found" | "nickname-conflict" = + "pending"; + const readSettingsUpdate = () => settingsUpdate; + yield* settings + .updateSettingsWith((latest) => { + const latestConfigured = latest.providerInstances[input.instanceId]; + if (!latestConfigured || latestConfigured.driver !== HERMES_DRIVER_KIND) { + settingsUpdate = "instance-not-found"; + return { providerInstances: latest.providerInstances }; + } + for (const [candidateId, candidateConfig] of Object.entries(latest.providerInstances)) { + if ( + candidateId === input.instanceId || + candidateConfig.driver !== HERMES_DRIVER_KIND || + candidateConfig.displayName === undefined + ) { + continue; + } + if ( + nicknameComparisonKey(candidateConfig.displayName) === + nicknameComparisonKey(normalizedNickname) + ) { + settingsUpdate = "nickname-conflict"; + return { providerInstances: latest.providerInstances }; + } + } + settingsUpdate = "updated"; + return { + providerInstances: { + ...latest.providerInstances, + [input.instanceId]: { + ...latestConfigured, + displayName: normalizedNickname, + }, + }, + }; + }) + .pipe( + Effect.tapError(() => + persistMetadata(input.instanceId, state.metadata).pipe(Effect.ignore), + ), + Effect.mapError(() => + managementError( + "rename-instance", + "persistence-failed", + "Failed to persist the Hermes instance display name.", + input.instanceId, + ), + ), + ); + const settingsUpdateResult = readSettingsUpdate(); + if (settingsUpdateResult !== "updated") { + yield* persistMetadata(input.instanceId, state.metadata).pipe(Effect.ignore); + return yield* managementError( + "rename-instance", + settingsUpdateResult === "pending" ? "internal-error" : settingsUpdateResult, + settingsUpdateResult === "nickname-conflict" + ? `A Hermes gateway named '${normalizedNickname}' already exists.` + : settingsUpdateResult === "instance-not-found" + ? `Provider instance '${input.instanceId}' is no longer configured with the Hermes driver.` + : "Failed to determine the Hermes provider settings update result.", + input.instanceId, + ); + } + + const next: InstanceState = { + ...state, + metadata, + }; + yield* Ref.update(states, (current) => new Map(current).set(input.instanceId, next)); + yield* publishStatus(input.instanceId, next); + return statusFromState(input.instanceId, next) satisfies HermesGatewayRenameInstanceResult; + }), + ); + const revokeInstance = (instanceId: ProviderInstanceId) => - Effect.gen(function* () { - const state = yield* getState(instanceId); - const metadata: InstanceMetadata = { ...state.metadata, revoked: true }; - yield* persistMetadata(instanceId, metadata).pipe( - Effect.mapError((error) => - managementError("revoke-instance", error.code, error.message, instanceId), - ), - ); - const next: InstanceState = { - metadata, - ...(state.lastSeen ? { lastSeen: state.lastSeen } : {}), - }; - yield* Ref.update(states, (current) => new Map(current).set(instanceId, next)); - if (state.connection) { - yield* failPendingRequests(instanceId, "The Hermes gateway credential was revoked."); - yield* state.connection.transport.close(4003, "Hermes gateway credential revoked"); - } - yield* publishStatus(instanceId, next); - yield* secretStore - .remove(credentialSecretName(instanceId)) - .pipe( + enrollmentSemaphore.withPermits(1)( + Effect.gen(function* () { + const state = yield* getState(instanceId); + const metadata: InstanceMetadata = { ...state.metadata, revoked: true }; + yield* persistMetadata(instanceId, metadata).pipe( + Effect.mapError((error) => + managementError("revoke-instance", error.code, error.message, instanceId), + ), + ); + const next: InstanceState = { + metadata, + ...(state.lastSeen ? { lastSeen: state.lastSeen } : {}), + }; + yield* Ref.update(states, (current) => new Map(current).set(instanceId, next)); + if (state.connection) { + yield* failPendingRequests(instanceId, "The Hermes gateway credential was revoked."); + yield* state.connection.transport.close(4003, "Hermes gateway credential revoked"); + } + yield* publishStatus(instanceId, next); + yield* secretStore + .remove(credentialSecretName(instanceId)) + .pipe( + Effect.mapError(() => + managementError( + "revoke-instance", + "persistence-failed", + "Failed to remove the Hermes gateway credential.", + instanceId, + ), + ), + ); + return statusFromState(instanceId, next) satisfies HermesGatewayRevokeInstanceResult; + }), + ); + + const removeInstance = (instanceId: ProviderInstanceId) => + enrollmentSemaphore.withPermits(1)( + Effect.gen(function* () { + const currentSettings = yield* settings.getSettings.pipe( Effect.mapError(() => managementError( - "revoke-instance", - "persistence-failed", - "Failed to remove the Hermes gateway credential.", + "remove-instance", + "internal-error", + "Failed to read server settings.", instanceId, ), ), ); - return statusFromState(instanceId, next) satisfies HermesGatewayRevokeInstanceResult; - }); + const configured = currentSettings.providerInstances[instanceId]; + const cachedState = (yield* Ref.get(states)).get(instanceId); + const metadata = + cachedState?.metadata ?? + (yield* readMetadata(instanceId).pipe( + Effect.mapError(() => + managementError( + "remove-instance", + "persistence-failed", + "Failed to read Hermes gateway metadata.", + instanceId, + ), + ), + )); + + if (!metadata && configured?.driver !== HERMES_DRIVER_KIND) { + return yield* managementError( + "remove-instance", + "instance-not-found", + `Hermes gateway instance '${instanceId}' is not configured or enrolled.`, + instanceId, + ); + } + if (metadata && !metadata.removed && !metadata.revoked) { + return yield* managementError( + "remove-instance", + "instance-not-revoked", + "Revoke the Hermes gateway instance before removing it.", + instanceId, + ); + } + + const tombstone = + metadata && !metadata.removed + ? ({ + ...metadata, + revoked: true, + removed: true, + } satisfies InstanceMetadata) + : metadata; + if (tombstone && !metadata?.removed) { + yield* persistMetadata(instanceId, tombstone).pipe( + Effect.mapError((error) => + managementError("remove-instance", error.code, error.message, instanceId), + ), + ); + } + + const updatedSettings = yield* settings + .updateSettingsWith((current) => { + if (current.providerInstances[instanceId]?.driver !== HERMES_DRIVER_KIND) { + return {}; + } + const providerInstances = { ...current.providerInstances }; + delete providerInstances[instanceId]; + return { providerInstances }; + }) + .pipe( + Effect.tapError(() => + metadata && tombstone !== metadata + ? persistMetadata(instanceId, metadata).pipe(Effect.ignore) + : Effect.void, + ), + Effect.mapError(() => + managementError( + "remove-instance", + "persistence-failed", + "Failed to remove the Hermes provider instance from server settings.", + instanceId, + ), + ), + ); + if (updatedSettings.providerInstances[instanceId]?.driver === HERMES_DRIVER_KIND) { + if (metadata && tombstone !== metadata) { + yield* persistMetadata(instanceId, metadata).pipe(Effect.ignore); + } + return yield* managementError( + "remove-instance", + "instance-not-found", + `Hermes provider instance '${instanceId}' changed while it was being removed.`, + instanceId, + ); + } + yield* Ref.update(enrollments, (current) => { + const next = new Map(current); + for (const [token, enrollment] of current) { + if (enrollment.input.instanceId === instanceId) next.delete(token); + } + return next; + }); + yield* Ref.update(states, (current) => { + const next = new Map(current); + if (tombstone) { + next.set(instanceId, { metadata: tombstone }); + } else { + next.delete(instanceId); + } + return next; + }); + yield* secretStore.remove(credentialSecretName(instanceId)).pipe( + Effect.catch((error) => + Effect.logWarning("Failed to clean up a removed Hermes gateway credential", { + instanceId, + error, + }), + ), + ); + return { instanceId } satisfies HermesGatewayRemoveInstanceResult; + }), + ); const registerConnectionEffect = ( hello: HermesGatewayConnectionHello, @@ -580,6 +902,24 @@ export const makeHermesGatewayBroker = Effect.gen(function* () { ), ); } + const currentSettings = yield* settings.getSettings.pipe( + Effect.mapError(() => + rejection( + hello.requestId, + "internal-error", + "Failed to validate the Hermes gateway provider instance.", + ), + ), + ); + if (currentSettings.providerInstances[instanceId]?.driver !== HERMES_DRIVER_KIND) { + return yield* Effect.fail( + rejection( + hello.requestId, + "invalid-authentication", + "The Hermes gateway provider instance is no longer configured.", + ), + ); + } if ( hello.protocolVersion !== HERMES_GATEWAY_PROTOCOL_VERSION || @@ -733,10 +1073,7 @@ export const makeHermesGatewayBroker = Effect.gen(function* () { const registerConnection = ( hello: HermesGatewayConnectionHello, transport: HermesGatewayTransport, - ) => - hello.authentication.type === "enrollment-token" - ? enrollmentSemaphore.withPermits(1)(registerConnectionEffect(hello, transport)) - : registerConnectionEffect(hello, transport); + ) => enrollmentSemaphore.withPermits(1)(registerConnectionEffect(hello, transport)); const receive = (registration: HermesGatewayConnectionRegistration, message: PluginMessage) => Effect.gen(function* () { @@ -873,11 +1210,40 @@ export const makeHermesGatewayBroker = Effect.gen(function* () { Effect.map((current) => current.get(instanceId)?.connection !== undefined), ); + const defaultInstanceId = defaultInstanceIdForDriver(HERMES_DRIVER_KIND); + yield* Effect.gen(function* () { + const metadata = yield* readMetadata(defaultInstanceId); + if (!metadata || metadata.removed) return; + yield* settings.updateSettingsWith((current) => { + if (current.providerInstances[defaultInstanceId] !== undefined) return {}; + return { + providerInstances: { + ...current.providerInstances, + [defaultInstanceId]: { + driver: HERMES_DRIVER_KIND, + displayName: metadata.nickname, + enabled: true, + config: {}, + }, + }, + }; + }); + }).pipe( + Effect.catch((error) => + Effect.logWarning("Failed to migrate a legacy Hermes gateway instance into settings", { + instanceId: defaultInstanceId, + error, + }), + ), + ); + return { createEnrollment, getInstanceStatus, listInstances, + renameInstance, revokeInstance, + removeInstance, registerConnection, receive, disconnect, diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.test.ts new file mode 100644 index 00000000000..6f83f9d1d5b --- /dev/null +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.test.ts @@ -0,0 +1,37 @@ +import { assert, it } from "@effect/vitest"; +import { + DEFAULT_SERVER_SETTINGS, + HERMES_DRIVER_KIND, + ProviderInstanceId, +} from "@t3tools/contracts"; + +import { deriveProviderInstanceConfigMap } from "./ProviderInstanceRegistryHydration.ts"; + +it("does not synthesize a legacy default Hermes instance", () => { + const instances = deriveProviderInstanceConfigMap(DEFAULT_SERVER_SETTINGS); + + assert.isUndefined(instances[ProviderInstanceId.make("hermes")]); + assert.equal(instances[ProviderInstanceId.make("codex")]?.driver, "codex"); +}); + +it("preserves an explicit Hermes instance whose id matches the driver default", () => { + const instanceId = ProviderInstanceId.make("hermes"); + const instances = deriveProviderInstanceConfigMap({ + ...DEFAULT_SERVER_SETTINGS, + providerInstances: { + [instanceId]: { + driver: HERMES_DRIVER_KIND, + displayName: "Research Hermes", + enabled: true, + config: {}, + }, + }, + }); + + assert.deepEqual(instances[instanceId], { + driver: HERMES_DRIVER_KIND, + displayName: "Research Hermes", + enabled: true, + config: {}, + }); +}); diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.ts index 0fd88b4262a..ccfc512811e 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.ts @@ -18,7 +18,8 @@ * `defaultInstanceIdForDriver(driverKind)` — literally the driver kind as a * routing slug), we synthesize an envelope from the legacy field. The * registry decodes both flavours through the same `configSchema` and ends - * up with one uniform `ProviderInstance` per entry. + * up with one uniform `ProviderInstance` per entry. Drivers such as Hermes + * that require an explicitly paired remote instance opt out of synthesis. * * Explicit `providerInstances` entries always win — users can already * override the legacy `providers.` blob by authoring a @@ -43,6 +44,7 @@ */ import { defaultInstanceIdForDriver, + HERMES_DRIVER_KIND, type ProviderInstanceConfig, type ProviderInstanceConfigMap, ServerSettings, @@ -62,9 +64,10 @@ import { ProviderInstanceRegistryMutableLayer } from "./ProviderInstanceRegistry * * Strategy: * 1. Copy all explicit `settings.providerInstances` entries verbatim. - * 2. For each built-in driver whose `defaultInstanceIdForDriver(id)` key - * is *not* already in the explicit map, synthesize an entry from the - * matching legacy `settings.providers.` blob. + * 2. For each legacy-compatible built-in driver whose + * `defaultInstanceIdForDriver(id)` key is *not* already in the explicit + * map, synthesize an entry from the matching legacy + * `settings.providers.` blob. * * The returned map is the input the registry consumes; pure & exported * separately so the hydration logic can be exercised by unit tests @@ -82,6 +85,12 @@ export const deriveProviderInstanceConfigMap = ( // config always wins over the legacy mirror. continue; } + if (driver.driverKind === HERMES_DRIVER_KIND) { + // Hermes is configured only through explicit gateway instances. A + // synthesized legacy default would resurrect a revoked-and-removed + // instance in settings and provider pickers. + continue; + } // Only built-in drivers have a legacy mirror; the registry's // `providers` struct is keyed on the same literal slug as diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 5efbb6f1c14..15269e2496f 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -280,20 +280,24 @@ function makeMutableServerSettingsService( return Effect.gen(function* () { const settingsRef = yield* Ref.make(initial); const changes = yield* PubSub.unbounded(); + const updateSettingsWith = ( + update: (current: ContractServerSettings) => Parameters[1], + ) => + Effect.gen(function* () { + const current = yield* Ref.get(settingsRef); + const next = applyServerSettingsPatch(current, update(current)); + encodeServerSettings(next); + yield* Ref.set(settingsRef, next); + yield* PubSub.publish(changes, next); + return next; + }); return { start: Effect.void, ready: Effect.void, getSettings: Ref.get(settingsRef), - updateSettings: (patch) => - Effect.gen(function* () { - const current = yield* Ref.get(settingsRef); - const next = applyServerSettingsPatch(current, patch); - encodeServerSettings(next); - yield* Ref.set(settingsRef, next); - yield* PubSub.publish(changes, next); - return next; - }), + updateSettings: (patch) => updateSettingsWith(() => patch), + updateSettingsWith, get streamChanges() { return Stream.fromPubSub(changes); }, diff --git a/apps/server/src/provider/Services/HermesGatewayBroker.ts b/apps/server/src/provider/Services/HermesGatewayBroker.ts index 959c746d754..76dd979105a 100644 --- a/apps/server/src/provider/Services/HermesGatewayBroker.ts +++ b/apps/server/src/provider/Services/HermesGatewayBroker.ts @@ -4,6 +4,9 @@ import type { HermesGatewayEnrollmentResult, HermesGatewayInstanceStatus, HermesGatewayPluginToT3Message, + HermesGatewayRemoveInstanceResult, + HermesGatewayRenameInstanceInput, + HermesGatewayRenameInstanceResult, HermesGatewayRevokeInstanceResult, HermesGatewayT3ToPluginMessage, ProviderInstanceId, @@ -47,9 +50,15 @@ export interface HermesGatewayBrokerShape { ReadonlyArray, HermesGatewayManagementError >; + readonly renameInstance: ( + input: HermesGatewayRenameInstanceInput, + ) => Effect.Effect; readonly revokeInstance: ( instanceId: ProviderInstanceId, ) => Effect.Effect; + readonly removeInstance: ( + instanceId: ProviderInstanceId, + ) => Effect.Effect; readonly registerConnection: ( hello: HermesGatewayConnectionHello, transport: HermesGatewayTransport, @@ -87,7 +96,9 @@ export const HermesGatewayBroker = Context.Reference( createEnrollment: unavailable, getInstanceStatus: unavailable, listInstances: unavailable(), + renameInstance: unavailable, revokeInstance: unavailable, + removeInstance: unavailable, registerConnection: unavailable, receive: unavailable, disconnect: unavailable, diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 871b79eca90..fda6f3c5fd5 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -563,6 +563,7 @@ const buildAppUnderTest = (options?: { ready: Effect.void, getSettings: Effect.succeed(DEFAULT_SERVER_SETTINGS), updateSettings: () => Effect.succeed(DEFAULT_SERVER_SETTINGS), + updateSettingsWith: () => Effect.succeed(DEFAULT_SERVER_SETTINGS), streamChanges: Stream.empty, ...options?.layers?.serverSettings, }), diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 487ae9b45b8..69a3709ecba 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -90,6 +90,55 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(settingsLayer)); }); + it.effect("does not report post-commit materialization failure as an update failure", () => { + const platformCause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "readFile", + pathOrDescriptor: "provider environment secret", + description: "Secret backend unavailable after settings commit.", + }); + const cause = new ServerSecretStore.SecretStoreReadError({ + resource: "provider environment secret", + cause: platformCause, + }); + const configLayer = Layer.fresh( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-server-settings-post-commit-failure-test-", + }), + ); + const settingsLayer = ServerSettingsModule.layer.pipe( + Layer.provide(makeFailingSecretStoreLayer(cause)), + Layer.provideMerge(configLayer), + ); + + return Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + const hermesId = ProviderInstanceId.make("hermes_remote"); + yield* fileSystem.writeFileString( + serverConfig.settingsPath, + '{"providerInstances":{"hermes_remote":{"driver":"hermes","displayName":"Remote Hermes","config":{}},"codex_personal":{"driver":"codex","environment":[{"name":"OPENROUTER_API_KEY","value":"","sensitive":true,"valueRedacted":true}],"config":{}}}}', + ); + + const next = yield* serverSettings.updateSettingsWith((current) => { + const providerInstances = { ...current.providerInstances }; + delete providerInstances[hermesId]; + return { providerInstances }; + }); + + assert.isUndefined(next.providerInstances[hermesId]); + assert.equal( + next.providerInstances[ProviderInstanceId.make("codex_personal")]?.environment?.[0]?.value, + "", + ); + const persisted = yield* fileSystem.readFileString(serverConfig.settingsPath); + assert.notInclude(persisted, "hermes_remote"); + assert.include(persisted, "codex_personal"); + }).pipe(Effect.provide(settingsLayer)); + }); + it.effect("decodes nested settings patches", () => Effect.gen(function* () { assert.deepEqual( diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 4119a72640f..bee359321d4 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -125,6 +125,15 @@ export class ServerSettingsService extends Context.Service< patch: ServerSettingsPatch, ) => Effect.Effect; + /** + * Compute and persist a patch while holding the settings write lock. + * Use this for read-modify-write operations on whole-map fields such as + * `providerInstances`, so concurrent settings edits are preserved. + */ + readonly updateSettingsWith: ( + update: (current: ServerSettings) => ServerSettingsPatch, + ) => Effect.Effect; + /** Stream of settings change events. */ readonly streamChanges: Stream.Stream; } @@ -144,17 +153,24 @@ const makeTest = (overrides: DeepPartial = {}) => : {}), }); const currentSettingsRef = yield* Ref.make(initialSettings); + const writeSemaphore = yield* Semaphore.make(1); + const updateSettingsWith = (update: (current: ServerSettings) => ServerSettingsPatch) => + writeSemaphore.withPermits(1)( + Ref.get(currentSettingsRef).pipe( + Effect.map((currentSettings) => + applyServerSettingsPatch(currentSettings, update(currentSettings)), + ), + Effect.flatMap(normalizeServerSettings), + Effect.tap((nextSettings) => Ref.set(currentSettingsRef, nextSettings)), + ), + ); return { start: Effect.void, ready: Effect.void, getSettings: Ref.get(currentSettingsRef), - updateSettings: (patch) => - Ref.get(currentSettingsRef).pipe( - Effect.map((currentSettings) => applyServerSettingsPatch(currentSettings, patch)), - Effect.flatMap(normalizeServerSettings), - Effect.tap((nextSettings) => Ref.set(currentSettingsRef, nextSettings)), - ), + updateSettings: (patch) => updateSettingsWith(() => patch), + updateSettingsWith, streamChanges: Stream.empty, } satisfies ServerSettingsService["Service"]; }); @@ -556,6 +572,35 @@ const make = Effect.gen(function* () { yield* Deferred.succeed(startedDeferred, undefined).pipe(Effect.orDie); }); + const updateSettingsWith = (update: (current: ServerSettings) => ServerSettingsPatch) => + writeSemaphore.withPermits(1)( + Effect.gen(function* () { + const current = yield* getSettingsFromCache; + const nextPersisted = yield* persistProviderEnvironmentSecrets( + current, + applyServerSettingsPatch(current, update(current)), + ); + const next = yield* normalizeServerSettings(nextPersisted); + yield* writeSettingsAtomically(next); + yield* Cache.set(settingsCache, cacheKey, next); + yield* emitChange(next); + const materialized = yield* materializeProviderEnvironmentSecrets(next).pipe( + Effect.catch((error: ServerSettingsError) => + Effect.logWarning( + "settings update committed but provider environment secrets could not be materialized", + { + operation: error.operation, + providerInstanceId: error.providerInstanceId, + environmentVariable: error.environmentVariable, + cause: error.cause, + }, + ).pipe(Effect.as(next)), + ), + ); + return resolveTextGenerationProvider(materialized); + }), + ); + return { start, ready: Deferred.await(startedDeferred), @@ -563,22 +608,8 @@ const make = Effect.gen(function* () { Effect.flatMap(materializeProviderEnvironmentSecrets), Effect.map(resolveTextGenerationProvider), ), - updateSettings: (patch) => - writeSemaphore.withPermits(1)( - Effect.gen(function* () { - const current = yield* getSettingsFromCache; - const nextPersisted = yield* persistProviderEnvironmentSecrets( - current, - applyServerSettingsPatch(current, patch), - ); - const next = yield* normalizeServerSettings(nextPersisted); - yield* writeSettingsAtomically(next); - yield* Cache.set(settingsCache, cacheKey, next); - yield* emitChange(next); - const materialized = yield* materializeProviderEnvironmentSecrets(next); - return resolveTextGenerationProvider(materialized); - }), - ), + updateSettings: (patch) => updateSettingsWith(() => patch), + updateSettingsWith, get streamChanges() { return Stream.fromPubSub(changesPubSub).pipe( Stream.mapEffect((settings) => diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 123259decf8..4cbb38ee1b9 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -311,7 +311,9 @@ const RPC_REQUIRED_SCOPE = new Map([ [WS_METHODS.hermesGatewayCreateEnrollment, AuthOrchestrationOperateScope], [WS_METHODS.hermesGatewayGetInstanceStatus, AuthOrchestrationReadScope], [WS_METHODS.hermesGatewayListInstances, AuthOrchestrationReadScope], + [WS_METHODS.hermesGatewayRenameInstance, AuthOrchestrationOperateScope], [WS_METHODS.hermesGatewayRevokeInstance, AuthOrchestrationOperateScope], + [WS_METHODS.hermesGatewayRemoveInstance, AuthOrchestrationOperateScope], [WS_METHODS.cloudGetRelayClientStatus, AuthRelayWriteScope], [WS_METHODS.cloudInstallRelayClient, AuthRelayWriteScope], [WS_METHODS.sourceControlLookupRepository, AuthOrchestrationReadScope], @@ -1545,12 +1547,24 @@ const makeWsRpcLayer = ( hermesGatewayBroker.listInstances, { "rpc.aggregate": "hermes-gateway" }, ), + [WS_METHODS.hermesGatewayRenameInstance]: (input) => + observeRpcEffect( + WS_METHODS.hermesGatewayRenameInstance, + hermesGatewayBroker.renameInstance(input), + { "rpc.aggregate": "hermes-gateway" }, + ), [WS_METHODS.hermesGatewayRevokeInstance]: ({ instanceId }) => observeRpcEffect( WS_METHODS.hermesGatewayRevokeInstance, hermesGatewayBroker.revokeInstance(instanceId), { "rpc.aggregate": "hermes-gateway" }, ), + [WS_METHODS.hermesGatewayRemoveInstance]: ({ instanceId }) => + observeRpcEffect( + WS_METHODS.hermesGatewayRemoveInstance, + hermesGatewayBroker.removeInstance(instanceId), + { "rpc.aggregate": "hermes-gateway" }, + ), [WS_METHODS.serverDiscoverSourceControl]: (_input) => observeRpcEffect( WS_METHODS.serverDiscoverSourceControl, diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.logic.ts b/apps/web/src/components/settings/AddProviderInstanceDialog.logic.ts index 43f3c30e670..bb76ce79112 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.logic.ts +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.logic.ts @@ -9,6 +9,37 @@ const INSTANCE_ID_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/; export const ADD_PROVIDER_WIZARD_STEPS = ["Driver", "Identity", "Config"] as const; +/** + * Hermes thread bindings outlive a removed gateway, so a new gateway must + * never derive its routing identity from a reusable display name. The UUID is + * generated once by the dialog and remains stable across enrollment retries. + */ +export function createHermesProviderInstanceId(label: string, randomUuid: () => string) { + const suffix = randomUuid() + .replace(/[^a-zA-Z0-9]/gu, "") + .toLowerCase(); + if (suffix.length === 0) { + throw new Error("Could not generate a Hermes instance ID."); + } + const labelSlug = label + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/gu, "-") + .replace(/^-+|-+$/gu, "") + .slice(0, 44); + const shortSuffix = suffix.slice(0, 12); + return labelSlug ? `hermes-${labelSlug}-${shortSuffix}` : `hermes-${shortSuffix}`; +} + +export function isHermesInstanceRemovedError(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "code" in error && + error.code === "instance-removed" + ); +} + export function isOwnedHermesEnrollmentRetry(input: { readonly driver: ProviderDriverKind; readonly instanceId: string; diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.test.ts b/apps/web/src/components/settings/AddProviderInstanceDialog.test.ts index ae5d613560f..fda07aa9f47 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.test.ts +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.test.ts @@ -3,11 +3,45 @@ import { describe, expect, it } from "vite-plus/test"; import { ProviderDriverKind } from "@t3tools/contracts"; import { + createHermesProviderInstanceId, + isHermesInstanceRemovedError, isOwnedHermesEnrollmentRetry, resolveWizardNavigation, validateProviderInstanceIdForWizard, } from "./AddProviderInstanceDialog.logic"; +describe("Hermes provider instance identity", () => { + it("keeps the readable label prefix while adding a stable random suffix", () => { + expect( + createHermesProviderInstanceId("Research Team", () => "019f99cc-30d4-72c4-b3dd-2ee59cecb856"), + ).toBe("hermes-research-team-019f99cc30d4"); + }); + + it("never reuses an ID when a later dialog uses the same label", () => { + expect( + createHermesProviderInstanceId("Research", () => "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), + ).not.toBe( + createHermesProviderInstanceId("Research", () => "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), + ); + }); + + it("stays within the provider instance ID length limit for long labels", () => { + const instanceId = createHermesProviderInstanceId( + "A very long research Hermes display name that should be truncated before persistence", + () => "019f99cc-30d4-72c4-b3dd-2ee59cecb856", + ); + + expect(instanceId.length).toBeLessThanOrEqual(64); + expect(instanceId).toMatch(/^hermes-[a-z0-9-]+-[a-z0-9]{12}$/u); + }); + + it("recognizes a server tombstone rejection so the dialog can rotate its nonce", () => { + expect(isHermesInstanceRemovedError({ code: "instance-removed" })).toBe(true); + expect(isHermesInstanceRemovedError({ code: "nickname-conflict" })).toBe(false); + expect(isHermesInstanceRemovedError(new Error("instance removed"))).toBe(false); + }); +}); + describe("resolveWizardNavigation", () => { const invalidId = { instanceIdError: "Instance ID is required." }; const validId = { instanceIdError: null }; diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx index 9d57bf533ab..23e1e5110b3 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx @@ -13,7 +13,7 @@ import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime" import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings"; -import { cn } from "../../lib/utils"; +import { cn, randomUUID } from "../../lib/utils"; import { normalizeProviderAccentColor } from "../../providerInstances"; import { Button } from "../ui/button"; import { ACPRegistryIcon, Gemini, GithubCopilotIcon, PiAgentIcon, type Icon } from "../Icons"; @@ -34,6 +34,8 @@ import { ProviderSettingsForm, deriveProviderSettingsFields } from "./ProviderSe import { AnimatedHeight } from "../AnimatedHeight"; import { ADD_PROVIDER_WIZARD_STEPS, + createHermesProviderInstanceId, + isHermesInstanceRemovedError, isOwnedHermesEnrollmentRetry, resolveWizardNavigation, type WizardNavigation, @@ -61,9 +63,9 @@ const PROVIDER_ACCENT_SWATCHES = [ /** * Normalize a user-provided label into a slug suffix for the instance id. * The full id is formed by prefixing the driver slug — e.g. label "Work" on - * driver "codex" becomes `codex_work`. Output is trimmed to 48 chars so the - * final composed id stays under the 64-char slug cap enforced by - * `ProviderInstanceId` in `@t3tools/contracts`. + * driver "codex" becomes `codex_work`. Hermes uses a random durable identity + * instead because its historical thread bindings must never target a newly + * created gateway with the same display name. */ function slugifyLabel(value: string): string { return value @@ -76,9 +78,6 @@ function slugifyLabel(value: string): string { function deriveInstanceId(driver: ProviderDriverKind, label: string): string { const slug = slugifyLabel(label); - if (driver === "hermes") { - return slug ? `hermes-${slug.replaceAll("_", "-")}` : ""; - } return slug ? `${driver}_${slug}` : ""; } @@ -135,6 +134,7 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns const [label, setLabel] = useState(""); const [accentColor, setAccentColor] = useState(""); const [instanceIdOverride, setInstanceIdOverride] = useState(null); + const [hermesIdentityNonce, setHermesIdentityNonce] = useState(randomUUID); // Driver-specific config drafts keyed by driver so toggling between drivers // during the same dialog session does not lose in-progress input. const [configByDriver, setConfigByDriver] = useState>>({}); @@ -167,6 +167,19 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns useEffect(() => { if (open) return; + setWizardStep(0); + setDriver(DEFAULT_DRIVER_KIND); + setLabel(""); + setAccentColor(""); + setInstanceIdOverride(null); + setHermesIdentityNonce(randomUUID()); + setConfigByDriver({}); + setHasAttemptedSubmit(false); + setHermesConnectorUrl( + typeof window === "undefined" + ? "http://localhost/api/hermes-gateway/ws" + : defaultHermesConnectorUrl(window.location.origin), + ); setHermesEnrollment(null); setSaveError(null); setIsSaving(false); @@ -179,7 +192,10 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns ); const driverOption = DRIVER_OPTION_BY_VALUE[driver] ?? DEFAULT_DRIVER_OPTION; - const instanceId = instanceIdOverride ?? deriveInstanceId(driver, label); + const instanceId = + driver === "hermes" + ? createHermesProviderInstanceId(label, () => hermesIdentityNonce) + : (instanceIdOverride ?? deriveInstanceId(driver, label)); const driverSettingsFields = useMemo( () => deriveProviderSettingsFields(driverOption), [driverOption], @@ -283,7 +299,12 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns }, }); if (enrollmentResult._tag === "Failure") { - throw squashAtomCommandFailure(enrollmentResult); + const enrollmentError = squashAtomCommandFailure(enrollmentResult); + if (isHermesInstanceRemovedError(enrollmentError)) { + setCreatedHermesIdentity(null); + setHermesIdentityNonce(randomUUID()); + } + throw enrollmentError; } setHermesEnrollment(enrollmentResult.value); toastManager.add({ @@ -455,15 +476,19 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns placeholder={`${driver}_work`} value={instanceId} onChange={(event) => { + if (driver === "hermes") return; setInstanceIdOverride(event.target.value); }} + readOnly={driver === "hermes"} aria-invalid={showInstanceIdError} /> {showInstanceIdError ? ( {instanceIdError} ) : ( - Routing key used by threads and sessions. Letters, digits, '-', or '_'. + {driver === "hermes" + ? "Generated once for this gateway so historical threads can never be rebound." + : "Routing key used by threads and sessions. Letters, digits, '-', or '_'."} )} diff --git a/apps/web/src/components/settings/HermesGatewayInstanceSection.logic.test.ts b/apps/web/src/components/settings/HermesGatewayInstanceSection.logic.test.ts index 4e4b6f4279f..4507eec9630 100644 --- a/apps/web/src/components/settings/HermesGatewayInstanceSection.logic.test.ts +++ b/apps/web/src/components/settings/HermesGatewayInstanceSection.logic.test.ts @@ -1,9 +1,12 @@ import { describe, expect, it } from "vite-plus/test"; import { + canRemoveHermesGatewayInstance, defaultHermesConnectorUrl, formatHermesLastConnected, + hermesGatewayLifecycleAction, hermesGatewayStatusLabel, + isHermesInstanceNotFoundError, messageFromUnknownError, shouldApplyHermesConnectorStatusUrl, } from "./HermesGatewayInstanceSection.logic"; @@ -23,6 +26,31 @@ describe("Hermes gateway settings logic", () => { ).toEqual(["Offline", "Connecting", "Connected", "Upgrade required", "Revoked"]); }); + it("only offers permanent removal after credentials are revoked", () => { + expect(canRemoveHermesGatewayInstance("connected")).toBe(false); + expect(canRemoveHermesGatewayInstance("offline")).toBe(false); + expect(canRemoveHermesGatewayInstance("revoked")).toBe(true); + }); + + it("offers setup cleanup only for a structured missing-instance response", () => { + expect(isHermesInstanceNotFoundError({ code: "instance-not-found" })).toBe(true); + expect(isHermesInstanceNotFoundError(new Error("Network unavailable"))).toBe(false); + expect(isHermesInstanceNotFoundError({ code: "internal-error" })).toBe(false); + + expect(hermesGatewayLifecycleAction({ status: "offline", instanceNotFound: true })).toBe( + "remove-setup", + ); + expect(hermesGatewayLifecycleAction({ status: "offline", instanceNotFound: false })).toBe( + "revoke", + ); + expect(hermesGatewayLifecycleAction({ status: "connected", instanceNotFound: false })).toBe( + "revoke", + ); + expect(hermesGatewayLifecycleAction({ status: "revoked", instanceNotFound: false })).toBe( + "remove-instance", + ); + }); + it("keeps invalid server timestamps readable and extracts structured messages", () => { expect(formatHermesLastConnected(null)).toBe("Never"); expect(formatHermesLastConnected("not-a-date")).toBe("not-a-date"); diff --git a/apps/web/src/components/settings/HermesGatewayInstanceSection.logic.ts b/apps/web/src/components/settings/HermesGatewayInstanceSection.logic.ts index 65c84358639..f5a661470c9 100644 --- a/apps/web/src/components/settings/HermesGatewayInstanceSection.logic.ts +++ b/apps/web/src/components/settings/HermesGatewayInstanceSection.logic.ts @@ -25,6 +25,27 @@ export function hermesGatewayStatusLabel(status: HermesGatewayConnectionState): } } +export function canRemoveHermesGatewayInstance(status: HermesGatewayConnectionState): boolean { + return status === "revoked"; +} + +export function isHermesInstanceNotFoundError(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "code" in error && + error.code === "instance-not-found" + ); +} + +export function hermesGatewayLifecycleAction(input: { + readonly status: HermesGatewayConnectionState; + readonly instanceNotFound: boolean; +}): "revoke" | "remove-instance" | "remove-setup" { + if (input.instanceNotFound) return "remove-setup"; + return canRemoveHermesGatewayInstance(input.status) ? "remove-instance" : "revoke"; +} + export function formatHermesLastConnected(value: string | null): string { if (value === null) return "Never"; const date = new Date(value); diff --git a/apps/web/src/components/settings/HermesGatewayInstanceSection.tsx b/apps/web/src/components/settings/HermesGatewayInstanceSection.tsx index 30480d2ef82..8ececbb50dc 100644 --- a/apps/web/src/components/settings/HermesGatewayInstanceSection.tsx +++ b/apps/web/src/components/settings/HermesGatewayInstanceSection.tsx @@ -7,7 +7,14 @@ import { type HermesGatewayInstanceStatus, type ProviderInstanceId, } from "@t3tools/contracts"; -import { CheckIcon, CopyIcon, LoaderIcon, RefreshCwIcon, UnplugIcon } from "lucide-react"; +import { + CheckIcon, + CopyIcon, + LoaderIcon, + RefreshCwIcon, + Trash2Icon, + UnplugIcon, +} from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; @@ -21,7 +28,9 @@ import { toastManager } from "../ui/toast"; import { defaultHermesConnectorUrl, formatHermesLastConnected, + hermesGatewayLifecycleAction, hermesGatewayStatusLabel, + isHermesInstanceNotFoundError, messageFromUnknownError, shouldApplyHermesConnectorStatusUrl, } from "./HermesGatewayInstanceSection.logic"; @@ -45,6 +54,7 @@ function browserDefaultConnectorUrl() { export function HermesGatewayInstanceSection(props: { readonly instanceId: ProviderInstanceId; readonly nickname: string; + readonly onRemoved?: (() => void) | undefined; }) { const environmentId = usePrimaryEnvironment()?.environmentId ?? null; const getStatus = useAtomCommand(serverEnvironment.hermesGatewayGetInstanceStatus, { @@ -56,11 +66,17 @@ export function HermesGatewayInstanceSection(props: { const revokeInstance = useAtomCommand(serverEnvironment.hermesGatewayRevokeInstance, { reportFailure: false, }); + const removeInstance = useAtomCommand(serverEnvironment.hermesGatewayRemoveInstance, { + reportFailure: false, + }); const [connectorUrl, setConnectorUrl] = useState(browserDefaultConnectorUrl); const [status, setStatus] = useState(null); const [enrollment, setEnrollment] = useState(null); const [error, setError] = useState(null); - const [pendingAction, setPendingAction] = useState<"status" | "enroll" | "revoke" | null>(null); + const [instanceNotFound, setInstanceNotFound] = useState(false); + const [pendingAction, setPendingAction] = useState< + "status" | "enroll" | "revoke" | "remove" | null + >(null); const connectorUrlHasLocalEditsRef = useRef(false); const { copyToClipboard, isCopied } = useCopyToClipboard({ target: "Hermes enrollment command", @@ -83,12 +99,20 @@ export function HermesGatewayInstanceSection(props: { }); if (result._tag === "Success") { setStatus(result.value); + setInstanceNotFound(false); if (shouldApplyHermesConnectorStatusUrl(connectorUrlHasLocalEditsRef.current)) { setConnectorUrl(result.value.connectorUrl); } setError(null); - } else if (!quiet) { - setError(messageFromUnknownError(squashAtomCommandFailure(result))); + } else { + const failure = squashAtomCommandFailure(result); + if (isHermesInstanceNotFoundError(failure)) { + setStatus(null); + setInstanceNotFound(true); + setError(null); + } else if (!quiet) { + setError(messageFromUnknownError(failure)); + } } if (!quiet) setPendingAction(null); }, @@ -120,6 +144,7 @@ export function HermesGatewayInstanceSection(props: { connectorUrlHasLocalEditsRef.current = false; setConnectorUrl(result.value.connectorUrl); setEnrollment(result.value); + setInstanceNotFound(false); setStatus((current) => current ? { ...current, connectorUrl: result.value.connectorUrl, status: "offline" } @@ -148,7 +173,34 @@ export function HermesGatewayInstanceSection(props: { setPendingAction(null); }; + const handleRemove = async () => { + if (environmentId === null) return; + setPendingAction("remove"); + setError(null); + const result = await removeInstance({ + environmentId, + input: { instanceId: props.instanceId }, + }); + if (result._tag === "Success") { + props.onRemoved?.(); + toastManager.add({ + type: "success", + title: instanceNotFound ? "Hermes setup removed" : "Hermes instance removed", + description: + "Existing threads remain readable and stay bound to this unavailable instance.", + }); + } else { + setError(messageFromUnknownError(squashAtomCommandFailure(result))); + setPendingAction(null); + } + }; + const connectionState = status?.status ?? "offline"; + const lifecycleAction = hermesGatewayLifecycleAction({ + status: connectionState, + instanceNotFound, + }); + const canRemove = lifecycleAction !== "revoke"; const isBusy = pendingAction !== null; return ( @@ -246,9 +298,14 @@ export function HermesGatewayInstanceSection(props: { ) : null} {error ?

{error}

: null} + {instanceNotFound ? ( +

+ This setup has no enrolled Hermes gateway. Create an enrollment or remove the setup. +

+ ) : null}
- {connectionState !== "revoked" ? ( + {lifecycleAction !== "remove-instance" ? ( + {canRemove ? ( + + ) : ( + + )}
); diff --git a/apps/web/src/components/settings/ProviderInstanceCard.test.ts b/apps/web/src/components/settings/ProviderInstanceCard.test.ts index 87b6017cd54..2e745e6b808 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.test.ts +++ b/apps/web/src/components/settings/ProviderInstanceCard.test.ts @@ -39,8 +39,8 @@ describe("deriveProviderModelsForDisplay", () => { }); describe("isProviderInstanceDisplayNameEditable", () => { - it("keeps Hermes nicknames immutable without changing other providers", () => { - expect(isProviderInstanceDisplayNameEditable("hermes")).toBe(false); + it("allows Hermes display names to change without changing the instance id", () => { + expect(isProviderInstanceDisplayNameEditable("hermes")).toBe(true); expect(isProviderInstanceDisplayNameEditable("codex")).toBe(true); expect(isProviderInstanceDisplayNameEditable("claudeAgent")).toBe(true); }); diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index b24c16b0ff7..361d7cc23d9 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -138,7 +138,7 @@ export function deriveProviderModelsForDisplay(input: { } export function isProviderInstanceDisplayNameEditable(driver: string): boolean { - return driver !== "hermes"; + return driver.length > 0; } function ProviderAuthEmail(props: { @@ -345,6 +345,7 @@ interface ProviderInstanceCardProps { * `{ onDelete: undefined }` are treated as distinct shapes. */ readonly onDelete?: (() => void) | undefined; + readonly onHermesRemoved?: (() => void) | undefined; /** * Optional outer reset button rendered next to the driver icon. Built-in * default slots supply a reset-to-factory control here; custom instances @@ -394,6 +395,7 @@ export function ProviderInstanceCard({ onExpandedChange, onUpdate, onDelete, + onHermesRemoved, headerAction, hiddenModels, favoriteModels, @@ -405,10 +407,10 @@ export function ProviderInstanceCard({ isUpdating = false, }: ProviderInstanceCardProps) { const environmentId = usePrimaryEnvironment()?.environmentId ?? null; - const revokeHermesInstance = useAtomCommand(serverEnvironment.hermesGatewayRevokeInstance, { + const renameHermesInstance = useAtomCommand(serverEnvironment.hermesGatewayRenameInstance, { reportFailure: false, }); - const [isDeleting, setIsDeleting] = useState(false); + const [isRenaming, setIsRenaming] = useState(false); const enabled = instance.enabled ?? true; // The server-reported status wins when present; otherwise fall back to // "disabled"/"warning" based on the local `enabled` flag so the dot @@ -439,7 +441,7 @@ export function ProviderInstanceCard({ .replace(/[-_]+/g, " ") .trim()); const displayName = isHermes - ? instanceId === "hermes" || hermesNickname === "Hermes" + ? hermesNickname === "Hermes" ? "Hermes" : `Hermes · ${hermesNickname}` : configuredDisplayName || driverOption?.label || String(instance.driver); @@ -482,6 +484,40 @@ export function ProviderInstanceCard({ const updateDisplayName = (value: string) => { const trimmed = value.trim(); + if (isHermes) { + if (trimmed.length === 0) { + toastManager.add({ + type: "error", + title: "Hermes name is required", + description: "Choose a unique name for this Hermes instance.", + }); + return; + } + if (environmentId === null) { + toastManager.add({ + type: "error", + title: "Could not rename Hermes instance", + description: "Reconnect this browser to the T3 server and try again.", + }); + return; + } + void (async () => { + setIsRenaming(true); + const result = await renameHermesInstance({ + environmentId, + input: { instanceId, nickname: trimmed }, + }); + if (result._tag === "Failure") { + toastManager.add({ + type: "error", + title: "Could not rename Hermes instance", + description: messageFromUnknownError(squashAtomCommandFailure(result)), + }); + } + setIsRenaming(false); + })(); + return; + } const { displayName: _omit, ...rest } = instance; onUpdate( trimmed.length > 0 @@ -529,49 +565,6 @@ export function ProviderInstanceCard({ ); }; - const handleDelete = async () => { - if (!onDelete || isDeleting) return; - if (!isHermes) { - onDelete(); - return; - } - if (environmentId === null) { - toastManager.add({ - type: "error", - title: "Could not delete Hermes instance", - description: "Reconnect this browser to the T3 server so its gateway can be revoked first.", - }); - return; - } - setIsDeleting(true); - const result = await revokeHermesInstance({ - environmentId, - input: { instanceId }, - }); - if (result._tag === "Success") { - onDelete(); - setIsDeleting(false); - return; - } - const failure = squashAtomCommandFailure(result); - const wasNeverEnrolled = - typeof failure === "object" && - failure !== null && - "code" in failure && - failure.code === "instance-not-found"; - if (wasNeverEnrolled) { - onDelete(); - setIsDeleting(false); - return; - } - toastManager.add({ - type: "error", - title: "Could not delete Hermes instance", - description: `${messageFromUnknownError(failure)} The instance was kept so a live gateway credential is not orphaned.`, - }); - setIsDeleting(false); - }; - const titleIconNode = driverKind ? ( ) : null} - {onDelete ? ( + {onDelete && !isHermes ? ( void handleDelete()} + onClick={onDelete} aria-label={`Delete provider instance ${instanceId}`} > - {isDeleting ? ( - - ) : ( - - )} + } /> @@ -809,7 +797,9 @@ export function ProviderInstanceCard({ {isProviderInstanceDisplayNameEditable(String(instance.driver)) ? (
- ) : ( -
- Hermes nickname -
- {displayName} -
- - Fixed after creation because this nickname identifies the paired Hermes instance. - Delete and re-add the instance to choose a different nickname. - -
- )} + ) : null}
{isHermes ? ( - + ) : (
{ + it("requires Hermes instances to be explicitly enrolled", () => { + expect(isExplicitProviderInstanceOnly(ProviderDriverKind.make("hermes"))).toBe(true); + expect(isExplicitProviderInstanceOnly(ProviderDriverKind.make("codex"))).toBe(false); + }); +}); + describe("project grouping toggle", () => { it("enables repository grouping and disables into separate projects", () => { expect(isProjectGroupingEnabled("repository")).toBe(true); diff --git a/apps/web/src/components/settings/SettingsPanels.logic.ts b/apps/web/src/components/settings/SettingsPanels.logic.ts index 51e318225ae..ee25adcdb5d 100644 --- a/apps/web/src/components/settings/SettingsPanels.logic.ts +++ b/apps/web/src/components/settings/SettingsPanels.logic.ts @@ -20,6 +20,15 @@ export function projectGroupingModeFromToggle( return lastEnabledMode === "repository_path" ? "repository_path" : "repository"; } +/** + * Gateway-backed Hermes instances only exist after enrollment creates an + * explicit provider instance. Unlike local CLI drivers, Hermes has no useful + * built-in default process to synthesize from legacy provider settings. + */ +export function isExplicitProviderInstanceOnly(driver: ProviderDriverKind): boolean { + return driver === "hermes"; +} + const LAST_ENABLED_PROJECT_GROUPING_MODE_KEY = "t3code:last-enabled-project-grouping-mode"; export function readLastEnabledProjectGroupingMode(): SidebarProjectGroupingMode { diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index fa7c7299667..88e6c5947be 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -84,6 +84,7 @@ import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta"; import { buildProviderInstanceUpdatePatch, formatDiagnosticsDescription, + isExplicitProviderInstanceOnly, isProjectGroupingEnabled, projectGroupingModeFromToggle, readLastEnabledProjectGroupingMode, @@ -1125,11 +1126,12 @@ export function ProviderSettingsPanel() { ); const visibleProviderSettings = PROVIDER_SETTINGS.filter( (providerSettings) => - providerSettings.provider !== "cursor" || - serverProviders.some( - (provider) => - provider.instanceId === defaultInstanceIdForDriver(ProviderDriverKind.make("cursor")), - ), + !isExplicitProviderInstanceOnly(providerSettings.provider) && + (providerSettings.provider !== "cursor" || + serverProviders.some( + (provider) => + provider.instanceId === defaultInstanceIdForDriver(ProviderDriverKind.make("cursor")), + )), ); const textGenerationModelSelection = resolveAppModelSelectionState(settings, serverProviders); const textGenInstanceId = textGenerationModelSelection.instanceId; @@ -1320,6 +1322,13 @@ export function ProviderSettingsPanel() { }); }; + const cleanupRemovedProviderInstancePreferences = (id: ProviderInstanceId) => { + updateSettings({ + providerModelPreferences: withoutProviderInstanceKey(settings.providerModelPreferences, id), + favorites: withoutProviderInstanceFavorites(settings.favorites ?? [], id), + }); + }; + const updateProviderModelPreferences = ( instanceId: ProviderInstanceId, next: { @@ -1499,6 +1508,7 @@ export function ProviderSettingsPanel() { } }} onDelete={row.isDefault ? undefined : () => deleteProviderInstance(row.instanceId)} + onHermesRemoved={() => cleanupRemovedProviderInstancePreferences(row.instanceId)} headerAction={headerAction} hiddenModels={modelPreferences.hiddenModels} favoriteModels={favoriteModels} diff --git a/apps/web/src/providerInstances.test.ts b/apps/web/src/providerInstances.test.ts index 2ccc64dcab3..06aae694673 100644 --- a/apps/web/src/providerInstances.test.ts +++ b/apps/web/src/providerInstances.test.ts @@ -219,6 +219,19 @@ describe("deriveProviderInstanceEntries", () => { "Hermes · Lab", ]); }); + + it("shows an edited name for an explicit Hermes instance using the legacy default id", () => { + const [entry] = deriveProviderInstanceEntries([ + provider({ + provider: ProviderDriverKind.make("hermes"), + instanceId: "hermes", + displayName: "Research", + }), + ]); + + expect(entry?.displayName).toBe("Hermes · Research"); + expect(entry?.instanceId).toBe("hermes"); + }); }); describe("resolveSelectableProviderInstance", () => { diff --git a/apps/web/src/providerInstances.ts b/apps/web/src/providerInstances.ts index 23457ed17bd..9d548d1c769 100644 --- a/apps/web/src/providerInstances.ts +++ b/apps/web/src/providerInstances.ts @@ -165,7 +165,6 @@ function resolveInstanceDisplayName( const trimmedSnapshotName = snapshot.displayName?.trim(); const kindLabel = driverKindLabel(driverKind); if (driverKind === "hermes") { - if (isDefault) return "Hermes"; const candidate = trimmedSnapshotName && trimmedSnapshotName !== kindLabel ? trimmedSnapshotName diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index d5fd8ed624d..59744de8bb9 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -327,10 +327,18 @@ export function createServerEnvironmentAtoms( label: "environment-data:server:hermes-gateway-list-instances", tag: WS_METHODS.hermesGatewayListInstances, }), + hermesGatewayRenameInstance: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:hermes-gateway-rename-instance", + tag: WS_METHODS.hermesGatewayRenameInstance, + }), hermesGatewayRevokeInstance: createEnvironmentRpcCommand(runtime, { label: "environment-data:server:hermes-gateway-revoke-instance", tag: WS_METHODS.hermesGatewayRevokeInstance, }), + hermesGatewayRemoveInstance: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:hermes-gateway-remove-instance", + tag: WS_METHODS.hermesGatewayRemoveInstance, + }), updateProvider: createEnvironmentRpcCommand(runtime, { label: "environment-data:server:update-provider", tag: WS_METHODS.serverUpdateProvider, diff --git a/packages/contracts/src/hermesGateway.ts b/packages/contracts/src/hermesGateway.ts index 4afdf40b822..d4482ea6e12 100644 --- a/packages/contracts/src/hermesGateway.ts +++ b/packages/contracts/src/hermesGateway.ts @@ -164,6 +164,9 @@ export const HermesGatewayRenameInstanceInput = Schema.Struct({ }); export type HermesGatewayRenameInstanceInput = typeof HermesGatewayRenameInstanceInput.Type; +export const HermesGatewayRenameInstanceResult = HermesGatewayInstanceStatus; +export type HermesGatewayRenameInstanceResult = typeof HermesGatewayRenameInstanceResult.Type; + export const HermesGatewayRevokeInstanceInput = Schema.Struct({ instanceId: ProviderInstanceId, }); @@ -172,11 +175,23 @@ export type HermesGatewayRevokeInstanceInput = typeof HermesGatewayRevokeInstanc export const HermesGatewayRevokeInstanceResult = HermesGatewayInstanceStatus; export type HermesGatewayRevokeInstanceResult = typeof HermesGatewayRevokeInstanceResult.Type; +export const HermesGatewayRemoveInstanceInput = Schema.Struct({ + instanceId: ProviderInstanceId, +}); +export type HermesGatewayRemoveInstanceInput = typeof HermesGatewayRemoveInstanceInput.Type; + +export const HermesGatewayRemoveInstanceResult = Schema.Struct({ + instanceId: ProviderInstanceId, +}); +export type HermesGatewayRemoveInstanceResult = typeof HermesGatewayRemoveInstanceResult.Type; + export const HermesGatewayManagementOperation = Schema.Literals([ "create-enrollment", "get-status", "list-instances", + "rename-instance", "revoke-instance", + "remove-instance", ]); export type HermesGatewayManagementOperation = typeof HermesGatewayManagementOperation.Type; @@ -185,6 +200,8 @@ export const HermesGatewayManagementErrorCode = Schema.Literals([ "nickname-conflict", "invalid-connector-url", "instance-revoked", + "instance-removed", + "instance-not-revoked", "persistence-failed", "internal-error", ]); diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index f7b331d03ce..2e69e5c44aa 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -66,6 +66,10 @@ import { HermesGatewayInstanceStatus, HermesGatewayListInstancesResult, HermesGatewayManagementError, + HermesGatewayRemoveInstanceInput, + HermesGatewayRemoveInstanceResult, + HermesGatewayRenameInstanceInput, + HermesGatewayRenameInstanceResult, HermesGatewayRevokeInstanceInput, HermesGatewayRevokeInstanceResult, } from "./hermesGateway.ts"; @@ -233,7 +237,9 @@ export const WS_METHODS = { hermesGatewayCreateEnrollment: "hermesGateway.createEnrollment", hermesGatewayGetInstanceStatus: "hermesGateway.getInstanceStatus", hermesGatewayListInstances: "hermesGateway.listInstances", + hermesGatewayRenameInstance: "hermesGateway.renameInstance", hermesGatewayRevokeInstance: "hermesGateway.revokeInstance", + hermesGatewayRemoveInstance: "hermesGateway.removeInstance", // Cloud environment methods cloudGetRelayClientStatus: "cloud.getRelayClientStatus", @@ -374,12 +380,24 @@ export const WsHermesGatewayListInstancesRpc = Rpc.make(WS_METHODS.hermesGateway error: Schema.Union([HermesGatewayManagementError, EnvironmentAuthorizationError]), }); +export const WsHermesGatewayRenameInstanceRpc = Rpc.make(WS_METHODS.hermesGatewayRenameInstance, { + payload: HermesGatewayRenameInstanceInput, + success: HermesGatewayRenameInstanceResult, + error: Schema.Union([HermesGatewayManagementError, EnvironmentAuthorizationError]), +}); + export const WsHermesGatewayRevokeInstanceRpc = Rpc.make(WS_METHODS.hermesGatewayRevokeInstance, { payload: HermesGatewayRevokeInstanceInput, success: HermesGatewayRevokeInstanceResult, error: Schema.Union([HermesGatewayManagementError, EnvironmentAuthorizationError]), }); +export const WsHermesGatewayRemoveInstanceRpc = Rpc.make(WS_METHODS.hermesGatewayRemoveInstance, { + payload: HermesGatewayRemoveInstanceInput, + success: HermesGatewayRemoveInstanceResult, + error: Schema.Union([HermesGatewayManagementError, EnvironmentAuthorizationError]), +}); + export const WsCloudGetRelayClientStatusRpc = Rpc.make(WS_METHODS.cloudGetRelayClientStatus, { payload: Schema.Struct({}), success: RelayClientStatusSchema, @@ -762,7 +780,9 @@ export const WsRpcGroup = RpcGroup.make( WsHermesGatewayCreateEnrollmentRpc, WsHermesGatewayGetInstanceStatusRpc, WsHermesGatewayListInstancesRpc, + WsHermesGatewayRenameInstanceRpc, WsHermesGatewayRevokeInstanceRpc, + WsHermesGatewayRemoveInstanceRpc, WsCloudGetRelayClientStatusRpc, WsCloudInstallRelayClientRpc, WsSourceControlLookupRepositoryRpc, From 0b837b8ef11e690f3cf4633312fed78e5793bf72 Mon Sep 17 00:00:00 2001 From: Ben Davis <45952064+bmdavis419@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:39:33 -0700 Subject: [PATCH 03/68] fix: isolate web auth cookies by port --- apps/server/src/auth/EnvironmentAuth.test.ts | 36 ++++++++++++++++++- .../src/auth/EnvironmentAuthPolicy.test.ts | 18 ++++++++++ apps/server/src/auth/utils.test.ts | 18 +++++++++- apps/server/src/auth/utils.ts | 4 ++- 4 files changed, 73 insertions(+), 3 deletions(-) diff --git a/apps/server/src/auth/EnvironmentAuth.test.ts b/apps/server/src/auth/EnvironmentAuth.test.ts index 335e0685197..933069e2926 100644 --- a/apps/server/src/auth/EnvironmentAuth.test.ts +++ b/apps/server/src/auth/EnvironmentAuth.test.ts @@ -18,6 +18,7 @@ const makeServerConfigLayer = (overrides?: Partial[0] => ({ cookies: { - t3_session: sessionToken, + [cookieName]: sessionToken, }, headers: {}, }) as unknown as Parameters< @@ -100,6 +102,38 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { }).pipe(Effect.provide(makeEnvironmentAuthLayer())), ); + it.effect("uses the port-specific web cookie when sibling instance cookies are present", () => + Effect.gen(function* () { + const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; + const pairingCredential = yield* serverAuth.issuePairingCredential(); + const exchanged = yield* serverAuth.createBrowserSession( + pairingCredential.credential, + requestMetadata, + ); + const request = { + cookies: { + t3_session: "session-owned-by-the-canonical-instance", + t3_session_7446: exchanged.sessionToken, + }, + headers: {}, + } as unknown as Parameters< + EnvironmentAuth.EnvironmentAuth["Service"]["authenticateHttpRequest"] + >[0]; + + const verified = yield* serverAuth.authenticateHttpRequest(request); + + expect(verified.sessionId.length).toBeGreaterThan(0); + expect(verified.subject).toBe("one-time-token"); + }).pipe( + Effect.provide( + makeEnvironmentAuthLayer({ + mode: "web", + port: 7446, + }), + ), + ), + ); + it.effect("does not exchange ordinary pairing grants for administrative access tokens", () => Effect.gen(function* () { const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; diff --git a/apps/server/src/auth/EnvironmentAuthPolicy.test.ts b/apps/server/src/auth/EnvironmentAuthPolicy.test.ts index 95269fb6c37..b8474fbfb19 100644 --- a/apps/server/src/auth/EnvironmentAuthPolicy.test.ts +++ b/apps/server/src/auth/EnvironmentAuthPolicy.test.ts @@ -75,6 +75,24 @@ it.layer(NodeServices.layer)("EnvironmentAuthPolicy.layer", (it) => { makeEnvironmentAuthPolicyLayer({ mode: "web", host: "127.0.0.1", + port: 3773, + }), + ), + ), + ); + + it.effect("isolates browser session cookies for web servers on non-default ports", () => + Effect.gen(function* () { + const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy; + const descriptor = yield* policy.getDescriptor(); + + expect(descriptor.sessionCookieName).toBe("t3_session_7446"); + }).pipe( + Effect.provide( + makeEnvironmentAuthPolicyLayer({ + mode: "web", + host: "127.0.0.1", + port: 7446, }), ), ), diff --git a/apps/server/src/auth/utils.test.ts b/apps/server/src/auth/utils.test.ts index 90dc0f8ddf9..adc908827a0 100644 --- a/apps/server/src/auth/utils.test.ts +++ b/apps/server/src/auth/utils.test.ts @@ -1,6 +1,22 @@ import { describe, expect, it } from "vite-plus/test"; -import { deriveAuthClientMetadata } from "./utils.ts"; +import { deriveAuthClientMetadata, resolveSessionCookieName } from "./utils.ts"; + +describe("resolveSessionCookieName", () => { + it("preserves the legacy cookie name for the canonical web port", () => { + expect(resolveSessionCookieName({ mode: "web", port: 3773 })).toBe("t3_session"); + }); + + it("isolates web sessions on non-default ports", () => { + expect(resolveSessionCookieName({ mode: "web", port: 7446 })).toBe("t3_session_7446"); + expect(resolveSessionCookieName({ mode: "web", port: 7447 })).toBe("t3_session_7447"); + }); + + it("keeps desktop sessions isolated by port", () => { + expect(resolveSessionCookieName({ mode: "desktop", port: 3773 })).toBe("t3_session_3773"); + expect(resolveSessionCookieName({ mode: "desktop", port: 7446 })).toBe("t3_session_7446"); + }); +}); describe("deriveAuthClientMetadata", () => { it("labels Electron user agents as Electron instead of Chrome", () => { diff --git a/apps/server/src/auth/utils.ts b/apps/server/src/auth/utils.ts index 39f04988ac5..3282f04a180 100644 --- a/apps/server/src/auth/utils.ts +++ b/apps/server/src/auth/utils.ts @@ -8,13 +8,15 @@ import * as NodeCrypto from "node:crypto"; import * as Encoding from "effect/Encoding"; import * as Result from "effect/Result"; +import { DEFAULT_PORT } from "../config.ts"; + const SESSION_COOKIE_NAME = "t3_session"; export function resolveSessionCookieName(input: { readonly mode: "web" | "desktop"; readonly port: number; }): string { - if (input.mode !== "desktop") { + if (input.mode === "web" && input.port === DEFAULT_PORT) { return SESSION_COOKIE_NAME; } From 5faee6114bd5325b8dd567861bbae1e8665bb8d4 Mon Sep 17 00:00:00 2001 From: Ben Davis <45952064+bmdavis419@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:34:15 -0700 Subject: [PATCH 04/68] fix: harden Hermes gateway streaming lifecycle --- .../Layers/ProjectionPipeline.test.ts | 111 +++++ .../Layers/ProjectionPipeline.ts | 3 + .../Layers/ProviderCommandReactor.test.ts | 43 ++ .../Layers/ProviderCommandReactor.ts | 3 +- .../Layers/ProviderRuntimeIngestion.test.ts | 438 +++++++++++++++++- .../Layers/ProviderRuntimeIngestion.ts | 102 ++++ ...ecider.assistantMessageReplacement.test.ts | 73 +++ apps/server/src/orchestration/decider.ts | 28 ++ .../src/orchestration/projector.test.ts | 77 +++ apps/server/src/orchestration/projector.ts | 11 +- .../src/provider/Layers/HermesAdapter.test.ts | 220 ++++++++- .../src/provider/Layers/HermesAdapter.ts | 65 +-- .../Layers/HermesGatewayBroker.test.ts | 8 +- .../provider/Layers/ProviderService.test.ts | 95 ++++ .../src/provider/Layers/ProviderService.ts | 94 +++- .../hermes-t3-gateway/COMPATIBILITY.md | 22 +- integrations/hermes-t3-gateway/README.md | 10 +- integrations/hermes-t3-gateway/adapter.py | 52 ++- integrations/hermes-t3-gateway/connection.py | 4 +- integrations/hermes-t3-gateway/plugin.yaml | 2 +- integrations/hermes-t3-gateway/protocol.py | 4 +- .../hermes-t3-gateway/tests/test_adapter.py | 343 +++++++++++++- .../tests/test_connection.py | 24 +- .../hermes-t3-gateway/tests/test_protocol.py | 9 +- packages/contracts/src/hermesGateway.test.ts | 101 ++-- packages/contracts/src/hermesGateway.ts | 21 +- packages/contracts/src/orchestration.ts | 12 + .../contracts/src/providerRuntime.test.ts | 28 ++ packages/contracts/src/providerRuntime.ts | 18 + 29 files changed, 1884 insertions(+), 137 deletions(-) create mode 100644 apps/server/src/orchestration/decider.assistantMessageReplacement.test.ts diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 926182a3ef0..42b5d7624c6 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -1652,6 +1652,117 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { }), ); + it.effect("replaces an assistant snapshot between legacy appended deltas", () => + Effect.gen(function* () { + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + const now = "2026-07-24T12:00:00.000Z"; + const projectId = ProjectId.make("project-replacement"); + const threadId = ThreadId.make("thread-replacement"); + const messageId = MessageId.make("assistant-replacement"); + + yield* eventStore.append({ + type: "project.created", + eventId: EventId.make("evt-replacement-1"), + aggregateKind: "project", + aggregateId: projectId, + occurredAt: now, + commandId: CommandId.make("cmd-replacement-1"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-replacement-1"), + metadata: {}, + payload: { + projectId, + title: "Project Replacement", + workspaceRoot: "/tmp/project-replacement", + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }); + + yield* eventStore.append({ + type: "thread.created", + eventId: EventId.make("evt-replacement-2"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.make("cmd-replacement-2"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-replacement-2"), + metadata: {}, + payload: { + threadId, + projectId, + title: "Thread Replacement", + modelSelection: { + instanceId: ProviderInstanceId.make("hermes"), + model: "hermes", + }, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }); + + const appendMessage = (input: { + eventNumber: number; + text: string; + streaming: boolean; + textOperation?: "replace"; + }) => + eventStore.append({ + type: "thread.message-sent", + eventId: EventId.make(`evt-replacement-${input.eventNumber}`), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.make(`cmd-replacement-${input.eventNumber}`), + causationEventId: null, + correlationId: CorrelationId.make(`cmd-replacement-${input.eventNumber}`), + metadata: {}, + payload: { + threadId, + messageId, + role: "assistant", + text: input.text, + turnId: null, + streaming: input.streaming, + ...(input.textOperation !== undefined ? { textOperation: input.textOperation } : {}), + createdAt: now, + updatedAt: now, + }, + }); + + yield* appendMessage({ eventNumber: 3, text: "Hello wor", streaming: true }); + yield* appendMessage({ + eventNumber: 4, + text: "Hello there", + streaming: true, + textOperation: "replace", + }); + yield* appendMessage({ eventNumber: 5, text: "!", streaming: true }); + yield* appendMessage({ eventNumber: 6, text: "", streaming: false }); + + yield* projectionPipeline.bootstrap; + + const messageRows = yield* sql<{ readonly text: string; readonly isStreaming: unknown }>` + SELECT + text, + is_streaming AS "isStreaming" + FROM projection_thread_messages + WHERE message_id = ${messageId} + `; + assert.equal(messageRows.length, 1); + assert.equal(messageRows[0]?.text, "Hello there!"); + assert.isFalse(Boolean(messageRows[0]?.isStreaming)); + }), + ); + it.effect( "resolves turn-count conflicts when checkpoint completion rewrites provisional turns", () => diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 1f24a4a0200..9c8f2ee6b6b 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -887,6 +887,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti const nextText = Option.match(existingMessage, { onNone: () => event.payload.text, onSome: (message) => { + if (event.payload.textOperation === "replace") { + return event.payload.text; + } if (event.payload.streaming) { return `${message.text}${event.payload.text}`; } diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index a4ba11b9260..5fad7b18c63 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -477,6 +477,49 @@ describe("ProviderCommandReactor", () => { expect(thread?.session?.runtimeMode).toBe("approval-required"); }); + effectIt.effect("adopts a recovered active provider turn while binding a pending start", () => + Effect.gen(function* () { + const recoveredTurnId = asTurnId("turn-recovered-during-start"); + const harness = yield* Effect.promise(() => + createHarness({ + startSessionEffect: (session) => + Effect.succeed({ + ...session, + status: "running", + activeTurnId: recoveredTurnId, + }), + }), + ); + const now = "2026-01-01T00:00:00.000Z"; + + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-recovered-provider-turn"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-recovered-provider-turn"), + role: "user", + text: "steer the recovered turn", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }); + + yield* Effect.promise(() => waitFor(() => harness.sendTurn.mock.calls.length === 1)); + const readModel = yield* Effect.promise(() => harness.readModel()); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + + expect(thread?.session?.status).toBe("running"); + expect(thread?.session?.activeTurnId).toBe(recoveredTurnId); + expect(thread?.latestTurn).toMatchObject({ + turnId: recoveredTurnId, + state: "running", + }); + }), + ); + effectIt.effect("projects starting before a slow provider session finishes", () => Effect.gen(function* () { const releaseStart = yield* Deferred.make(); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index b6bff8c766a..d800b835f7c 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -527,8 +527,7 @@ const make = Effect.gen(function* () { providerName: session.provider, providerInstanceId: session.providerInstanceId, runtimeMode: desiredRuntimeMode, - // Provider turn ids are not orchestration turn ids. - activeTurnId: null, + activeTurnId: session.activeTurnId ?? null, lastError: session.lastError ?? null, updatedAt: session.updatedAt, }, diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index b9974de5ab0..a95d65579f7 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -27,6 +27,7 @@ import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as ManagedRuntime from "effect/ManagedRuntime"; +import * as Option from "effect/Option"; import * as PubSub from "effect/PubSub"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; @@ -35,7 +36,9 @@ import { afterEach, describe, expect, it } from "vite-plus/test"; import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts"; +import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts"; import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { ProjectionTurnRepository } from "../../persistence/Services/ProjectionTurns.ts"; import { ProviderService, type ProviderServiceShape, @@ -192,7 +195,10 @@ async function waitForThread( describe("ProviderRuntimeIngestion", () => { let runtime: ManagedRuntime.ManagedRuntime< - OrchestrationEngineService | ProviderRuntimeIngestionService | ProjectionSnapshotQuery, + | OrchestrationEngineService + | ProviderRuntimeIngestionService + | ProjectionSnapshotQuery + | ProjectionTurnRepository, unknown > | null = null; let scope: Scope.Closeable | null = null; @@ -234,9 +240,13 @@ describe("ProviderRuntimeIngestion", () => { Layer.provide(RepositoryIdentityResolver.layer), Layer.provide(SqlitePersistenceMemory), ); + const projectionTurnLayer = ProjectionTurnRepositoryLive.pipe( + Layer.provide(SqlitePersistenceMemory), + ); const layer = ProviderRuntimeIngestionLive.pipe( Layer.provideMerge(orchestrationLayer), Layer.provideMerge(projectionSnapshotLayer), + Layer.provideMerge(projectionTurnLayer), Layer.provideMerge(SqlitePersistenceMemory), Layer.provideMerge(Layer.succeed(ProviderService, provider.service)), Layer.provideMerge(makeTestServerSettingsLayer(options?.serverSettings)), @@ -247,6 +257,7 @@ describe("ProviderRuntimeIngestion", () => { const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService)); const snapshotQuery = await runtime.runPromise(Effect.service(ProjectionSnapshotQuery)); const ingestion = await runtime.runPromise(Effect.service(ProviderRuntimeIngestionService)); + const turnRepository = await runtime.runPromise(Effect.service(ProjectionTurnRepository)); scope = await Effect.runPromise(Scope.make("sequential")); await Effect.runPromise(ingestion.start().pipe(Scope.provide(scope))); const drain = () => Effect.runPromise(ingestion.drain); @@ -315,6 +326,7 @@ describe("ProviderRuntimeIngestion", () => { readModel: () => Effect.runPromise(snapshotQuery.getSnapshot()), emit: provider.emit, setProviderSession: provider.setSession, + turnRepository, drain, }; } @@ -946,6 +958,209 @@ describe("ProviderRuntimeIngestion", () => { expect(message?.streaming).toBe(false); }); + it("replaces streaming assistant text from authoritative snapshots without changing identity", async () => { + const harness = await createHarness({ serverSettings: { enableAssistantStreaming: true } }); + const now = "2026-01-01T00:00:00.000Z"; + const itemId = asItemId("item-streaming-snapshot"); + const turnId = asTurnId("turn-streaming-snapshot"); + + harness.emit({ + type: "content.delta", + eventId: asEventId("evt-streaming-snapshot-delta"), + provider: ProviderDriverKind.make("hermes"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId, + itemId, + payload: { + streamKind: "assistant_text", + delta: "draft", + }, + }); + harness.emit({ + type: "content.snapshot", + eventId: asEventId("evt-streaming-snapshot-replace"), + provider: ProviderDriverKind.make("hermes"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId, + itemId, + payload: { + streamKind: "assistant_text", + text: "authoritative", + }, + }); + harness.emit({ + type: "content.delta", + eventId: asEventId("evt-streaming-snapshot-tail"), + provider: ProviderDriverKind.make("hermes"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId, + itemId, + payload: { + streamKind: "assistant_text", + delta: " answer", + }, + }); + harness.emit({ + type: "item.completed", + eventId: asEventId("evt-streaming-snapshot-completed"), + provider: ProviderDriverKind.make("hermes"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId, + itemId, + payload: { + itemType: "assistant_message", + status: "completed", + }, + }); + + const thread = await waitForThread(harness.readModel, (entry) => + entry.messages.some( + (message: ProviderRuntimeTestMessage) => + message.id === `assistant:${itemId}` && + message.text === "authoritative answer" && + !message.streaming, + ), + ); + expect(thread.messages.filter((message) => message.id === `assistant:${itemId}`)).toHaveLength( + 1, + ); + }); + + it("applies repeated empty snapshots idempotently to a streaming assistant message", async () => { + const harness = await createHarness({ serverSettings: { enableAssistantStreaming: true } }); + const now = "2026-01-01T00:00:00.000Z"; + const itemId = asItemId("item-empty-snapshot"); + const turnId = asTurnId("turn-empty-snapshot"); + + harness.emit({ + type: "content.delta", + eventId: asEventId("evt-empty-snapshot-delta"), + provider: ProviderDriverKind.make("hermes"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId, + itemId, + payload: { + streamKind: "assistant_text", + delta: "remove me", + }, + }); + for (const eventId of ["evt-empty-snapshot-one", "evt-empty-snapshot-two"]) { + harness.emit({ + type: "content.snapshot", + eventId: asEventId(eventId), + provider: ProviderDriverKind.make("hermes"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId, + itemId, + payload: { + streamKind: "assistant_text", + text: "", + }, + }); + } + harness.emit({ + type: "item.completed", + eventId: asEventId("evt-empty-snapshot-completed"), + provider: ProviderDriverKind.make("hermes"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId, + itemId, + payload: { + itemType: "assistant_message", + status: "completed", + }, + }); + + const thread = await waitForThread(harness.readModel, (entry) => + entry.messages.some( + (message: ProviderRuntimeTestMessage) => + message.id === `assistant:${itemId}` && message.text === "" && !message.streaming, + ), + ); + expect(thread.messages.filter((message) => message.id === `assistant:${itemId}`)).toHaveLength( + 1, + ); + }); + + it("authoritatively replaces buffered assistant text before completion", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + const itemId = asItemId("item-buffered-snapshot"); + const turnId = asTurnId("turn-buffered-snapshot"); + + harness.emit({ + type: "content.delta", + eventId: asEventId("evt-buffered-snapshot-delta"), + provider: ProviderDriverKind.make("hermes"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId, + itemId, + payload: { + streamKind: "assistant_text", + delta: "draft", + }, + }); + harness.emit({ + type: "content.snapshot", + eventId: asEventId("evt-buffered-snapshot-replace"), + provider: ProviderDriverKind.make("hermes"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId, + itemId, + payload: { + streamKind: "assistant_text", + text: "final", + }, + }); + harness.emit({ + type: "content.delta", + eventId: asEventId("evt-buffered-snapshot-tail"), + provider: ProviderDriverKind.make("hermes"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId, + itemId, + payload: { + streamKind: "assistant_text", + delta: " answer", + }, + }); + harness.emit({ + type: "item.completed", + eventId: asEventId("evt-buffered-snapshot-completed"), + provider: ProviderDriverKind.make("hermes"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId, + itemId, + payload: { + itemType: "assistant_message", + status: "completed", + }, + }); + + const thread = await waitForThread(harness.readModel, (entry) => + entry.messages.some( + (message: ProviderRuntimeTestMessage) => + message.id === `assistant:${itemId}` && + message.text === "final answer" && + !message.streaming, + ), + ); + expect(thread.messages.filter((message) => message.id === `assistant:${itemId}`)).toHaveLength( + 1, + ); + }); + it("uses assistant item completion detail when no assistant deltas were streamed", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; @@ -1573,6 +1788,85 @@ describe("ProviderRuntimeIngestion", () => { expect(threadAfterSteer.latestTurn?.state).toBe("running"); }); + effectIt.effect( + "consumes a follow-up pending start when Hermes acknowledges the active turn", + () => + Effect.gen(function* () { + const harness = yield* Effect.promise(() => createHarness()); + const threadId = asThreadId("thread-1"); + const activeTurnId = asTurnId("turn-hermes-active"); + const followUpMessageId = asMessageId("msg-hermes-follow-up"); + const createdAt = "2026-01-01T00:00:00.000Z"; + + harness.setProviderSession({ + provider: ProviderDriverKind.make("hermes"), + status: "running", + runtimeMode: "approval-required", + threadId, + createdAt, + updatedAt: createdAt, + activeTurnId, + }); + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-hermes-active-turn-started"), + provider: ProviderDriverKind.make("hermes"), + createdAt, + threadId, + turnId: activeTurnId, + }); + yield* Effect.promise(() => + waitForThread( + harness.readModel, + (thread) => + thread.session?.status === "running" && thread.session.activeTurnId === activeTurnId, + 2_000, + threadId, + ), + ); + + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-hermes-follow-up"), + threadId, + message: { + messageId: followUpMessageId, + role: "user", + text: "steer the active Hermes turn", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: "2026-01-01T00:00:01.000Z", + }); + + const pendingBeforeAck = yield* harness.turnRepository.getPendingTurnStartByThreadId({ + threadId, + }); + expect(Option.getOrUndefined(pendingBeforeAck)?.messageId).toBe(followUpMessageId); + + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-hermes-follow-up-ack"), + provider: ProviderDriverKind.make("hermes"), + createdAt: "2026-01-01T00:00:02.000Z", + threadId, + turnId: activeTurnId, + }); + yield* Effect.promise(() => harness.drain()); + + const pendingAfterAck = yield* harness.turnRepository.getPendingTurnStartByThreadId({ + threadId, + }); + const activeTurn = yield* harness.turnRepository.getByTurnId({ + threadId, + turnId: activeTurnId, + }); + expect(Option.isNone(pendingAfterAck)).toBe(true); + expect(Option.getOrUndefined(activeTurn)?.pendingMessageId).toBe(followUpMessageId); + }), + ); + it("does not mark the source proposed plan implemented for an unrelated turn.started when no thread active turn is tracked", async () => { const harness = await createHarness(); const sourceThreadId = asThreadId("thread-plan"); @@ -2543,6 +2837,65 @@ describe("ProviderRuntimeIngestion", () => { expect(message?.streaming).toBe(false); }); + it("replaces a spilled buffered projection when an authoritative snapshot arrives", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + const itemId = asItemId("item-buffer-spill-snapshot"); + const turnId = asTurnId("turn-buffer-spill-snapshot"); + + harness.emit({ + type: "content.delta", + eventId: asEventId("evt-buffer-spill-before-snapshot"), + provider: ProviderDriverKind.make("hermes"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId, + itemId, + payload: { + streamKind: "assistant_text", + delta: "x".repeat(40_000), + }, + }); + harness.emit({ + type: "content.snapshot", + eventId: asEventId("evt-buffer-spill-snapshot"), + provider: ProviderDriverKind.make("hermes"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId, + itemId, + payload: { + streamKind: "assistant_text", + text: "authoritative after spill", + }, + }); + harness.emit({ + type: "item.completed", + eventId: asEventId("evt-buffer-spill-snapshot-completed"), + provider: ProviderDriverKind.make("hermes"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId, + itemId, + payload: { + itemType: "assistant_message", + status: "completed", + }, + }); + + const thread = await waitForThread(harness.readModel, (entry) => + entry.messages.some( + (message: ProviderRuntimeTestMessage) => + message.id === `assistant:${itemId}` && + message.text === "authoritative after spill" && + !message.streaming, + ), + ); + expect(thread.messages.filter((message) => message.id === `assistant:${itemId}`)).toHaveLength( + 1, + ); + }); + it("does not duplicate assistant completion when item.completed is followed by turn.completed", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; @@ -2849,6 +3202,89 @@ describe("ProviderRuntimeIngestion", () => { ).toBe(true); }); + it("upserts unknown provider item lifecycle into one meaningful activity", async () => { + const harness = await createHarness(); + const itemId = asItemId("item-generic-activity"); + const turnId = asTurnId("turn-generic-activity"); + + harness.emit({ + type: "item.started", + eventId: asEventId("evt-generic-activity-started"), + provider: ProviderDriverKind.make("hermes"), + createdAt: "2026-01-01T00:00:00.000Z", + threadId: asThreadId("thread-1"), + turnId, + itemId, + payload: { + itemType: "unknown", + status: "inProgress", + title: "Reading skill hermes-agent", + detail: "Opening the skill instructions.", + data: { unsafeRawPayload: "not projected" }, + }, + }); + harness.emit({ + type: "item.updated", + eventId: asEventId("evt-generic-activity-updated"), + provider: ProviderDriverKind.make("hermes"), + createdAt: "2026-01-01T00:00:01.000Z", + threadId: asThreadId("thread-1"), + turnId, + itemId, + payload: { + itemType: "unknown", + status: "inProgress", + title: "Reading skill hermes-agent", + detail: "Parsing the workflow.", + }, + }); + harness.emit({ + type: "item.completed", + eventId: asEventId("evt-generic-activity-completed"), + provider: ProviderDriverKind.make("hermes"), + createdAt: "2026-01-01T00:00:02.000Z", + threadId: asThreadId("thread-1"), + turnId, + itemId, + payload: { + itemType: "unknown", + status: "completed", + title: "Read skill hermes-agent", + detail: "Skill instructions loaded.", + }, + }); + + const activityId = `provider:hermes:item:${itemId}`; + const thread = await waitForThread(harness.readModel, (entry) => + entry.activities.some( + (activity: ProviderRuntimeTestActivity) => + activity.id === activityId && + activity.payload && + typeof activity.payload === "object" && + (activity.payload as Record).status === "completed", + ), + ); + const activities = thread.activities.filter((activity) => activity.id === activityId); + const activity = activities[0]; + const payload = + activity?.payload && typeof activity.payload === "object" + ? (activity.payload as Record) + : undefined; + + expect(activities).toHaveLength(1); + expect(activity?.kind).toBe("provider.activity"); + expect(activity?.tone).toBe("info"); + expect(activity?.summary).toBe("Read skill hermes-agent"); + expect(payload).toMatchObject({ + itemType: "unknown", + status: "completed", + providerItemId: itemId, + title: "Read skill hermes-agent", + detail: "Skill instructions loaded.", + }); + expect(payload?.data).toBeUndefined(); + }); + it("consumes P1 runtime events into thread metadata, diff checkpoints, and activities", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 6750c9bb77e..b8d2d8ce8cc 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -2,6 +2,7 @@ import { ApprovalRequestId, type AssistantDeliveryMode, CommandId, + EventId, MessageId, type OrchestrationEvent, type OrchestrationMessage, @@ -306,6 +307,44 @@ function requestKindFromCanonicalRequestType( } } +function genericProviderActivityId(event: ProviderRuntimeEvent): EventId { + return EventId.make( + event.itemId + ? `provider:${event.provider}:item:${event.itemId}` + : `provider:${event.provider}:event:${event.eventId}`, + ); +} + +function genericProviderActivity( + event: Extract< + ProviderRuntimeEvent, + { type: "item.started" | "item.updated" | "item.completed" } + >, + maybeSequence: { readonly sequence?: number }, +): OrchestrationThreadActivity { + const title = event.payload.title ? truncateDetail(event.payload.title, 120) : undefined; + const detail = event.payload.detail ? truncateDetail(event.payload.detail) : undefined; + const status = + event.payload.status ?? (event.type === "item.completed" ? "completed" : "inProgress"); + + return { + id: genericProviderActivityId(event), + createdAt: event.createdAt, + tone: "info", + kind: "provider.activity", + summary: title ?? (detail ? truncateDetail(detail, 120) : "Provider activity"), + payload: { + itemType: event.payload.itemType, + status, + ...(event.itemId ? { providerItemId: event.itemId } : {}), + ...(title ? { title } : {}), + ...(detail ? { detail } : {}), + }, + turnId: toTurnId(event.turnId) ?? null, + ...maybeSequence, + }; +} + export function runtimeEventToActivities( event: ProviderRuntimeEvent, taskTitle?: string, @@ -614,6 +653,9 @@ export function runtimeEventToActivities( } case "item.updated": { + if (event.payload.itemType === "unknown") { + return [genericProviderActivity(event, maybeSequence)]; + } if (!isToolLifecycleItemType(event.payload.itemType)) { return []; } @@ -637,6 +679,9 @@ export function runtimeEventToActivities( } case "item.completed": { + if (event.payload.itemType === "unknown") { + return [genericProviderActivity(event, maybeSequence)]; + } if (!isToolLifecycleItemType(event.payload.itemType)) { return []; } @@ -659,6 +704,9 @@ export function runtimeEventToActivities( } case "item.started": { + if (event.payload.itemType === "unknown") { + return [genericProviderActivity(event, maybeSequence)]; + } if (!isToolLifecycleItemType(event.payload.itemType)) { return []; } @@ -899,6 +947,9 @@ const make = Effect.gen(function* () { ), ); + const replaceBufferedAssistantText = (messageId: MessageId, text: string) => + Cache.set(bufferedAssistantTextByMessageId, messageId, text); + const takeBufferedAssistantText = (messageId: MessageId) => Cache.getOption(bufferedAssistantTextByMessageId, messageId).pipe( Effect.flatMap((existingText) => @@ -1463,6 +1514,10 @@ const make = Effect.gen(function* () { event.type === "content.delta" && event.payload.streamKind === "assistant_text" ? event.payload.delta : undefined; + const assistantSnapshot = + event.type === "content.snapshot" && event.payload.streamKind === "assistant_text" + ? event.payload.text + : undefined; const proposedPlanDelta = event.type === "turn.proposed.delta" ? event.payload.delta : undefined; @@ -1507,6 +1562,53 @@ const make = Effect.gen(function* () { } } + if (assistantSnapshot !== undefined) { + const turnId = toTurnId(event.turnId); + const assistantMessageId = yield* getOrCreateAssistantMessageId({ + threadId: thread.id, + event, + ...(turnId ? { turnId } : {}), + }); + if (turnId) { + yield* rememberAssistantMessageId(thread.id, turnId, assistantMessageId); + } + + const assistantDeliveryMode: AssistantDeliveryMode = yield* Effect.map( + serverSettingsService.getSettings, + (settings) => (settings.enableAssistantStreaming ? "streaming" : "buffered"), + ); + if (assistantDeliveryMode === "buffered") { + const detailedThread = yield* getLoadedThreadDetail(); + const hasProjectedMessage = + findMessageById(detailedThread?.messages ?? [], assistantMessageId) !== undefined; + if (hasProjectedMessage) { + yield* orchestrationEngine.dispatch({ + type: "thread.message.assistant.replace", + commandId: yield* providerCommandId(event, "assistant-snapshot-buffer-spill"), + threadId: thread.id, + messageId: assistantMessageId, + text: assistantSnapshot, + ...(turnId ? { turnId } : {}), + createdAt: now, + }); + yield* clearBufferedAssistantText(assistantMessageId); + } else { + yield* replaceBufferedAssistantText(assistantMessageId, assistantSnapshot); + } + } else { + yield* orchestrationEngine.dispatch({ + type: "thread.message.assistant.replace", + commandId: yield* providerCommandId(event, "assistant-snapshot"), + threadId: thread.id, + messageId: assistantMessageId, + text: assistantSnapshot, + ...(turnId ? { turnId } : {}), + createdAt: now, + }); + yield* clearBufferedAssistantText(assistantMessageId); + } + } + const pauseForUserTurnId = event.type === "request.opened" || event.type === "user-input.requested" ? toTurnId(event.turnId) diff --git a/apps/server/src/orchestration/decider.assistantMessageReplacement.test.ts b/apps/server/src/orchestration/decider.assistantMessageReplacement.test.ts new file mode 100644 index 00000000000..2246812a294 --- /dev/null +++ b/apps/server/src/orchestration/decider.assistantMessageReplacement.test.ts @@ -0,0 +1,73 @@ +import { + CommandId, + DEFAULT_PROVIDER_INTERACTION_MODE, + EventId, + MessageId, + ProjectId, + ProviderInstanceId, + ThreadId, + TurnId, +} from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import { expect, it } from "@effect/vitest"; + +import { decideOrchestrationCommand } from "./decider.ts"; +import { createEmptyReadModel, projectEvent } from "./projector.ts"; + +it.layer(NodeServices.layer)("assistant message replacement", (it) => { + it.effect("emits a streaming replacement message event", () => + Effect.gen(function* () { + const now = "2026-07-24T12:00:00.000Z"; + const threadId = ThreadId.make("thread-replacement"); + const readModel = yield* projectEvent(createEmptyReadModel(now), { + sequence: 1, + eventId: EventId.make("event-thread-created"), + aggregateKind: "thread", + aggregateId: threadId, + type: "thread.created", + occurredAt: now, + commandId: CommandId.make("command-thread-created"), + causationEventId: null, + correlationId: CommandId.make("command-thread-created"), + metadata: {}, + payload: { + threadId, + projectId: ProjectId.make("project-replacement"), + title: "Replacement", + modelSelection: { + instanceId: ProviderInstanceId.make("hermes"), + model: "hermes", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }); + + const decided = yield* decideOrchestrationCommand({ + command: { + type: "thread.message.assistant.replace", + commandId: CommandId.make("command-message-replace"), + threadId, + messageId: MessageId.make("assistant:message-1"), + text: "Hello there", + turnId: TurnId.make("turn-1"), + createdAt: now, + }, + readModel, + }); + + const event = Array.isArray(decided) ? decided[0] : decided; + if (event?.type !== "thread.message-sent") { + throw new Error("expected thread.message-sent"); + } + expect(event.payload.text).toBe("Hello there"); + expect(event.payload.streaming).toBe(true); + expect(event.payload.textOperation).toBe("replace"); + }), + ); +}); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 100369ae6e3..fb81ac8ab11 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -1018,6 +1018,34 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.message.assistant.replace": { + yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.message-sent", + payload: { + threadId: command.threadId, + messageId: command.messageId, + role: "assistant", + text: command.text, + turnId: command.turnId ?? null, + streaming: true, + textOperation: "replace", + createdAt: command.createdAt, + updatedAt: command.createdAt, + }, + }; + } + case "thread.message.assistant.complete": { yield* requireThread({ readModel, diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index 9c07a312023..d14cfc14350 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -488,6 +488,83 @@ describe("orchestration projector", () => { expect(message?.updatedAt).toBe(completeAt); }); + it("replaces an assistant snapshot between legacy appended deltas", async () => { + const createdAt = "2026-02-23T09:00:00.000Z"; + const model = createEmptyReadModel(createdAt); + const afterCreate = await Effect.runPromise( + projectEvent( + model, + makeEvent({ + sequence: 1, + type: "thread.created", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: createdAt, + commandId: "cmd-create", + payload: { + threadId: "thread-1", + projectId: "project-1", + title: "demo", + modelSelection: { + provider: ProviderDriverKind.make("hermes"), + model: "hermes", + }, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt, + updatedAt: createdAt, + }, + }), + ), + ); + + const messageEvent = (input: { + sequence: number; + text: string; + streaming: boolean; + textOperation?: "replace"; + }) => + makeEvent({ + sequence: input.sequence, + type: "thread.message-sent", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: createdAt, + commandId: `cmd-message-${input.sequence}`, + payload: { + threadId: "thread-1", + messageId: "assistant:msg-replace", + role: "assistant", + text: input.text, + turnId: "turn-1", + streaming: input.streaming, + ...(input.textOperation !== undefined ? { textOperation: input.textOperation } : {}), + createdAt, + updatedAt: createdAt, + }, + }); + + let projected = afterCreate; + for (const event of [ + messageEvent({ sequence: 2, text: "Hello wor", streaming: true }), + messageEvent({ + sequence: 3, + text: "Hello there", + streaming: true, + textOperation: "replace", + }), + messageEvent({ sequence: 4, text: "!", streaming: true }), + messageEvent({ sequence: 5, text: "", streaming: false }), + ]) { + projected = await Effect.runPromise(projectEvent(projected, event)); + } + + const message = projected.threads[0]?.messages[0]; + expect(message?.text).toBe("Hello there!"); + expect(message?.streaming).toBe(false); + }); + it("prunes reverted turn messages from in-memory thread snapshot", async () => { const createdAt = "2026-02-23T10:00:00.000Z"; const model = createEmptyReadModel(createdAt); diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 0504cb36f9a..260d3d05884 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -471,11 +471,14 @@ export function projectEvent( entry.id === message.id ? { ...entry, - text: message.streaming - ? `${entry.text}${message.text}` - : message.text.length > 0 + text: + payload.textOperation === "replace" ? message.text - : entry.text, + : message.streaming + ? `${entry.text}${message.text}` + : message.text.length > 0 + ? message.text + : entry.text, streaming: message.streaming, updatedAt: message.updatedAt, turnId: message.turnId, diff --git a/apps/server/src/provider/Layers/HermesAdapter.test.ts b/apps/server/src/provider/Layers/HermesAdapter.test.ts index f32f8adecc4..ea371631b9f 100644 --- a/apps/server/src/provider/Layers/HermesAdapter.test.ts +++ b/apps/server/src/provider/Layers/HermesAdapter.test.ts @@ -6,6 +6,7 @@ import { HermesGatewaySessionId, ProviderInstanceId, ThreadId, + TurnId, type HermesGatewayT3ToPluginMessage, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; @@ -23,10 +24,9 @@ import { makeHermesAdapter, sanitizeHermesItemData, sanitizeHermesRequestArgs, - shouldProjectHermesTurnStarted, } from "./HermesAdapter.ts"; -it("sanitizes persisted gateway payloads and suppresses steering acknowledgements", () => { +it("sanitizes persisted gateway payloads", () => { assert.deepEqual( sanitizeHermesItemData("command_execution", { command: "git status", @@ -43,12 +43,6 @@ it("sanitizes persisted gateway payloads and suppresses steering acknowledgement }), { command: "git push" }, ); - const steerRequestId = HermesGatewayRequestId.make("steer-request"); - const startRequestId = HermesGatewayRequestId.make("start-request"); - const pendingSteerRequestIds = new Set([steerRequestId]); - assert.isFalse(shouldProjectHermesTurnStarted(pendingSteerRequestIds, steerRequestId)); - assert.isTrue(shouldProjectHermesTurnStarted(pendingSteerRequestIds, startRequestId)); - assert.isFalse(pendingSteerRequestIds.has(steerRequestId)); }); it.effect("keeps cwd local while forwarding turn text byte-for-byte and steering follow-ups", () => @@ -101,6 +95,19 @@ it.effect("keeps cwd local while forwarding turn text byte-for-byte and steering Effect.provideService(HermesGatewayBroker, broker), ); yield* Effect.yieldNow; + const firstEventAfterOrphanExitFiber = yield* Stream.runHead(adapter.streamEvents).pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* PubSub.publish(brokerEvents, { + instanceId, + message: { + type: "session.exited", + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + threadId, + sessionId: HermesGatewaySessionId.make("session-before-context"), + recoverable: false, + }, + }); const session = yield* adapter.startSession({ threadId, providerInstanceId: instanceId, @@ -135,13 +142,17 @@ it.effect("keeps cwd local while forwarding turn text byte-for-byte and steering }); const startEvent = Option.getOrUndefined(yield* Fiber.join(startEventFiber)); assert.equal(startEvent?.type, "turn.started"); + const firstEventAfterOrphanExit = Option.getOrUndefined( + yield* Fiber.join(firstEventAfterOrphanExitFiber), + ); + assert.equal(firstEventAfterOrphanExit?.type, "session.started"); yield* adapter.sendTurn({ threadId, input: "follow up" }); const turnSteer = sent.find((message) => message.type === "turn.steer"); if (!turnSteer || turnSteer.type !== "turn.steer") { return yield* Effect.die(new Error("turn.steer was not sent")); } - const nextEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe( + const steerEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe( Effect.forkChild({ startImmediately: true }), ); yield* PubSub.publish(brokerEvents, { @@ -155,6 +166,12 @@ it.effect("keeps cwd local while forwarding turn text byte-for-byte and steering turnId: turnSteer.turnId, }, }); + const steerEvent = Option.getOrUndefined(yield* Fiber.join(steerEventFiber)); + assert.equal(steerEvent?.type, "turn.started"); + + const nextEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe( + Effect.forkChild({ startImmediately: true }), + ); yield* PubSub.publish(brokerEvents, { instanceId, message: { @@ -169,5 +186,190 @@ it.effect("keeps cwd local while forwarding turn text byte-for-byte and steering }); const nextEvent = Option.getOrUndefined(yield* Fiber.join(nextEventFiber)); assert.equal(nextEvent?.type, "content.delta"); + + const newerTurnId = TurnId.make("newer-turn"); + const newerTurnFiber = yield* Stream.runHead(adapter.streamEvents).pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* PubSub.publish(brokerEvents, { + instanceId, + message: { + type: "turn.started", + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + requestId: HermesGatewayRequestId.make("newer-turn-request"), + threadId, + sessionId, + turnId: newerTurnId, + }, + }); + yield* Fiber.join(newerTurnFiber); + + const afterStaleExitFiber = yield* Stream.runHead(adapter.streamEvents).pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* PubSub.publish(brokerEvents, { + instanceId, + message: { + type: "session.exited", + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + threadId, + sessionId: HermesGatewaySessionId.make("session-stale"), + recoverable: false, + }, + }); + yield* PubSub.publish(brokerEvents, { + instanceId, + message: { + type: "content.delta", + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + threadId, + sessionId, + turnId: newerTurnId, + streamKind: "assistant_text", + delta: "still active", + }, + }); + const afterStaleExit = Option.getOrUndefined(yield* Fiber.join(afterStaleExitFiber)); + assert.equal(afterStaleExit?.type, "content.delta"); + const afterStaleExitSession = (yield* adapter.listSessions())[0]; + assert.equal(afterStaleExitSession?.status, "running"); + assert.equal(afterStaleExitSession?.activeTurnId, newerTurnId); + + const staleCompletionFiber = yield* Stream.runHead(adapter.streamEvents).pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* PubSub.publish(brokerEvents, { + instanceId, + message: { + type: "turn.completed", + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + threadId, + sessionId, + turnId: turnStart.turnId, + state: "completed", + }, + }); + yield* Fiber.join(staleCompletionFiber); + + const afterStaleCompletion = (yield* adapter.listSessions())[0]; + assert.equal(afterStaleCompletion?.status, "running"); + assert.equal(afterStaleCompletion?.activeTurnId, newerTurnId); + + const activeCompletionFiber = yield* Stream.runHead(adapter.streamEvents).pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* PubSub.publish(brokerEvents, { + instanceId, + message: { + type: "turn.completed", + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + threadId, + sessionId, + turnId: newerTurnId, + state: "completed", + }, + }); + yield* Fiber.join(activeCompletionFiber); + + const afterActiveCompletion = (yield* adapter.listSessions())[0]; + assert.equal(afterActiveCompletion?.status, "ready"); + assert.equal(afterActiveCompletion?.activeTurnId, undefined); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("resumes active Hermes turns for steering and projects authoritative snapshots", () => + Effect.gen(function* () { + const instanceId = ProviderInstanceId.make("hermes_reconnected"); + const threadId = ThreadId.make("thread-reconnected"); + const sessionId = HermesGatewaySessionId.make("session-reconnected"); + const activeTurnId = TurnId.make("turn-already-running"); + const sent: Array = []; + const brokerEvents = yield* PubSub.unbounded(); + const broker: HermesGatewayBrokerShape = { + createEnrollment: () => Effect.die(new Error("unused")), + getInstanceStatus: () => Effect.die(new Error("unused")), + listInstances: Effect.succeed([]), + renameInstance: () => Effect.die(new Error("unused")), + revokeInstance: () => Effect.die(new Error("unused")), + removeInstance: () => Effect.die(new Error("unused")), + registerConnection: () => Effect.die(new Error("unused")), + receive: () => Effect.void, + disconnect: () => Effect.void, + request: (_instanceId, message) => { + sent.push(message); + if (message.type === "session.ensure") { + return Effect.succeed({ + type: "session.ready", + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + requestId: message.requestId, + threadId: message.threadId, + sessionId, + resumed: true, + activeTurnId, + }); + } + if (message.type === "turn.steer") { + return Effect.succeed({ + type: "turn.started", + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + requestId: message.requestId, + threadId: message.threadId, + sessionId: message.sessionId, + turnId: message.turnId, + }); + } + return Effect.die(new Error(`unexpected request ${message.type}`)); + }, + send: (_instanceId, message) => Effect.sync(() => sent.push(message)).pipe(Effect.asVoid), + isConnected: () => Effect.succeed(true), + stream: Stream.fromPubSub(brokerEvents), + streamStatuses: Stream.empty, + }; + const adapter = yield* makeHermesAdapter({ instanceId }).pipe( + Effect.provideService(HermesGatewayBroker, broker), + ); + yield* Effect.yieldNow; + + const session = yield* adapter.startSession({ + threadId, + providerInstanceId: instanceId, + runtimeMode: "full-access", + resumeCursor: { + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + sessionId, + }, + }); + assert.equal(session.status, "running"); + assert.equal(session.activeTurnId, activeTurnId); + + yield* adapter.sendTurn({ threadId, input: "steer the restored turn" }); + const steer = sent.find((message) => message.type === "turn.steer"); + assert.equal(steer?.type === "turn.steer" ? steer.turnId : undefined, activeTurnId); + + const snapshotEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* PubSub.publish(brokerEvents, { + instanceId, + message: { + type: "content.snapshot", + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + threadId, + sessionId, + turnId: activeTurnId, + streamKind: "assistant_text", + text: "the complete restored answer", + contentIndex: 0, + }, + }); + const snapshotEvent = Option.getOrUndefined(yield* Fiber.join(snapshotEventFiber)); + assert.equal(snapshotEvent?.type, "content.snapshot"); + if (snapshotEvent?.type === "content.snapshot") { + assert.deepEqual(snapshotEvent.payload, { + streamKind: "assistant_text", + text: "the complete restored answer", + contentIndex: 0, + }); + } }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); diff --git a/apps/server/src/provider/Layers/HermesAdapter.ts b/apps/server/src/provider/Layers/HermesAdapter.ts index 4720bb91d4b..5f3139f2701 100644 --- a/apps/server/src/provider/Layers/HermesAdapter.ts +++ b/apps/server/src/provider/Layers/HermesAdapter.ts @@ -107,11 +107,6 @@ export const sanitizeHermesRequestArgs = ( return Object.keys(sanitized).length > 0 ? sanitized : undefined; }; -export const shouldProjectHermesTurnStarted = ( - pendingSteerRequestIds: Set, - incomingRequestId: HermesGatewayRequestId, -) => !pendingSteerRequestIds.delete(incomingRequestId); - export const makeHermesAdapter = Effect.fn("makeHermesAdapter")(function* (input: { readonly instanceId: ProviderInstanceId; }) { @@ -119,7 +114,6 @@ export const makeHermesAdapter = Effect.fn("makeHermesAdapter")(function* (input const broker = yield* HermesGatewayBroker; const events = yield* PubSub.unbounded(); const sessions = new Map(); - const pendingSteerRequestIds = new Set(); const randomId = crypto.randomUUIDv4.pipe( Effect.mapError( @@ -188,10 +182,6 @@ export const makeHermesAdapter = Effect.fn("makeHermesAdapter")(function* (input case "session.ready": return undefined; case "turn.started": { - const isSteeringAcknowledgement = !shouldProjectHermesTurnStarted( - pendingSteerRequestIds, - message.requestId, - ); if (context) { updateSession(context, { status: "running", @@ -201,7 +191,6 @@ export const makeHermesAdapter = Effect.fn("makeHermesAdapter")(function* (input context.turns.push({ id: TurnId.make(message.turnId), items: [] }); } } - if (isSteeringAcknowledgement) return undefined; return { ...base, type: "turn.started", @@ -218,6 +207,16 @@ export const makeHermesAdapter = Effect.fn("makeHermesAdapter")(function* (input ...(message.contentIndex !== undefined ? { contentIndex: message.contentIndex } : {}), }, }; + case "content.snapshot": + return { + ...base, + type: "content.snapshot", + payload: { + streamKind: message.streamKind, + text: message.text, + ...(message.contentIndex !== undefined ? { contentIndex: message.contentIndex } : {}), + }, + }; case "item.started": case "item.updated": case "item.completed": { @@ -267,7 +266,7 @@ export const makeHermesAdapter = Effect.fn("makeHermesAdapter")(function* (input payload: { answers: message.answers }, }; case "turn.completed": - if (context) { + if (context?.session.activeTurnId === message.turnId) { updateSession(context, { status: message.state === "failed" ? "error" : "ready" }); const { activeTurnId: _activeTurnId, ...session } = context.session; context.session = session; @@ -282,7 +281,7 @@ export const makeHermesAdapter = Effect.fn("makeHermesAdapter")(function* (input }, }; case "turn.aborted": - if (context) { + if (context?.session.activeTurnId === message.turnId) { const { activeTurnId: _activeTurnId, ...session } = context.session; context.session = { ...session, status: "ready", updatedAt: nowIso() }; } @@ -292,7 +291,15 @@ export const makeHermesAdapter = Effect.fn("makeHermesAdapter")(function* (input payload: { reason: message.reason }, }; case "session.exited": - if (context) updateSession(context, { status: message.recoverable ? "error" : "closed" }); + if (!context || context.hermesSessionId !== message.sessionId) { + return undefined; + } + const { activeTurnId: _activeTurnId, ...session } = context.session; + context.session = { + ...session, + status: message.recoverable ? "error" : "closed", + updatedAt: nowIso(), + }; return { ...base, type: "session.exited", @@ -365,13 +372,15 @@ export const makeHermesAdapter = Effect.fn("makeHermesAdapter")(function* (input }); } const createdAt = nowIso(); + const activeTurnId = response.activeTurnId; const session = { provider: PROVIDER, providerInstanceId: input.instanceId, - status: "ready", + status: activeTurnId === undefined ? "ready" : "running", runtimeMode: sessionInput.runtimeMode, ...(sessionInput.cwd !== undefined ? { cwd: sessionInput.cwd } : {}), ...(sessionInput.modelSelection?.model ? { model: sessionInput.modelSelection.model } : {}), + ...(activeTurnId !== undefined ? { activeTurnId } : {}), threadId: sessionInput.threadId, resumeCursor: { protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, @@ -383,7 +392,7 @@ export const makeHermesAdapter = Effect.fn("makeHermesAdapter")(function* (input sessions.set(sessionInput.threadId, { session, hermesSessionId: response.sessionId, - turns: [], + turns: activeTurnId === undefined ? [] : [{ id: activeTurnId, items: [] }], }); yield* emit({ ...(yield* eventBase({ threadId: sessionInput.threadId })), @@ -422,22 +431,16 @@ export const makeHermesAdapter = Effect.fn("makeHermesAdapter")(function* (input const activeTurnId = context.session.activeTurnId; const turnId = activeTurnId ?? TurnId.make(`hermes-turn-${yield* randomId}`); const outboundRequestId = yield* requestId; - if (activeTurnId) pendingSteerRequestIds.add(outboundRequestId); - const response = yield* broker - .request(input.instanceId, { - type: activeTurnId ? "turn.steer" : "turn.start", - protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, - requestId: outboundRequestId, - threadId: turnInput.threadId, - sessionId: context.hermesSessionId, - turnId, - text, - }) - .pipe( - Effect.tapError(() => Effect.sync(() => pendingSteerRequestIds.delete(outboundRequestId))), - ); + const response = yield* broker.request(input.instanceId, { + type: activeTurnId ? "turn.steer" : "turn.start", + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + requestId: outboundRequestId, + threadId: turnInput.threadId, + sessionId: context.hermesSessionId, + turnId, + text, + }); if (response.type !== "turn.started") { - pendingSteerRequestIds.delete(outboundRequestId); return yield* new ProviderAdapterRequestError({ provider: PROVIDER, method: activeTurnId ? "turn.steer" : "turn.start", diff --git a/apps/server/src/provider/Layers/HermesGatewayBroker.test.ts b/apps/server/src/provider/Layers/HermesGatewayBroker.test.ts index b3b5791ba55..836431a364b 100644 --- a/apps/server/src/provider/Layers/HermesGatewayBroker.test.ts +++ b/apps/server/src/provider/Layers/HermesGatewayBroker.test.ts @@ -87,7 +87,7 @@ const hello = ( type: "connection.hello", requestId: HermesGatewayRequestId.make(`hello-${protocolVersion}`), protocolVersion, - pluginVersion: "0.1.0", + pluginVersion: "0.2.0", hermesVersion: "1.0.0", capabilities: { ...capabilities, protocolVersion }, authentication, @@ -157,7 +157,7 @@ it.effect("authenticates before applying incompatible connection state", () => instanceId, credential: HermesGatewayCredential.make("not-the-real-credential"), }, - 2, + 3, ), secondTransport, ), @@ -185,7 +185,7 @@ it.effect("authenticates before applying incompatible connection state", () => instanceId, credential: first.accepted.credential!, }, - 2, + 3, ), secondTransport, ), @@ -201,7 +201,7 @@ it.effect("authenticates before applying incompatible connection state", () => }); const incompatibleEnrollment = yield* Effect.flip( broker.registerConnection( - hello({ type: "enrollment-token", token: otherEnrollment.oneTimeToken }, 2), + hello({ type: "enrollment-token", token: otherEnrollment.oneTimeToken }, 3), secondTransport, ), ); diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index ccbbce1759f..def796b3abb 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -1280,6 +1280,101 @@ routing.layer("ProviderServiceLive routing", (it) => { assert.equal(runtimePayload.lastRuntimeEvent, "provider.sendTurn"); } } + + yield* advanceTestClock(50); + routing.codex.emit({ + type: "turn.completed", + eventId: asEventId("evt-runtime-status-stale-completion"), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:01.000Z", + threadId: session.threadId, + turnId: asTurnId("turn-from-an-older-request"), + payload: { + state: "completed", + }, + }); + yield* advanceTestClock(50); + + const afterStaleCompletion = yield* runtimeRepository.getByThreadId({ + threadId: session.threadId, + }); + assert.equal(Option.isSome(afterStaleCompletion), true); + if (Option.isSome(afterStaleCompletion)) { + assert.equal(afterStaleCompletion.value.status, "running"); + const payload = afterStaleCompletion.value.runtimePayload; + assert.equal(payload !== null && typeof payload === "object", true); + if (payload !== null && typeof payload === "object" && !Array.isArray(payload)) { + assert.equal( + "activeTurnId" in payload ? payload.activeTurnId : undefined, + `turn-${String(session.threadId)}`, + ); + assert.equal( + "lastRuntimeEvent" in payload ? payload.lastRuntimeEvent : undefined, + "turn.completed", + ); + } + } + + routing.codex.emit({ + type: "turn.completed", + eventId: asEventId("evt-runtime-status-active-completion"), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:02.000Z", + threadId: session.threadId, + turnId: asTurnId(`turn-${String(session.threadId)}`), + payload: { + state: "completed", + }, + }); + yield* advanceTestClock(50); + + const afterActiveCompletion = yield* runtimeRepository.getByThreadId({ + threadId: session.threadId, + }); + assert.equal(Option.isSome(afterActiveCompletion), true); + if (Option.isSome(afterActiveCompletion)) { + assert.equal(afterActiveCompletion.value.status, "running"); + const payload = afterActiveCompletion.value.runtimePayload; + assert.equal(payload !== null && typeof payload === "object", true); + if (payload !== null && typeof payload === "object" && !Array.isArray(payload)) { + assert.equal("activeTurnId" in payload ? payload.activeTurnId : undefined, null); + assert.equal("cwd" in payload ? payload.cwd : undefined, session.cwd); + assert.equal( + "lastRuntimeEvent" in payload ? payload.lastRuntimeEvent : undefined, + "turn.completed", + ); + } + } + + routing.codex.emit({ + type: "session.exited", + eventId: asEventId("evt-runtime-status-session-exit"), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:03.000Z", + threadId: session.threadId, + payload: { + exitKind: "error", + recoverable: true, + }, + }); + yield* advanceTestClock(50); + + const afterSessionExit = yield* runtimeRepository.getByThreadId({ + threadId: session.threadId, + }); + assert.equal(Option.isSome(afterSessionExit), true); + if (Option.isSome(afterSessionExit)) { + assert.equal(afterSessionExit.value.status, "error"); + const payload = afterSessionExit.value.runtimePayload; + assert.equal(payload !== null && typeof payload === "object", true); + if (payload !== null && typeof payload === "object" && !Array.isArray(payload)) { + assert.equal("activeTurnId" in payload ? payload.activeTurnId : undefined, null); + assert.equal( + "lastRuntimeEvent" in payload ? payload.lastRuntimeEvent : undefined, + "session.exited", + ); + } + } }), ); diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 2eaaeb8ce3c..70dc153f843 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -162,6 +162,19 @@ function readPersistedCwd( return trimmed.length > 0 ? trimmed : undefined; } +function readPersistedActiveTurnId( + runtimePayload: ProviderSessionDirectory.ProviderRuntimeBinding["runtimePayload"], +): string | null | undefined { + if (!runtimePayload || typeof runtimePayload !== "object" || Array.isArray(runtimePayload)) { + return undefined; + } + const rawActiveTurnId = + "activeTurnId" in runtimePayload ? runtimePayload.activeTurnId : undefined; + return typeof rawActiveTurnId === "string" || rawActiveTurnId === null + ? rawActiveTurnId + : undefined; +} + const dieOnMissingBindingInstanceId = ( operation: string, payload: { @@ -281,6 +294,70 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( }); }); + const persistRuntimeLifecycle = (event: ProviderRuntimeEvent) => + Effect.gen(function* () { + if ( + event.type !== "turn.started" && + event.type !== "turn.completed" && + event.type !== "turn.aborted" && + event.type !== "session.exited" + ) { + return; + } + + const binding = Option.getOrUndefined(yield* directory.getBinding(event.threadId)); + if (!binding || event.providerInstanceId === undefined) { + return; + } + if ( + binding.provider !== event.provider || + binding.providerInstanceId !== event.providerInstanceId + ) { + yield* Effect.logWarning("provider.session.runtime.lifecycle-binding-mismatch", { + threadId: event.threadId, + eventProvider: event.provider, + eventProviderInstanceId: event.providerInstanceId, + bindingProvider: binding.provider, + bindingProviderInstanceId: binding.providerInstanceId, + eventType: event.type, + }); + return; + } + + const persistedActiveTurnId = readPersistedActiveTurnId(binding.runtimePayload); + const terminalMatchesActiveTurn = + (event.type === "turn.completed" || event.type === "turn.aborted") && + event.turnId !== undefined && + persistedActiveTurnId === event.turnId; + const isSessionExit = event.type === "session.exited"; + const status = + event.type === "turn.started" || terminalMatchesActiveTurn + ? "running" + : isSessionExit + ? event.payload.recoverable === true || event.payload.exitKind === "error" + ? "error" + : "stopped" + : undefined; + const activeTurnId = + event.type === "turn.started" + ? (event.turnId ?? persistedActiveTurnId) + : terminalMatchesActiveTurn || isSessionExit + ? null + : persistedActiveTurnId; + + yield* directory.upsert({ + threadId: event.threadId, + provider: event.provider, + providerInstanceId: event.providerInstanceId, + ...(status !== undefined ? { status } : {}), + runtimePayload: { + ...(activeTurnId !== undefined ? { activeTurnId } : {}), + lastRuntimeEvent: event.type, + lastRuntimeEventAt: event.createdAt, + }, + }); + }); + const processRuntimeEvent = ( source: { readonly instanceId: ProviderInstanceId; @@ -293,7 +370,22 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( increment(providerRuntimeEventsTotal, { provider: canonicalEvent.provider, eventType: canonicalEvent.type, - }).pipe(Effect.andThen(publishRuntimeEvent(canonicalEvent))), + }).pipe( + Effect.andThen( + persistRuntimeLifecycle(canonicalEvent).pipe( + Effect.catchCause((cause) => + Effect.logWarning("provider.session.runtime.lifecycle-persist-failed", { + threadId: canonicalEvent.threadId, + provider: canonicalEvent.provider, + providerInstanceId: canonicalEvent.providerInstanceId, + eventType: canonicalEvent.type, + errorTag: causeErrorTag(cause), + }), + ), + ), + ), + Effect.andThen(publishRuntimeEvent(canonicalEvent)), + ), ), ); diff --git a/integrations/hermes-t3-gateway/COMPATIBILITY.md b/integrations/hermes-t3-gateway/COMPATIBILITY.md index c4a7db7ace3..73072f749e4 100644 --- a/integrations/hermes-t3-gateway/COMPATIBILITY.md +++ b/integrations/hermes-t3-gateway/COMPATIBILITY.md @@ -4,11 +4,15 @@ The plugin deliberately uses only the public Hermes plugin and platform-adapter surfaces audited at Hermes Agent commit `3910ab28c0892fcf846fc61318d2fd15689eddf1`. -## Mapped in V1 +This inventory describes gateway wire protocol v2. Protocol v2 adds active-turn +recovery in `session.ready` and authoritative `content.snapshot` replacement; +older server/plugin pairs are rejected during the handshake. + +## Mapped in the initial scope | Hermes surface | T3 gateway event | | ----------------------------------------- | ---------------------------------------------- | -| Cumulative `send` / `edit_message` output | `content.delta` | +| Cumulative `send` / `edit_message` output | `content.delta` / `content.snapshot` | | Final stream edit | `item.completed`, `turn.completed` | | `pre_tool_call` / `post_tool_call` hooks | Typed `item.started` / `item.completed` | | Live adapter status text | Generic `unknown` activity item | @@ -20,14 +24,16 @@ surfaces audited at Hermes Agent commit ## Known limitations - The platform adapter receives cumulative rendered text, not the underlying - token stream category. V1 maps it to `assistant_text`; Hermes reasoning, + token stream category. The current adapter maps it to `assistant_text`; Hermes reasoning, plan, and command-output stream categories are not publicly exposed here. -- The delta contract has no replacement operation. Rare edits that revise - already-emitted text become a meaningful generic activity instead of - corrupting the T3 transcript. +- Prefix-extending cumulative edits emit `content.delta`; edits that revise or + clear already-emitted text emit an authoritative `content.snapshot`. +- Hermes' exact first-chat T3 home-channel notice is suppressed at the adapter + output boundary. The plugin does not assign a home channel or redirect + proactive delivery; other Hermes platform notices pass through unchanged. - Hermes' documented tool hook surface exposes a `task_id`, tool name, arguments, string result, and duration. The audited runtime additionally - supplies `session_id` and `tool_call_id`; V1 uses them when present and falls + supplies `session_id` and `tool_call_id`; the adapter uses them when present and falls back to the documented IDs for older versions. It projects only canonical, whitelisted fields (command/cwd, file path, search query, image path, or MCP server/operation); arbitrary arguments and raw results never cross the wire. @@ -45,4 +51,4 @@ surfaces audited at Hermes Agent commit Hermes success response begins with `⏩ Steer queued`. Unknown future response shapes fail closed with `protocol.error` rather than completing the turn. - Attachments are not accepted. They are the first planned post-stability - feature; the capability is reserved and fixed to `false` in protocol V1. + feature; the capability is reserved and fixed to `false` in protocol v2. diff --git a/integrations/hermes-t3-gateway/README.md b/integrations/hermes-t3-gateway/README.md index f2b9557c341..213d67b6690 100644 --- a/integrations/hermes-t3-gateway/README.md +++ b/integrations/hermes-t3-gateway/README.md @@ -4,6 +4,9 @@ Experimental Hermes platform plugin for connecting one already-running Hermes process to T3 Code. The plugin makes an outbound WebSocket connection; Hermes does not need to listen on a public port. +The gateway wire protocol is v2. The T3 server and Hermes plugin must be updated +together; mismatched versions fail the connection handshake closed. + Each T3 thread maps deterministically to one Hermes gateway session. A new T3 thread creates a new session identity, later messages use the same identity, active-turn messages use Hermes' native `/steer` path, and stopping a turn does @@ -43,9 +46,10 @@ The long-lived credential is never printed. Restart `hermes gateway` after enrollment. `hermes t3 status` reports the local enrollment without revealing the credential. -## V1 scope +## Initial scope - Text input and live assistant streaming +- Authoritative text replacement when Hermes revises cumulative streamed output - Multiple concurrent Hermes sessions in one process - Active-turn steering and interrupt - Dangerous-command approvals @@ -54,6 +58,10 @@ the credential. - Reconnect with bounded backoff - Version-incompatible and revoked credentials fail closed +T3 threads are session-only in the initial scope. The adapter suppresses Hermes' exact +first-chat `/sethome` notice without assigning a home channel or redirecting +cron and cross-platform delivery. + Attachments intentionally advertise `false`. Adding bounded image/file input is the first post-stability feature and should not reuse arbitrary raw payloads. diff --git a/integrations/hermes-t3-gateway/adapter.py b/integrations/hermes-t3-gateway/adapter.py index 42ad56fe0d5..672ed4e6afb 100644 --- a/integrations/hermes-t3-gateway/adapter.py +++ b/integrations/hermes-t3-gateway/adapter.py @@ -35,6 +35,13 @@ logger = logging.getLogger(__name__) +_T3_HOME_CHANNEL_NOTICE = ( + "📬 No home channel is set for T3. " + "A home channel is where Hermes delivers cron job results " + "and cross-platform messages.\n\n" + "Type /sethome to make this chat your home channel, or ignore to skip." +) + def _hermes_version() -> str: try: @@ -163,6 +170,10 @@ async def send( if turn is None: return SendResult(success=False, error="no active T3 turn") try: + if content == _T3_HOME_CHANNEL_NOTICE: + if bool((metadata or {}).get("notify")): + await self._complete_turn(turn) + return SendResult(success=True, message_id=turn.message_id) await self._emit_assistant_content(turn, content) if bool((metadata or {}).get("notify")): await self._complete_turn(turn) @@ -187,6 +198,10 @@ async def edit_message( if turn is None: return SendResult(success=False, error="no active T3 turn") try: + if content == _T3_HOME_CHANNEL_NOTICE: + if finalize: + await self._complete_turn(turn) + return SendResult(success=True, message_id=message_id) await self._emit_assistant_content(turn, content) if finalize: await self._complete_turn(turn) @@ -343,6 +358,7 @@ async def _ensure_session(self, message: dict[str, Any]) -> None: self._sessions[thread_id] = session_id self._active_session_threads.add(thread_id) self._thread_by_session[session_id] = thread_id + active_turn = self._active_turns.get(thread_id) await self._send_frame( frame( "session.ready", @@ -350,6 +366,11 @@ async def _ensure_session(self, message: dict[str, Any]) -> None: threadId=thread_id, sessionId=session_id, resumed=bool(resume_id and resume_id == session_id), + **( + {"activeTurnId": active_turn.turn_id} + if active_turn is not None + else {} + ), ) ) await self._send_status() @@ -647,7 +668,6 @@ async def _stop_session(self, message: dict[str, Any]) -> None: async def _emit_assistant_content(self, turn: _TurnState, content: str) -> None: visible = str(content or "").replace(" ▉", "").replace("▉", "") if not turn.assistant_started: - turn.assistant_started = True await self._send_frame( frame( "item.started", @@ -660,9 +680,9 @@ async def _emit_assistant_content(self, turn: _TurnState, content: str) -> None: title="Hermes response", ) ) + turn.assistant_started = True if visible.startswith(turn.visible_text): delta = visible[len(turn.visible_text) :] - turn.visible_text = visible if delta: await self._send_frame( frame( @@ -676,13 +696,21 @@ async def _emit_assistant_content(self, turn: _TurnState, content: str) -> None: contentIndex=0, ) ) + turn.visible_text = visible elif visible != turn.visible_text: - # Hermes' public platform edit hook exposes cumulative rendered text, - # but the v1 T3 delta contract has no replacement operation. Preserve - # the valid prefix and report the rare rewrite as generic activity. - await self._emit_generic_activity( - turn, "Hermes revised already-streamed text; replacement is deferred." + await self._send_frame( + frame( + "content.snapshot", + threadId=turn.thread_id, + sessionId=turn.session_id, + turnId=turn.turn_id, + itemId=turn.message_id, + streamKind="assistant_text", + text=visible, + contentIndex=0, + ) ) + turn.visible_text = visible async def _complete_turn(self, turn: _TurnState) -> None: if self._active_turns.get(turn.thread_id) is not turn: @@ -737,9 +765,9 @@ async def _emit_generic_activity(self, turn: _TurnState, detail: str) -> None: normalized_detail = str(detail)[:2_000] if turn.generic_activity_detail == normalized_detail: return - turn.generic_activity_detail = normalized_detail - if turn.generic_activity_id is None: - turn.generic_activity_id = item_id() + activity_id = turn.generic_activity_id + if activity_id is None: + activity_id = item_id() event_type = "item.started" else: event_type = "item.updated" @@ -749,13 +777,15 @@ async def _emit_generic_activity(self, turn: _TurnState, detail: str) -> None: threadId=turn.thread_id, sessionId=turn.session_id, turnId=turn.turn_id, - itemId=turn.generic_activity_id, + itemId=activity_id, itemType="unknown", status="inProgress", title="Hermes activity", detail=normalized_detail, ) ) + turn.generic_activity_id = activity_id + turn.generic_activity_detail = normalized_detail def emit_tool_started( self, diff --git a/integrations/hermes-t3-gateway/connection.py b/integrations/hermes-t3-gateway/connection.py index b2bce92296a..95ee41cf442 100644 --- a/integrations/hermes-t3-gateway/connection.py +++ b/integrations/hermes-t3-gateway/connection.py @@ -10,7 +10,7 @@ from typing import Any from urllib.parse import urlsplit, urlunsplit -from .protocol import WEBSOCKET_PATH, connection_hello +from .protocol import PROTOCOL_VERSION, WEBSOCKET_PATH, connection_hello logger = logging.getLogger(__name__) @@ -93,7 +93,7 @@ async def authenticate_socket( raise RuntimeError( f"expected connection.accepted, received {message.get('type')!r}" ) - if message.get("protocolVersion") != 1: + if message.get("protocolVersion") != PROTOCOL_VERSION: raise RuntimeError("T3 accepted the connection with an incompatible version") return message diff --git a/integrations/hermes-t3-gateway/plugin.yaml b/integrations/hermes-t3-gateway/plugin.yaml index 02d7c6f7a81..5b99ccea110 100644 --- a/integrations/hermes-t3-gateway/plugin.yaml +++ b/integrations/hermes-t3-gateway/plugin.yaml @@ -1,7 +1,7 @@ name: hermes-t3-gateway label: T3 Code kind: platform -version: 0.1.0 +version: 0.2.0 description: > Experimental outbound gateway that exposes one Hermes Agent process as a named T3 Code provider instance over an authenticated WebSocket. diff --git a/integrations/hermes-t3-gateway/protocol.py b/integrations/hermes-t3-gateway/protocol.py index 24e752681b8..a2a80b54304 100644 --- a/integrations/hermes-t3-gateway/protocol.py +++ b/integrations/hermes-t3-gateway/protocol.py @@ -6,8 +6,8 @@ from datetime import datetime, timezone from typing import Any -PROTOCOL_VERSION = 1 -PLUGIN_VERSION = "0.1.0" +PROTOCOL_VERSION = 2 +PLUGIN_VERSION = "0.2.0" WEBSOCKET_PATH = "/api/hermes-gateway/ws" CAPABILITIES = { diff --git a/integrations/hermes-t3-gateway/tests/test_adapter.py b/integrations/hermes-t3-gateway/tests/test_adapter.py index da2d7a6822f..02e25ceb4b5 100644 --- a/integrations/hermes-t3-gateway/tests/test_adapter.py +++ b/integrations/hermes-t3-gateway/tests/test_adapter.py @@ -176,11 +176,34 @@ async def asyncSetUp(self): self.connection = FakeConnection() self.adapter._connection = self.connection + async def _start_turn(self, thread_id: str, turn_id: str): + await self.adapter._handle_server_frame( + { + "type": "session.ensure", + "protocolVersion": 2, + "requestId": f"ensure-{thread_id}", + "threadId": thread_id, + } + ) + session_id = self.adapter._sessions[thread_id] + await self.adapter._handle_server_frame( + { + "type": "turn.start", + "protocolVersion": 2, + "requestId": f"start-{thread_id}", + "threadId": thread_id, + "sessionId": session_id, + "turnId": turn_id, + "text": "Start", + } + ) + return session_id + async def test_thread_ensure_start_stream_and_complete(self): await self.adapter._handle_server_frame( { "type": "session.ensure", - "protocolVersion": 1, + "protocolVersion": 2, "requestId": "ensure-1", "threadId": "thread-1", } @@ -193,7 +216,7 @@ async def test_thread_ensure_start_stream_and_complete(self): await self.adapter._handle_server_frame( { "type": "turn.start", - "protocolVersion": 1, + "protocolVersion": 2, "requestId": "start-1", "threadId": "thread-1", "sessionId": ready["sessionId"], @@ -216,11 +239,305 @@ async def test_thread_ensure_start_stream_and_complete(self): ] self.assertEqual(deltas, ["Hello", " world"]) + async def test_cumulative_edits_emit_delta_snapshot_delta_then_finalize(self): + await self._start_turn("thread-snapshot", "turn-snapshot") + content_start = len(self.connection.messages) + + await self.adapter.send("thread-snapshot", "Hello") + duplicate_start = len(self.connection.messages) + await self.adapter.edit_message("thread-snapshot", "message", "Hello") + self.assertEqual(len(self.connection.messages), duplicate_start) + + await self.adapter.edit_message("thread-snapshot", "message", "Help") + snapshot_duplicate_start = len(self.connection.messages) + await self.adapter.edit_message("thread-snapshot", "message", "Help") + self.assertEqual(len(self.connection.messages), snapshot_duplicate_start) + + await self.adapter.edit_message( + "thread-snapshot", + "message", + "Helpful", + finalize=True, + ) + + content_frames = self.connection.messages[content_start:] + self.assertEqual( + [message["type"] for message in content_frames], + [ + "item.started", + "content.delta", + "content.snapshot", + "content.delta", + "item.completed", + "turn.completed", + "connection.status", + ], + ) + self.assertEqual(content_frames[1]["delta"], "Hello") + self.assertEqual(content_frames[2]["text"], "Help") + self.assertEqual(content_frames[3]["delta"], "ful") + + async def test_empty_and_duplicate_cumulative_edits_are_reconciled(self): + await self._start_turn("thread-empty", "turn-empty") + content_start = len(self.connection.messages) + + await self.adapter.send("thread-empty", "") + duplicate_start = len(self.connection.messages) + await self.adapter.edit_message("thread-empty", "message", "") + self.assertEqual(len(self.connection.messages), duplicate_start) + + await self.adapter.edit_message("thread-empty", "message", "Visible") + await self.adapter.edit_message("thread-empty", "message", "") + empty_snapshot_end = len(self.connection.messages) + await self.adapter.edit_message("thread-empty", "message", "") + self.assertEqual(len(self.connection.messages), empty_snapshot_end) + await self.adapter.edit_message( + "thread-empty", + "message", + "", + finalize=True, + ) + + content_frames = self.connection.messages[content_start:] + self.assertEqual( + [message["type"] for message in content_frames], + [ + "item.started", + "content.delta", + "content.snapshot", + "item.completed", + "turn.completed", + "connection.status", + ], + ) + self.assertEqual(content_frames[1]["delta"], "Visible") + self.assertEqual(content_frames[2]["text"], "") + + async def test_failed_content_sends_do_not_advance_visible_text(self): + await self._start_turn("thread-retry", "turn-retry") + await self.adapter.send("thread-retry", "Hello") + original_send = self.connection.send + + async def fail_content(message): + if message["type"] in {"content.delta", "content.snapshot"}: + raise ConnectionError("send failed") + await original_send(message) + + self.connection.send = fail_content + failed = await self.adapter.edit_message( + "thread-retry", + "message", + "Hello world", + ) + self.assertFalse(failed.success) + self.assertEqual( + self.adapter._active_turns["thread-retry"].visible_text, + "Hello", + ) + + self.connection.send = original_send + retried = await self.adapter.edit_message( + "thread-retry", + "message", + "Hello world", + ) + self.assertTrue(retried.success) + self.assertEqual(self.connection.messages[-1]["delta"], " world") + + self.connection.send = fail_content + failed_snapshot = await self.adapter.edit_message( + "thread-retry", + "message", + "Hi", + ) + self.assertFalse(failed_snapshot.success) + self.assertEqual( + self.adapter._active_turns["thread-retry"].visible_text, + "Hello world", + ) + + self.connection.send = original_send + retried_snapshot = await self.adapter.edit_message( + "thread-retry", + "message", + "Hi", + ) + self.assertTrue(retried_snapshot.success) + self.assertEqual(self.connection.messages[-1]["type"], "content.snapshot") + self.assertEqual(self.connection.messages[-1]["text"], "Hi") + + async def test_failed_generic_activity_start_retries_the_full_lifecycle(self): + await self._start_turn("thread-activity-retry", "turn-activity-retry") + turn = self.adapter._active_turns["thread-activity-retry"] + original_send = self.connection.send + + async def fail_activity_start(message): + if message["type"] == "item.started": + raise ConnectionError("send failed") + await original_send(message) + + self.connection.send = fail_activity_start + with self.assertRaisesRegex(ConnectionError, "send failed"): + await self.adapter._emit_generic_activity(turn, "Reading repository") + + self.assertIsNone(turn.generic_activity_id) + self.assertIsNone(turn.generic_activity_detail) + + self.connection.send = original_send + await self.adapter._emit_generic_activity(turn, "Reading repository") + started = self.connection.messages[-1] + self.assertEqual(started["type"], "item.started") + self.assertEqual(started["detail"], "Reading repository") + self.assertEqual(turn.generic_activity_id, started["itemId"]) + self.assertEqual(turn.generic_activity_detail, "Reading repository") + + await self.adapter._emit_generic_activity(turn, "Running tests") + updated = self.connection.messages[-1] + self.assertEqual(updated["type"], "item.updated") + self.assertEqual(updated["itemId"], started["itemId"]) + self.assertEqual(updated["detail"], "Running tests") + + async def test_exact_t3_home_channel_notice_is_suppressed(self): + await self._start_turn("thread-notice", "turn-notice") + content_start = len(self.connection.messages) + notice = ( + "📬 No home channel is set for T3. " + "A home channel is where Hermes delivers cron job results " + "and cross-platform messages.\n\n" + "Type /sethome to make this chat your home channel, or ignore to skip." + ) + + suppressed = await self.adapter.send("thread-notice", notice) + self.assertTrue(suppressed.success) + self.assertEqual(len(self.connection.messages), content_start) + self.assertFalse( + self.adapter._active_turns["thread-notice"].assistant_started + ) + + await self.adapter.edit_message( + "thread-notice", + "message", + "The actual Hermes response", + finalize=True, + ) + content_frames = self.connection.messages[content_start:] + self.assertEqual( + [message["type"] for message in content_frames], + [ + "item.started", + "content.delta", + "item.completed", + "turn.completed", + "connection.status", + ], + ) + self.assertEqual(content_frames[1]["delta"], "The actual Hermes response") + + async def test_terminal_send_suppresses_exact_home_notice_and_completes_turn(self): + await self._start_turn("thread-terminal-notice-send", "turn-terminal-notice-send") + content_start = len(self.connection.messages) + notice = ( + "📬 No home channel is set for T3. " + "A home channel is where Hermes delivers cron job results " + "and cross-platform messages.\n\n" + "Type /sethome to make this chat your home channel, or ignore to skip." + ) + + suppressed = await self.adapter.send( + "thread-terminal-notice-send", + notice, + metadata={"notify": True}, + ) + + self.assertTrue(suppressed.success) + self.assertNotIn("thread-terminal-notice-send", self.adapter._active_turns) + self.assertEqual( + [ + message["type"] + for message in self.connection.messages[content_start:] + ], + ["turn.completed", "connection.status"], + ) + + async def test_terminal_edit_suppresses_exact_home_notice_and_completes_turn(self): + await self._start_turn("thread-terminal-notice-edit", "turn-terminal-notice-edit") + content_start = len(self.connection.messages) + notice = ( + "📬 No home channel is set for T3. " + "A home channel is where Hermes delivers cron job results " + "and cross-platform messages.\n\n" + "Type /sethome to make this chat your home channel, or ignore to skip." + ) + + suppressed = await self.adapter.edit_message( + "thread-terminal-notice-edit", + "message", + notice, + finalize=True, + ) + + self.assertTrue(suppressed.success) + self.assertNotIn("thread-terminal-notice-edit", self.adapter._active_turns) + self.assertEqual( + [ + message["type"] + for message in self.connection.messages[content_start:] + ], + ["turn.completed", "connection.status"], + ) + + async def test_near_match_home_channel_text_is_not_suppressed(self): + await self._start_turn("thread-notice-near-match", "turn-notice-near-match") + content_start = len(self.connection.messages) + await self.adapter.edit_message( + "thread-notice-near-match", + "message", + ( + "📬 No home channel is set for T3. " + "A home channel is where Hermes delivers cron job results " + "and cross-platform messages.\n\n" + "Type /sethome to make this chat your home channel, " + "or ignore to skip. " + ), + finalize=True, + ) + self.assertEqual( + [ + message["type"] + for message in self.connection.messages[content_start:] + ], + [ + "item.started", + "content.delta", + "item.completed", + "turn.completed", + "connection.status", + ], + ) + + async def test_session_ready_reports_an_active_turn_on_reconnect(self): + session_id = await self._start_turn("thread-reconnect", "turn-reconnect") + + await self.adapter._handle_server_frame( + { + "type": "session.ensure", + "protocolVersion": 2, + "requestId": "ensure-reconnect", + "threadId": "thread-reconnect", + "resumeSessionId": session_id, + } + ) + + ready = self.connection.messages[-2] + self.assertEqual(ready["type"], "session.ready") + self.assertTrue(ready["resumed"]) + self.assertEqual(ready["activeTurnId"], "turn-reconnect") + async def test_steer_uses_official_hermes_command(self): await self.adapter._handle_server_frame( { "type": "session.ensure", - "protocolVersion": 1, + "protocolVersion": 2, "requestId": "ensure-2", "threadId": "thread-2", } @@ -229,7 +546,7 @@ async def test_steer_uses_official_hermes_command(self): await self.adapter._handle_server_frame( { "type": "turn.start", - "protocolVersion": 1, + "protocolVersion": 2, "requestId": "start-2", "threadId": "thread-2", "sessionId": session_id, @@ -248,7 +565,7 @@ async def accept_steer(_event): await self.adapter._handle_server_frame( { "type": "turn.steer", - "protocolVersion": 1, + "protocolVersion": 2, "requestId": "steer-2", "threadId": "thread-2", "sessionId": session_id, @@ -283,7 +600,7 @@ async def test_rejected_steer_emits_error_without_completing_active_turn(self): await self.adapter._handle_server_frame( { "type": "session.ensure", - "protocolVersion": 1, + "protocolVersion": 2, "requestId": "ensure-rejected-steer", "threadId": "thread-rejected-steer", } @@ -292,7 +609,7 @@ async def test_rejected_steer_emits_error_without_completing_active_turn(self): await self.adapter._handle_server_frame( { "type": "turn.start", - "protocolVersion": 1, + "protocolVersion": 2, "requestId": "start-rejected-steer", "threadId": "thread-rejected-steer", "sessionId": session_id, @@ -309,7 +626,7 @@ async def reject_steer(_event): await self.adapter._handle_server_frame( { "type": "turn.steer", - "protocolVersion": 1, + "protocolVersion": 2, "requestId": "steer-rejected", "threadId": "thread-rejected-steer", "sessionId": session_id, @@ -339,7 +656,7 @@ async def test_failed_steer_emits_correlated_internal_error(self): await self.adapter._handle_server_frame( { "type": "session.ensure", - "protocolVersion": 1, + "protocolVersion": 2, "requestId": "ensure-failed-steer", "threadId": "thread-failed-steer", } @@ -348,7 +665,7 @@ async def test_failed_steer_emits_correlated_internal_error(self): await self.adapter._handle_server_frame( { "type": "turn.start", - "protocolVersion": 1, + "protocolVersion": 2, "requestId": "start-failed-steer", "threadId": "thread-failed-steer", "sessionId": session_id, @@ -365,7 +682,7 @@ async def fail_steer(_event): await self.adapter._handle_server_frame( { "type": "turn.steer", - "protocolVersion": 1, + "protocolVersion": 2, "requestId": "steer-failed", "threadId": "thread-failed-steer", "sessionId": session_id, @@ -386,7 +703,7 @@ async def test_session_status_counts_ready_sessions_and_stop_decrements(self): await self.adapter._handle_server_frame( { "type": "session.ensure", - "protocolVersion": 1, + "protocolVersion": 2, "requestId": "ensure-3", "threadId": "thread-3", } @@ -396,7 +713,7 @@ async def test_session_status_counts_ready_sessions_and_stop_decrements(self): await self.adapter._handle_server_frame( { "type": "session.stop", - "protocolVersion": 1, + "protocolVersion": 2, "requestId": "stop-3", "threadId": "thread-3", "sessionId": session_id, diff --git a/integrations/hermes-t3-gateway/tests/test_connection.py b/integrations/hermes-t3-gateway/tests/test_connection.py index 8f26110aab0..59e802c791b 100644 --- a/integrations/hermes-t3-gateway/tests/test_connection.py +++ b/integrations/hermes-t3-gateway/tests/test_connection.py @@ -60,7 +60,7 @@ async def test_enrollment_handshake_returns_credential(self): socket = FakeSocket( { "type": "connection.accepted", - "protocolVersion": 1, + "protocolVersion": 2, "instanceId": "provider-instance", "nickname": "Research", "credential": "persistent-secret", @@ -77,13 +77,33 @@ async def test_enrollment_handshake_returns_credential(self): {"type": "enrollment-token", "token": "once"}, ) + async def test_accepted_handshake_rejects_an_incompatible_protocol(self): + socket = FakeSocket( + { + "type": "connection.accepted", + "protocolVersion": 1, + "instanceId": "provider-instance", + "nickname": "Research", + } + ) + with self.assertRaisesRegex(RuntimeError, "incompatible version"): + await connection.authenticate_socket( + socket, + authentication={ + "type": "instance-credential", + "instanceId": "provider-instance", + "credential": "secret", + }, + hermes_version="0.19.0", + ) + async def test_rejected_handshake_fails_closed(self): socket = FakeSocket( { "type": "connection.rejected", "code": "version-incompatible", "message": "upgrade required", - "expectedProtocolVersion": 1, + "expectedProtocolVersion": 2, } ) with self.assertRaises(connection.ConnectionRejected) as raised: diff --git a/integrations/hermes-t3-gateway/tests/test_protocol.py b/integrations/hermes-t3-gateway/tests/test_protocol.py index 4064214b343..cacf8af3f4b 100644 --- a/integrations/hermes-t3-gateway/tests/test_protocol.py +++ b/integrations/hermes-t3-gateway/tests/test_protocol.py @@ -14,7 +14,7 @@ class ProtocolTests(unittest.TestCase): - def test_hello_matches_v1_contract(self): + def test_hello_matches_v2_contract(self): hello = protocol.connection_hello( hermes_version="0.19.0", authentication={"type": "enrollment-token", "token": "once"}, @@ -22,15 +22,16 @@ def test_hello_matches_v1_contract(self): ) self.assertEqual(hello["type"], "connection.hello") self.assertEqual(hello["requestId"], "request-1") - self.assertEqual(hello["protocolVersion"], 1) + self.assertEqual(hello["protocolVersion"], 2) self.assertFalse(hello["capabilities"]["attachments"]) self.assertTrue(hello["capabilities"]["streaming"]) def test_server_frame_validation_is_closed(self): with self.assertRaisesRegex(ValueError, "unsupported"): - protocol.validate_server_frame({"type": "made.up", "protocolVersion": 1}) + protocol.validate_server_frame({"type": "made.up", "protocolVersion": 2}) with self.assertRaisesRegex(ValueError, "version"): - protocol.validate_server_frame({"type": "ping", "protocolVersion": 2}) + # Protocol v1 peers must upgrade before sending runtime frames. + protocol.validate_server_frame({"type": "ping", "protocolVersion": 1}) def test_tool_types_map_to_canonical_items(self): self.assertEqual( diff --git a/packages/contracts/src/hermesGateway.test.ts b/packages/contracts/src/hermesGateway.test.ts index 0cfef7b7994..9c75ffc4134 100644 --- a/packages/contracts/src/hermesGateway.test.ts +++ b/packages/contracts/src/hermesGateway.test.ts @@ -67,12 +67,12 @@ describe("Hermes gateway management contracts", () => { status: "connected", connectorUrl: "wss://siva.davis7.space/hermes", lastConnectedAt: "2026-07-23T12:00:00.000Z", - pluginVersion: "0.1.0", + pluginVersion: "0.2.0", hermesVersion: "1.2.3", activeSessionCount: 2, - protocolVersion: 1, + protocolVersion: 2, capabilities: { - protocolVersion: 1, + protocolVersion: 2, streaming: true, activity: true, approvals: true, @@ -86,10 +86,10 @@ describe("Hermes gateway management contracts", () => { const upgradeRequired = decodeInstanceStatus({ ...connected, status: "upgrade-required", - protocolVersion: 2, + protocolVersion: 3, capabilities: null, }); - expect(upgradeRequired.protocolVersion).toBe(2); + expect(upgradeRequired.protocolVersion).toBe(3); expect(upgradeRequired.capabilities).toBeNull(); }); }); @@ -99,11 +99,11 @@ describe("Hermes gateway handshake", () => { const hello = decodeHello({ type: "connection.hello", requestId: "hello-1", - protocolVersion: 1, - pluginVersion: "0.1.0", + protocolVersion: 2, + pluginVersion: "0.2.0", hermesVersion: "1.2.3", capabilities: { - protocolVersion: 1, + protocolVersion: 2, streaming: true, activity: true, approvals: true, @@ -123,11 +123,11 @@ describe("Hermes gateway handshake", () => { const hello = decodeHello({ type: "connection.hello", requestId: "hello-2", - protocolVersion: 1, - pluginVersion: "0.1.0", + protocolVersion: 2, + pluginVersion: "0.2.0", hermesVersion: "1.2.3", capabilities: { - protocolVersion: 1, + protocolVersion: 2, streaming: true, activity: true, approvals: true, @@ -148,11 +148,11 @@ describe("Hermes gateway handshake", () => { const hello = decodeHello({ type: "connection.hello", requestId: "hello-future", - protocolVersion: 2, - pluginVersion: "0.2.0", + protocolVersion: 3, + pluginVersion: "0.3.0", hermesVersion: "2.0.0", capabilities: { - protocolVersion: 2, + protocolVersion: 3, streaming: true, activity: true, approvals: true, @@ -165,14 +165,14 @@ describe("Hermes gateway handshake", () => { }, }); - expect(hello.protocolVersion).toBe(2); - expect(hello.capabilities.protocolVersion).toBe(2); + expect(hello.protocolVersion).toBe(3); + expect(hello.capabilities.protocolVersion).toBe(3); }); it("reserves attachments for a future protocol version", () => { expect(() => decodeCapabilities({ - protocolVersion: 1, + protocolVersion: 2, streaming: true, activity: true, approvals: true, @@ -183,12 +183,12 @@ describe("Hermes gateway handshake", () => { }); }); -describe("T3 to Hermes v1 messages", () => { +describe("T3 to Hermes v2 messages", () => { it("decodes session creation and opaque resume cursors", () => { expect( decodeT3Message({ type: "session.ensure", - protocolVersion: 1, + protocolVersion: 2, requestId: "ensure-1", threadId: "thread-1", resumeSessionId: "opaque/hermes/session/value", @@ -197,7 +197,7 @@ describe("T3 to Hermes v1 messages", () => { expect( decodeResumeCursor({ - protocolVersion: 1, + protocolVersion: 2, sessionId: "opaque/hermes/session/value", }).sessionId, ).toBe("opaque/hermes/session/value"); @@ -205,7 +205,7 @@ describe("T3 to Hermes v1 messages", () => { it("decodes start and steering as distinct turn operations", () => { const context = { - protocolVersion: 1, + protocolVersion: 2, requestId: "turn-command-1", threadId: "thread-1", sessionId: "session-1", @@ -219,7 +219,7 @@ describe("T3 to Hermes v1 messages", () => { it("decodes interrupt, approval, structured input, stop, and ping", () => { const turnContext = { - protocolVersion: 1, + protocolVersion: 2, threadId: "thread-1", sessionId: "session-1", turnId: "turn-1", @@ -251,7 +251,7 @@ describe("T3 to Hermes v1 messages", () => { expect( decodeT3Message({ type: "session.stop", - protocolVersion: 1, + protocolVersion: 2, requestId: "stop-1", threadId: "thread-1", sessionId: "session-1", @@ -260,7 +260,7 @@ describe("T3 to Hermes v1 messages", () => { expect( decodeT3Message({ type: "ping", - protocolVersion: 1, + protocolVersion: 2, requestId: "ping-1", sentAt: "2026-07-23T12:00:00.000Z", }).type, @@ -271,7 +271,8 @@ describe("T3 to Hermes v1 messages", () => { expect(() => decodeT3Message({ type: "ping", - protocolVersion: 2, + // Protocol v1 peers must upgrade before sending post-handshake frames. + protocolVersion: 1, requestId: "ping-1", sentAt: "2026-07-23T12:00:00.000Z", }), @@ -279,25 +280,38 @@ describe("T3 to Hermes v1 messages", () => { }); }); -describe("Hermes to T3 v1 events", () => { +describe("Hermes to T3 v2 events", () => { const turnContext = { - protocolVersion: 1, + protocolVersion: 2, threadId: "thread-1", sessionId: "session-1", turnId: "turn-1", }; it("decodes session readiness, turn start, streaming text, and completion", () => { - expect( - decodePluginMessage({ - type: "session.ready", - protocolVersion: 1, - requestId: "ensure-1", - threadId: "thread-1", - sessionId: "session-1", - resumed: false, - }).type, - ).toBe("session.ready"); + const legacyReady = decodePluginMessage({ + type: "session.ready", + protocolVersion: 2, + requestId: "ensure-1", + threadId: "thread-1", + sessionId: "session-1", + resumed: false, + }); + expect(legacyReady.type).toBe("session.ready"); + const activeReady = decodePluginMessage({ + type: "session.ready", + protocolVersion: 2, + requestId: "ensure-2", + threadId: "thread-1", + sessionId: "session-1", + resumed: true, + activeTurnId: "turn-1", + }); + expect(activeReady.type).toBe("session.ready"); + if (activeReady.type !== "session.ready") { + throw new Error("expected session.ready"); + } + expect(activeReady.activeTurnId).toBe("turn-1"); expect( decodePluginMessage({ type: "turn.started", @@ -313,6 +327,19 @@ describe("Hermes to T3 v1 events", () => { ...turnContext, }).type, ).toBe("content.delta"); + const snapshot = decodePluginMessage({ + type: "content.snapshot", + streamKind: "assistant_text", + text: "", + itemId: "message-1", + contentIndex: 0, + ...turnContext, + }); + expect(snapshot.type).toBe("content.snapshot"); + if (snapshot.type !== "content.snapshot") { + throw new Error("expected content.snapshot"); + } + expect(snapshot.text).toBe(""); expect( decodePluginMessage({ type: "turn.completed", diff --git a/packages/contracts/src/hermesGateway.ts b/packages/contracts/src/hermesGateway.ts index d4482ea6e12..1f0f8260019 100644 --- a/packages/contracts/src/hermesGateway.ts +++ b/packages/contracts/src/hermesGateway.ts @@ -21,7 +21,7 @@ import { ProviderApprovalDecision, ProviderUserInputAnswers } from "./orchestrat import { ProviderInstanceId } from "./providerInstance.ts"; import { CanonicalItemType, CanonicalRequestType, UserInputQuestion } from "./providerRuntime.ts"; -export const HERMES_GATEWAY_PROTOCOL_VERSION = 1 as const; +export const HERMES_GATEWAY_PROTOCOL_VERSION = 2 as const; export const HermesGatewayProtocolVersion = Schema.Literal(HERMES_GATEWAY_PROTOCOL_VERSION); export type HermesGatewayProtocolVersion = typeof HermesGatewayProtocolVersion.Type; @@ -87,7 +87,7 @@ export type HermesGatewayCapabilities = typeof HermesGatewayCapabilities.Type; * * This deliberately permits capability shapes from a newer protocol so T3 can * return a structured `version-incompatible` rejection instead of failing the - * WebSocket frame decoder. Accepted v1 connections must subsequently validate + * WebSocket frame decoder. Accepted v2 connections must subsequently validate * this advertisement with `HermesGatewayCapabilities`. */ export const HermesGatewayHelloCapabilities = Schema.Struct({ @@ -112,7 +112,7 @@ export type HermesGatewayConnectionState = typeof HermesGatewayConnectionState.T /** * Public instance state used by settings and provider-picker surfaces. * - * `protocolVersion` is not restricted to v1 here so the UI can report the + * `protocolVersion` is not restricted to v2 here so the UI can report the * unsupported version observed from a plugin that needs an upgrade. */ export const HermesGatewayInstanceStatus = Schema.Struct({ @@ -241,7 +241,7 @@ export type HermesGatewayAuthentication = typeof HermesGatewayAuthentication.Typ /** * `protocolVersion` accepts any positive integer at the initial boundary so * T3 can reject incompatible plugins with a structured upgrade response. - * Once accepted, all remaining v1 frames use the literal v1 schema. + * Once accepted, all remaining v2 frames use the literal v2 schema. */ export const HermesGatewayConnectionHello = Schema.Struct({ type: Schema.Literal("connection.hello"), @@ -380,6 +380,7 @@ export const HermesGatewaySessionReady = Schema.Struct({ threadId: ThreadId, sessionId: HermesGatewaySessionId, resumed: Schema.Boolean, + activeTurnId: Schema.optional(TurnId), }); export type HermesGatewaySessionReady = typeof HermesGatewaySessionReady.Type; @@ -412,6 +413,17 @@ export const HermesGatewayContentDelta = Schema.Struct({ }); export type HermesGatewayContentDelta = typeof HermesGatewayContentDelta.Type; +export const HermesGatewayContentSnapshot = Schema.Struct({ + type: Schema.Literal("content.snapshot"), + protocolVersion: HermesGatewayProtocolVersion, + ...HermesGatewayTurnContext.fields, + itemId: Schema.optional(HermesGatewayItemId), + streamKind: HermesGatewayContentStreamKind, + text: Schema.String, + contentIndex: Schema.optional(NonNegativeInt), +}); +export type HermesGatewayContentSnapshot = typeof HermesGatewayContentSnapshot.Type; + export const HermesGatewayItemStatus = Schema.Literals([ "inProgress", "completed", @@ -569,6 +581,7 @@ export const HermesGatewayPluginToT3Message = Schema.Union([ HermesGatewaySessionReady, HermesGatewayTurnStarted, HermesGatewayContentDelta, + HermesGatewayContentSnapshot, HermesGatewayItemStarted, HermesGatewayItemUpdated, HermesGatewayItemCompleted, diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 84b7a8fa07f..d64c9d47953 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -813,6 +813,16 @@ const ThreadMessageAssistantDeltaCommand = Schema.Struct({ createdAt: IsoDateTime, }); +const ThreadMessageAssistantReplaceCommand = Schema.Struct({ + type: Schema.Literal("thread.message.assistant.replace"), + commandId: CommandId, + threadId: ThreadId, + messageId: MessageId, + text: Schema.String, + turnId: Schema.optional(TurnId), + createdAt: IsoDateTime, +}); + const ThreadMessageAssistantCompleteCommand = Schema.Struct({ type: Schema.Literal("thread.message.assistant.complete"), commandId: CommandId, @@ -863,6 +873,7 @@ const ThreadRevertCompleteCommand = Schema.Struct({ const InternalOrchestrationCommand = Schema.Union([ ThreadSessionSetCommand, ThreadMessageAssistantDeltaCommand, + ThreadMessageAssistantReplaceCommand, ThreadMessageAssistantCompleteCommand, ThreadProposedPlanUpsertCommand, ThreadTurnDiffCompleteCommand, @@ -1028,6 +1039,7 @@ export const ThreadMessageSentPayload = Schema.Struct({ attachments: Schema.optional(Schema.Array(ChatAttachment)), turnId: Schema.NullOr(TurnId), streaming: Schema.Boolean, + textOperation: Schema.optional(Schema.Literal("replace")), createdAt: IsoDateTime, updatedAt: IsoDateTime, }); diff --git a/packages/contracts/src/providerRuntime.test.ts b/packages/contracts/src/providerRuntime.test.ts index 0e6ced58fd5..afd234aff90 100644 --- a/packages/contracts/src/providerRuntime.test.ts +++ b/packages/contracts/src/providerRuntime.test.ts @@ -69,6 +69,34 @@ describe("ProviderRuntimeEvent", () => { expect(parsed.payload.planMarkdown).toBe("# Ship it"); }); + it("decodes authoritative content snapshots, including empty text", () => { + const parsed = decodeRuntimeEvent({ + type: "content.snapshot", + eventId: "event-content-snapshot-1", + provider: "hermes", + providerInstanceId: "hermes-research", + sessionId: "runtime-session-1", + createdAt: "2026-02-28T00:00:00.000Z", + threadId: "thread-1", + turnId: "turn-1", + itemId: "message-1", + payload: { + streamKind: "assistant_text", + text: "", + contentIndex: 0, + summaryIndex: 1, + }, + }); + + expect(parsed.type).toBe("content.snapshot"); + if (parsed.type !== "content.snapshot") { + throw new Error("expected content.snapshot"); + } + expect(parsed.payload.text).toBe(""); + expect(parsed.payload.contentIndex).toBe(0); + expect(parsed.payload.summaryIndex).toBe(1); + }); + it("decodes user-input.requested with structured questions", () => { const parsed = decodeRuntimeEvent({ type: "user-input.requested", diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index eb2563eff00..12910cd6980 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -170,6 +170,7 @@ const ProviderRuntimeEventType = Schema.Literals([ "item.updated", "item.completed", "content.delta", + "content.snapshot", "request.opened", "request.resolved", "user-input.requested", @@ -220,6 +221,7 @@ const ItemStartedType = Schema.Literal("item.started"); const ItemUpdatedType = Schema.Literal("item.updated"); const ItemCompletedType = Schema.Literal("item.completed"); const ContentDeltaType = Schema.Literal("content.delta"); +const ContentSnapshotType = Schema.Literal("content.snapshot"); const RequestOpenedType = Schema.Literal("request.opened"); const RequestResolvedType = Schema.Literal("request.resolved"); const UserInputRequestedType = Schema.Literal("user-input.requested"); @@ -418,6 +420,14 @@ const ContentDeltaPayload = Schema.Struct({ }); export type ContentDeltaPayload = typeof ContentDeltaPayload.Type; +export const ContentSnapshotPayload = Schema.Struct({ + streamKind: RuntimeContentStreamKind, + text: Schema.String, + contentIndex: Schema.optional(Schema.Int), + summaryIndex: Schema.optional(Schema.Int), +}); +export type ContentSnapshotPayload = typeof ContentSnapshotPayload.Type; + const RequestOpenedPayload = Schema.Struct({ requestType: CanonicalRequestType, detail: Schema.optional(TrimmedNonEmptyStringSchema), @@ -791,6 +801,13 @@ const ProviderRuntimeContentDeltaEvent = Schema.Struct({ }); export type ProviderRuntimeContentDeltaEvent = typeof ProviderRuntimeContentDeltaEvent.Type; +const ProviderRuntimeContentSnapshotEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: ContentSnapshotType, + payload: ContentSnapshotPayload, +}); +export type ProviderRuntimeContentSnapshotEvent = typeof ProviderRuntimeContentSnapshotEvent.Type; + const ProviderRuntimeRequestOpenedEvent = Schema.Struct({ ...ProviderRuntimeEventBase.fields, type: RequestOpenedType, @@ -989,6 +1006,7 @@ export const ProviderRuntimeEventV2 = Schema.Union([ ProviderRuntimeItemUpdatedEvent, ProviderRuntimeItemCompletedEvent, ProviderRuntimeContentDeltaEvent, + ProviderRuntimeContentSnapshotEvent, ProviderRuntimeRequestOpenedEvent, ProviderRuntimeRequestResolvedEvent, ProviderRuntimeUserInputRequestedEvent, From 1fa4219b487d27fa0555f452f18d7e1425c9dfa3 Mon Sep 17 00:00:00 2001 From: Ben Davis <45952064+bmdavis419@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:42:02 -0700 Subject: [PATCH 05/68] fix: serialize Hermes activity lifecycle --- integrations/hermes-t3-gateway/adapter.py | 98 ++++++++++--------- .../hermes-t3-gateway/tests/test_adapter.py | 85 ++++++++++++++++ 2 files changed, 139 insertions(+), 44 deletions(-) diff --git a/integrations/hermes-t3-gateway/adapter.py b/integrations/hermes-t3-gateway/adapter.py index 672ed4e6afb..2306ce1aa47 100644 --- a/integrations/hermes-t3-gateway/adapter.py +++ b/integrations/hermes-t3-gateway/adapter.py @@ -64,6 +64,10 @@ class _TurnState: tool_items: dict[str, str] = field(default_factory=dict) generic_activity_id: str | None = None generic_activity_detail: str | None = None + generic_activity_lock: asyncio.Lock = field( + default_factory=asyncio.Lock, + repr=False, + ) @dataclass @@ -728,64 +732,70 @@ async def _complete_turn(self, turn: _TurnState) -> None: title="Hermes response", ) ) - if turn.generic_activity_id is not None: + async with turn.generic_activity_lock: + if self._active_turns.get(turn.thread_id) is not turn: + return + if turn.generic_activity_id is not None: + await self._send_frame( + frame( + "item.completed", + threadId=turn.thread_id, + sessionId=turn.session_id, + turnId=turn.turn_id, + itemId=turn.generic_activity_id, + itemType="unknown", + status="completed", + title="Hermes activity", + **( + {"detail": turn.generic_activity_detail} + if turn.generic_activity_detail + else {} + ), + ) + ) await self._send_frame( frame( - "item.completed", + "turn.completed", threadId=turn.thread_id, sessionId=turn.session_id, turnId=turn.turn_id, - itemId=turn.generic_activity_id, - itemType="unknown", - status="completed", - title="Hermes activity", - **( - {"detail": turn.generic_activity_detail} - if turn.generic_activity_detail - else {} - ), + state="completed", + stopReason=None, ) ) - await self._send_frame( - frame( - "turn.completed", - threadId=turn.thread_id, - sessionId=turn.session_id, - turnId=turn.turn_id, - state="completed", - stopReason=None, - ) - ) - self._active_turns.pop(turn.thread_id, None) + self._active_turns.pop(turn.thread_id, None) await self._send_status() async def _emit_generic_activity(self, turn: _TurnState, detail: str) -> None: if not detail: return normalized_detail = str(detail)[:2_000] - if turn.generic_activity_detail == normalized_detail: - return - activity_id = turn.generic_activity_id - if activity_id is None: - activity_id = item_id() - event_type = "item.started" - else: - event_type = "item.updated" - await self._send_frame( - frame( - event_type, - threadId=turn.thread_id, - sessionId=turn.session_id, - turnId=turn.turn_id, - itemId=activity_id, - itemType="unknown", - status="inProgress", - title="Hermes activity", - detail=normalized_detail, + async with turn.generic_activity_lock: + if self._active_turns.get(turn.thread_id) is not turn: + return + if turn.generic_activity_detail == normalized_detail: + return + activity_id = turn.generic_activity_id + if activity_id is None: + activity_id = item_id() + event_type = "item.started" + else: + event_type = "item.updated" + await self._send_frame( + frame( + event_type, + threadId=turn.thread_id, + sessionId=turn.session_id, + turnId=turn.turn_id, + itemId=activity_id, + itemType="unknown", + status="inProgress", + title="Hermes activity", + detail=normalized_detail, + ) ) - ) - turn.generic_activity_id = activity_id - turn.generic_activity_detail = normalized_detail + turn.generic_activity_id = activity_id + turn.generic_activity_detail = normalized_detail def emit_tool_started( self, diff --git a/integrations/hermes-t3-gateway/tests/test_adapter.py b/integrations/hermes-t3-gateway/tests/test_adapter.py index 02e25ceb4b5..9282e467311 100644 --- a/integrations/hermes-t3-gateway/tests/test_adapter.py +++ b/integrations/hermes-t3-gateway/tests/test_adapter.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import dataclasses import enum import importlib.util @@ -397,6 +398,90 @@ async def fail_activity_start(message): self.assertEqual(updated["itemId"], started["itemId"]) self.assertEqual(updated["detail"], "Running tests") + async def test_concurrent_generic_activity_updates_share_one_lifecycle(self): + await self._start_turn("thread-activity-concurrent", "turn-activity-concurrent") + turn = self.adapter._active_turns["thread-activity-concurrent"] + original_send = self.connection.send + first_send_started = asyncio.Event() + release_first_send = asyncio.Event() + + async def block_first_activity_send(message): + if message["type"] == "item.started" and not first_send_started.is_set(): + first_send_started.set() + await release_first_send.wait() + await original_send(message) + + self.connection.send = block_first_activity_send + first_update = asyncio.create_task( + self.adapter._emit_generic_activity(turn, "Reading repository") + ) + await first_send_started.wait() + second_update = asyncio.create_task( + self.adapter._emit_generic_activity(turn, "Running tests") + ) + await asyncio.sleep(0) + release_first_send.set() + await asyncio.gather(first_update, second_update) + + activity_frames = [ + message + for message in self.connection.messages + if message["type"] in {"item.started", "item.updated"} + ] + self.assertEqual( + [message["type"] for message in activity_frames], + ["item.started", "item.updated"], + ) + self.assertEqual(activity_frames[0]["itemId"], activity_frames[1]["itemId"]) + self.assertEqual(turn.generic_activity_id, activity_frames[0]["itemId"]) + self.assertEqual(turn.generic_activity_detail, "Running tests") + + async def test_turn_completion_waits_for_in_flight_generic_activity_update(self): + await self._start_turn("thread-activity-complete", "turn-activity-complete") + turn = self.adapter._active_turns["thread-activity-complete"] + await self.adapter._emit_generic_activity(turn, "Reading repository") + activity_id = turn.generic_activity_id + original_send = self.connection.send + update_send_started = asyncio.Event() + release_update_send = asyncio.Event() + + async def block_activity_update(message): + if message["type"] == "item.updated": + update_send_started.set() + await release_update_send.wait() + await original_send(message) + + self.connection.send = block_activity_update + in_flight_update = asyncio.create_task( + self.adapter._emit_generic_activity(turn, "Running tests") + ) + await update_send_started.wait() + completion = asyncio.create_task(self.adapter._complete_turn(turn)) + await asyncio.sleep(0) + self.assertFalse(completion.done()) + + release_update_send.set() + await asyncio.gather(in_flight_update, completion) + + lifecycle_frames = [ + message + for message in self.connection.messages + if message["type"] + in {"item.started", "item.updated", "item.completed", "turn.completed"} + ] + self.assertEqual( + [message["type"] for message in lifecycle_frames], + ["item.started", "item.updated", "item.completed", "turn.completed"], + ) + self.assertTrue( + all( + message["itemId"] == activity_id + for message in lifecycle_frames + if message["type"].startswith("item.") + ) + ) + self.assertNotIn("thread-activity-complete", self.adapter._active_turns) + async def test_exact_t3_home_channel_notice_is_suppressed(self): await self._start_turn("thread-notice", "turn-notice") content_start = len(self.connection.messages) From 35ddbc4ab12f832b7511374c828e7bfc7d59a0f3 Mon Sep 17 00:00:00 2001 From: Ben Davis <45952064+bmdavis419@users.noreply.github.com> Date: Fri, 24 Jul 2026 21:11:47 -0700 Subject: [PATCH 06/68] fix: support reverse-proxied dev origins --- apps/server/src/auth/EnvironmentAuth.test.ts | 3 +- .../src/auth/EnvironmentAuthPolicy.test.ts | 18 ++++ apps/server/src/auth/EnvironmentAuthPolicy.ts | 2 +- apps/server/src/auth/SessionStore.ts | 2 +- apps/server/src/cli/config.test.ts | 5 + apps/server/src/cli/config.ts | 25 +++++ apps/server/src/config.ts | 1 + apps/web/src/viteHelpers.test.ts | 21 ++++ apps/web/src/viteHelpers.ts | 20 ++++ apps/web/vite.config.ts | 57 +++++----- docs/reference/scripts.md | 3 + scripts/dev-runner.test.ts | 75 +++++++++++++ scripts/dev-runner.ts | 100 +++++++++++++++++- 13 files changed, 298 insertions(+), 34 deletions(-) create mode 100644 apps/web/src/viteHelpers.test.ts create mode 100644 apps/web/src/viteHelpers.ts diff --git a/apps/server/src/auth/EnvironmentAuth.test.ts b/apps/server/src/auth/EnvironmentAuth.test.ts index 933069e2926..d9fdaf2571a 100644 --- a/apps/server/src/auth/EnvironmentAuth.test.ts +++ b/apps/server/src/auth/EnvironmentAuth.test.ts @@ -128,7 +128,8 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { Effect.provide( makeEnvironmentAuthLayer({ mode: "web", - port: 7446, + port: 13773, + authCookiePort: 7446, }), ), ), diff --git a/apps/server/src/auth/EnvironmentAuthPolicy.test.ts b/apps/server/src/auth/EnvironmentAuthPolicy.test.ts index b8474fbfb19..73b4d00e3b1 100644 --- a/apps/server/src/auth/EnvironmentAuthPolicy.test.ts +++ b/apps/server/src/auth/EnvironmentAuthPolicy.test.ts @@ -98,6 +98,24 @@ it.layer(NodeServices.layer)("EnvironmentAuthPolicy.layer", (it) => { ), ); + it.effect("uses the browser-facing port when a reverse proxy changes the port", () => + Effect.gen(function* () { + const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy; + const descriptor = yield* policy.getDescriptor(); + + expect(descriptor.sessionCookieName).toBe("t3_session_7446"); + }).pipe( + Effect.provide( + makeEnvironmentAuthPolicyLayer({ + mode: "web", + host: "127.0.0.1", + port: 13773, + authCookiePort: 7446, + }), + ), + ), + ); + it.effect("uses remote-reachable policy for wildcard web hosts", () => Effect.gen(function* () { const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy; diff --git a/apps/server/src/auth/EnvironmentAuthPolicy.ts b/apps/server/src/auth/EnvironmentAuthPolicy.ts index 7ffef0ff0a5..9ed1bd9f33d 100644 --- a/apps/server/src/auth/EnvironmentAuthPolicy.ts +++ b/apps/server/src/auth/EnvironmentAuthPolicy.ts @@ -40,7 +40,7 @@ export const make = Effect.gen(function* () { sessionMethods: ["browser-session-cookie", "bearer-access-token", "dpop-access-token"], sessionCookieName: resolveSessionCookieName({ mode: config.mode, - port: config.port, + port: config.authCookiePort ?? config.port, }), }; diff --git a/apps/server/src/auth/SessionStore.ts b/apps/server/src/auth/SessionStore.ts index 12ecb7dba4d..7e0c12be7c6 100644 --- a/apps/server/src/auth/SessionStore.ts +++ b/apps/server/src/auth/SessionStore.ts @@ -469,7 +469,7 @@ export const make = Effect.gen(function* () { const changesPubSub = yield* PubSub.unbounded(); const cookieName = resolveSessionCookieName({ mode: serverConfig.mode, - port: serverConfig.port, + port: serverConfig.authCookiePort ?? serverConfig.port, }); const emitUpsert = (clientSession: AuthClientSession) => diff --git a/apps/server/src/cli/config.test.ts b/apps/server/src/cli/config.test.ts index f6c2a63e192..e0d8e358061 100644 --- a/apps/server/src/cli/config.test.ts +++ b/apps/server/src/cli/config.test.ts @@ -92,6 +92,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { T3CODE_LOG_LEVEL: "Warn", T3CODE_MODE: "desktop", T3CODE_PORT: "4001", + T3CODE_AUTH_COOKIE_PORT: "7446", T3CODE_HOST: "0.0.0.0", T3CODE_HOME: baseDir, VITE_DEV_SERVER_URL: "http://127.0.0.1:5173", @@ -111,6 +112,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { ...defaultObservabilityConfig, mode: "desktop", port: 4001, + authCookiePort: 7446, cwd: process.cwd(), baseDir, ...derivedPaths, @@ -141,6 +143,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { { mode: Option.some("web"), port: Option.some(8788), + authCookiePort: Option.some(9443), host: Option.some("127.0.0.1"), baseDir: Option.some(baseDir), cwd: Option.none(), @@ -162,6 +165,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { T3CODE_LOG_LEVEL: "Warn", T3CODE_MODE: "desktop", T3CODE_PORT: "4001", + T3CODE_AUTH_COOKIE_PORT: "7446", T3CODE_HOST: "0.0.0.0", T3CODE_HOME: join(NodeOS.tmpdir(), "ignored-base"), VITE_DEV_SERVER_URL: "http://127.0.0.1:5173", @@ -181,6 +185,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { ...defaultObservabilityConfig, mode: "web", port: 8788, + authCookiePort: 9443, cwd: process.cwd(), baseDir, ...derivedPaths, diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index 5a4cde0a6fd..f5545e5e86e 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -26,6 +26,13 @@ export const portFlag = Flag.integer("port").pipe( Flag.withDescription("Port for the HTTP/WebSocket server."), Flag.optional, ); +export const authCookiePortFlag = Flag.integer("auth-cookie-port").pipe( + Flag.withSchema(PortSchema), + Flag.withDescription( + "Browser-facing port used to namespace auth cookies when a reverse proxy exposes a different port.", + ), + Flag.optional, +); export const hostFlag = Flag.string("host").pipe( Flag.withDescription("Host/interface to bind (for example 127.0.0.1, 0.0.0.0, or a Tailnet IP)."), Flag.optional, @@ -103,6 +110,10 @@ const EnvServerConfig = Config.all({ Config.map(Option.getOrUndefined), ), port: Config.port("T3CODE_PORT").pipe(Config.option, Config.map(Option.getOrUndefined)), + authCookiePort: Config.port("T3CODE_AUTH_COOKIE_PORT").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ), host: Config.string("T3CODE_HOST").pipe(Config.option, Config.map(Option.getOrUndefined)), t3Home: Config.string("T3CODE_HOME").pipe(Config.option, Config.map(Option.getOrUndefined)), devUrl: Config.url("VITE_DEV_SERVER_URL").pipe(Config.option, Config.map(Option.getOrUndefined)), @@ -135,6 +146,7 @@ const EnvServerConfig = Config.all({ export interface CliServerFlags { readonly mode: Option.Option; readonly port: Option.Option; + readonly authCookiePort?: Option.Option; readonly host: Option.Option; readonly baseDir: Option.Option; readonly cwd: Option.Option; @@ -164,6 +176,7 @@ export const projectLocationFlags = { export const sharedServerCommandFlags = { mode: modeFlag, port: portFlag, + authCookiePort: authCookiePortFlag, host: hostFlag, baseDir: baseDirFlag, cwd: Argument.string("cwd").pipe( @@ -214,6 +227,7 @@ export const resolveServerConfig = ( const normalizedFlags = { mode: flags.mode ?? Option.none(), port: flags.port ?? Option.none(), + authCookiePort: flags.authCookiePort ?? Option.none(), host: flags.host ?? Option.none(), baseDir: flags.baseDir ?? Option.none(), cwd: flags.cwd ?? Option.none(), @@ -257,6 +271,12 @@ export const resolveServerConfig = ( }, }, ); + const explicitlyConfiguredAuthCookiePort = Option.getOrUndefined( + resolveOptionPrecedence( + normalizedFlags.authCookiePort, + Option.fromUndefinedOr(env.authCookiePort), + ), + ); const devUrl = Option.getOrElse( resolveOptionPrecedence(normalizedFlags.devUrl, Option.fromUndefinedOr(env.devUrl)), () => undefined, @@ -326,6 +346,9 @@ export const resolveServerConfig = ( ), () => 443, ); + const authCookiePort = + explicitlyConfiguredAuthCookiePort ?? + (tailscaleServeEnabled ? tailscaleServePort : undefined); const staticDir = devUrl ? undefined : yield* ServerConfig.resolveStaticDir(); const host = Option.getOrElse( resolveOptionPrecedence( @@ -356,6 +379,7 @@ export const resolveServerConfig = ( otlpServiceName: env.otlpServiceName, mode, port, + ...(authCookiePort !== undefined ? { authCookiePort } : {}), cwd, baseDir, ...derivedPaths, @@ -383,6 +407,7 @@ export const resolveCliAuthConfig = ( { mode: Option.none(), port: Option.none(), + authCookiePort: Option.none(), host: Option.none(), baseDir: flags.baseDir, cwd: Option.none(), diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 3b081c95c34..a6c1f2791d2 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -67,6 +67,7 @@ export class ServerConfig extends Context.Service< readonly otlpServiceName: string; readonly mode: RuntimeMode; readonly port: number; + readonly authCookiePort?: number; readonly host: string | undefined; readonly cwd: string; readonly baseDir: string; diff --git a/apps/web/src/viteHelpers.test.ts b/apps/web/src/viteHelpers.test.ts new file mode 100644 index 00000000000..6aa40ba1814 --- /dev/null +++ b/apps/web/src/viteHelpers.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolveDevProxyTarget } from "./viteHelpers"; + +describe("resolveDevProxyTarget", () => { + it("keeps the dev proxy on the private backend when the browser URL is public", () => { + const browserWebSocketUrl = "wss://siva.example.test:7446"; + const privateProxyUrl = "http://localhost:13773"; + + expect(resolveDevProxyTarget(privateProxyUrl)).toBe("http://localhost:13773/"); + expect(resolveDevProxyTarget(privateProxyUrl)).not.toBe( + resolveDevProxyTarget(browserWebSocketUrl), + ); + }); + + it("converts websocket schemes and strips paths", () => { + expect(resolveDevProxyTarget("ws://localhost:13773/api/hermes-gateway/ws?token=test")).toBe( + "http://localhost:13773/", + ); + }); +}); diff --git a/apps/web/src/viteHelpers.ts b/apps/web/src/viteHelpers.ts new file mode 100644 index 00000000000..e2f9bf53554 --- /dev/null +++ b/apps/web/src/viteHelpers.ts @@ -0,0 +1,20 @@ +export function resolveDevProxyTarget(rawUrl: string | undefined) { + if (!rawUrl) { + return undefined; + } + + try { + const url = new URL(rawUrl); + if (url.protocol === "ws:") { + url.protocol = "http:"; + } else if (url.protocol === "wss:") { + url.protocol = "https:"; + } + url.pathname = ""; + url.search = ""; + url.hash = ""; + return url.toString(); + } catch { + return undefined; + } +} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 6e5b532b58a..aa4313243c4 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -8,6 +8,7 @@ import { defineConfig } from "vite-plus"; import pkg from "./package.json" with { type: "json" }; import { loadRepoEnv } from "../../scripts/lib/public-config"; +import { resolveDevProxyTarget } from "./src/viteHelpers"; const repoEnv = loadRepoEnv(); Object.assign(process.env, repoEnv); @@ -15,6 +16,18 @@ Object.assign(process.env, repoEnv); const port = Number(process.env.PORT ?? 5733); const host = process.env.HOST?.trim() || "localhost"; const configuredWsUrl = process.env.VITE_WS_URL?.trim(); +const configuredDevProxyUrl = process.env.T3CODE_DEV_PROXY_URL?.trim(); +const configuredDevServerUrl = (() => { + const rawValue = process.env.VITE_DEV_SERVER_URL?.trim(); + if (!rawValue) { + return undefined; + } + try { + return new URL(rawValue); + } catch { + return undefined; + } +})(); const configuredRelayUrl = repoEnv.VITE_T3CODE_RELAY_URL?.trim() || ""; const configuredClerkPublishableKey = repoEnv.VITE_CLERK_PUBLISHABLE_KEY?.trim() || ""; const configuredClerkJwtTemplate = repoEnv.VITE_CLERK_JWT_TEMPLATE?.trim() || ""; @@ -65,28 +78,7 @@ const unitTestProject = { }, } satisfies TestProjectInlineConfiguration; -function resolveDevProxyTarget(wsUrl: string | undefined): string | undefined { - if (!wsUrl) { - return undefined; - } - - try { - const url = new URL(wsUrl); - if (url.protocol === "ws:") { - url.protocol = "http:"; - } else if (url.protocol === "wss:") { - url.protocol = "https:"; - } - url.pathname = ""; - url.search = ""; - url.hash = ""; - return url.toString(); - } catch { - return undefined; - } -} - -const devProxyTarget = resolveDevProxyTarget(configuredWsUrl); +const devProxyTarget = resolveDevProxyTarget(configuredDevProxyUrl ?? configuredWsUrl); export default defineConfig(() => { return { @@ -145,6 +137,11 @@ export default defineConfig(() => { host, port, strictPort: true, + ...(configuredDevServerUrl + ? { + allowedHosts: [configuredDevServerUrl.hostname], + } + : {}), ...(devProxyTarget ? { proxy: { @@ -164,12 +161,16 @@ export default defineConfig(() => { } : {}), hmr: { - // Explicit config so Vite's HMR WebSocket connects reliably - // inside Electron's BrowserWindow. Vite 8 uses console.debug for - // connection logs — enable "Verbose" in DevTools to see them. - protocol: "ws", - host, - clientPort: port, + // Follow the browser-facing dev origin when reverse-proxied, while + // preserving the explicit loopback config used by Electron. + protocol: configuredDevServerUrl?.protocol === "https:" ? "wss" : "ws", + host: configuredDevServerUrl?.hostname ?? host, + clientPort: configuredDevServerUrl + ? Number( + configuredDevServerUrl.port || + (configuredDevServerUrl.protocol === "https:" ? "443" : "80"), + ) + : port, }, }, build: { diff --git a/docs/reference/scripts.md b/docs/reference/scripts.md index 746aa66d563..62d97747069 100644 --- a/docs/reference/scripts.md +++ b/docs/reference/scripts.md @@ -7,6 +7,9 @@ - Web dev commands do not auto-open a browser. Open the one-time pairing URL printed by the server so the first browser navigation is authenticated. Set `T3CODE_NO_BROWSER=0` only when interactive auto-open is intentional. - Pass dev-runner flags directly after the root task name, for example: `vp run dev --home-dir /tmp/t3code-dev` +- For a reverse-proxied remote dev origin, pass `--public-url` so browser HTTP, + WebSocket, HMR, allowed-host, and port-scoped auth-cookie settings stay aligned: + `vp run dev --public-url https://siva.example.test:7446` - `vp run start` — Runs the production server (serves built web app as static files). - `vp run build` — Builds contracts, web app, and server. - `vp run typecheck` — Strict TypeScript checks for all packages. diff --git a/scripts/dev-runner.test.ts b/scripts/dev-runner.test.ts index 3b79db49f5b..1053cf29e96 100644 --- a/scripts/dev-runner.test.ts +++ b/scripts/dev-runner.test.ts @@ -61,6 +61,20 @@ const devServerInput = { runArgs: ["--inspect", "secret-token-value"], } as const; +const defaultCreateEnvInput = { + mode: "dev", + baseEnv: {}, + serverOffset: 0, + webOffset: 0, + t3Home: undefined, + browser: undefined, + autoBootstrapProjectFromCwd: undefined, + logWebSocketEvents: undefined, + host: undefined, + port: undefined, + devUrl: undefined, +} as const; + it.layer(NodeServices.layer)("dev-runner", (it) => { describe("getDevRunnerModeArgs", () => { it.effect("lets Vite+ honor the desktop dev task graph", () => @@ -147,6 +161,67 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { }), ); + it.effect("configures remote dev targets and cookie isolation from one public URL", () => + Effect.gen(function* () { + const env = yield* createDevRunnerEnv({ + mode: "dev", + baseEnv: {}, + serverOffset: 0, + webOffset: 0, + t3Home: undefined, + browser: undefined, + autoBootstrapProjectFromCwd: undefined, + logWebSocketEvents: undefined, + host: undefined, + port: undefined, + devUrl: undefined, + publicUrl: new URL("https://siva.example.test:7446"), + }); + + assert.equal(env.VITE_DEV_SERVER_URL, "https://siva.example.test:7446"); + assert.equal(env.VITE_HTTP_URL, "https://siva.example.test:7446"); + assert.equal(env.VITE_WS_URL, "wss://siva.example.test:7446"); + assert.equal(env.T3CODE_DEV_PROXY_URL, "http://localhost:13773"); + assert.equal(env.T3CODE_AUTH_COOKIE_PORT, "7446"); + }), + ); + + it.effect("rejects public URLs that are not HTTP origins", () => + Effect.gen(function* () { + const unsupportedProtocol = yield* Effect.flip( + createDevRunnerEnv({ + ...defaultCreateEnvInput, + publicUrl: new URL("ftp://siva.example.test:7446"), + }), + ); + const pathUrl = yield* Effect.flip( + createDevRunnerEnv({ + ...defaultCreateEnvInput, + publicUrl: new URL("https://siva.example.test:7446/app"), + }), + ); + + assert.equal(unsupportedProtocol._tag, "DevRunnerPublicUrlError"); + assert.equal(unsupportedProtocol.reason, "unsupported-protocol"); + assert.equal(pathUrl._tag, "DevRunnerPublicUrlError"); + assert.equal(pathUrl.reason, "not-an-origin"); + }), + ); + + it.effect("rejects conflicting public and explicit dev URLs", () => + Effect.gen(function* () { + const error = yield* Effect.flip( + createDevRunnerEnv({ + ...defaultCreateEnvInput, + devUrl: new URL("http://localhost:7331"), + publicUrl: new URL("https://siva.example.test:7446"), + }), + ); + + assert.equal(error._tag, "DevRunnerConflictingUrlOptionsError"); + }), + ); + it.effect("allows browser auto-open to be explicitly enabled", () => Effect.gen(function* () { const env = yield* createDevRunnerEnv({ diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index 1938232300f..5e6695bb17b 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -81,6 +81,29 @@ export class DevRunnerInvalidPortOffsetError extends Schema.TaggedErrorClass()( + "DevRunnerPublicUrlError", + { + reason: Schema.Literals(["unsupported-protocol", "not-an-origin"]), + protocol: Schema.String, + }, +) { + override get message(): string { + return this.reason === "unsupported-protocol" + ? `Public URL must use HTTP or HTTPS; received protocol "${this.protocol}".` + : "Public URL must be an origin without credentials, a path, query parameters, or a fragment."; + } +} + +export class DevRunnerConflictingUrlOptionsError extends Schema.TaggedErrorClass()( + "DevRunnerConflictingUrlOptionsError", + {}, +) { + override get message(): string { + return "Use either --public-url or --dev-url, not both."; + } +} + export class DevRunnerPortExhaustedError extends Schema.TaggedErrorClass()( "DevRunnerPortExhaustedError", { @@ -131,6 +154,8 @@ export class DevRunnerProcessExitError extends Schema.TaggedErrorClass => Config.option, Config.map((value) => Option.getOrUndefined(value)), ); +const optionalUrlConfig = (name: string): Config.Config => + Config.url(name).pipe( + Config.option, + Config.map((value) => Option.getOrUndefined(value)), + ); const optionalIntegerConfig = (name: string): Config.Config => Config.int(name).pipe( Config.option, @@ -227,6 +257,7 @@ interface CreateDevRunnerEnvInput { readonly host: string | undefined; readonly port: number | undefined; readonly devUrl: URL | undefined; + readonly publicUrl?: URL | undefined; } export function createDevRunnerEnv({ @@ -241,18 +272,68 @@ export function createDevRunnerEnv({ host, port, devUrl, -}: CreateDevRunnerEnvInput): Effect.Effect { + publicUrl, +}: CreateDevRunnerEnvInput): Effect.Effect< + NodeJS.ProcessEnv, + DevRunnerPublicUrlError | DevRunnerConflictingUrlOptionsError, + Path.Path +> { return Effect.gen(function* () { const serverPort = port ?? BASE_SERVER_PORT + serverOffset; const webPort = BASE_WEB_PORT + webOffset; const configuredBaseDir = t3Home?.trim() || baseEnv.T3CODE_HOME?.trim() || undefined; const resolvedBaseDir = yield* resolveBaseDir(configuredBaseDir); const isDesktopMode = mode === "dev:desktop"; + const configuredPublicUrl = isDesktopMode ? undefined : publicUrl; + + if (configuredPublicUrl !== undefined && devUrl !== undefined) { + return yield* new DevRunnerConflictingUrlOptionsError(); + } + if ( + configuredPublicUrl !== undefined && + configuredPublicUrl.protocol !== "http:" && + configuredPublicUrl.protocol !== "https:" + ) { + return yield* new DevRunnerPublicUrlError({ + reason: "unsupported-protocol", + protocol: configuredPublicUrl.protocol, + }); + } + if ( + configuredPublicUrl !== undefined && + (configuredPublicUrl.username !== "" || + configuredPublicUrl.password !== "" || + configuredPublicUrl.pathname !== "/" || + configuredPublicUrl.search !== "" || + configuredPublicUrl.hash !== "") + ) { + return yield* new DevRunnerPublicUrlError({ + reason: "not-an-origin", + protocol: configuredPublicUrl.protocol, + }); + } + + const publicHttpUrl = configuredPublicUrl?.origin; + const publicWsUrl = + configuredPublicUrl === undefined + ? undefined + : (() => { + const url = new URL(configuredPublicUrl); + url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; + return url.origin; + })(); + const publicPort = + configuredPublicUrl === undefined + ? undefined + : Number( + configuredPublicUrl.port || (configuredPublicUrl.protocol === "https:" ? "443" : "80"), + ); const output: NodeJS.ProcessEnv = { ...baseEnv, PORT: String(webPort), VITE_DEV_SERVER_URL: + publicHttpUrl ?? devUrl?.toString() ?? `http://${isDesktopMode ? DESKTOP_DEV_LOOPBACK_HOST : "localhost"}:${webPort}`, }; @@ -265,8 +346,12 @@ export function createDevRunnerEnv({ if (!isDesktopMode) { output.T3CODE_PORT = String(serverPort); - output.VITE_HTTP_URL = `http://localhost:${serverPort}`; - output.VITE_WS_URL = `ws://localhost:${serverPort}`; + output.T3CODE_DEV_PROXY_URL = `http://localhost:${serverPort}`; + output.VITE_HTTP_URL = publicHttpUrl ?? `http://localhost:${serverPort}`; + output.VITE_WS_URL = publicWsUrl ?? `ws://localhost:${serverPort}`; + if (publicPort !== undefined) { + output.T3CODE_AUTH_COOKIE_PORT = String(publicPort); + } } else { output.T3CODE_PORT = String(serverPort); output.VITE_HTTP_URL = `http://${DESKTOP_DEV_LOOPBACK_HOST}:${serverPort}`; @@ -479,6 +564,7 @@ interface DevRunnerCliInput { readonly host: string | undefined; readonly port: number | undefined; readonly devUrl: URL | undefined; + readonly publicUrl?: URL | undefined; readonly dryRun: boolean; readonly runArgs: ReadonlyArray; } @@ -517,6 +603,7 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { host: input.host, port: input.port, devUrl: input.devUrl, + publicUrl: input.publicUrl, }); const selectionSuffix = @@ -627,6 +714,13 @@ const devRunnerCli = Command.make("dev-runner", { Flag.optional, Flag.map(Option.getOrUndefined), ), + publicUrl: Flag.string("public-url").pipe( + Flag.withSchema(Schema.URLFromString), + Flag.withDescription( + "Browser-facing HTTP(S) origin for remote dev access; also namespaces auth cookies by its public port.", + ), + Flag.withFallbackConfig(optionalUrlConfig("T3CODE_PUBLIC_URL")), + ), dryRun: Flag.boolean("dry-run").pipe( Flag.withDescription("Resolve mode/ports/env and print, but do not spawn Vite+."), Flag.withDefault(false), From 965a76b531ecdc57b15343603d737ed958d6f8b2 Mon Sep 17 00:00:00 2001 From: Ben Davis <45952064+bmdavis419@users.noreply.github.com> Date: Fri, 24 Jul 2026 21:33:46 -0700 Subject: [PATCH 07/68] fix: recover legacy Hermes gateway state --- .../Layers/HermesGatewayBroker.test.ts | 45 +++++++++++++++++++ .../provider/Layers/HermesGatewayBroker.ts | 8 +++- .../src/state/threads-sync.test.ts | 1 + packages/client-runtime/src/state/threads.ts | 44 ++++++++++-------- 4 files changed, 78 insertions(+), 20 deletions(-) diff --git a/apps/server/src/provider/Layers/HermesGatewayBroker.test.ts b/apps/server/src/provider/Layers/HermesGatewayBroker.test.ts index 836431a364b..8c789200e53 100644 --- a/apps/server/src/provider/Layers/HermesGatewayBroker.test.ts +++ b/apps/server/src/provider/Layers/HermesGatewayBroker.test.ts @@ -18,6 +18,7 @@ import * as Context from "effect/Context"; import * as Deferred from "effect/Deferred"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; @@ -36,6 +37,7 @@ const otherInstanceId = ProviderInstanceId.make("hermes_other"); const defaultHermesInstanceId = ProviderInstanceId.make("hermes"); const metadataSecretNameForTest = (id: ProviderInstanceId) => `hermes-gateway-metadata-${Buffer.from(id, "utf8").toString("base64url")}`; +const encodeUnknownJson = Schema.encodeUnknownSync(Schema.UnknownFromJsonString); class HermesTestInstance extends Context.Service()( "t3/provider/Layers/HermesGatewayBroker.test/HermesTestInstance", ) {} @@ -554,6 +556,49 @@ it.effect("checks nickname uniqueness from persisted metadata after restart", () }), ); +it.effect("keeps previous-protocol metadata manageable after restart", () => + Effect.gen(function* () { + const secrets = makeSecretStore(); + const previousProtocolVersion = HERMES_GATEWAY_PROTOCOL_VERSION - 1; + const legacyMetadata = (nickname: string) => ({ + nickname, + connectorUrl: "https://t3.example.test", + revoked: false, + lastSeen: { + pluginVersion: "0.1.0", + hermesVersion: "0.19.0", + capabilities: { + ...capabilities, + protocolVersion: previousProtocolVersion, + }, + connectedAt: "2026-07-24T00:00:00.000Z", + activeSessionCount: 0, + }, + }); + yield* secrets.set( + metadataSecretNameForTest(instanceId), + new TextEncoder().encode(encodeUnknownJson(legacyMetadata("Legacy Remote"))), + ); + yield* secrets.set( + metadataSecretNameForTest(otherInstanceId), + new TextEncoder().encode(encodeUnknownJson(legacyMetadata("Legacy Other"))), + ); + + const broker = yield* makeBroker(secrets); + const legacyStatus = yield* broker.getInstanceStatus(instanceId); + assert.equal(legacyStatus.protocolVersion, previousProtocolVersion); + assert.isNull(legacyStatus.capabilities); + assert.equal((yield* broker.revokeInstance(instanceId)).status, "revoked"); + + const enrollment = yield* broker.createEnrollment({ + instanceId: otherInstanceId, + nickname: "Legacy Other", + connectorUrl: "https://t3.example.test", + }); + assert.equal(enrollment.instanceId, otherInstanceId); + }), +); + it.effect( "renames the display label while preserving instance identity and normalized uniqueness", () => diff --git a/apps/server/src/provider/Layers/HermesGatewayBroker.ts b/apps/server/src/provider/Layers/HermesGatewayBroker.ts index 53902e237a0..8be5c69863a 100644 --- a/apps/server/src/provider/Layers/HermesGatewayBroker.ts +++ b/apps/server/src/provider/Layers/HermesGatewayBroker.ts @@ -6,6 +6,7 @@ import { HermesGatewayCredential, HermesGatewayEnrollmentToken, HermesGatewayCapabilities, + HermesGatewayHelloCapabilities, HermesGatewayManagementError, type HermesGatewayConnectionHello, type HermesGatewayCreateEnrollmentInput, @@ -65,7 +66,7 @@ const InstanceMetadata = Schema.Struct({ Schema.Struct({ pluginVersion: Schema.String, hermesVersion: Schema.String, - capabilities: HermesGatewayCapabilities, + capabilities: HermesGatewayHelloCapabilities, connectedAt: Schema.String, activeSessionCount: Schema.Number, }), @@ -155,7 +156,10 @@ const statusFromState = ( activeSessionCount: state.connection?.activeSessionCount ?? 0, protocolVersion: observed?.capabilities.protocolVersion ?? state.upgradeRequired?.protocolVersion ?? null, - capabilities: observed?.capabilities ?? null, + capabilities: + observed?.capabilities !== undefined && isStrictCapabilities(observed.capabilities) + ? observed.capabilities + : null, }; })(), instanceId, diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts index 8d0e4d0a35c..18be3fd74e0 100644 --- a/packages/client-runtime/src/state/threads-sync.test.ts +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -559,6 +559,7 @@ describe("EnvironmentThreads", () => { expect(Option.isNone(recovered.error)).toBe(true); expect(yield* Ref.get(harness.subscriptionCount)).toBe(2); + expect(yield* Ref.get(harness.loaderCalls)).toBe(1); expect(yield* Ref.get(harness.retryCount)).toBe(0); }), ); diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index 196229cc8b1..df09aae6076 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -21,6 +21,7 @@ import { EnvironmentSupervisor } from "../connection/supervisor.ts"; import * as ConnectionWakeups from "../connection/wakeups.ts"; import { EnvironmentCacheStore } from "../platform/persistence.ts"; import { subscribeDynamic } from "../rpc/client.ts"; +import type { RpcSession } from "../rpc/session.ts"; import { ThreadSnapshotLoader } from "./threadSnapshotHttp.ts"; import { parseThreadKey, threadKey } from "./entities.ts"; import { applyThreadDetailEvent } from "./threadReducer.ts"; @@ -79,6 +80,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make const lastSequence = yield* SubscriptionRef.make( Option.match(cached, { onNone: () => 0, onSome: (snapshot) => snapshot.snapshotSequence }), ); + const snapshotSession = yield* Ref.make(undefined); const awaitingCompletion = yield* Ref.make(false); const persistence = yield* Queue.sliding(1); @@ -253,24 +255,30 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make let current = yield* SubscriptionRef.get(state); if (Option.isNone(current.data) && current.status !== "deleted") { - const prepared = yield* SubscriptionRef.get(supervisor.prepared).pipe( - Effect.flatMap( - Option.match({ - onSome: Effect.succeed, - onNone: () => - SubscriptionRef.changes(supervisor.prepared).pipe( - Stream.filter(Option.isSome), - Stream.map((value) => value.value), - Stream.runHead, - Effect.map(Option.getOrThrow), - ), - }), - ), - ); - const httpSnapshot = yield* snapshotLoader.load(prepared, threadId); - if (Option.isSome(httpSnapshot)) { - yield* applyItem({ kind: "snapshot", snapshot: httpSnapshot.value }); - current = yield* SubscriptionRef.get(state); + const shouldLoadSnapshot = yield* Ref.modify(snapshotSession, (previous) => [ + previous !== session, + session, + ]); + if (shouldLoadSnapshot) { + const prepared = yield* SubscriptionRef.get(supervisor.prepared).pipe( + Effect.flatMap( + Option.match({ + onSome: Effect.succeed, + onNone: () => + SubscriptionRef.changes(supervisor.prepared).pipe( + Stream.filter(Option.isSome), + Stream.map((value) => value.value), + Stream.runHead, + Effect.map(Option.getOrThrow), + ), + }), + ), + ); + const httpSnapshot = yield* snapshotLoader.load(prepared, threadId); + if (Option.isSome(httpSnapshot)) { + yield* applyItem({ kind: "snapshot", snapshot: httpSnapshot.value }); + current = yield* SubscriptionRef.get(state); + } } } From 5b519775f5f8c3d7340c030e8876d874803f92a7 Mon Sep 17 00:00:00 2001 From: Ben Davis <45952064+bmdavis419@users.noreply.github.com> Date: Fri, 24 Jul 2026 22:09:20 -0700 Subject: [PATCH 08/68] fix: unify assistant text merge and detach status text from unknown Two defects that reach users who have never touched Hermes. The append-vs-replace rule for assistant message text was hand-written in three projections. `textOperation: "replace"` shipped to two of them: the live client reducer appended revised snapshots instead of replacing them, so a provider revising cumulative output mid-turn rendered "Hello worldHello, world!" in every connected browser until a refresh pulled the corrected text from the server projection. Extract one `mergeAssistantMessageText` into contracts and have the projector, the projection pipeline, and the client reducer all call it, so the next payload field that changes text semantics cannot land in two of three implementations. Generic provider activity was keyed on `itemType: "unknown"`, which Codex deliberately emits as its "could not classify this item" sentinel and relies on being inert. Codex threads consequently grew activity rows summarized as the literal string "Provider activity". Add an explicit `status_text` item type for renderable provider status lines, restore `unknown` to inert, and prefer the status detail over the generic title when summarizing. Also adds the optional `model` field to connection.hello so Hermes can report its real configured model instead of a placeholder, and ignores __pycache__. Regression tests cover both: the reducer test fails with the exact "Hello worldHello, world!" corruption when the replace branch is removed, and Codex's unknown sentinel is asserted to produce no activity. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 2 + .../Layers/ProjectionPipeline.ts | 21 +++--- .../Layers/ProviderRuntimeIngestion.test.ts | 70 +++++++++++++++++-- .../Layers/ProviderRuntimeIngestion.ts | 11 +-- apps/server/src/orchestration/projector.ts | 16 ++--- .../src/state/threadReducer.test.ts | 48 +++++++++++++ .../client-runtime/src/state/threadReducer.ts | 13 ++-- packages/contracts/src/hermesGateway.ts | 9 +++ packages/contracts/src/orchestration.ts | 34 +++++++++ packages/contracts/src/providerRuntime.ts | 5 ++ 10 files changed, 194 insertions(+), 35 deletions(-) diff --git a/.gitignore b/.gitignore index 8e1669c8115..c1f7d342c90 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,5 @@ node_modules/ *.log .env* !.env.example +__pycache__/ +*.py[cod] diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 9c8f2ee6b6b..8e57acb6b20 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -1,6 +1,7 @@ import { ApprovalRequestId, type ChatAttachment, + mergeAssistantMessageText, type OrchestrationEvent, type OrchestrationSessionStatus, ThreadId, @@ -886,18 +887,14 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti const previousMessage = Option.getOrUndefined(existingMessage); const nextText = Option.match(existingMessage, { onNone: () => event.payload.text, - onSome: (message) => { - if (event.payload.textOperation === "replace") { - return event.payload.text; - } - if (event.payload.streaming) { - return `${message.text}${event.payload.text}`; - } - if (event.payload.text.length === 0) { - return message.text; - } - return event.payload.text; - }, + onSome: (message) => + mergeAssistantMessageText(message.text, { + text: event.payload.text, + streaming: event.payload.streaming, + ...(event.payload.textOperation !== undefined + ? { textOperation: event.payload.textOperation } + : {}), + }), }); const nextAttachments = event.payload.attachments !== undefined diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index a95d65579f7..e6f378b46e6 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -3202,7 +3202,7 @@ describe("ProviderRuntimeIngestion", () => { ).toBe(true); }); - it("upserts unknown provider item lifecycle into one meaningful activity", async () => { + it("upserts status_text provider item lifecycle into one meaningful activity", async () => { const harness = await createHarness(); const itemId = asItemId("item-generic-activity"); const turnId = asTurnId("turn-generic-activity"); @@ -3216,7 +3216,7 @@ describe("ProviderRuntimeIngestion", () => { turnId, itemId, payload: { - itemType: "unknown", + itemType: "status_text", status: "inProgress", title: "Reading skill hermes-agent", detail: "Opening the skill instructions.", @@ -3232,7 +3232,7 @@ describe("ProviderRuntimeIngestion", () => { turnId, itemId, payload: { - itemType: "unknown", + itemType: "status_text", status: "inProgress", title: "Reading skill hermes-agent", detail: "Parsing the workflow.", @@ -3247,7 +3247,7 @@ describe("ProviderRuntimeIngestion", () => { turnId, itemId, payload: { - itemType: "unknown", + itemType: "status_text", status: "completed", title: "Read skill hermes-agent", detail: "Skill instructions loaded.", @@ -3274,9 +3274,9 @@ describe("ProviderRuntimeIngestion", () => { expect(activities).toHaveLength(1); expect(activity?.kind).toBe("provider.activity"); expect(activity?.tone).toBe("info"); - expect(activity?.summary).toBe("Read skill hermes-agent"); + expect(activity?.summary).toBe("Skill instructions loaded."); expect(payload).toMatchObject({ - itemType: "unknown", + itemType: "status_text", status: "completed", providerItemId: itemId, title: "Read skill hermes-agent", @@ -3285,6 +3285,64 @@ describe("ProviderRuntimeIngestion", () => { expect(payload?.data).toBeUndefined(); }); + // Regression: `unknown` is the canonical "could not classify this item" + // sentinel and adapters rely on it being INERT. CodexAdapter deliberately + // emits it for item.updated; routing generic activity through it put rows + // summarized as the literal string "Provider activity" into Codex threads. + // Renderable status text has its own item type (`status_text`) instead. + it("does not create activities for the unknown item sentinel", async () => { + const harness = await createHarness(); + const itemId = asItemId("item-codex-unknown"); + const turnId = asTurnId("turn-codex-unknown"); + + harness.emit({ + type: "item.updated", + eventId: asEventId("evt-codex-unknown-updated"), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", + threadId: asThreadId("thread-1"), + turnId, + itemId, + payload: { + itemType: "unknown", + status: "inProgress", + }, + }); + // A tool item after it gives the poll something real to settle on, so the + // assertion is "the unknown item produced nothing" rather than a race. + harness.emit({ + type: "item.started", + eventId: asEventId("evt-codex-unknown-tool"), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:01.000Z", + threadId: asThreadId("thread-1"), + turnId, + itemId: asItemId("item-codex-tool"), + payload: { + itemType: "command_execution", + status: "inProgress", + title: "ls", + }, + }); + + const thread = await waitForThread(harness.readModel, (entry) => + entry.activities.some( + (activity: ProviderRuntimeTestActivity) => activity.kind === "tool.started", + ), + ); + + expect( + thread.activities.some( + (activity: ProviderRuntimeTestActivity) => activity.id === `provider:codex:item:${itemId}`, + ), + ).toBe(false); + expect( + thread.activities.some( + (activity: ProviderRuntimeTestActivity) => activity.kind === "provider.activity", + ), + ).toBe(false); + }); + it("consumes P1 runtime events into thread metadata, diff checkpoints, and activities", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index b8d2d8ce8cc..73a266604d3 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -332,7 +332,10 @@ function genericProviderActivity( createdAt: event.createdAt, tone: "info", kind: "provider.activity", - summary: title ?? (detail ? truncateDetail(detail, 120) : "Provider activity"), + // Status text is the interesting part here; the title is a generic label + // ("Hermes activity"). Prefer the detail so the row reads as the status the + // provider is actually reporting. + summary: detail ? truncateDetail(detail, 120) : (title ?? "Working…"), payload: { itemType: event.payload.itemType, status, @@ -653,7 +656,7 @@ export function runtimeEventToActivities( } case "item.updated": { - if (event.payload.itemType === "unknown") { + if (event.payload.itemType === "status_text") { return [genericProviderActivity(event, maybeSequence)]; } if (!isToolLifecycleItemType(event.payload.itemType)) { @@ -679,7 +682,7 @@ export function runtimeEventToActivities( } case "item.completed": { - if (event.payload.itemType === "unknown") { + if (event.payload.itemType === "status_text") { return [genericProviderActivity(event, maybeSequence)]; } if (!isToolLifecycleItemType(event.payload.itemType)) { @@ -704,7 +707,7 @@ export function runtimeEventToActivities( } case "item.started": { - if (event.payload.itemType === "unknown") { + if (event.payload.itemType === "status_text") { return [genericProviderActivity(event, maybeSequence)]; } if (!isToolLifecycleItemType(event.payload.itemType)) { diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 260d3d05884..97e1fba4f8c 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -1,5 +1,6 @@ import type { OrchestrationEvent, OrchestrationReadModel, ThreadId } from "@t3tools/contracts"; import { + mergeAssistantMessageText, OrchestrationCheckpointSummary, OrchestrationMessage, OrchestrationSession, @@ -471,14 +472,13 @@ export function projectEvent( entry.id === message.id ? { ...entry, - text: - payload.textOperation === "replace" - ? message.text - : message.streaming - ? `${entry.text}${message.text}` - : message.text.length > 0 - ? message.text - : entry.text, + text: mergeAssistantMessageText(entry.text, { + text: message.text, + streaming: message.streaming, + ...(payload.textOperation !== undefined + ? { textOperation: payload.textOperation } + : {}), + }), streaming: message.streaming, updatedAt: message.updatedAt, turnId: message.turnId, diff --git a/packages/client-runtime/src/state/threadReducer.test.ts b/packages/client-runtime/src/state/threadReducer.test.ts index 211f8748f4e..4a1e2803812 100644 --- a/packages/client-runtime/src/state/threadReducer.test.ts +++ b/packages/client-runtime/src/state/threadReducer.test.ts @@ -319,6 +319,54 @@ describe("applyThreadDetailEvent", () => { } }); + // Regression: a `replace` snapshot supersedes the text we hold rather than + // appending to it, even while `streaming` is true. Providers that emit + // revisable cumulative output (Hermes) revise mid-turn; appending rendered + // "Hello worldHello, world!" in the browser until a refresh pulled the + // corrected text from the server projection. + it("replaces text when the payload is an authoritative snapshot", () => { + const threadWithMessage: OrchestrationThread = { + ...baseThread, + messages: [ + { + id: MessageId.make("msg-2"), + role: "assistant", + text: "Hello world", + turnId: TurnId.make("turn-1"), + streaming: true, + createdAt: "2026-04-01T06:00:00.000Z", + updatedAt: "2026-04-01T06:00:00.000Z", + }, + ], + }; + + const result = applyThreadDetailEvent(threadWithMessage, { + ...baseEventFields, + sequence: 7, + occurredAt: "2026-04-01T06:01:00.000Z", + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.message-sent", + payload: { + threadId: ThreadId.make("thread-1"), + messageId: MessageId.make("msg-2"), + role: "assistant", + text: "Hello, world!", + turnId: TurnId.make("turn-1"), + streaming: true, + textOperation: "replace", + createdAt: "2026-04-01T06:00:00.000Z", + updatedAt: "2026-04-01T06:01:00.000Z", + }, + }); + + expect(result.kind).toBe("updated"); + if (result.kind === "updated") { + expect(result.thread.messages).toHaveLength(1); + expect(result.thread.messages[0]?.text).toBe("Hello, world!"); + } + }); + it("updates latestTurn for assistant messages with a turn", () => { const result = applyThreadDetailEvent(baseThread, { ...baseEventFields, diff --git a/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts index ce0dca52f5a..135375c576d 100644 --- a/packages/client-runtime/src/state/threadReducer.ts +++ b/packages/client-runtime/src/state/threadReducer.ts @@ -12,6 +12,7 @@ import type { OrchestrationThreadActivity, TurnId, } from "@t3tools/contracts"; +import { mergeAssistantMessageText } from "@t3tools/contracts"; export type ThreadDetailReducerResult = | { readonly kind: "updated"; readonly thread: OrchestrationThread } @@ -246,11 +247,13 @@ export function applyThreadDetailEvent( ? entry : { ...entry, - text: message.streaming - ? `${entry.text}${message.text}` - : message.text.length > 0 - ? message.text - : entry.text, + text: mergeAssistantMessageText(entry.text, { + text: message.text, + streaming: message.streaming, + ...(event.payload.textOperation !== undefined + ? { textOperation: event.payload.textOperation } + : {}), + }), streaming: message.streaming, ...(message.turnId !== undefined ? { turnId: message.turnId } : {}), ...(message.streaming ? {} : { updatedAt: message.updatedAt }), diff --git a/packages/contracts/src/hermesGateway.ts b/packages/contracts/src/hermesGateway.ts index 1f0f8260019..74d0c88ea89 100644 --- a/packages/contracts/src/hermesGateway.ts +++ b/packages/contracts/src/hermesGateway.ts @@ -251,6 +251,15 @@ export const HermesGatewayConnectionHello = Schema.Struct({ hermesVersion: TrimmedNonEmptyString, capabilities: HermesGatewayHelloCapabilities, authentication: HermesGatewayAuthentication, + /** + * The model Hermes is configured to run, reported so T3 can show something + * truthful in the picker instead of a placeholder. Read-only — Hermes owns + * model selection, and T3 declares `sessionModelSwitch: "unsupported"`. + * + * Optional so a plugin that predates this field still connects: an absent + * value degrades to the generic label rather than failing the handshake. + */ + model: Schema.optional(TrimmedNonEmptyString), }); export type HermesGatewayConnectionHello = typeof HermesGatewayConnectionHello.Type; diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index d64c9d47953..e6d00949362 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -1044,6 +1044,40 @@ export const ThreadMessageSentPayload = Schema.Struct({ updatedAt: IsoDateTime, }); +/** + * The single append-vs-replace rule for assistant message text. + * + * Three projections consume `thread.message-sent` — the server projector, the + * server projection pipeline, and the live client reducer — and each one has to + * fold an incoming payload into the text it already holds. Hand-writing that + * fold per projection is how `textOperation: "replace"` shipped to two of the + * three: browsers appended revised snapshots (rendering `"Hello worldHello, + * world!"`) until a refresh pulled the corrected text from the server. + * + * Every projection calls this instead. A new payload field that changes text + * semantics is added here once, and no projection can silently miss it. + * + * Rules, in order: + * - `replace` — an authoritative snapshot supersedes what we hold. Providers + * that emit revisable cumulative output (Hermes) rely on this. + * - `streaming` — an incremental delta appends. + * - empty non-streaming text — a terminal event carrying no text keeps what we + * already have rather than blanking the message. + * - otherwise — the payload is the whole text. + */ +export const mergeAssistantMessageText = ( + existingText: string, + payload: { + readonly text: string; + readonly streaming: boolean; + readonly textOperation?: "replace" | undefined; + }, +): string => { + if (payload.textOperation === "replace") return payload.text; + if (payload.streaming) return `${existingText}${payload.text}`; + return payload.text.length > 0 ? payload.text : existingText; +}; + export const ThreadTurnStartRequestedPayload = Schema.Struct({ threadId: ThreadId, messageId: MessageId, diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index 12910cd6980..3cd7bef3c79 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -128,6 +128,11 @@ export const CanonicalItemType = Schema.Literals([ "review_exited", "context_compaction", "error", + // Free-form provider status line ("Thinking…", "Searching the web…"). + // Deliberately distinct from `unknown`: adapters use `unknown` as the + // "could not classify this item" sentinel and rely on it being inert, so + // renderable status text needs its own type rather than riding that one. + "status_text", "unknown", ]); export type CanonicalItemType = typeof CanonicalItemType.Type; From e8be967295d1b50c2efaec70809ff8f76e06f0b2 Mon Sep 17 00:00:00 2001 From: Ben Davis <45952064+bmdavis419@users.noreply.github.com> Date: Fri, 24 Jul 2026 22:26:05 -0700 Subject: [PATCH 09/68] feat: surface the Hermes model on instance status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `connection.hello` gained an optional `model` field, but the value died in the broker: `HermesGatewayInstanceStatus` carried pluginVersion and hermesVersion with nowhere for the model to go, so the provider picker had no way to name the model Hermes actually runs. Add `model` to the status struct as a required nullable field. Null covers both "no plugin has connected yet" and "the connected plugin predates the field" — the plugin ships separately from the server, so a plugin that never sends it must still complete the handshake rather than failing the frame decoder. Tests cover the present and absent paths on both the hello frame and the status projection. The model is volatile liveness, not durable config: it belongs in broker memory alongside pluginVersion/hermesVersion/capabilities and is never persisted to settings. Co-Authored-By: Claude Opus 5 (1M context) --- packages/contracts/src/hermesGateway.test.ts | 37 ++++++++++++++++++++ packages/contracts/src/hermesGateway.ts | 7 ++++ 2 files changed, 44 insertions(+) diff --git a/packages/contracts/src/hermesGateway.test.ts b/packages/contracts/src/hermesGateway.test.ts index 9c75ffc4134..992e1c3d6ca 100644 --- a/packages/contracts/src/hermesGateway.test.ts +++ b/packages/contracts/src/hermesGateway.test.ts @@ -69,6 +69,7 @@ describe("Hermes gateway management contracts", () => { lastConnectedAt: "2026-07-23T12:00:00.000Z", pluginVersion: "0.2.0", hermesVersion: "1.2.3", + model: "gpt-5.6-terra", activeSessionCount: 2, protocolVersion: 2, capabilities: { @@ -82,6 +83,12 @@ describe("Hermes gateway management contracts", () => { }); expect(connected.status).toBe("connected"); expect(connected.activeSessionCount).toBe(2); + expect(connected.model).toBe("gpt-5.6-terra"); + + // A plugin that predates the `model` field still produces a valid status; + // the picker falls back to the generic label rather than failing to decode. + const withoutModel = decodeInstanceStatus({ ...connected, model: null }); + expect(withoutModel.model).toBeNull(); const upgradeRequired = decodeInstanceStatus({ ...connected, @@ -134,6 +141,7 @@ describe("Hermes gateway handshake", () => { userInput: true, attachments: false, }, + model: "gpt-5.6-terra", authentication: { type: "instance-credential", instanceId: "hermes-research", @@ -142,6 +150,35 @@ describe("Hermes gateway handshake", () => { }); expect(hello.authentication.type).toBe("instance-credential"); + expect(hello.model).toBe("gpt-5.6-terra"); + }); + + // The plugin ships separately from the server, so a plugin that predates the + // `model` field must still complete the handshake rather than failing the + // frame decoder. T3 falls back to the generic model label. + it("accepts a hello from a plugin that reports no model", () => { + const hello = decodeHello({ + type: "connection.hello", + requestId: "hello-no-model", + protocolVersion: 2, + pluginVersion: "0.2.0", + hermesVersion: "1.2.3", + capabilities: { + protocolVersion: 2, + streaming: true, + activity: true, + approvals: true, + userInput: true, + attachments: false, + }, + authentication: { + type: "instance-credential", + instanceId: "hermes-research", + credential: "persistent-secret", + }, + }); + + expect(hello.model).toBeUndefined(); }); it("decodes a future-version hello so the broker can reject it explicitly", () => { diff --git a/packages/contracts/src/hermesGateway.ts b/packages/contracts/src/hermesGateway.ts index 74d0c88ea89..08d096ea396 100644 --- a/packages/contracts/src/hermesGateway.ts +++ b/packages/contracts/src/hermesGateway.ts @@ -123,6 +123,13 @@ export const HermesGatewayInstanceStatus = Schema.Struct({ lastConnectedAt: Schema.NullOr(IsoDateTime), pluginVersion: Schema.NullOr(TrimmedNonEmptyString), hermesVersion: Schema.NullOr(TrimmedNonEmptyString), + /** + * The model the connected plugin reported at handshake, surfaced so the + * provider picker can name the model Hermes actually runs instead of a + * placeholder. Null when no plugin has connected yet, or when the connected + * plugin predates the `model` field on `connection.hello`. + */ + model: Schema.NullOr(TrimmedNonEmptyString), activeSessionCount: NonNegativeInt, protocolVersion: Schema.NullOr(PositiveInt), capabilities: Schema.NullOr(HermesGatewayCapabilities), From 758f999f9b6ad3c8d1fbf13242b6a17bc29d34ae Mon Sep 17 00:00:00 2001 From: Ben Davis <45952064+bmdavis419@users.noreply.github.com> Date: Fri, 24 Jul 2026 22:31:58 -0700 Subject: [PATCH 10/68] fix: correct Hermes plugin steer capture, model reporting, and task scheduling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Steer interception could silently drop assistant output. A steer targets a RUNNING turn, and the capture swallowed any content whose chat_id matched while the contextvar was set — so genuine assistant text emitted during the await was recorded as control traffic and never reached the transcript. Because only the last captured message was inspected, the "Steer queued" prefix check then failed the steer closed. Capture now discriminates on the correlation id the plugin already stamps. The identifier differs per path: `send` receives it as `reply_to` (the active-session bypass at gateway/platforms/base.py:4933 passes `_reply_anchor_for_event(event)`, which falls through to `event.message_id` for a platform with no thread_id), while `edit_message` receives its explicit `message_id`, matched against the synthetic control id so retry and finalize edits of the acknowledgement stay suppressed. Removing the guard makes the new test fail with the assistant's item.started and content.delta frames disappearing. Report Hermes' real configured model on connection.hello via the documented read-only `load_config_readonly()` accessor, copying out only a trimmed string so the shared process-wide config cache is never mutated or re-exposed. Every failure mode omits the field rather than sending null, so an older or absent Hermes still completes the handshake. `_schedule` checked whether *a* loop was running rather than whether it was the adapter's own loop, so a hook firing from another thread's loop could schedule work onto the wrong one; created tasks were also unreferenced and collectable mid-flight, surfacing failures only as "task exception was never retrieved". It now verifies loop identity, holds a strong reference until completion, and logs exceptions. Emit the new `status_text` item type for live status lines instead of the `unknown` sentinel other adapters rely on being inert. Add install.sh (POSIX sh, HERMES_HOME-aware, idempotent, non-interactive) and re-pin COMPATIBILITY.md from 3910ab28 to the installed 62e07223 with per-surface line references. The re-audit found the tool-hook kwargs claim understated what Hermes supplies; both plugin hooks take **kwargs so the extra keys are inert. The home-channel notice literal was reconstructed from Hermes' inline f-string and still matches byte-for-byte — now pinned by a regression test, since Hermes builds it inline rather than exporting it. Verified against the Hermes v0.19.0 install on this machine: install.sh links and enables the plugin, `hermes plugins list` shows it, `hermes t3 status` runs, and configured_model() returns gpt-5.6-terra. Co-Authored-By: Claude Opus 5 (1M context) --- .../hermes-t3-gateway/COMPATIBILITY.md | 88 ++++++-- integrations/hermes-t3-gateway/README.md | 20 +- integrations/hermes-t3-gateway/adapter.py | 104 +++++++-- integrations/hermes-t3-gateway/install.sh | 80 +++++++ integrations/hermes-t3-gateway/protocol.py | 34 ++- .../hermes-t3-gateway/tests/test_adapter.py | 208 +++++++++++++++++- .../hermes-t3-gateway/tests/test_protocol.py | 72 ++++++ 7 files changed, 555 insertions(+), 51 deletions(-) create mode 100755 integrations/hermes-t3-gateway/install.sh diff --git a/integrations/hermes-t3-gateway/COMPATIBILITY.md b/integrations/hermes-t3-gateway/COMPATIBILITY.md index 73072f749e4..d0d13f21429 100644 --- a/integrations/hermes-t3-gateway/COMPATIBILITY.md +++ b/integrations/hermes-t3-gateway/COMPATIBILITY.md @@ -1,8 +1,22 @@ # Hermes event compatibility inventory The plugin deliberately uses only the public Hermes plugin and platform-adapter -surfaces audited at Hermes Agent commit -`3910ab28c0892fcf846fc61318d2fd15689eddf1`. +surfaces audited at Hermes Agent upstream commit `62e07223` (v0.19.0). + +Audited surfaces, all present at that commit: + +| Surface | Location at 62e07223 | +| ---------------------------------------- | ------------------------------------ | +| `save_env_value` / `get_env_path` | `hermes_cli/config.py:8137` / `:688` | +| `load_config_readonly` | `hermes_cli/config.py:7415` | +| `build_session_key` | `gateway/session.py:1029` | +| `resolve_gateway_approval` | `tools/approval.py:2073` | +| `resolve_gateway_clarify` | `tools/clarify_gateway.py:160` | +| `register_platform` (`**entry_kwargs`) | `hermes_cli/plugins.py:931` | +| `/steer` active-run handler | `gateway/run.py:11280` | +| Home-channel notice text | `gateway/run.py:13780` | +| Active-command inline dispatch | `gateway/platforms/base.py:4926` | +| User-plugin path `$HERMES_HOME/plugins/` | `hermes_cli/plugins.py:10`, `:1350` | This inventory describes gateway wire protocol v2. Protocol v2 adds active-turn recovery in `session.ready` and authoritative `content.snapshot` replacement; @@ -10,16 +24,17 @@ older server/plugin pairs are rejected during the handshake. ## Mapped in the initial scope -| Hermes surface | T3 gateway event | -| ----------------------------------------- | ---------------------------------------------- | -| Cumulative `send` / `edit_message` output | `content.delta` / `content.snapshot` | -| Final stream edit | `item.completed`, `turn.completed` | -| `pre_tool_call` / `post_tool_call` hooks | Typed `item.started` / `item.completed` | -| Live adapter status text | Generic `unknown` activity item | -| `send_exec_approval` | `request.opened` / `request.resolved` | -| `send_clarify` | `user-input.requested` / `user-input.resolved` | -| `/steer` gateway command | `turn.steer` | -| Adapter interrupt event | `turn.interrupt` | +| Hermes surface | T3 gateway event | +| -------------------------------------------- | ---------------------------------------------- | +| Cumulative `send` / `edit_message` output | `content.delta` / `content.snapshot` | +| Final stream edit | `item.completed`, `turn.completed` | +| `pre_tool_call` / `post_tool_call` hooks | Typed `item.started` / `item.completed` | +| Live adapter status text | `status_text` activity item | +| `load_config_readonly()["model"]["default"]` | Optional `model` on `connection.hello` | +| `send_exec_approval` | `request.opened` / `request.resolved` | +| `send_clarify` | `user-input.requested` / `user-input.resolved` | +| `/steer` gateway command | `turn.steer` | +| Adapter interrupt event | `turn.interrupt` | ## Known limitations @@ -31,12 +46,26 @@ older server/plugin pairs are rejected during the handshake. - Hermes' exact first-chat T3 home-channel notice is suppressed at the adapter output boundary. The plugin does not assign a home channel or redirect proactive delivery; other Hermes platform notices pass through unchanged. + This match is **exact string equality**, which is fragile: Hermes builds the + notice inline from an f-string (`gateway/run.py:13780`) rather than exporting + a constant, so any wording change upstream silently stops the suppression and + the notice reaches the transcript. Re-verified byte-for-byte at 62e07223 by + reconstructing the f-string with `platform_name="t3"` (`Platform("t3").value` + → `"t3"`, `.title()` → `"T3"`) and the non-Slack `/sethome` branch; it still + matches. A regression test pins the literal. - Hermes' documented tool hook surface exposes a `task_id`, tool name, - arguments, string result, and duration. The audited runtime additionally - supplies `session_id` and `tool_call_id`; the adapter uses them when present and falls - back to the documented IDs for older versions. It projects only canonical, - whitelisted fields (command/cwd, file path, search query, image path, or MCP - server/operation); arbitrary arguments and raw results never cross the wire. + arguments, string result, and duration. Verified at 62e07223: the runtime + additionally supplies `session_id`, `tool_call_id`, `turn_id`, + `api_request_id`, and `middleware_trace` on both hooks + (`hermes_cli/plugins.py:2146` for `pre_tool_call`, `model_tools.py:1050` for + `post_tool_call`), and `post_tool_call` also supplies `status`, `error_type`, + and `error_message`. The adapter consumes `session_id`, `tool_call_id`, and + `status` when present and falls back to the documented IDs for older + versions. It projects only canonical, whitelisted fields (command/cwd, file + path, search query, image path, or MCP server/operation); arbitrary arguments + and raw results never cross the wire. +- `post_tool_call` passes `result` as `Any`, not a guaranteed `str` — the + adapter never forwards it, so the looser type is inert here. - Approval resolution is session-FIFO in Hermes. T3 request IDs identify the UI prompt, then resolve the oldest matching Hermes approval for that session. - The public `clarify` hook is a single question. The wire protocol supports an @@ -45,10 +74,25 @@ older server/plugin pairs are rejected during the handshake. plugin uses the stream consumer's required `finalize=True` edit as the authoritative completion boundary. - Active `/steer` dispatch returns a textual Hermes control acknowledgement - through the normal platform `send(..., notify=True)` path. The plugin - captures that response in the originating steering request's async context, - suppresses it from the transcript, and acknowledges T3 only when the audited - Hermes success response begins with `⏩ Steer queued`. Unknown future response - shapes fail closed with `protocol.error` rather than completing the turn. + through the normal platform `send(..., notify=True)` path + (`gateway/platforms/base.py:4926`). The plugin captures that response in the + originating steering request's async context and suppresses it from the + transcript. Because a steer targets a _running_ turn, the capture is + correlated by the steering `requestId` — which the base adapter passes back + as `reply_to` via `_reply_anchor_for_event` — and not by `chat_id`. Genuine + assistant output emitted on the same thread during the steer window carries a + different correlation id and reaches the transcript untouched. +- The plugin acknowledges T3 only when the audited Hermes success response + begins with `⏩ Steer queued`. That prefix is likewise matched against an + inline f-string (`gateway/run.py:11280`) rather than an exported constant, so + it carries the same drift risk as the home-channel notice. Confirmed present + at 62e07223. Unknown future response shapes fail closed with `protocol.error` + rather than completing the turn. +- Hermes' configured default model is read once per handshake from the + documented read-only accessor `load_config_readonly()["model"]["default"]`. + That accessor returns the shared process-wide config cache and its docstring + forbids mutation, so the plugin copies out only a trimmed string. Any failure + — missing key, import error, older Hermes — omits the optional `model` field + from `connection.hello` rather than sending null or empty. - Attachments are not accepted. They are the first planned post-stability feature; the capability is reserved and fixed to `false` in protocol v2. diff --git a/integrations/hermes-t3-gateway/README.md b/integrations/hermes-t3-gateway/README.md index 213d67b6690..8d9fa49cfe0 100644 --- a/integrations/hermes-t3-gateway/README.md +++ b/integrations/hermes-t3-gateway/README.md @@ -14,15 +14,19 @@ not delete its transcript. ## Install from this repository -Copy or symlink this directory into the active Hermes profile: +Run the install script. It symlinks this directory into the active Hermes +profile's user-plugin directory and enables the plugin: ```bash -mkdir -p ~/.hermes/plugins -ln -s /path/to/t3code/integrations/hermes-t3-gateway \ - ~/.hermes/plugins/hermes-t3-gateway -hermes plugins enable hermes-t3-gateway +./integrations/hermes-t3-gateway/install.sh ``` +The script is safe to re-run: an existing correct symlink is left in place, and +enabling an already-enabled plugin is a no-op. It installs into +`$HERMES_HOME/plugins/` when `HERMES_HOME` is set, and `~/.hermes/plugins/` +otherwise. It fails with instructions if `hermes` is not on `PATH`, and refuses +to replace a real directory already sitting at the target path. + In T3 Code, open the Hermes instance settings, choose **Add Hermes**, enter a unique nickname, and copy the generated enrollment command. It has this shape: @@ -42,10 +46,14 @@ profile-aware `save_env_value` helper: - `HERMES_T3_GATEWAY_CREDENTIAL` - `HERMES_T3_GATEWAY_NICKNAME` -The long-lived credential is never printed. Restart `hermes gateway` after +The long-lived credential is never printed. Run `hermes gateway restart` after enrollment. `hermes t3 status` reports the local enrollment without revealing the credential. +The handshake also reports Hermes' configured default model so T3 can show a +truthful label in its picker. It is read-only — Hermes owns model selection — +and is omitted entirely if it cannot be read. + ## Initial scope - Text input and live assistant streaming diff --git a/integrations/hermes-t3-gateway/adapter.py b/integrations/hermes-t3-gateway/adapter.py index 2306ce1aa47..191391f4deb 100644 --- a/integrations/hermes-t3-gateway/adapter.py +++ b/integrations/hermes-t3-gateway/adapter.py @@ -6,9 +6,9 @@ import contextvars import logging import os -import threading import uuid import weakref +from collections.abc import Coroutine from dataclasses import dataclass, field from typing import Any @@ -42,6 +42,14 @@ "Type /sethome to make this chat your home channel, or ignore to skip." ) +# T3's canonical item type for a free-form provider status line. Deliberately +# not `unknown`: that value is the "could not classify this" sentinel other +# adapters rely on being inert, so routing status text through it made stray +# activity rows appear in unrelated provider threads. T3 renders these rows +# preferring `detail` over `title`, so the live status string is sent as +# `detail`. +_STATUS_ITEM_TYPE = "status_text" + def _hermes_version() -> str: try: @@ -76,6 +84,16 @@ class _SteerControlResponse: request_id: str messages: list[str] = field(default_factory=list) + @property + def control_message_id(self) -> str: + """Synthetic id returned for captured control traffic. + + `edit_message` correlates against this so a later edit of the control + acknowledgement is captured too, while genuine assistant edits (which + carry the stream's own message id) pass straight through. + """ + return f"t3-steer-control-{self.request_id}" + _steer_control_response = contextvars.ContextVar[_SteerControlResponse | None]( "hermes_t3_steer_control_response", @@ -113,6 +131,10 @@ def __init__(self, config: PlatformConfig): self._active_turns: dict[str, _TurnState] = {} self._approval_requests: dict[str, tuple[str, str]] = {} self._user_input_requests: dict[str, tuple[str, str]] = {} + # Strong references to fire-and-forget tasks. asyncio only holds a weak + # reference to a running task, so without this the GC may collect one + # mid-flight and its exception surfaces as a bare warning. + self._scheduled_tasks: set[asyncio.Task[Any]] = set() type(self)._instances.add(self) @property @@ -166,8 +188,12 @@ async def send( reply_to: str | None = None, metadata: dict[str, Any] | None = None, ) -> SendResult: - del reply_to - captured = self._capture_steer_control_response(chat_id, content) + # `reply_to` is the base adapter's reply anchor. For the inline + # slash-command path it is `_reply_anchor_for_event(event)`, which for + # this platform resolves to the dispatched MessageEvent's `message_id` + # — the steering requestId. That is the only correlation identifier + # `send` receives, so it is the capture discriminator here. + captured = self._capture_steer_control_response(chat_id, content, reply_to) if captured is not None: return captured turn = self._active_turns.get(str(chat_id)) @@ -195,7 +221,11 @@ async def edit_message( metadata: dict[str, Any] | None = None, ) -> SendResult: del metadata - captured = self._capture_steer_control_response(chat_id, content) + # `edit_message` never carries the reply anchor; its correlation + # identifier is the id of the message being edited. Only an edit of a + # message this adapter already reported as captured control traffic is + # control traffic itself. + captured = self._capture_steer_control_response(chat_id, content, message_id) if captured is not None: return captured turn = self._active_turns.get(str(chat_id)) @@ -518,15 +548,29 @@ def _capture_steer_control_response( self, chat_id: str, content: str, + correlation_id: str | None, ) -> SendResult | None: + """Capture only the steering command's own acknowledgement. + + A steer targets a RUNNING turn, so Hermes can legitimately emit + assistant output on the same thread while the steering command is + still awaited. Matching on `chat_id` alone would swallow that output + and drop it from the transcript, so the capture is keyed on the + steering `requestId` the plugin stamped on the dispatched + `MessageEvent` (and, for follow-up edits, on the synthetic control + message id this method returns). Everything else falls through to the + normal assistant-content path. + """ control = _steer_control_response.get() if control is None or control.thread_id != str(chat_id): return None + if correlation_id is None: + return None + correlation = str(correlation_id) + if correlation not in {control.request_id, control.control_message_id}: + return None control.messages.append(str(content)) - return SendResult( - success=True, - message_id=f"t3-steer-control-{control.request_id}", - ) + return SendResult(success=True, message_id=control.control_message_id) async def _interrupt_turn(self, message: dict[str, Any]) -> None: thread_id = str(message["threadId"]) @@ -743,7 +787,7 @@ async def _complete_turn(self, turn: _TurnState) -> None: sessionId=turn.session_id, turnId=turn.turn_id, itemId=turn.generic_activity_id, - itemType="unknown", + itemType=_STATUS_ITEM_TYPE, status="completed", title="Hermes activity", **( @@ -788,7 +832,7 @@ async def _emit_generic_activity(self, turn: _TurnState, detail: str) -> None: sessionId=turn.session_id, turnId=turn.turn_id, itemId=activity_id, - itemType="unknown", + itemType=_STATUS_ITEM_TYPE, status="inProgress", title="Hermes activity", detail=normalized_detail, @@ -937,20 +981,42 @@ async def _handle_connection_state( if reason: logger.warning("T3 gateway offline: %s", reason) - def _schedule(self, coroutine) -> None: + def _schedule(self, coroutine: Coroutine[Any, Any, Any]) -> None: + """Run a coroutine on the adapter's bound loop from any thread. + + Hermes calls the tool hooks from the agent thread, so this is the + boundary back onto the gateway loop. `create_task` is only valid when + the *running* loop is the adapter's own loop — checking merely for "a + loop is running" would schedule onto whichever unrelated loop happens + to be current. Created tasks are held in a strong-reference set (asyncio + only holds a weak one) and their exceptions are logged rather than + surfacing as bare "task exception was never retrieved" warnings. + """ loop = self._event_loop if loop is None or loop.is_closed(): coroutine.close() return - if threading.current_thread() is threading.main_thread(): - try: - asyncio.get_running_loop() - except RuntimeError: - asyncio.run_coroutine_threadsafe(coroutine, loop) - else: - loop.create_task(coroutine) - else: + try: + running_loop: asyncio.AbstractEventLoop | None = asyncio.get_running_loop() + except RuntimeError: + running_loop = None + if running_loop is loop: + task = loop.create_task(coroutine) + self._scheduled_tasks.add(task) + task.add_done_callback(self._finish_scheduled_task) + return + try: asyncio.run_coroutine_threadsafe(coroutine, loop) + except RuntimeError: # loop closed between the check and the submit + coroutine.close() + + def _finish_scheduled_task(self, task: asyncio.Task[Any]) -> None: + self._scheduled_tasks.discard(task) + if task.cancelled(): + return + error = task.exception() + if error is not None: + logger.error("T3 gateway background task failed: %s", error, exc_info=error) def check_requirements() -> bool: diff --git a/integrations/hermes-t3-gateway/install.sh b/integrations/hermes-t3-gateway/install.sh new file mode 100755 index 00000000000..ac7f6cdabef --- /dev/null +++ b/integrations/hermes-t3-gateway/install.sh @@ -0,0 +1,80 @@ +#!/bin/sh +# Install the T3 Code gateway plugin into the active Hermes profile. +# +# Symlinks this directory into "$HERMES_HOME/plugins/hermes-t3-gateway" (the +# documented user-plugin path, hermes_cli/plugins.py:10) and enables it. +# Safe to re-run: an existing correct symlink is left alone, and enabling an +# already-enabled plugin is a no-op in Hermes. + +set -eu + +PLUGIN_NAME="hermes-t3-gateway" +SOURCE_DIR=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd -P) +HERMES_HOME_DIR=${HERMES_HOME:-"$HOME/.hermes"} +PLUGINS_DIR="$HERMES_HOME_DIR/plugins" +TARGET="$PLUGINS_DIR/$PLUGIN_NAME" + +if ! command -v hermes >/dev/null 2>&1; then + cat >&2 < $SOURCE_DIR" + else + echo "• Relinking $TARGET (was -> $CURRENT)" + rm -f "$TARGET" + ln -s "$SOURCE_DIR" "$TARGET" + fi +elif [ -e "$TARGET" ]; then + cat >&2 < $SOURCE_DIR" +fi + +echo "• Enabling $PLUGIN_NAME" +# --no-allow-tool-override: this plugin registers a platform adapter and two +# observer hooks. It never replaces a built-in tool, and passing the flag keeps +# the run non-interactive instead of stopping on the consent prompt. +hermes plugins enable "$PLUGIN_NAME" --no-allow-tool-override + +cat < --token + + 2. Restart the gateway so it picks up the new connection: + + hermes gateway restart + + Verify anytime with: hermes t3 status +EOF diff --git a/integrations/hermes-t3-gateway/protocol.py b/integrations/hermes-t3-gateway/protocol.py index a2a80b54304..f86d8583cf3 100644 --- a/integrations/hermes-t3-gateway/protocol.py +++ b/integrations/hermes-t3-gateway/protocol.py @@ -55,13 +55,40 @@ def frame(frame_type: str, **payload: Any) -> dict[str, Any]: } +def configured_model() -> str | None: + """Return Hermes' configured default model, or None when unavailable. + + Reads `hermes_cli.config.load_config_readonly()`, the documented read-only + accessor. That function returns the *shared, process-wide cached* config + dict, so nothing here mutates it or hands a nested structure to a caller + that might: only a trimmed string copy of `model.default` leaves this + function. + + Every failure mode — no Hermes on the path, an older Hermes without the + accessor, a config with no model section — degrades to None so the field is + omitted from the handshake rather than sent as null or empty. + """ + try: + from hermes_cli.config import load_config_readonly + + model = load_config_readonly().get("model", {}).get("default") + except Exception: # noqa: BLE001 - model reporting must never break the handshake + return None + if not isinstance(model, str): + return None + trimmed = model.strip() + return trimmed or None + + def connection_hello( *, hermes_version: str, authentication: dict[str, str], hello_request_id: str | None = None, + model: str | None = None, ) -> dict[str, Any]: - return { + resolved_model = model if model is not None else configured_model() + hello: dict[str, Any] = { "type": "connection.hello", "requestId": hello_request_id or request_id(), "protocolVersion": PROTOCOL_VERSION, @@ -70,6 +97,11 @@ def connection_hello( "capabilities": dict(CAPABILITIES), "authentication": authentication, } + # Optional on the wire: omit entirely rather than send null/empty so a + # server that has the field still falls back to its generic label. + if resolved_model: + hello["model"] = resolved_model + return hello def protocol_error( diff --git a/integrations/hermes-t3-gateway/tests/test_adapter.py b/integrations/hermes-t3-gateway/tests/test_adapter.py index 9282e467311..c33f8353294 100644 --- a/integrations/hermes-t3-gateway/tests/test_adapter.py +++ b/integrations/hermes-t3-gateway/tests/test_adapter.py @@ -8,6 +8,7 @@ import sys import types import unittest +import unittest.mock ROOT = pathlib.Path(__file__).resolve().parents[1] PACKAGE = "hermes_t3_gateway_adapter_test" @@ -77,14 +78,18 @@ async def handle_message(self, event): and event.text.startswith("/steer ") ): # Faithful model of Hermes BasePlatformAdapter's active-command - # path: the gateway handler returns a control acknowledgement, - # then BasePlatformAdapter sends it through the platform adapter - # with notify=True. + # bypass path (gateway/platforms/base.py ~4926 at upstream + # 62e07223): the gateway handler returns a control + # acknowledgement, then the base adapter sends it through the + # platform adapter with `reply_to=_reply_anchor_for_event(event)` + # — which, for a platform with no thread_id, is the dispatched + # event's own message_id — and notify=True metadata. response = await self._message_handler(event) if response: await self.send( event.source.chat_id, response, + reply_to=event.message_id, metadata={"notify": True}, ) @@ -398,6 +403,35 @@ async def fail_activity_start(message): self.assertEqual(updated["itemId"], started["itemId"]) self.assertEqual(updated["detail"], "Running tests") + async def test_live_status_uses_status_text_not_the_unknown_sentinel(self): + await self._start_turn("thread-status-type", "turn-status-type") + turn = self.adapter._active_turns["thread-status-type"] + + await self.adapter._emit_generic_activity(turn, "Reading repository") + await self.adapter._emit_generic_activity(turn, "Running tests") + await self.adapter._complete_turn(turn) + + status_frames = [ + message + for message in self.connection.messages + if message.get("itemId") == turn.generic_activity_id + ] + self.assertEqual( + [message["type"] for message in status_frames], + ["item.started", "item.updated", "item.completed"], + ) + # `unknown` is the canonical "could not classify" sentinel other + # adapters rely on being inert; status lines get their own type. + self.assertEqual( + {message["itemType"] for message in status_frames}, + {"status_text"}, + ) + # T3 renders these rows preferring `detail`, so the real status string + # must ride there rather than only in `title`. + self.assertEqual(status_frames[0]["detail"], "Reading repository") + self.assertEqual(status_frames[1]["detail"], "Running tests") + self.assertEqual(status_frames[2]["detail"], "Running tests") + async def test_concurrent_generic_activity_updates_share_one_lifecycle(self): await self._start_turn("thread-activity-concurrent", "turn-activity-concurrent") turn = self.adapter._active_turns["thread-activity-concurrent"] @@ -482,6 +516,23 @@ async def block_activity_update(message): ) self.assertNotIn("thread-activity-complete", self.adapter._active_turns) + def test_home_channel_notice_literal_matches_hermes_construction(self): + # Hermes builds this notice inline from an f-string rather than + # exporting a constant (gateway/run.py:13780 at upstream 62e07223), and + # the adapter suppresses it by exact string equality. Reconstruct it the + # same way so upstream wording drift fails here loudly instead of + # leaking the notice into a T3 transcript. + platform_name = "t3" # Platform("t3").value + sethome_cmd = "/sethome" # non-Slack branch + expected = ( + f"📬 No home channel is set for {platform_name.title()}. " + f"A home channel is where Hermes delivers cron job results " + f"and cross-platform messages.\n\n" + f"Type {sethome_cmd} to make this chat your home channel, " + f"or ignore to skip." + ) + self.assertEqual(adapter_module._T3_HOME_CHANNEL_NOTICE, expected) + async def test_exact_t3_home_channel_notice_is_suppressed(self): await self._start_turn("thread-notice", "turn-notice") content_start = len(self.connection.messages) @@ -681,6 +732,90 @@ async def accept_steer(_event): self.assertEqual(deltas, ["Actual response after steering"]) self.assertNotIn("thread-2", self.adapter._active_turns) + async def test_assistant_output_during_a_steer_is_not_captured_as_control(self): + session_id = await self._start_turn("thread-steer-race", "turn-steer-race") + messages_before_steer = len(self.connection.messages) + + async def stream_while_steering(event): + # A steer targets a RUNNING turn, so Hermes can emit genuine + # assistant output on this same thread while the steering command + # is still being awaited. That output must reach the transcript. + await self.adapter.edit_message( + "thread-steer-race", + "hermes-stream-message", + "Mid-steer assistant output", + ) + del event + return "⏩ Steer queued — arrives after the next tool call: 'Focus'" + + self.adapter._message_handler = stream_while_steering + await self.adapter._handle_server_frame( + { + "type": "turn.steer", + "protocolVersion": 2, + "requestId": "steer-race", + "threadId": "thread-steer-race", + "sessionId": session_id, + "turnId": "turn-steer-race", + "text": "Focus", + } + ) + + steer_messages = self.connection.messages[messages_before_steer:] + self.assertEqual( + [message["type"] for message in steer_messages], + ["item.started", "content.delta", "turn.started"], + ) + self.assertEqual(steer_messages[1]["delta"], "Mid-steer assistant output") + # The acknowledgement itself is still captured and suppressed, so the + # steer is acknowledged rather than failing closed on the prefix check. + self.assertEqual(steer_messages[2]["requestId"], "steer-race") + self.assertIn("thread-steer-race", self.adapter._active_turns) + self.assertEqual( + self.adapter._active_turns["thread-steer-race"].visible_text, + "Mid-steer assistant output", + ) + + async def test_steer_control_acknowledgement_edits_stay_suppressed(self): + session_id = await self._start_turn("thread-steer-edit", "turn-steer-edit") + messages_before_steer = len(self.connection.messages) + acknowledgement = "⏩ Steer queued — arrives after the next tool call: 'Focus'" + + async def edit_own_acknowledgement(event): + sent = await self.adapter.send( + "thread-steer-edit", + acknowledgement, + reply_to=event.message_id, + metadata={"notify": True}, + ) + # A retry/finalize edit of the control message correlates by the + # synthetic control message id, so it stays out of the transcript. + await self.adapter.edit_message( + "thread-steer-edit", + sent.message_id, + acknowledgement, + finalize=True, + ) + + self.adapter._message_handler = edit_own_acknowledgement + await self.adapter._handle_server_frame( + { + "type": "turn.steer", + "protocolVersion": 2, + "requestId": "steer-edit", + "threadId": "thread-steer-edit", + "sessionId": session_id, + "turnId": "turn-steer-edit", + "text": "Focus", + } + ) + + steer_messages = self.connection.messages[messages_before_steer:] + self.assertEqual( + [message["type"] for message in steer_messages], ["turn.started"] + ) + self.assertIn("thread-steer-edit", self.adapter._active_turns) + async def test_rejected_steer_emits_error_without_completing_active_turn(self): await self.adapter._handle_server_frame( { @@ -784,6 +919,73 @@ async def fail_steer(_event): self.assertEqual(steer_messages[0]["code"], "internal-error") self.assertIn("thread-failed-steer", self.adapter._active_turns) + async def test_schedule_keeps_a_strong_reference_until_the_task_finishes(self): + self.adapter._event_loop = asyncio.get_running_loop() + released = asyncio.Event() + + async def work(): + await asyncio.sleep(0) + released.set() + + self.adapter._schedule(work()) + self.assertEqual(len(self.adapter._scheduled_tasks), 1) + await released.wait() + await asyncio.sleep(0) + self.assertEqual(self.adapter._scheduled_tasks, set()) + + async def test_schedule_logs_background_task_failures(self): + self.adapter._event_loop = asyncio.get_running_loop() + + async def boom(): + raise RuntimeError("background frame failed") + + with self.assertLogs(adapter_module.logger, level="ERROR") as captured: + self.adapter._schedule(boom()) + await asyncio.sleep(0) + await asyncio.sleep(0) + self.assertTrue( + any("background frame failed" in line for line in captured.output) + ) + self.assertEqual(self.adapter._scheduled_tasks, set()) + + async def test_schedule_does_not_create_tasks_on_a_foreign_loop(self): + other_loop = asyncio.new_event_loop() + self.adapter._event_loop = other_loop + + async def work(): + return None + + coroutine = work() + try: + with ( + unittest.mock.patch.object(other_loop, "create_task") as create_task, + unittest.mock.patch.object( + adapter_module.asyncio, "run_coroutine_threadsafe" + ) as threadsafe, + ): + # The running loop is this test's loop, not the adapter's, so + # create_task would schedule onto the wrong loop entirely. + self.adapter._schedule(coroutine) + create_task.assert_not_called() + threadsafe.assert_called_once_with(coroutine, other_loop) + self.assertEqual(self.adapter._scheduled_tasks, set()) + finally: + coroutine.close() + other_loop.close() + + async def test_schedule_closes_the_coroutine_when_the_loop_is_gone(self): + self.adapter._event_loop = None + started = False + + async def work(): + nonlocal started + started = True + + coroutine = work() + self.adapter._schedule(coroutine) + self.assertFalse(started) + self.assertEqual(self.adapter._scheduled_tasks, set()) + async def test_session_status_counts_ready_sessions_and_stop_decrements(self): await self.adapter._handle_server_frame( { diff --git a/integrations/hermes-t3-gateway/tests/test_protocol.py b/integrations/hermes-t3-gateway/tests/test_protocol.py index cacf8af3f4b..ac020484917 100644 --- a/integrations/hermes-t3-gateway/tests/test_protocol.py +++ b/integrations/hermes-t3-gateway/tests/test_protocol.py @@ -2,7 +2,10 @@ import importlib.util import pathlib +import sys +import types import unittest +from contextlib import contextmanager ROOT = pathlib.Path(__file__).resolve().parents[1] SPEC = importlib.util.spec_from_file_location( @@ -13,18 +16,87 @@ SPEC.loader.exec_module(protocol) +@contextmanager +def fake_hermes_config(loader): + """Install a stand-in `hermes_cli.config` for the duration of a test.""" + saved = { + name: sys.modules.get(name) for name in ("hermes_cli", "hermes_cli.config") + } + package = types.ModuleType("hermes_cli") + package.__path__ = [] + config = types.ModuleType("hermes_cli.config") + if loader is not None: + config.load_config_readonly = loader + package.config = config + sys.modules["hermes_cli"] = package + sys.modules["hermes_cli.config"] = config + try: + yield + finally: + for name, module in saved.items(): + if module is None: + sys.modules.pop(name, None) + else: + sys.modules[name] = module + + class ProtocolTests(unittest.TestCase): def test_hello_matches_v2_contract(self): hello = protocol.connection_hello( hermes_version="0.19.0", authentication={"type": "enrollment-token", "token": "once"}, hello_request_id="request-1", + model="gpt-5.6-terra", ) self.assertEqual(hello["type"], "connection.hello") self.assertEqual(hello["requestId"], "request-1") self.assertEqual(hello["protocolVersion"], 2) self.assertFalse(hello["capabilities"]["attachments"]) self.assertTrue(hello["capabilities"]["streaming"]) + self.assertEqual(hello["model"], "gpt-5.6-terra") + + def test_hello_reports_the_configured_hermes_model(self): + config = {"model": {"default": "gpt-5.6-terra"}} + + with fake_hermes_config(lambda: config): + hello = protocol.connection_hello( + hermes_version="0.19.0", + authentication={"type": "enrollment-token", "token": "once"}, + ) + + self.assertEqual(hello["model"], "gpt-5.6-terra") + # `load_config_readonly` returns the shared process-wide cache; the + # lookup must never mutate it. + self.assertEqual(config, {"model": {"default": "gpt-5.6-terra"}}) + + def test_hello_omits_model_when_hermes_cannot_report_one(self): + def missing_section(): + return {"agent": {}} + + def older_hermes(): + raise ImportError("load_config_readonly is unavailable") + + for loader in (missing_section, older_hermes, None): + with self.subTest(loader=getattr(loader, "__name__", "absent")): + with fake_hermes_config(loader): + hello = protocol.connection_hello( + hermes_version="0.19.0", + authentication={ + "type": "enrollment-token", + "token": "once", + }, + ) + # Omitted entirely — never null or empty. + self.assertNotIn("model", hello) + + def test_configured_model_ignores_blank_and_non_string_values(self): + for value in ("", " ", None, 5, {"default": "nested"}): + config = {"model": {"default": value}} + with ( + self.subTest(value=value), + fake_hermes_config(lambda config=config: config), + ): + self.assertIsNone(protocol.configured_model()) def test_server_frame_validation_is_closed(self): with self.assertRaisesRegex(ValueError, "unsupported"): From 3261623f90e674f7c469cc1faff02bf9f33cf8b8 Mon Sep 17 00:00:00 2001 From: Ben Davis <45952064+bmdavis419@users.noreply.github.com> Date: Fri, 24 Jul 2026 22:40:31 -0700 Subject: [PATCH 11/68] fix: make Hermes pairing recoverable and self-updating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A failed enrollment left an orphaned provider instance in the picker, permanently `instance-not-found`, with no explanation and no way to clear it except editing settings by hand. Enrolling before persisting is not possible: `createEnrollment` reads `providerInstances[instanceId]` and fails unless the instance is already persisted with the Hermes driver, so the settings write is a hard precondition of the RPC. The failure path therefore cleans up after itself. The write registers a pending undo in a ref rather than closure state, and two effects drain it — the existing `!open` reset and a new unmount cleanup, since the parent renders this dialog conditionally and closing unmounts it, so the `!open` branch alone would never run. Rollback reads the latest settings map instead of restoring a captured pre-write snapshot, which would silently revert instances another client added in the interim, and no-ops when the entry is already gone or was replaced. The claimed instance id is retained across a rollback with a `persisted: false` flag so the retry path still refuses to mint a duplicate id while knowing it must re-write the instance. Pairing completes out-of-band on another host, so the dialog now shows an explicit waiting state: what to run, that `hermes gateway restart` is required rather than an error, and a ticking expiry with a way to mint a fresh command. It flips to connected on its own through the status stream already published by the broker — no new polling. That live update watches the snapshot's `checkedAt` revision rather than the connected flag, because `upgrade-required` closes the connection and is therefore indistinguishable from "still offline" through connectedness alone; keying on it would leave the panel waiting forever on a plugin that needs updating. An authoritative status re-read then distinguishes upgrade-required and revoked from a plain wait. Also surfaces the install step the flow always required but never mentioned, and stops labelling the terminal button "Done" while pairing is still pending. Co-Authored-By: Claude Opus 5 (1M context) --- .../AddProviderInstanceDialog.logic.ts | 99 ++++- .../AddProviderInstanceDialog.test.ts | 104 +++++ .../settings/AddProviderInstanceDialog.tsx | 390 ++++++++++++++++-- ...HermesGatewayInstanceSection.logic.test.ts | 84 ++++ .../HermesGatewayInstanceSection.logic.ts | 73 +++- 5 files changed, 714 insertions(+), 36 deletions(-) diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.logic.ts b/apps/web/src/components/settings/AddProviderInstanceDialog.logic.ts index bb76ce79112..10030681c7a 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.logic.ts +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.logic.ts @@ -1,4 +1,4 @@ -import type { ProviderDriverKind } from "@t3tools/contracts"; +import type { HermesGatewayConnectionState, ProviderDriverKind } from "@t3tools/contracts"; export type WizardNavigation = | { readonly kind: "navigate"; readonly step: number } @@ -31,6 +31,16 @@ export function createHermesProviderInstanceId(label: string, randomUuid: () => return labelSlug ? `hermes-${labelSlug}-${shortSuffix}` : `hermes-${shortSuffix}`; } +/** + * Command that installs the gateway plugin into the Hermes profile. + * + * Installation is a manual step performed on the Hermes host — T3 has no + * remote access to it — so the dialog documents the command rather than + * implying anything happens automatically. The script symlinks this repository + * checkout into `~/.hermes/plugins` and enables the plugin. + */ +export const HERMES_PLUGIN_INSTALL_COMMAND = "./integrations/hermes-t3-gateway/install.sh" as const; + export function isHermesInstanceRemovedError(error: unknown): boolean { return ( typeof error === "object" && @@ -40,6 +50,30 @@ export function isHermesInstanceRemovedError(error: unknown): boolean { ); } +/** + * Drop an instance the dialog itself just wrote, without clobbering anything + * that changed underneath it. + * + * Used to roll back the settings write when Hermes enrollment fails. The + * server is authoritative between the write and the rollback, so this reads + * the *latest* map rather than restoring a captured pre-write snapshot — that + * would silently revert instances added by another client in between. If the + * id is already gone, or has been replaced by a different config object, the + * write is not ours to undo and `null` signals "no rollback needed". + */ +export function rollbackProviderInstances(input: { + readonly latest: Readonly>; + readonly instanceId: string; + readonly written: T; +}): Record | null { + const current = input.latest[input.instanceId]; + if (current === undefined) return null; + if (current !== input.written) return null; + const next = { ...input.latest }; + delete next[input.instanceId]; + return next; +} + export function isOwnedHermesEnrollmentRetry(input: { readonly driver: ProviderDriverKind; readonly instanceId: string; @@ -52,6 +86,69 @@ export function isOwnedHermesEnrollmentRetry(input: { ); } +/** + * Presentation state of the post-enrollment "waiting for Hermes" panel. + * + * `waiting` is the expected steady state immediately after the command is + * generated — the user still has to paste it and restart the gateway — so it + * is deliberately not modelled as an error. `expired` and `blocked` are the + * two states that need a fresh command, and `connected` is terminal success. + */ +export type HermesWaitingPhase = "waiting" | "connected" | "blocked" | "expired"; + +export interface HermesWaitingState { + readonly phase: HermesWaitingPhase; + readonly statusLabelState: HermesGatewayConnectionState; + /** True while T3 legitimately has nothing to report yet. */ + readonly isPending: boolean; + /** True when the only way forward is minting a replacement command. */ + readonly needsNewCommand: boolean; +} + +/** + * Resolve what the waiting panel should show. + * + * Connection wins over expiry: once the gateway has dialled in, the one-time + * token has already been consumed and its expiry is irrelevant. `revoked` and + * `upgrade-required` are surfaced ahead of the generic wait because neither + * resolves by waiting longer. + */ +export function resolveHermesWaitingState(input: { + readonly connectionState: HermesGatewayConnectionState | null; + readonly isExpired: boolean; +}): HermesWaitingState { + if (input.connectionState === "connected") { + return { + phase: "connected", + statusLabelState: "connected", + isPending: false, + needsNewCommand: false, + }; + } + if (input.connectionState === "upgrade-required" || input.connectionState === "revoked") { + return { + phase: "blocked", + statusLabelState: input.connectionState, + isPending: false, + needsNewCommand: input.connectionState === "revoked", + }; + } + if (input.isExpired) { + return { + phase: "expired", + statusLabelState: input.connectionState ?? "offline", + isPending: false, + needsNewCommand: true, + }; + } + return { + phase: "waiting", + statusLabelState: input.connectionState ?? "offline", + isPending: true, + needsNewCommand: false, + }; +} + export function validateProviderInstanceIdForWizard(input: { readonly driver: ProviderDriverKind; readonly instanceId: string; diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.test.ts b/apps/web/src/components/settings/AddProviderInstanceDialog.test.ts index fda07aa9f47..dfaab9e2dd1 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.test.ts +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.test.ts @@ -6,7 +6,9 @@ import { createHermesProviderInstanceId, isHermesInstanceRemovedError, isOwnedHermesEnrollmentRetry, + resolveHermesWaitingState, resolveWizardNavigation, + rollbackProviderInstances, validateProviderInstanceIdForWizard, } from "./AddProviderInstanceDialog.logic"; @@ -131,3 +133,105 @@ describe("Hermes enrollment retry ownership", () => { ).toContain("already exists"); }); }); + +describe("rollbackProviderInstances", () => { + interface TestInstance { + readonly driver: string; + readonly enabled: boolean; + } + const written: TestInstance = { driver: "hermes", enabled: true }; + const other: TestInstance = { driver: "codex", enabled: true }; + + it("removes only the instance this dialog wrote", () => { + expect( + rollbackProviderInstances({ + latest: { "hermes-a": written, codex_work: other }, + instanceId: "hermes-a", + written, + }), + ).toEqual({ codex_work: other }); + }); + + it("preserves instances another client added between the write and the rollback", () => { + const concurrent: TestInstance = { driver: "claude", enabled: true }; + expect( + rollbackProviderInstances({ + latest: { "hermes-a": written, "added-elsewhere": concurrent }, + instanceId: "hermes-a", + written, + }), + ).toEqual({ "added-elsewhere": concurrent }); + }); + + it("does nothing when the id is already gone or was replaced by a different config", () => { + expect( + rollbackProviderInstances({ latest: { codex_work: other }, instanceId: "hermes-a", written }), + ).toBeNull(); + expect( + rollbackProviderInstances({ + latest: { "hermes-a": { ...written } }, + instanceId: "hermes-a", + written, + }), + ).toBeNull(); + }); +}); + +describe("resolveHermesWaitingState", () => { + it("treats a not-yet-dialled-in gateway as an expected pending wait, not an error", () => { + expect(resolveHermesWaitingState({ connectionState: null, isExpired: false })).toEqual({ + phase: "waiting", + statusLabelState: "offline", + isPending: true, + needsNewCommand: false, + }); + expect(resolveHermesWaitingState({ connectionState: "offline", isExpired: false })).toEqual({ + phase: "waiting", + statusLabelState: "offline", + isPending: true, + needsNewCommand: false, + }); + expect(resolveHermesWaitingState({ connectionState: "connecting", isExpired: false })).toEqual({ + phase: "waiting", + statusLabelState: "connecting", + isPending: true, + needsNewCommand: false, + }); + }); + + it("flips to connected and ignores expiry once the gateway has dialled in", () => { + expect(resolveHermesWaitingState({ connectionState: "connected", isExpired: true })).toEqual({ + phase: "connected", + statusLabelState: "connected", + isPending: false, + needsNewCommand: false, + }); + }); + + it("offers a fresh command when the one-time token expired unused", () => { + expect(resolveHermesWaitingState({ connectionState: "offline", isExpired: true })).toEqual({ + phase: "expired", + statusLabelState: "offline", + isPending: false, + needsNewCommand: true, + }); + }); + + it("surfaces upgrade-required and revoked ahead of waiting, since neither resolves by waiting", () => { + expect( + resolveHermesWaitingState({ connectionState: "upgrade-required", isExpired: false }), + ).toEqual({ + phase: "blocked", + statusLabelState: "upgrade-required", + isPending: false, + // A protocol upgrade needs a new plugin, not a new token. + needsNewCommand: false, + }); + expect(resolveHermesWaitingState({ connectionState: "revoked", isExpired: false })).toEqual({ + phase: "blocked", + statusLabelState: "revoked", + isPending: false, + needsNewCommand: true, + }); + }); +}); diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx index 23e1e5110b3..d86f80db8fe 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx @@ -1,9 +1,11 @@ "use client"; import { Radio as RadioPrimitive } from "@base-ui/react/radio"; -import { CheckIcon, CopyIcon, LoaderIcon } from "lucide-react"; -import { useEffect, useMemo, useState } from "react"; +import { CheckIcon, CopyIcon, LoaderIcon, TriangleAlertIcon } from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useAtomValue } from "@effect/atom-react"; import { + type HermesGatewayConnectionState, type HermesGatewayEnrollmentResult, ProviderInstanceId, ProviderDriverKind, @@ -35,19 +37,26 @@ import { AnimatedHeight } from "../AnimatedHeight"; import { ADD_PROVIDER_WIZARD_STEPS, createHermesProviderInstanceId, + HERMES_PLUGIN_INSTALL_COMMAND, isHermesInstanceRemovedError, isOwnedHermesEnrollmentRetry, + resolveHermesWaitingState, resolveWizardNavigation, + rollbackProviderInstances, type WizardNavigation, validateProviderInstanceIdForWizard, } from "./AddProviderInstanceDialog.logic"; import { AddProviderInstanceWizardSteps } from "./AddProviderInstanceWizardSteps"; import { usePrimaryEnvironment } from "../../state/environments"; -import { serverEnvironment } from "../../state/server"; +import { primaryServerProvidersAtom, serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; import { defaultHermesConnectorUrl, formatHermesLastConnected, + hermesGatewayProviderSignal, + hermesGatewayStatusGuidance, + hermesGatewayStatusLabel, + isHermesEnrollmentExpired, messageFromUnknownError, } from "./HermesGatewayInstanceSection.logic"; @@ -113,6 +122,17 @@ const COMING_SOON_DRIVER_OPTIONS: readonly ComingSoonDriverOption[] = [ }, ]; +const HERMES_BADGE_VARIANT_BY_STATUS: Record< + HermesGatewayConnectionState, + "success" | "warning" | "error" | "secondary" +> = { + connected: "success", + connecting: "warning", + offline: "secondary", + "upgrade-required": "warning", + revoked: "error", +}; + interface AddProviderInstanceDialogProps { open: boolean; onOpenChange: (open: boolean) => void; @@ -128,6 +148,10 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns const createHermesEnrollment = useAtomCommand(serverEnvironment.hermesGatewayCreateEnrollment, { reportFailure: false, }); + const getHermesStatus = useAtomCommand(serverEnvironment.hermesGatewayGetInstanceStatus, { + reportFailure: false, + }); + const serverProviders = useAtomValue(primaryServerProvidersAtom); const [wizardStep, setWizardStep] = useState(0); const [driver, setDriver] = useState(DEFAULT_DRIVER_KIND); @@ -154,7 +178,17 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns const [createdHermesIdentity, setCreatedHermesIdentity] = useState<{ readonly instanceId: string; readonly nickname: string; + /** + * False once a failed enrollment rolled the settings write back. The id + * stays claimed by this dialog session (so validation keeps exempting it + * and no duplicate is ever created), but the next attempt has to write it + * again before the server will accept an enrollment for it. + */ + readonly persisted: boolean; } | null>(null); + const [hermesConnectionState, setHermesConnectionState] = + useState(null); + const [nowMillis, setNowMillis] = useState(() => Date.now()); const { copyToClipboard, isCopied } = useCopyToClipboard({ target: "Hermes enrollment command", onCopy: () => @@ -164,9 +198,53 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns description: "Run it in the terminal where Hermes is installed.", }), }); + const { copyToClipboard: copyInstallCommand, isCopied: isInstallCommandCopied } = + useCopyToClipboard({ + target: "Hermes plugin install command", + onCopy: () => + toastManager.add({ + type: "success", + title: "Install command copied", + description: "Run it from the T3 Code checkout on the Hermes host.", + }), + }); + + // Rollback of a failed Hermes enrollment must survive the dialog closing + // mid-failure, so the pending undo lives in a ref that an unmount-safe + // effect drains rather than in the `handleSave` closure alone. + const pendingRollbackRef = useRef<{ + readonly instanceId: string; + readonly written: ProviderInstanceConfig; + } | null>(null); + const latestProviderInstancesRef = useRef(settings.providerInstances); + latestProviderInstancesRef.current = settings.providerInstances; + const environmentIdRef = useRef(environmentId); + environmentIdRef.current = environmentId; + const updateServerSettingsRef = useRef(updateServerSettings); + updateServerSettingsRef.current = updateServerSettings; + const rollbackPendingHermesInstance = useCallback(async () => { + const pending = pendingRollbackRef.current; + pendingRollbackRef.current = null; + const rollbackEnvironmentId = environmentIdRef.current; + if (pending === null || rollbackEnvironmentId === null) return; + const providerInstances = rollbackProviderInstances({ + latest: latestProviderInstancesRef.current ?? {}, + instanceId: pending.instanceId, + written: pending.written, + }); + if (providerInstances === null) return; + await updateServerSettingsRef.current({ + environmentId: rollbackEnvironmentId, + input: { patch: { providerInstances } }, + }); + }, []); + + // Fires both when the dialog is closed and when it is unmounted outright + // (its parent renders it conditionally), so an orphan is never left behind. useEffect(() => { if (open) return; + void rollbackPendingHermesInstance(); setWizardStep(0); setDriver(DEFAULT_DRIVER_KIND); setLabel(""); @@ -184,7 +262,70 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns setSaveError(null); setIsSaving(false); setCreatedHermesIdentity(null); - }, [open]); + setHermesConnectionState(null); + }, [open, rollbackPendingHermesInstance]); + + useEffect( + () => () => { + void rollbackPendingHermesInstance(); + }, + [rollbackPendingHermesInstance], + ); + + const enrolledInstanceId = hermesEnrollment?.instanceId ?? null; + // The server pushes a fresh `ServerProvider` snapshot over the already-open + // `subscribeServerConfig` stream every time the broker publishes a gateway + // status change, so this flips the moment Hermes dials in — no polling. + const hermesProviderSignal = useMemo( + () => + enrolledInstanceId === null + ? null + : hermesGatewayProviderSignal(serverProviders, enrolledInstanceId), + [serverProviders, enrolledInstanceId], + ); + const hermesProviderSignalKey = hermesProviderSignal + ? `${hermesProviderSignal.present}:${hermesProviderSignal.connected}:${hermesProviderSignal.revision ?? ""}` + : null; + + // The pushed snapshot only tells us connected/not. Resolve the authoritative + // `HermesGatewayConnectionState` once per change so `upgrade-required` and + // `revoked` are distinguishable from a plain wait. + useEffect(() => { + if (enrolledInstanceId === null || environmentId === null) return; + if (hermesProviderSignalKey === null) return; + let cancelled = false; + void (async () => { + const result = await getHermesStatus({ + environmentId, + input: { instanceId: ProviderInstanceId.make(enrolledInstanceId) }, + }); + if (cancelled) return; + if (result._tag === "Success") setHermesConnectionState(result.value.status); + })(); + return () => { + cancelled = true; + }; + }, [enrolledInstanceId, environmentId, getHermesStatus, hermesProviderSignalKey]); + + // Re-evaluate the enrollment expiry on a coarse tick so the panel switches + // itself to the "mint a new command" affordance without a manual refresh. + useEffect(() => { + if (hermesEnrollment === null) return; + setNowMillis(Date.now()); + const interval = window.setInterval(() => setNowMillis(Date.now()), 15_000); + return () => window.clearInterval(interval); + }, [hermesEnrollment]); + + const hermesWaitingState = useMemo( + () => + hermesEnrollment === null + ? null + : resolveHermesWaitingState({ + connectionState: hermesConnectionState, + isExpired: isHermesEnrollmentExpired(hermesEnrollment.expiresAt, nowMillis), + }), + [hermesEnrollment, hermesConnectionState, nowMillis], + ); const existingIds = useMemo( () => new Set(Object.keys(settings.providerInstances ?? {})), @@ -261,11 +402,15 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns // keeps the type boundary honest and guards against any future drift in // the slug rules. const brandedId = ProviderInstanceId.make(instanceId); - const isOwnedHermesRetry = isOwnedHermesEnrollmentRetry({ - driver, - instanceId, - createdHermesInstanceId: createdHermesIdentity?.instanceId ?? null, - }); + // Only skip the settings write when the instance this dialog created is + // still persisted. After a rolled-back failure the id is still ours, but + // it has to be written again before the server will enroll it. + const isOwnedHermesRetry = + isOwnedHermesEnrollmentRetry({ + driver, + instanceId, + createdHermesInstanceId: createdHermesIdentity?.instanceId ?? null, + }) && createdHermesIdentity?.persisted === true; const nextMap = { ...settings.providerInstances, [brandedId]: nextInstance, @@ -277,6 +422,12 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns if (environmentId === null) { throw new Error("Connect this browser to a T3 server before pairing Hermes."); } + // The server rejects `create-enrollment` unless the instance already + // exists in `providerInstances` with the Hermes driver, so the write + // genuinely has to come first. Instead of enroll-first, the write is + // registered as a pending rollback that the failure path — and the + // close/unmount effects — undo, so a failed enrollment never leaves a + // visible `instance-not-found` orphan in the model picker. if (!isOwnedHermesRetry) { const settingsResult = await updateServerSettings({ environmentId, @@ -285,9 +436,11 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns if (settingsResult._tag === "Failure") { throw squashAtomCommandFailure(settingsResult); } + pendingRollbackRef.current = { instanceId, written: nextInstance }; setCreatedHermesIdentity({ instanceId, nickname: label.trim(), + persisted: true, }); } const enrollmentResult = await createHermesEnrollment({ @@ -300,12 +453,24 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns }); if (enrollmentResult._tag === "Failure") { const enrollmentError = squashAtomCommandFailure(enrollmentResult); + // The server already deleted the tombstoned instance itself, so + // there is nothing left for us to roll back — just rotate the nonce + // so the next attempt uses a fresh, untainted id. if (isHermesInstanceRemovedError(enrollmentError)) { + pendingRollbackRef.current = null; setCreatedHermesIdentity(null); setHermesIdentityNonce(randomUUID()); + } else { + await rollbackPendingHermesInstance(); + setCreatedHermesIdentity((current) => + current ? { ...current, persisted: false } : current, + ); } throw enrollmentError; } + // Enrollment succeeded: the instance is legitimately persisted now. + pendingRollbackRef.current = null; + setHermesConnectionState(null); setHermesEnrollment(enrollmentResult.value); toastManager.add({ type: "success", @@ -336,6 +501,50 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns } }; + /** + * Mint a replacement one-time command for the instance already persisted by + * this dialog session. The instance itself is untouched, so there is nothing + * to roll back here — only the expired/revoked token is replaced. + */ + const handleRegenerateEnrollment = async () => { + const identity = createdHermesIdentity; + if (identity === null || environmentId === null) return; + try { + setIsSaving(true); + setSaveError(null); + const enrollmentResult = await createHermesEnrollment({ + environmentId, + input: { + instanceId: ProviderInstanceId.make(identity.instanceId), + nickname: identity.nickname, + connectorUrl: hermesConnectorUrl, + }, + }); + if (enrollmentResult._tag === "Failure") { + const enrollmentError = squashAtomCommandFailure(enrollmentResult); + if (isHermesInstanceRemovedError(enrollmentError)) { + setCreatedHermesIdentity(null); + setHermesIdentityNonce(randomUUID()); + setHermesEnrollment(null); + } + throw enrollmentError; + } + setHermesConnectionState(null); + setHermesEnrollment(enrollmentResult.value); + setNowMillis(Date.now()); + } catch (error) { + const message = messageFromUnknownError(error); + setSaveError(message); + toastManager.add({ + type: "error", + title: "Could not create a new Hermes command", + description: message, + }); + } finally { + setIsSaving(false); + } + }; + return ( @@ -360,29 +569,137 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns data-slot="dialog-panel" className="space-y-4 bg-zinc-25/80 px-6 py-5 ring-1 ring-black/5 dark:bg-white/2 dark:ring-white/5" > - {hermesEnrollment ? ( -
-
-

Connect Hermes

-

- This one-time command expires{" "} - {formatHermesLastConnected(hermesEnrollment.expiresAt)}. -

-
-
- - {hermesEnrollment.command} - - + {hermesWaitingState.isPending ? ( + + ) : hermesWaitingState.phase === "connected" ? ( + + ) : null} + {hermesGatewayStatusLabel(hermesWaitingState.statusLabelState)} +
+ + {hermesWaitingState.phase === "connected" ? ( +

+ The gateway dialled in and its persistent credential is stored on the Hermes + host. You can close this dialog and start a thread against it. +

+ ) : ( +
    +
  1. +

    + 1. Install the plugin on the Hermes host +

    +

    + Run this from your T3 Code checkout on the machine running Hermes. It + symlinks the plugin into ~/.hermes/plugins and enables it. T3 + Code cannot do this for you — skip it only if you already installed the + plugin there. +

    +
    + + {HERMES_PLUGIN_INSTALL_COMMAND} + + +
    +
  2. + +
  3. +

    + 2. Run the one-time connect command +

    + {hermesWaitingState.phase === "expired" ? ( +

    + + + This command expired{" "} + {formatHermesLastConnected(hermesEnrollment.expiresAt)}. Generate a new + one below — the instance itself is unchanged. + +

    + ) : ( +

    + Expires {formatHermesLastConnected(hermesEnrollment.expiresAt)}. +

    + )} +
    + + {hermesEnrollment.command} + + +
    +
  4. + +
  5. +

    + 3. Restart the gateway on that host +

    +

    + Run hermes gateway restart. This step is required — the plugin + only dials T3 Code after the restart, so nothing is wrong while this panel + still says {hermesGatewayStatusLabel(hermesWaitingState.statusLabelState)}. + This page updates itself the moment Hermes connects. +

    +
  6. +
+ )} + + {hermesWaitingState.needsNewCommand ? ( +
+ +
+ ) : null} + + {saveError ?

{saveError}

: null} +

The persistent gateway credential is delivered directly to the plugin and is never shown in T3 Code. @@ -577,8 +894,9 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns {saveError ?

{saveError}

: null} {createdHermesIdentity ? (

- The Hermes instance was added. Retry enrollment for this exact instance, or - close this dialog and finish pairing from its Settings card. + {createdHermesIdentity.persisted + ? "The Hermes instance was added. Retry enrollment for this exact instance, or close this dialog and finish pairing from its Settings card." + : "Nothing was left behind — the half-created instance was removed. Retry to create it again with the same identity."}

) : null} @@ -587,8 +905,12 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns {hermesEnrollment ? ( - ) : ( +
+

+ Without an installed service this runs in the foreground and keeps the + terminal — that is expected. Use hermes gateway install to run + it as a background service instead.

diff --git a/apps/web/src/hooks/useCopyToClipboard.test.ts b/apps/web/src/hooks/useCopyToClipboard.test.ts index ccac333cd48..8d3194363f2 100644 --- a/apps/web/src/hooks/useCopyToClipboard.test.ts +++ b/apps/web/src/hooks/useCopyToClipboard.test.ts @@ -55,4 +55,72 @@ describe("writeTextToClipboard", () => { await expect(writeTextToClipboard("", "plan")).resolves.toBe(false); expect(writeText).not.toHaveBeenCalled(); }); + + // `navigator.clipboard` only exists in a secure context, so it is undefined + // over plain http — which is how T3 is reached on a LAN or Tailscale host. + // Without the legacy fallback every copy button on such an origin silently + // does nothing, including the one handing over a pairing command. + it("falls back to execCommand in an insecure context", async () => { + const execCommand = vi.fn(() => true); + vi.stubGlobal("window", {}); + vi.stubGlobal("navigator", {}); + const appended: Array> = []; + const makeElement = () => { + const element: Record = { + style: {}, + value: "", + setAttribute: () => {}, + select: () => {}, + setSelectionRange: () => {}, + remove: () => {}, + }; + return element; + }; + vi.stubGlobal("document", { + createElement: () => { + const element = makeElement(); + appended.push(element); + return element; + }, + body: { appendChild: () => {} }, + activeElement: null, + execCommand, + }); + + await expect(writeTextToClipboard("hermes t3 connect --url x", "command")).resolves.toBe(true); + expect(execCommand).toHaveBeenCalledWith("copy"); + }); + + it("falls back to execCommand when a secure-context write is rejected", async () => { + const writeText = vi.fn(() => Promise.reject(new Error("denied"))); + const execCommand = vi.fn(() => true); + vi.stubGlobal("window", {}); + vi.stubGlobal("navigator", { clipboard: { writeText } }); + const appended: Array> = []; + const makeElement = () => { + const element: Record = { + style: {}, + value: "", + setAttribute: () => {}, + select: () => {}, + setSelectionRange: () => {}, + remove: () => {}, + }; + return element; + }; + vi.stubGlobal("document", { + createElement: () => { + const element = makeElement(); + appended.push(element); + return element; + }, + body: { appendChild: () => {} }, + activeElement: null, + execCommand, + }); + + await expect(writeTextToClipboard("value", "command")).resolves.toBe(true); + expect(writeText).toHaveBeenCalled(); + expect(execCommand).toHaveBeenCalledWith("copy"); + }); }); diff --git a/apps/web/src/hooks/useCopyToClipboard.ts b/apps/web/src/hooks/useCopyToClipboard.ts index 0129f2d6593..7119d97bb1b 100644 --- a/apps/web/src/hooks/useCopyToClipboard.ts +++ b/apps/web/src/hooks/useCopyToClipboard.ts @@ -24,12 +24,51 @@ export class ClipboardWriteError extends Schema.TaggedErrorClass
diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index aa7547c8ba6..2a6571d190f 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -131,6 +131,7 @@ import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore" import { buildSidebarProjectPickerEntries, buildSidebarProjectSnapshots, + selectProjectsOfKind, } from "../sidebarProjectGrouping"; const EMPTY_BROWSE_ENTRIES: FilesystemBrowseResult["entries"] = []; @@ -521,10 +522,16 @@ function OpenCommandPaletteDialog(props: { ), [environments], ); + // Everything in the palette that *offers* a project — the project search + // list, "new thread in …", the add-project dedupe — works from this list. + // Agent projects are synthetic and have no workspace to open, browse, or + // add. Id-keyed lookup maps below still read the unfiltered list: they + // resolve a project the user is already in, they do not offer one. + const workspaceProjects = useMemo(() => selectProjectsOfKind(projects, "workspace"), [projects]); const orderedProjects = useMemo( () => orderItemsByPreferredIds({ - items: projects, + items: workspaceProjects, preferredIds: projectOrder, getId: getProjectOrderKey, getPreferenceIds: (project) => [ @@ -532,12 +539,13 @@ function OpenCommandPaletteDialog(props: { legacyProjectCwdPreferenceKey(project.workspaceRoot), ], }), - [projectOrder, projects], + [projectOrder, workspaceProjects], ); const unsortedProjectGroups = useMemo( () => buildSidebarProjectSnapshots({ - projects: clientSettings.sidebarProjectSortOrder === "manual" ? orderedProjects : projects, + projects: + clientSettings.sidebarProjectSortOrder === "manual" ? orderedProjects : workspaceProjects, settings: projectGroupingSettings, primaryEnvironmentId, resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null, @@ -548,7 +556,7 @@ function OpenCommandPaletteDialog(props: { orderedProjects, primaryEnvironmentId, projectGroupingSettings, - projects, + workspaceProjects, ], ); const projectGroups = useMemo( @@ -1147,7 +1155,7 @@ function OpenCommandPaletteDialog(props: { const actionItems: Array = []; - if (projects.length > 0) { + if (workspaceProjects.length > 0) { const activeProjectTitle = projectPickerEntries.find((entry) => entry.isPreferred)?.group.displayName ?? (currentProjectId ? (projectTitleById.get(currentProjectId) ?? null) : null); @@ -1297,7 +1305,7 @@ function OpenCommandPaletteDialog(props: { if (cwd.length === 0) return; const existing = findProjectByPath( - projects.filter((project) => project.environmentId === input.environmentId), + workspaceProjects.filter((project) => project.environmentId === input.environmentId), cwd, ); if (existing) { @@ -1387,7 +1395,7 @@ function OpenCommandPaletteDialog(props: { environments, navigate, primaryEnvironmentId, - projects, + workspaceProjects, providers, setOpen, clientSettings.sidebarThreadSortOrder, diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 59784bf8fac..703f0bb2dea 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -1,8 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { archiveSelectedThreadEntries, + buildAgentSidebarEntries, buildMultiSelectThreadContextMenuItems, createThreadJumpHintVisibilityController, + formatAgentSidebarLabel, + resolveAgentStatusKey, + type AgentSidebarProjectInput, getSidebarThreadIdsToPrewarm, getVisibleSidebarThreadIds, resolveAdjacentThreadId, @@ -34,6 +38,7 @@ import { OrchestrationLatestTurn, ProjectId, ProviderInstanceId, + type ServerProvider, ThreadId, } from "@t3tools/contracts"; @@ -1057,6 +1062,7 @@ function makeProject(overrides: Partial = {}): Project { createdAt: "2026-03-09T10:00:00.000Z", updatedAt: "2026-03-09T10:00:00.000Z", scripts: [], + agentInstanceId: null, ...rest, }; } @@ -1424,3 +1430,123 @@ describe("sortLogicalProjectsForSidebar", () => { ).toEqual(["logical-newer", "logical-older"]); }); }); + +describe("agents sidebar section", () => { + const instanceId = ProviderInstanceId.make("hermes_workstation"); + + function makeServerProvider(overrides: Partial = {}): ServerProvider { + return { + instanceId, + driver: "hermes", + displayName: "Hermes · Workstation", + enabled: true, + installed: true, + version: null, + status: "ready", + auth: { status: "authenticated", type: "gateway" }, + checkedAt: "2026-03-09T10:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + ...overrides, + } as ServerProvider; + } + + function makeAgentProject(overrides: Partial = {}) { + return { + agentInstanceId: instanceId, + title: "Workstation", + displayName: "Workstation", + ...overrides, + }; + } + + it("maps a connected instance to the ready dot", () => { + expect(resolveAgentStatusKey(makeServerProvider())).toBe("ready"); + }); + + it("treats a disabled instance as disabled regardless of probe status", () => { + expect(resolveAgentStatusKey(makeServerProvider({ enabled: false, status: "ready" }))).toBe( + "disabled", + ); + }); + + it("treats an offline gateway's warning probe as a warning dot", () => { + expect(resolveAgentStatusKey(makeServerProvider({ status: "warning" }))).toBe("warning"); + }); + + it("treats a missing snapshot as a warning, not as disabled", () => { + // An enrolled agent whose provider entry has not arrived yet is + // "check on this", never "you turned this off". + expect(resolveAgentStatusKey(undefined)).toBe("warning"); + }); + + it("strips the Hermes brand prefix from the row label", () => { + expect( + formatAgentSidebarLabel({ + providerDisplayName: "Hermes · Workstation", + projectTitle: "Workstation", + instanceId, + }), + ).toBe("Workstation"); + }); + + it("falls back to the project title, then the instance id, when no provider name exists", () => { + expect( + formatAgentSidebarLabel({ + providerDisplayName: undefined, + projectTitle: "Workstation", + instanceId, + }), + ).toBe("Workstation"); + expect( + formatAgentSidebarLabel({ + providerDisplayName: "Hermes · ", + projectTitle: " ", + instanceId, + }), + ).toBe("hermes_workstation"); + }); + + it("builds one entry per agent project, label-sorted and status-resolved", () => { + const secondInstanceId = ProviderInstanceId.make("hermes_laptop"); + const entries = buildAgentSidebarEntries({ + projects: [ + makeAgentProject(), + makeAgentProject({ + agentInstanceId: secondInstanceId, + title: "Laptop", + displayName: "Laptop", + }), + ], + providerByInstanceId: new Map([ + [String(instanceId), makeServerProvider()], + [ + String(secondInstanceId), + makeServerProvider({ instanceId: secondInstanceId, status: "warning" }), + ], + ]), + providerDisplayNameByInstanceId: new Map([ + [String(instanceId), "Hermes · Workstation"], + [String(secondInstanceId), "Hermes · Laptop"], + ]), + }); + + expect(entries.map((entry) => entry.label)).toEqual(["Laptop", "Workstation"]); + expect(entries.map((entry) => entry.statusKey)).toEqual(["warning", "ready"]); + expect(entries.map((entry) => entry.statusLabel)).toEqual(["Not connected", "Connected"]); + }); + + it("skips projects that are not agent projects", () => { + const entries = buildAgentSidebarEntries({ + projects: [ + makeAgentProject(), + { agentInstanceId: null, title: "Real project", displayName: "Real project" }, + ], + providerByInstanceId: new Map([[String(instanceId), makeServerProvider()]]), + }); + + expect(entries).toHaveLength(1); + expect(entries[0]?.instanceId).toBe(instanceId); + }); +}); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 7aee3100d0e..9040ae4b62f 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1,6 +1,7 @@ import * as React from "react"; -import type { ContextMenuItem } from "@t3tools/contracts"; +import type { ContextMenuItem, ProviderInstanceId, ServerProvider } from "@t3tools/contracts"; import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; +import type { ProviderStatusKey } from "./settings/providerStatus"; import { getThreadSortTimestamp, sortThreads, @@ -831,3 +832,121 @@ export function sortScopedProjectsForSidebar< left.id.localeCompare(right.id), ); } + +/** + * Sidebar "Agents" section — pure logic. + * + * An agent is presented as a top-level peer to a project: a header row (its + * nickname + connection dot) with its threads underneath. Structurally it is + * still a `SidebarProjectSnapshot`, because the threads hang off the synthetic + * project row created for the instance — the only differences are how the row + * is labelled and that the leading affordance is a live connection dot rather + * than a favicon. + */ + +/** Minimal shape the agent-section helpers need off a grouped project. */ +export interface AgentSidebarProjectInput { + readonly agentInstanceId: ProviderInstanceId | null; + readonly title: string; + readonly displayName: string; +} + +export interface AgentSidebarEntry { + readonly instanceId: ProviderInstanceId; + readonly project: TProject; + /** Nickname, with the redundant "Hermes · " prefix stripped. */ + readonly label: string; + readonly statusKey: ProviderStatusKey; + readonly statusLabel: string; + /** True while the instance is mid-handshake, so the dot can pulse. */ + readonly statusPulse: boolean; +} + +const AGENT_STATUS_LABELS: Record = { + ready: "Connected", + // A provider snapshot reports `warning` both while an agent has never dialled + // in and when it dropped. Either way the user's next action is the same, so + // one honest label covers both rather than guessing which it is. + warning: "Not connected", + error: "Error", + disabled: "Disabled", +}; + +/** + * Presentation status for one agent instance, read off the pushed + * `ServerProvider` snapshot. + * + * A missing snapshot is `warning`, not `disabled`: the agent project row + * exists because the instance was enrolled, so an absent provider entry means + * the server has not reported on it yet, which is a "check on this" state + * rather than a deliberate off switch. + */ +export function resolveAgentStatusKey(provider: ServerProvider | undefined): ProviderStatusKey { + if (!provider) return "warning"; + if (!provider.enabled) return "disabled"; + switch (provider.status) { + case "ready": + case "error": + case "disabled": + return provider.status; + default: + return "warning"; + } +} + +/** + * Strip the driver-level prefix an instance display name carries so the + * sidebar row shows the nickname the user actually chose. `providerInstances` + * renders Hermes instances as "Hermes · workstation"; inside a section already + * titled "Agents", repeating the brand on every row is noise. + */ +export function formatAgentSidebarLabel(input: { + readonly providerDisplayName: string | undefined; + readonly projectTitle: string; + readonly instanceId: ProviderInstanceId; +}): string { + const fromProvider = input.providerDisplayName?.replace(/^Hermes\s*[·:–—-]\s*/u, "").trim(); + if (fromProvider) return fromProvider; + const fromProject = input.projectTitle.trim(); + if (fromProject) return fromProject; + return String(input.instanceId); +} + +/** + * Project grouped agent projects into renderable sidebar entries, ordered by + * label so the list is stable across status changes (a reconnecting agent + * must not jump position under the pointer). + */ +export function buildAgentSidebarEntries(input: { + readonly projects: ReadonlyArray; + readonly providerByInstanceId: ReadonlyMap; + readonly providerDisplayNameByInstanceId?: ReadonlyMap; +}): AgentSidebarEntry[] { + const entries: AgentSidebarEntry[] = []; + for (const project of input.projects) { + const instanceId = project.agentInstanceId; + // `== null` covers both the decoded `null` and an older snapshot that + // omits the field; neither is an agent. + if (instanceId == null) continue; + const provider = input.providerByInstanceId.get(instanceId); + const statusKey = resolveAgentStatusKey(provider); + entries.push({ + instanceId, + project, + label: formatAgentSidebarLabel({ + providerDisplayName: input.providerDisplayNameByInstanceId?.get(instanceId), + projectTitle: project.displayName || project.title, + instanceId, + }), + statusKey, + statusLabel: AGENT_STATUS_LABELS[statusKey], + statusPulse: statusKey === "warning" && provider?.auth.status === "unauthenticated", + }); + } + + return entries.toSorted( + (left, right) => + left.label.localeCompare(right.label) || + String(left.instanceId).localeCompare(String(right.instanceId)), + ); +} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index a1d95eaa734..650f1de74ee 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -22,6 +22,8 @@ import { ThreadWorktreeIndicator, } from "./ThreadStatusIndicators"; import { ProjectFavicon } from "./ProjectFavicon"; +import { HermesIcon } from "./HermesIcon"; +import { PROVIDER_STATUS_STYLES } from "./settings/providerStatus"; import { useAtomValue } from "@effect/atom-react"; import { autoAnimate } from "@formkit/auto-animate"; import React, { useCallback, useEffect, memo, useMemo, useRef, useState } from "react"; @@ -171,7 +173,9 @@ import { useThreadSelectionStore } from "../threadSelectionStore"; import { openCommandPalette } from "../commandPaletteBus"; import { archiveSelectedThreadEntries, + buildAgentSidebarEntries, buildMultiSelectThreadContextMenuItems, + type AgentSidebarEntry, getSidebarThreadIdsToPrewarm, resolveAdjacentThreadId, isContextMenuPointerDown, @@ -191,7 +195,8 @@ import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useIsMobile } from "~/hooks/useMediaQuery"; import { CommandDialogTrigger } from "./ui/command"; import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings"; -import { primaryServerKeybindingsAtom } from "../state/server"; +import { primaryServerKeybindingsAtom, primaryServerProvidersAtom } from "../state/server"; +import { deriveProviderInstanceEntries } from "../providerInstances"; import { derivePhysicalProjectKey, deriveProjectGroupingOverrideKey, @@ -202,6 +207,7 @@ import type { SidebarThreadSummary } from "../types"; import { buildPhysicalToLogicalProjectKeyMap, buildSidebarProjectSnapshots, + selectProjectsOfKind, type SidebarProjectGroupMember, type SidebarProjectSnapshot, } from "../sidebarProjectGrouping"; @@ -1055,6 +1061,14 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( interface SidebarProjectItemProps { project: SidebarProjectSnapshot; + /** + * Present when this row belongs to the Agents section. The row then renders + * the agent's mark plus a live connection dot instead of the folder favicon, + * navigates to the agent page instead of toggling on header click, and skips + * the project context menu (an agent's synthetic workspace is not something + * the user renames, regroups, or removes from here). + */ + agent?: AgentSidebarEntry | null; isThreadListExpanded: boolean; activeRouteThreadKey: string | null; newThreadShortcutLabel: string | null; @@ -1075,6 +1089,7 @@ interface SidebarProjectItemProps { const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjectItemProps) { const { project, + agent = null, isThreadListExpanded, activeRouteThreadKey, newThreadShortcutLabel, @@ -1344,6 +1359,21 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec visibleProjectThreads, ]); + const openAgentPage = useCallback(() => { + if (!agent) return; + if (isMobile) { + setOpenMobile(false); + } + void router.navigate({ + to: "/agents/$instanceId", + params: { instanceId: agent.instanceId }, + }); + }, [agent, isMobile, router, setOpenMobile]); + + const toggleProjectExpanded = useCallback(() => { + setProjectExpanded(projectPreferenceKeys, !projectExpanded); + }, [projectExpanded, projectPreferenceKeys, setProjectExpanded]); + const handleProjectButtonClick = useCallback( (event: React.MouseEvent) => { if (suppressProjectClickForContextMenuRef.current) { @@ -1366,16 +1396,22 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec if (useThreadSelectionStore.getState().hasSelection()) { clearSelection(); } - setProjectExpanded(projectPreferenceKeys, !projectExpanded); + // An agent header is a link to the agent, not a disclosure: the threads + // are toggled by the dedicated chevron button rendered beside it. + if (agent) { + openAgentPage(); + return; + } + toggleProjectExpanded(); }, [ + agent, clearSelection, dragInProgressRef, - projectExpanded, - projectPreferenceKeys, - setProjectExpanded, + openAgentPage, suppressProjectClickAfterDragRef, suppressProjectClickForContextMenuRef, + toggleProjectExpanded, ], ); @@ -1386,9 +1422,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec if (dragInProgressRef.current) { return; } - setProjectExpanded(projectPreferenceKeys, !projectExpanded); + if (agent) { + openAgentPage(); + return; + } + toggleProjectExpanded(); }, - [dragInProgressRef, projectExpanded, projectPreferenceKeys, setProjectExpanded], + [agent, dragInProgressRef, openAgentPage, toggleProjectExpanded], ); const handleProjectButtonPointerDownCapture = useCallback( @@ -1580,6 +1620,10 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const handleProjectButtonContextMenu = useCallback( (event: React.MouseEvent) => { event.preventDefault(); + // Rename / Group into… / Copy Path / Remove are all workspace verbs. + // An agent's synthetic project is owned by its enrollment lifecycle, so + // there is nothing here for the user to act on. + if (agent) return; suppressProjectClickForContextMenuRef.current = true; void (async () => { const api = readLocalApi(); @@ -1675,6 +1719,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec })(); }, [ + agent, copyPathToClipboard, handleRemoveProject, openProjectGroupingDialog, @@ -2230,7 +2275,32 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec onKeyDown={handleProjectButtonKeyDown} onContextMenu={handleProjectButtonContextMenu} > - {!projectExpanded && projectStatus ? ( + {agent ? ( + // Agents replace both leading affordances at once: the connection + // dot is the status (it comes from the pushed provider snapshot, + // not from thread activity), and the Hermes mark stands in for the + // project favicon since there is no site to fetch one from. + <> + + + } + > + + + {agent.statusLabel} + + + + ) : !projectExpanded && projectStatus ? ( )} - + {agent ? null : ( + + )} - {project.displayName} + {agent ? agent.label : project.displayName} - {project.groupedProjectCount > 1 ? ( + {!agent && project.groupedProjectCount > 1 ? ( {project.groupedProjectCount} projects ) : null} + {/* Agents need an explicit disclosure control: their header click + navigates to the agent page, so the thread list has no other way + to open. */} + {agent ? ( + + + +
+ } + /> + + {projectExpanded ? "Hide threads" : "Show threads"} + +
+ ) : null} {/* Environment badge – visible by default, crossfades with the "new thread" button on hover using the same pointer-events + opacity pattern as the thread row archive/timestamp swap. */} @@ -2488,6 +2597,84 @@ const SidebarProjectListRow = memo(function SidebarProjectListRow(props: Sidebar ); }); +interface SidebarAgentsContentProps { + agents: readonly AgentSidebarEntry[]; + expandedThreadListsByProject: ReadonlySet; + activeRouteProjectKey: string | null; + routeThreadKey: string | null; + newThreadShortcutLabel: string | null; + handleNewThread: ReturnType; + archiveThread: ReturnType["archiveThread"]; + deleteThread: ReturnType["deleteThread"]; + threadJumpLabelByKey: ReadonlyMap; + attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void; + expandThreadListForProject: (projectKey: string) => void; + collapseThreadListForProject: (projectKey: string) => void; + dragInProgressRef: React.RefObject; + suppressProjectClickAfterDragRef: React.RefObject; + suppressProjectClickForContextMenuRef: React.RefObject; + attachAgentListAutoAnimateRef: (node: HTMLElement | null) => void; +} + +/** + * Top-level "Agents" section, peer to Projects. + * + * Structurally a trimmed copy of `SidebarProjectsContent`'s list branch: same + * `SidebarGroup` → header → `SidebarMenu` → row shape, so agent rows inherit + * every interaction affordance project rows have (thread rows, selection, + * hover actions, jump hints, auto-animate). Deliberately omitted: + * + * - **The manual-sort / DnD branch.** Manual project order is a persisted + * user preference keyed on workspace path (`projectOrder` in the UI store); + * agents have no meaningful hand-ordering, and reusing that store would let + * an agent's synthetic path leak into the project order preference. Agents + * sort by label, which is stable across status changes. + * - **The sort menu and "add project" button.** Neither applies — agents + * arrive by enrollment in provider settings, not by picking a folder. + * + * The whole group renders nothing when no agent is enrolled, so users without + * a Hermes instance never see an empty section. + */ +const SidebarAgentsContent = memo(function SidebarAgentsContent(props: SidebarAgentsContentProps) { + if (props.agents.length === 0) { + return null; + } + + return ( + +
+ Agents +
+ + {props.agents.map((agent) => ( + + ))} + +
+ ); +}); + function LocalSecondaryStatus() { const { environments } = useEnvironments(); // The desktop reports which local secondary backends (e.g. the WSL backend) @@ -2760,7 +2947,9 @@ interface SidebarProjectsContentProps { suppressProjectClickAfterDragRef: React.RefObject; suppressProjectClickForContextMenuRef: React.RefObject; attachProjectListAutoAnimateRef: (node: HTMLElement | null) => void; + attachAgentListAutoAnimateRef: (node: HTMLElement | null) => void; projectsLength: number; + agents: readonly AgentSidebarEntry[]; } const SidebarProjectsContent = memo(function SidebarProjectsContent( @@ -2800,7 +2989,9 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( suppressProjectClickAfterDragRef, suppressProjectClickForContextMenuRef, attachProjectListAutoAnimateRef, + attachAgentListAutoAnimateRef, projectsLength, + agents, } = props; const handleProjectSortOrderChange = useCallback( @@ -2981,6 +3172,25 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( )} + + ); }); @@ -3054,6 +3264,9 @@ export default function Sidebar() { ), [environments], ); + // "No projects yet" must not be suppressed by an agent project: a user whose + // only project row is a synthetic agent still has no workspace. + const workspaceProjects = useMemo(() => selectProjectsOfKind(projects, "workspace"), [projects]); const orderedProjects = useMemo(() => { return orderItemsByPreferredIds({ items: projects, @@ -3103,6 +3316,42 @@ export default function Sidebar() { primaryEnvironmentId, ]); + // Agent projects are excluded from `sidebarProjects` by the builder's + // default; the Agents section asks for them explicitly. `orderedProjects` + // is passed through unchanged: it still carries agent rows, and the + // physical→logical key maps built from it are what let agent *threads* + // resolve to their agent row. + const agentProjects = useMemo( + () => + buildSidebarProjectSnapshots({ + projects: orderedProjects, + settings: projectGroupingSettings, + primaryEnvironmentId, + resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null, + kind: "agent", + }), + [environmentLabelById, orderedProjects, projectGroupingSettings, primaryEnvironmentId], + ); + const serverProviders = useAtomValue(primaryServerProvidersAtom); + // Push path, not polling: `primaryServerProvidersAtom` is fed by + // `subscribeServerConfig`, so a gateway connecting or dropping repaints the + // dot without the sidebar asking. + const agentEntries = useMemo(() => { + const providerByInstanceId = new Map( + serverProviders.map((provider) => [String(provider.instanceId), provider] as const), + ); + const providerDisplayNameByInstanceId = new Map( + deriveProviderInstanceEntries(serverProviders).map( + (entry) => [String(entry.instanceId), entry.displayName] as const, + ), + ); + return buildAgentSidebarEntries({ + projects: agentProjects, + providerByInstanceId, + providerDisplayNameByInstanceId, + }); + }, [agentProjects, serverProviders]); + const sidebarProjectByKey = useMemo( () => new Map(sidebarProjects.map((project) => [project.projectKey, project] as const)), [sidebarProjects], @@ -3249,6 +3498,15 @@ export default function Sidebar() { animatedProjectListsRef.current.add(node); }, []); + const animatedAgentListsRef = useRef(new WeakSet()); + const attachAgentListAutoAnimateRef = useCallback((node: HTMLElement | null) => { + if (!node || animatedAgentListsRef.current.has(node)) { + return; + } + autoAnimate(node, SIDEBAR_LIST_ANIMATION_OPTIONS); + animatedAgentListsRef.current.add(node); + }, []); + const animatedThreadListsRef = useRef(new WeakSet()); const attachThreadListAutoAnimateRef = useCallback((node: HTMLElement | null) => { if (!node || animatedThreadListsRef.current.has(node)) { @@ -3294,9 +3552,16 @@ export default function Sidebar() { visibleThreads, ]); const isManualProjectSorting = sidebarProjectSortOrder === "manual"; + // Agent rows render after the Projects group, so their threads continue the + // same visible ordering: thread-jump shortcuts and prev/next traversal reach + // an agent's threads exactly as they reach a project's. + const visibleSidebarSections = useMemo( + () => [...sortedProjects, ...agentEntries.map((agent) => agent.project)], + [agentEntries, sortedProjects], + ); const visibleSidebarThreadKeys = useMemo( () => - sortedProjects.flatMap((project) => { + visibleSidebarSections.flatMap((project) => { const projectThreads = sortThreads( (threadsByProjectKey.get(project.projectKey) ?? []).filter( (thread) => thread.archivedAt === null, @@ -3337,7 +3602,7 @@ export default function Sidebar() { expandedThreadListsByProject, projectExpandedById, routeThreadKey, - sortedProjects, + visibleSidebarSections, threadsByProjectKey, ], ); @@ -3631,7 +3896,9 @@ export default function Sidebar() { suppressProjectClickAfterDragRef={suppressProjectClickAfterDragRef} suppressProjectClickForContextMenuRef={suppressProjectClickForContextMenuRef} attachProjectListAutoAnimateRef={attachProjectListAutoAnimateRef} - projectsLength={projects.length} + attachAgentListAutoAnimateRef={attachAgentListAutoAnimateRef} + projectsLength={workspaceProjects.length} + agents={agentEntries} /> diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index e018491348e..63653724ef3 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -77,6 +77,7 @@ import { } from "../logicalProject"; import { buildSidebarProjectSnapshots, + selectProjectsOfKind, type SidebarProjectGroupMember, type SidebarProjectSnapshot, } from "../sidebarProjectGrouping"; @@ -990,6 +991,11 @@ function latestTurnDiff( export default function SidebarV2() { const projects = useProjects(); + // Project *groups* are already agent-free (the grouping builder excludes + // them by default); this is for the raw-count checks that gate "no projects + // yet" and the new-thread button, which must not be satisfied by a + // synthetic agent project. + const workspaceProjects = useMemo(() => selectProjectsOfKind(projects, "workspace"), [projects]); const projectOrder = useUiStateStore((store) => store.projectOrder); const threads = useThreadShells(); const router = useRouter(); @@ -2240,7 +2246,7 @@ export default function SidebarV2() { type="button" className="relative size-8 justify-center rounded-md border-0 bg-transparent p-0 text-sidebar-muted-foreground hover:bg-sidebar-row-hover hover:text-sidebar-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-sidebar" onClick={handleNewThreadClick} - disabled={projects.length === 0} + disabled={workspaceProjects.length === 0} aria-label="New thread" /> } @@ -2526,7 +2532,7 @@ export default function SidebarV2() { {activeThreads.length + snoozedThreads.length + settledThreads.length === 0 ? (
- {projects.length === 0 ? ( + {workspaceProjects.length === 0 ? ( <> No projects yet + {isExpanded ? ( +
+ {body === undefined || body.status === "loading" ? ( +

+ + Loading skill… +

+ ) : body.status === "error" ? ( +

{body.message}

+ ) : body.markdown === null || body.markdown.trim() === "" ? ( +

+ This agent knows the skill but did not return its contents. +

+ ) : ( + + )} +
+ ) : null} +
+ ); +} + +export function AgentPage({ instanceId }: { readonly instanceId: ProviderInstanceId }) { + const environmentId = usePrimaryEnvironment()?.environmentId ?? null; + const navigate = useNavigate(); + const handleNewThread = useNewThreadHandler(); + const providers = useAtomValue(primaryServerProvidersAtom); + + const describeInstance = useAtomCommand(serverEnvironment.providerDescribeInstance, { + reportFailure: false, + }); + const getSkillBody = useAtomCommand(serverEnvironment.providerGetSkillBody, { + reportFailure: false, + }); + const ensureAgentProject = useAtomCommand(serverEnvironment.providerEnsureAgentProject, { + reportFailure: false, + }); + const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, { + reportFailure: false, + }); + const getGatewayStatus = useAtomCommand(serverEnvironment.hermesGatewayGetInstanceStatus, { + reportFailure: false, + }); + + const [description, setDescription] = useState(null); + const [error, setError] = useState(null); + const [instanceNotFound, setInstanceNotFound] = useState(false); + const [pendingAction, setPendingAction] = useState(null); + const [skillQuery, setSkillQuery] = useState(""); + const [expandedSkillName, setExpandedSkillName] = useState(null); + const [skillBodies, setSkillBodies] = useState(() => new Map()); + + const refresh = useCallback( + async (quiet = false) => { + if (environmentId === null) return; + if (!quiet) setPendingAction("describe"); + const result = await describeInstance({ environmentId, input: { instanceId } }); + if (result._tag === "Success") { + setDescription(result.value); + setInstanceNotFound(false); + setError(null); + } else { + const failure = squashAtomCommandFailure(result); + if (isAgentInstanceNotFoundError(failure)) { + setDescription(null); + setInstanceNotFound(true); + setError(null); + } else if (!quiet) { + setError(messageFromAgentPageError(failure)); + } + } + if (!quiet) setPendingAction(null); + }, + [describeInstance, environmentId, instanceId], + ); + + // Initial read. Switching instances resets the page's per-skill state so a + // cached body never bleeds across agents. + useEffect(() => { + setDescription(null); + setSkillBodies(new Map()); + setExpandedSkillName(null); + setSkillQuery(""); + void refresh(); + }, [refresh]); + + // Ambient liveness rides the push path: `subscribeServerConfig` already + // republishes every `ServerProvider` when the broker changes state, so a + // changed revision is the cue to re-read authoritative detail. No polling. + const providerSignal = hermesGatewayProviderSignal(providers, instanceId); + const lastSignalRevisionRef = useRef(null); + useEffect(() => { + const revision = providerSignal.revision; + if (revision === null) return; + if (lastSignalRevisionRef.current === null) { + lastSignalRevisionRef.current = revision; + return; + } + if (lastSignalRevisionRef.current === revision) return; + lastSignalRevisionRef.current = revision; + void refresh(true); + }, [providerSignal.revision, refresh]); + + const handleReconnect = async () => { + if (environmentId === null) return; + setPendingAction("reconnect"); + setError(null); + // T3 never dials out to an agent — the plugin dials in. "Reconnect" is + // therefore a forced re-probe: re-run the driver's status check, re-read + // the authoritative gateway status where one exists, then re-describe. + const refreshResult = await refreshProviders({ environmentId, input: { instanceId } }); + if (refreshResult._tag !== "Success") { + setError(messageFromAgentPageError(squashAtomCommandFailure(refreshResult))); + setPendingAction(null); + return; + } + if (supportsGatewayReconnect(description?.identity.driver ?? null)) { + const statusResult = await getGatewayStatus({ environmentId, input: { instanceId } }); + if (statusResult._tag === "Success") { + toastManager.add({ + type: statusResult.value.status === "connected" ? "success" : "info", + title: `Agent is ${statusResult.value.status}`, + description: + statusResult.value.status === "connected" + ? "The gateway connection is live." + : "T3 Code re-probed the gateway. The agent's plugin dials in on its own schedule.", + }); + } + } + await refresh(true); + setPendingAction(null); + }; + + const handleNewAgentThread = async () => { + if (environmentId === null) return; + setPendingAction("newThread"); + setError(null); + // Get-or-create, never assume: the synthetic project may not exist yet, + // or may have been soft-deleted, and this call heals either case. + const result = await ensureAgentProject({ environmentId, input: { instanceId } }); + if (result._tag !== "Success") { + setError(messageFromAgentPageError(squashAtomCommandFailure(result))); + setPendingAction(null); + return; + } + await handleNewThread(scopeProjectRef(environmentId, result.value.projectId)); + setPendingAction(null); + }; + + const handleToggleSkill = (skillName: string) => { + const next = nextExpandedSkillName(expandedSkillName, skillName); + setExpandedSkillName(next); + if (next === null || environmentId === null) return; + if (!shouldFetchAgentSkillBody(skillBodies, skillName)) return; + setSkillBodies((cache) => upsertAgentSkillBody(cache, skillName, { status: "loading" })); + void (async () => { + const result = await getSkillBody({ environmentId, input: { instanceId, skillName } }); + setSkillBodies((cache) => + upsertAgentSkillBody( + cache, + skillName, + result._tag === "Success" + ? { status: "loaded", markdown: result.value.markdown } + : { + status: "error", + message: messageFromAgentPageError(squashAtomCommandFailure(result)), + }, + ), + ); + })(); + }; + + const status = description?.connection.status ?? "offline"; + const isBusy = isAgentPageBusy(pendingAction); + const title = agentPageTitle(description, instanceId); + const capabilities = useMemo( + () => agentCapabilityEntries(description?.capabilities), + [description?.capabilities], + ); + const skills = description?.skills ?? []; + const visibleSkills = useMemo(() => filterAgentSkills(skills, skillQuery), [skillQuery, skills]); + + return ( + + +
+
+ +

{title}

+ + {agentStatusLabel(status)} + +
+

+ {description?.connection.detail?.trim() || agentStatusGuidance(status)} +

+
+ + + + +
+ {error ?

{error}

: null} + {instanceNotFound ? ( +

+ This server has no provider instance named {instanceId}. It may have been + removed in provider settings. +

+ ) : null} +
+ + }> + +
+ + + + + + + + + + +
+
+

Reported capabilities

+ {capabilities.length === 0 ? ( +

+ This agent did not report any capability flags. +

+ ) : ( +
+ {capabilities.map((capability) => ( + + {capability.label} + + ))} +
+ )} +
+
+
+ + }> + + {description !== null && description.model === null ? ( +

+ This agent did not report a model. +

+ ) : ( +
+ + + + + +
+ )} +
+
+ + } + headerAction={ + skills.length > 0 ? ( + {skills.length} + ) : null + } + > + + {skills.length === 0 ? ( +

+ This agent did not report any skills. +

+ ) : ( +
+
+ + setSkillQuery(event.target.value)} + placeholder="Filter skills" + spellCheck={false} + className="pl-8" + aria-label="Filter skills" + /> +
+ {visibleSkills.length === 0 ? ( +

+ No skills match “{skillQuery}”. +

+ ) : ( +
+ {visibleSkills.map((skill) => ( + + ))} +
+ )} +
+ )} +
+
+ + }> + +

+ Not yet reported. No driver publishes its connected MCP servers today; this section + fills in once they do. +

+
+
+
+
+ ); +} + +export default AgentPage; diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 7367e21ceb9..3209e6bff03 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -33,6 +33,8 @@ import { useState, } from "react"; import { createPortal } from "react-dom"; +import { useNavigate } from "@tanstack/react-router"; +import { HermesIcon } from "../HermesIcon"; import { clampCollapsedComposerCursor, type ComposerTrigger, @@ -170,7 +172,11 @@ import { XIcon, } from "lucide-react"; import { proposedPlanTitle } from "../../proposedPlan"; -import { getProviderDisplayName, getProviderInteractionModeToggle } from "../../providerModels"; +import { + getProviderDisplayName, + getProviderInstanceRequiresWorkspace, + getProviderInteractionModeToggle, +} from "../../providerModels"; import { applyProviderInstanceSettings, deriveProviderInstanceEntries, @@ -266,6 +272,22 @@ function isInsideComposerFloatingLayer(element: Element): boolean { const ComposerFooterModeControls = memo(function ComposerFooterModeControls(props: { showInteractionModeToggle: boolean; + /** + * Whether the Supervised / Auto-accept / Auto / Full-access selector renders. + * Those modes scope filesystem and command access, so a provider with no + * workspace has nothing for them to scope and the control would imply a + * guarantee nothing enforces. + */ + showRuntimeModeSelector: boolean; + /** + * Opens the Agent page for the instance this composer is bound to. + * + * Rendered in the slot the Build toggle vacates: providers with no workspace + * set `showInteractionModeToggle: false`, so for them that slot is empty and + * the two controls never compete for it. Null when the composer has no + * agent to open (every workspace provider). + */ + onOpenAgent: (() => void) | null; interactionMode: ProviderInteractionMode; runtimeMode: RuntimeMode; showPlanToggle: boolean; @@ -320,7 +342,9 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop ) : null; - return ( + // The separator belongs to the selector: leaving it behind would render a + // dangling rule with nothing on either side of it. + const runtimeModeSelector = props.showRuntimeModeSelector ? ( <> @@ -366,9 +390,41 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop {runtimeModeOption.description} + + ) : null; + + const agentButton = props.onOpenAgent ? ( + <> + + + + } + > + + Agent + + Open agent details + + + ) : null; + + return ( + <> + {runtimeModeSelector} {interactionModeToggle} + {agentButton} + {props.showPlanToggle ? ( <> @@ -622,6 +678,7 @@ export interface ChatComposerProps { // -------------------------------------------------------------------------- export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) { + const navigate = useNavigate(); const { composerDraftTarget, environmentId, @@ -894,8 +951,30 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) providerStatuses, selectedProvider, ), + // Resolved per instance, not per driver kind: the composer already + // knows the exact instance the next turn routes to, and providers + // whose instances are always user-authored (Hermes) have no default + // instance for a kind-keyed lookup to find. + showRuntimeModeSelector: getProviderInstanceRequiresWorkspace( + providerStatuses, + selectedInstanceId, + ), }), - [providerStatuses, selectedProvider], + [providerStatuses, selectedInstanceId, selectedProvider], + ); + // Only providers with no workspace get an agent button; for everyone else + // this stays null and the Build toggle keeps the slot. + const openAgentPage = useMemo( + () => + composerProviderControls.showRuntimeModeSelector + ? null + : () => { + void navigate({ + to: "/agents/$instanceId", + params: { instanceId: selectedInstanceId }, + }); + }, + [composerProviderControls.showRuntimeModeSelector, navigate, selectedInstanceId], ); const selectedModelSelection = useMemo( () => createModelSelection(selectedInstanceId, selectedModel, selectedModelOptionsForDispatch), @@ -2705,6 +2784,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) planSidebarOpen={planSidebarOpen} runtimeMode={runtimeMode} showInteractionModeToggle={composerProviderControls.showInteractionModeToggle} + showRuntimeModeSelector={composerProviderControls.showRuntimeModeSelector} + onOpenAgent={openAgentPage} traitsMenuContent={providerTraitsMenuContent} onToggleInteractionMode={toggleInteractionMode} onTogglePlanSidebar={togglePlanSidebar} @@ -2720,6 +2801,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ) : null} { activeProjectName: "codething-mvp", activeThreadEnvironmentId: primaryEnvironmentId, primaryEnvironmentId, + requiresWorkspace: true, }), ).toBe(true); }); @@ -22,6 +23,7 @@ describe("shouldShowOpenInPicker", () => { activeProjectName: "codething-mvp", activeThreadEnvironmentId: EnvironmentId.make("environment-remote"), primaryEnvironmentId: null, + requiresWorkspace: true, }), ).toBe(false); }); @@ -32,6 +34,7 @@ describe("shouldShowOpenInPicker", () => { activeProjectName: "codething-mvp", activeThreadEnvironmentId: EnvironmentId.make("environment-remote"), primaryEnvironmentId, + requiresWorkspace: true, }), ).toBe(false); }); @@ -42,6 +45,18 @@ describe("shouldShowOpenInPicker", () => { activeProjectName: undefined, activeThreadEnvironmentId: primaryEnvironmentId, primaryEnvironmentId, + requiresWorkspace: true, + }), + ).toBe(false); + }); + + it("hides the picker for a provider that does not work in a workspace", () => { + expect( + shouldShowOpenInPicker({ + activeProjectName: "codething-mvp", + activeThreadEnvironmentId: primaryEnvironmentId, + primaryEnvironmentId, + requiresWorkspace: false, }), ).toBe(false); }); diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 3a7d57859a8..948c1d36271 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -34,6 +34,12 @@ interface ChatHeaderProps { availableEditors: ReadonlyArray; rightPanelOpen: boolean; gitCwd: string | null; + /** + * Whether this thread's provider works inside a workspace directory. False + * for directoryless agents (Hermes), where the breadcrumb project, Open-in + * picker, and git actions all describe a checkout that does not exist. + */ + requiresWorkspace: boolean; onRunProjectScript: (script: ProjectScript) => void; onAddProjectScript: (input: NewProjectScriptInput) => Promise; onUpdateProjectScript: ( @@ -47,8 +53,10 @@ export function shouldShowOpenInPicker(input: { readonly activeProjectName: string | undefined; readonly activeThreadEnvironmentId: EnvironmentId; readonly primaryEnvironmentId: EnvironmentId | null; + readonly requiresWorkspace: boolean; }): boolean { return ( + input.requiresWorkspace && Boolean(input.activeProjectName) && input.primaryEnvironmentId !== null && input.activeThreadEnvironmentId === input.primaryEnvironmentId @@ -69,6 +77,7 @@ export const ChatHeader = memo(function ChatHeader({ availableEditors, rightPanelOpen, gitCwd, + requiresWorkspace, onRunProjectScript, onAddProjectScript, onUpdateProjectScript, @@ -83,14 +92,17 @@ export const ChatHeader = memo(function ChatHeader({ activeProjectName, activeThreadEnvironmentId, primaryEnvironmentId, + requiresWorkspace, }); + const showProjectBreadcrumb = requiresWorkspace && Boolean(activeProjectName); return (
{/* The project always leads the header: knowing which project a thread lives in is priority zero, and the thread title alone - doesn't answer it. */} - {activeProjectName ? ( + doesn't answer it. Directoryless agent threads have no checkout + to name, so the chunk is suppressed rather than shown empty. */} + {showProjectBreadcrumb ? ( - {activeProjectScripts && ( + {/* `requiresWorkspace` is checked separately from the array itself: + an agent project is a real row carrying an empty `scripts` array, + and `[]` is truthy — so the control would render on a thread with + no directory for a script to run in. */} + {requiresWorkspace && activeProjectScripts && ( )} - {activeProjectName && ( + {requiresWorkspace && activeProjectName && ( void) | null; traitsMenuContent?: ReactNode; onToggleInteractionMode: () => void; onTogglePlanSidebar: () => void; @@ -58,22 +69,37 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls Chat Plan + {/* Trailing rule only when something follows it — the plan item + brings its own leading divider. */} + {props.showRuntimeModeSelector ? : null} + + ) : null} + {props.showRuntimeModeSelector ? ( + <> +
Access
+ { + if (!value || value === props.runtimeMode) return; + props.onRuntimeModeChange(value as RuntimeMode); + }} + > + Supervised + Auto-accept edits + Auto + Full access + + + ) : null} + {props.onOpenAgent ? ( + <> + + + Agent details + ) : null} -
Access
- { - if (!value || value === props.runtimeMode) return; - props.onRuntimeModeChange(value as RuntimeMode); - }} - > - Supervised - Auto-accept edits - Auto - Full access - {props.activePlan ? ( <> diff --git a/apps/web/src/components/chat/DraftHeroHeadline.logic.test.ts b/apps/web/src/components/chat/DraftHeroHeadline.logic.test.ts new file mode 100644 index 00000000000..25fc26c100c --- /dev/null +++ b/apps/web/src/components/chat/DraftHeroHeadline.logic.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { AGENT_DRAFT_HEADLINE, resolveDraftHeroHeadline } from "./DraftHeroHeadline.logic"; + +describe("resolveDraftHeroHeadline", () => { + it("asks what to build when a project is resolved", () => { + expect( + resolveDraftHeroHeadline({ + requiresWorkspace: true, + hasResolvedProject: true, + canChooseProject: true, + }), + ).toEqual({ kind: "build-in-project", showProjectSelector: true }); + }); + + it("still shows the selector for a resolved project with nothing else to pick", () => { + expect( + resolveDraftHeroHeadline({ + requiresWorkspace: true, + hasResolvedProject: true, + canChooseProject: false, + }), + ).toEqual({ kind: "build-in-project", showProjectSelector: true }); + }); + + it("prompts for a project choice when one can be picked", () => { + expect( + resolveDraftHeroHeadline({ + requiresWorkspace: true, + hasResolvedProject: false, + canChooseProject: true, + }), + ).toEqual({ kind: "choose-project", showProjectSelector: true }); + }); + + it("prompts to add a project when there is nothing to pick", () => { + expect( + resolveDraftHeroHeadline({ + requiresWorkspace: true, + hasResolvedProject: false, + canChooseProject: false, + }), + ).toEqual({ kind: "add-project", showProjectSelector: false }); + }); + + it("drops the directory question entirely for a workspaceless provider", () => { + for (const hasResolvedProject of [true, false]) { + for (const canChooseProject of [true, false]) { + expect( + resolveDraftHeroHeadline({ + requiresWorkspace: false, + hasResolvedProject, + canChooseProject, + }), + ).toEqual({ kind: "agent", showProjectSelector: false }); + } + } + }); + + it("pins the agent headline copy", () => { + expect(AGENT_DRAFT_HEADLINE).toBe("What's on your mind?"); + }); +}); diff --git a/apps/web/src/components/chat/DraftHeroHeadline.logic.ts b/apps/web/src/components/chat/DraftHeroHeadline.logic.ts new file mode 100644 index 00000000000..baf02b29cc5 --- /dev/null +++ b/apps/web/src/components/chat/DraftHeroHeadline.logic.ts @@ -0,0 +1,53 @@ +/** + * Pure headline/selector decision for the draft hero. + * + * Kept out of the component so the "which headline, and is the project picker + * even meaningful here?" question is testable without rendering the project + * menu and its whole sidebar-grouping dependency chain. + */ + +/** + * Which headline the draft hero renders, and whether the project picker + * participates at all. + * + * - `build-in-project` — a project is resolved: "What should we build in + * {selector}?" + * - `choose-project` — no project yet, but the user has projects to pick + * from: "{selector} to start" + * - `add-project` — no project and nothing to pick: "Add a project to start" + * - `agent` — the provider does not work in a workspace, so no directory + * question can be asked: "What's on your mind?" with no selector. + */ +export type DraftHeroHeadlineKind = "build-in-project" | "choose-project" | "add-project" | "agent"; + +export interface DraftHeroHeadlineDecision { + readonly kind: DraftHeroHeadlineKind; + /** + * Whether the project picker/selector renders at all. False for `agent`, + * where there is no directory to scope the thread to. + */ + readonly showProjectSelector: boolean; +} + +export const AGENT_DRAFT_HEADLINE = "What's on your mind?"; + +export function resolveDraftHeroHeadline(input: { + /** + * Absent-means-true capability flag, already resolved by the caller. When + * false the thread targets a directoryless agent. + */ + readonly requiresWorkspace: boolean; + readonly hasResolvedProject: boolean; + readonly canChooseProject: boolean; +}): DraftHeroHeadlineDecision { + if (!input.requiresWorkspace) { + return { kind: "agent", showProjectSelector: false }; + } + if (input.hasResolvedProject) { + return { kind: "build-in-project", showProjectSelector: true }; + } + if (input.canChooseProject) { + return { kind: "choose-project", showProjectSelector: true }; + } + return { kind: "add-project", showProjectSelector: false }; +} diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx index 98091f9aab6..57eccf7c3ca 100644 --- a/apps/web/src/components/chat/DraftHeroHeadline.tsx +++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx @@ -14,6 +14,7 @@ import { import { useProjects, useThreadShells } from "~/state/entities"; import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments"; import { sortLogicalProjectsForSidebar } from "../Sidebar.logic"; +import { AGENT_DRAFT_HEADLINE, resolveDraftHeroHeadline } from "./DraftHeroHeadline.logic"; import { Menu, MenuItem, @@ -27,11 +28,18 @@ import { interface DraftHeroHeadlineProps { readonly activeProjectRef: ScopedProjectRef | null; readonly activeProjectTitle: string | null; + /** + * Whether the thread's provider works inside a workspace directory. False + * for directoryless agents, where "what should we build in ?" is + * the wrong question and the project picker has nothing to scope. + */ + readonly requiresWorkspace: boolean; } export function DraftHeroHeadline({ activeProjectRef, activeProjectTitle, + requiresWorkspace, }: DraftHeroHeadlineProps) { const projects = useProjects(); const threads = useThreadShells(); @@ -95,6 +103,11 @@ export function DraftHeroHeadline({ const activeProjectDisplayName = activeProjectGroup?.displayName ?? activeProjectTitle; const hasResolvedProject = activeProjectTitle !== null; const canChooseProject = projectPickerEntries.length > 0; + const headline = resolveDraftHeroHeadline({ + requiresWorkspace, + hasResolvedProject, + canChooseProject, + }); const shouldShowProjectMenu = canChooseProject; const projectSelector = shouldShowProjectMenu ? ( @@ -146,9 +159,11 @@ export function DraftHeroHeadline({ return (

- {hasResolvedProject ? ( + {headline.kind === "agent" ? ( + AGENT_DRAFT_HEADLINE + ) : headline.kind === "build-in-project" ? ( <>What should we build in {projectSelector}? - ) : canChooseProject ? ( + ) : headline.kind === "choose-project" ? ( <>{projectSelector} to start ) : ( <>Add a project to start diff --git a/apps/web/src/environmentGrouping.test.ts b/apps/web/src/environmentGrouping.test.ts index 17d86ca0912..9b77c5c8aef 100644 --- a/apps/web/src/environmentGrouping.test.ts +++ b/apps/web/src/environmentGrouping.test.ts @@ -12,6 +12,8 @@ import { buildPhysicalToLogicalProjectKeyMap, buildSidebarProjectPickerEntries, buildSidebarProjectSnapshots, + isAgentProject, + selectProjectsOfKind, } from "./sidebarProjectGrouping"; import { orderItemsByPreferredIds } from "./components/Sidebar.logic"; import { legacyProjectCwdPreferenceKey } from "./uiStateStore"; @@ -46,6 +48,7 @@ function makeProject(overrides: Partial = {}): Project { createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", scripts: [], + agentInstanceId: null, ...overrides, }; } @@ -350,3 +353,102 @@ describe("environment grouping", () => { expect(groups.map((group) => group.displayName)).toEqual(["separate", "shared-repo"]); }); }); + +describe("agent project exclusion", () => { + const agentInstanceId = ProviderInstanceId.make("hermes_workstation"); + + function makeAgentProject(overrides: Partial = {}): Project { + return makeProject({ + id: ProjectId.make("project-agent"), + title: "Workstation", + workspaceRoot: "/home/user/.t3/agents/hermes_workstation", + agentInstanceId, + ...overrides, + }); + } + + it("identifies an agent project by its instance id", () => { + expect(isAgentProject(makeAgentProject())).toBe(true); + expect(isAgentProject(makeProject())).toBe(false); + }); + + it("splits a mixed project list by kind", () => { + const workspace = makeProject(); + const agent = makeAgentProject(); + + expect(selectProjectsOfKind([workspace, agent], "workspace")).toEqual([workspace]); + expect(selectProjectsOfKind([workspace, agent], "agent")).toEqual([agent]); + }); + + it("excludes agent projects from sidebar snapshots by default", () => { + const snapshots = buildSidebarProjectSnapshots({ + projects: [makeProject(), makeAgentProject()], + settings: defaultGroupingSettings, + primaryEnvironmentId, + resolveEnvironmentLabel: () => null, + }); + + expect(snapshots.map((snapshot) => snapshot.title)).toEqual(["shared-repo"]); + }); + + it("returns only agent projects when the agent kind is requested", () => { + const snapshots = buildSidebarProjectSnapshots({ + projects: [makeProject(), makeAgentProject()], + settings: defaultGroupingSettings, + primaryEnvironmentId, + resolveEnvironmentLabel: () => null, + kind: "agent", + }); + + expect(snapshots).toHaveLength(1); + expect(snapshots[0]?.agentInstanceId).toBe(agentInstanceId); + expect(snapshots[0]?.displayName).toBe("Workstation"); + }); + + it("never offers an agent project as a picker entry, even if one reaches the builder", () => { + // Deliberately routes agent groups into the picker builder to prove the + // second gate holds on its own — this is the shape a future caller that + // forgets `kind` would produce. + const groups = buildSidebarProjectSnapshots({ + projects: [makeProject(), makeAgentProject()], + settings: defaultGroupingSettings, + primaryEnvironmentId, + resolveEnvironmentLabel: () => null, + kind: "agent", + }); + expect(groups).toHaveLength(1); + + const entries = buildSidebarProjectPickerEntries({ + groups, + preferredProjectRef: null, + }); + + expect(entries).toEqual([]); + }); + + it("keeps a real project selectable when an agent project sits beside it", () => { + const groups = [ + ...buildSidebarProjectSnapshots({ + projects: [makeProject()], + settings: defaultGroupingSettings, + primaryEnvironmentId, + resolveEnvironmentLabel: () => null, + }), + ...buildSidebarProjectSnapshots({ + projects: [makeAgentProject()], + settings: defaultGroupingSettings, + primaryEnvironmentId, + resolveEnvironmentLabel: () => null, + kind: "agent", + }), + ]; + + const entries = buildSidebarProjectPickerEntries({ + groups, + preferredProjectRef: null, + }); + + expect(entries).toHaveLength(1); + expect(entries[0]?.group.title).toBe("shared-repo"); + }); +}); diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 2479d6ba02f..0c90e78f661 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -21,6 +21,7 @@ import { selectProjectGroupingSettings, } from "../logicalProject"; import { readThreadShell, useProjects, useThread } from "../state/entities"; +import { selectProjectsOfKind } from "../sidebarProjectGrouping"; import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions"; import { primaryServerSettingsAtom } from "../state/server"; import { resolveThreadRouteTarget } from "../threadRoutes"; @@ -300,9 +301,12 @@ export function useHandleNewThread() { : null, ); const projects = useProjects(); + // `defaultProjectRef` is the implicit target of the new-thread shortcut and + // the command palette's "new thread" action, so it must resolve to a + // workspace the user actually added — never a synthetic agent project. const orderedProjects = useMemo(() => { return orderItemsByPreferredIds({ - items: projects, + items: selectProjectsOfKind(projects, "workspace"), preferredIds: projectOrder, getId: getProjectOrderKey, getPreferenceIds: (project) => [ diff --git a/apps/web/src/providerModels.test.ts b/apps/web/src/providerModels.test.ts new file mode 100644 index 00000000000..9909fb6c48b --- /dev/null +++ b/apps/web/src/providerModels.test.ts @@ -0,0 +1,131 @@ +import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + getProviderInstanceRequiresWorkspace, + getProviderRequiresWorkspace, +} from "./providerModels"; + +function provider(input: { + driver: string; + instanceId: string; + requiresWorkspace?: boolean; +}): ServerProvider { + return { + instanceId: ProviderInstanceId.make(input.instanceId), + driver: ProviderDriverKind.make(input.driver), + enabled: true, + installed: true, + version: null, + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-01-01T00:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + ...(input.requiresWorkspace === undefined + ? {} + : { requiresWorkspace: input.requiresWorkspace }), + }; +} + +const codexKind = ProviderDriverKind.make("codex"); + +describe("getProviderRequiresWorkspace", () => { + it("treats an absent field as true so existing providers keep their directory UI", () => { + expect( + getProviderRequiresWorkspace([provider({ driver: "codex", instanceId: "codex" })], codexKind), + ).toBe(true); + }); + + it("honors an explicit false", () => { + expect( + getProviderRequiresWorkspace( + [provider({ driver: "codex", instanceId: "codex", requiresWorkspace: false })], + codexKind, + ), + ).toBe(false); + }); + + it("honors an explicit true", () => { + expect( + getProviderRequiresWorkspace( + [provider({ driver: "codex", instanceId: "codex", requiresWorkspace: true })], + codexKind, + ), + ).toBe(true); + }); + + it("returns true for an unknown driver rather than crashing", () => { + expect( + getProviderRequiresWorkspace( + [provider({ driver: "codex", instanceId: "codex", requiresWorkspace: false })], + ProviderDriverKind.make("someForkDriver"), + ), + ).toBe(true); + }); + + it("returns true for an empty providers array", () => { + expect(getProviderRequiresWorkspace([], codexKind)).toBe(true); + }); + + it("ignores non-default instances of the same driver kind", () => { + // The lookup is keyed on `defaultInstanceIdForDriver`, so a custom + // instance's flag must not leak onto the driver-kind read. + expect( + getProviderRequiresWorkspace( + [provider({ driver: "codex", instanceId: "codex_personal", requiresWorkspace: false })], + codexKind, + ), + ).toBe(true); + }); +}); + +describe("getProviderInstanceRequiresWorkspace", () => { + const hermesInstanceId = ProviderInstanceId.make("hermes-research-a1b2c3d4e5f6"); + + it("resolves a user-authored instance that the driver-kind lookup cannot reach", () => { + const providers = [ + provider({ + driver: "hermes", + instanceId: "hermes-research-a1b2c3d4e5f6", + requiresWorkspace: false, + }), + ]; + + expect(getProviderInstanceRequiresWorkspace(providers, hermesInstanceId)).toBe(false); + // No `hermes` default instance is ever synthesized, so the kind-keyed + // accessor cannot see this snapshot at all. + expect(getProviderRequiresWorkspace(providers, ProviderDriverKind.make("hermes"))).toBe(true); + }); + + it("treats an absent field as true", () => { + expect( + getProviderInstanceRequiresWorkspace( + [provider({ driver: "codex", instanceId: "codex" })], + ProviderInstanceId.make("codex"), + ), + ).toBe(true); + }); + + it("returns true for an unknown instance", () => { + expect( + getProviderInstanceRequiresWorkspace( + [provider({ driver: "codex", instanceId: "codex", requiresWorkspace: false })], + ProviderInstanceId.make("codex_missing"), + ), + ).toBe(true); + }); + + it("returns true for a null or undefined instance id", () => { + const providers = [ + provider({ driver: "codex", instanceId: "codex", requiresWorkspace: false }), + ]; + expect(getProviderInstanceRequiresWorkspace(providers, null)).toBe(true); + expect(getProviderInstanceRequiresWorkspace(providers, undefined)).toBe(true); + }); + + it("returns true for an empty providers array", () => { + expect(getProviderInstanceRequiresWorkspace([], hermesInstanceId)).toBe(true); + }); +}); diff --git a/apps/web/src/providerModels.ts b/apps/web/src/providerModels.ts index 9715344cba8..38173d23fde 100644 --- a/apps/web/src/providerModels.ts +++ b/apps/web/src/providerModels.ts @@ -53,6 +53,44 @@ export function getProviderInteractionModeToggle( return getProviderSnapshot(providers, provider)?.showInteractionModeToggle ?? true; } +/** + * Whether threads on this driver's default instance are rooted in a workspace + * directory. + * + * Absent means `true`: every provider that predates the flag, and every cached + * snapshot decoded before it existed, is a coding provider working in a + * checkout. Inverting this default would strip the directory UI (breadcrumb, + * checkout picker, branch selector, Open / Create PR) from every real thread. + * + * Driver-kind keyed, so it only ever resolves the *default* instance for the + * kind (`defaultInstanceIdForDriver`). Drivers whose instances are always + * user-authored — Hermes ids look like `hermes-