From a5c57bee4a4828059689ed44628a5340139b0f27 Mon Sep 17 00:00:00 2001 From: Nicky Molina Date: Thu, 30 Jul 2026 11:24:20 -0400 Subject: [PATCH 1/4] fix(hermes): enable model and reasoning controls --- .../src/provider/Drivers/HermesDriver.test.ts | 463 +++++++ .../src/provider/Drivers/HermesDriver.ts | 367 +++++- .../src/provider/Layers/HermesAdapter.test.ts | 220 +++- .../src/provider/Layers/HermesAdapter.ts | 146 ++- .../Layers/HermesGatewayBroker.test.ts | 8 +- .../provider/Layers/ProviderRegistry.test.ts | 90 ++ .../src/provider/Layers/ProviderRegistry.ts | 22 + apps/server/src/provider/hermesModels.test.ts | 123 ++ apps/server/src/provider/hermesModels.ts | 132 ++ apps/web/src/components/chat/ChatComposer.tsx | 58 +- apps/web/src/composer-logic.test.ts | 55 + apps/web/src/composer-logic.ts | 30 + .../hermes-t3-gateway/COMPATIBILITY.md | 30 +- integrations/hermes-t3-gateway/README.md | 20 +- integrations/hermes-t3-gateway/adapter.py | 714 ++++++++++- integrations/hermes-t3-gateway/connection.py | 2 +- integrations/hermes-t3-gateway/home.py | 2 +- integrations/hermes-t3-gateway/plugin.yaml | 2 +- integrations/hermes-t3-gateway/protocol.py | 173 ++- .../hermes-t3-gateway/tests/test_adapter.py | 1071 ++++++++++++++++- .../tests/test_connection.py | 20 +- .../hermes-t3-gateway/tests/test_home.py | 11 +- .../hermes-t3-gateway/tests/test_protocol.py | 183 ++- packages/contracts/src/hermesGateway.test.ts | 156 ++- packages/contracts/src/hermesGateway.ts | 94 +- 25 files changed, 3938 insertions(+), 254 deletions(-) create mode 100644 apps/server/src/provider/Drivers/HermesDriver.test.ts create mode 100644 apps/server/src/provider/hermesModels.test.ts create mode 100644 apps/server/src/provider/hermesModels.ts diff --git a/apps/server/src/provider/Drivers/HermesDriver.test.ts b/apps/server/src/provider/Drivers/HermesDriver.test.ts new file mode 100644 index 00000000000..d5c4b404101 --- /dev/null +++ b/apps/server/src/provider/Drivers/HermesDriver.test.ts @@ -0,0 +1,463 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { + DEFAULT_HERMES_MODEL, + HERMES_GATEWAY_PROTOCOL_VERSION, + ProviderInstanceId, + type HermesGatewayInstanceStatus, + type HermesGatewayModelsListResponse, + type HermesGatewayT3ToPluginMessage, +} from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; + +import * as ServerConfig from "../../config.ts"; +import * as ServerSettings from "../../serverSettings.ts"; +import { ProviderAdapterRequestError } from "../Errors.ts"; +import { HermesGatewayBroker } from "../Services/HermesGatewayBroker.ts"; +import { decodeHermesModelSlug } from "../hermesModels.ts"; +import { HermesDriver } from "./HermesDriver.ts"; + +const instanceId = ProviderInstanceId.make("hermes_catalog_test"); + +const connectedStatus: HermesGatewayInstanceStatus = { + instanceId, + nickname: "Catalog Hermes", + status: "connected", + connectorUrl: "wss://hermes.example.test/api/hermes-gateway/ws", + lastConnectedAt: null, + pluginVersion: "0.5.0", + hermesVersion: "0.19.0", + model: "anthropic/claude-sonnet-4", + connectionGeneration: 7, + activeSessionCount: 0, + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + capabilities: { + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + streaming: true, + activity: true, + approvals: true, + userInput: true, + attachments: true, + }, +}; + +type ModelsListRequest = Extract< + HermesGatewayT3ToPluginMessage, + { readonly type: "models.list.request" } +>; + +const catalogResponse = ( + request: ModelsListRequest, + model: string, +): HermesGatewayModelsListResponse => ({ + type: "models.list.response", + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + requestId: request.requestId, + currentProvider: "anthropic", + currentModel: model, + currentReasoningEffort: "high", + reasoningEfforts: ["none", "low", "high"], + models: [ + { + provider: "anthropic", + providerName: "Anthropic", + model, + supportsReasoning: true, + }, + ], +}); + +const testLayer = () => + ServerConfig.layerTest(process.cwd(), { prefix: "t3code-hermes-driver-test-" }).pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge( + ServerSettings.layerTest({ + providerInstances: { + [instanceId]: { driver: "hermes", displayName: "Catalog Hermes", config: {} }, + }, + }), + ), + ); + +it.effect("discovers the Hermes model catalog without blocking snapshots", () => + Effect.gen(function* () { + const releaseCatalog = yield* Deferred.make(); + const requests: Array = []; + const defaultBroker = yield* HermesGatewayBroker; + const broker = { + ...defaultBroker, + getInstanceStatus: () => Effect.succeed(connectedStatus), + isConnected: () => Effect.succeed(true), + request: (_requestedInstanceId, message) => + Effect.gen(function* () { + if (message.type !== "models.list.request") { + return yield* Effect.die( + new Error(`Unexpected Hermes gateway request '${message.type}'.`), + ); + } + requests.push(message); + yield* Deferred.await(releaseCatalog); + return { + type: "models.list.response", + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + requestId: message.requestId, + currentProvider: "anthropic", + currentModel: "anthropic/claude-sonnet-4", + currentReasoningEffort: "high", + reasoningEfforts: ["none", "low", "high"], + models: [ + { + provider: "anthropic", + providerName: "Anthropic", + model: "anthropic/claude-sonnet-4", + supportsReasoning: true, + }, + { + provider: "openrouter", + providerName: "OpenRouter", + model: "openai/gpt-5.4", + supportsReasoning: true, + }, + ], + } satisfies HermesGatewayModelsListResponse; + }), + streamStatuses: Stream.empty, + } satisfies typeof defaultBroker; + + const provider = yield* HermesDriver.create({ + instanceId, + displayName: "Catalog Hermes", + environment: [], + enabled: true, + config: HermesDriver.defaultConfig(), + }).pipe(Effect.provideService(HermesGatewayBroker, broker)); + + // The inventory request is deliberately parked. A snapshot must still be + // available immediately with the stable default model sentinel. + const initial = yield* provider.snapshot.getSnapshot; + expect(initial.models).toHaveLength(1); + expect(initial.models[0]?.slug).toBe(DEFAULT_HERMES_MODEL); + expect(initial.models[0]?.name).toBe("anthropic/claude-sonnet-4 (Hermes default)"); + + // Repeated reads in one connection generation share the in-flight + // discovery rather than issuing a request per status consumer. + yield* provider.snapshot.getSnapshot; + yield* Effect.yieldNow; + expect(requests.map((request) => request.type)).toEqual(["models.list.request"]); + + const catalogUpdate = yield* Stream.runHead(provider.snapshot.streamChanges).pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* Effect.yieldNow; + yield* Deferred.succeed(releaseCatalog, undefined); + + const updated = Option.getOrThrow(yield* Fiber.join(catalogUpdate)); + expect(updated.models.map((model) => model.name)).toEqual([ + "anthropic/claude-sonnet-4 (Hermes default)", + "anthropic/claude-sonnet-4", + "openai/gpt-5.4", + ]); + expect(decodeHermesModelSlug(updated.models[1]?.slug ?? "")).toEqual({ + mode: "specific", + provider: "anthropic", + model: "anthropic/claude-sonnet-4", + }); + expect(decodeHermesModelSlug(updated.models[2]?.slug ?? "")).toEqual({ + mode: "specific", + provider: "openrouter", + model: "openai/gpt-5.4", + }); + expect(updated.models[2]?.capabilities?.optionDescriptors?.[0]).toMatchObject({ + id: "reasoningEffort", + }); + expect(updated.models[2]?.capabilities?.optionDescriptors?.[0]).not.toHaveProperty( + "currentValue", + ); + expect(requests).toHaveLength(1); + }).pipe(Effect.provide(testLayer()), Effect.scoped), +); + +it.effect("retries a transient Hermes model catalog failure with bounded backoff", () => + Effect.gen(function* () { + const requests: Array = []; + const defaultBroker = yield* HermesGatewayBroker; + const broker = { + ...defaultBroker, + getInstanceStatus: () => Effect.succeed(connectedStatus), + isConnected: () => Effect.succeed(true), + request: (_requestedInstanceId, message) => + Effect.gen(function* () { + if (message.type !== "models.list.request") { + return yield* Effect.die( + new Error(`Unexpected Hermes gateway request '${message.type}'.`), + ); + } + requests.push(message); + if (requests.length === 1) { + return yield* new ProviderAdapterRequestError({ + provider: "hermes", + method: message.type, + detail: "temporary gateway failure", + }); + } + return catalogResponse(message, "anthropic/claude-opus-4"); + }), + streamStatuses: Stream.empty, + } satisfies typeof defaultBroker; + + const provider = yield* HermesDriver.create({ + instanceId, + displayName: "Catalog Hermes", + environment: [], + enabled: true, + config: HermesDriver.defaultConfig(), + }).pipe(Effect.provideService(HermesGatewayBroker, broker)); + const catalogUpdate = yield* Stream.runHead(provider.snapshot.streamChanges).pipe( + Effect.forkChild({ startImmediately: true }), + ); + + const initial = yield* provider.snapshot.getSnapshot; + expect(initial.models).toHaveLength(1); + yield* Effect.yieldNow; + expect(requests).toHaveLength(1); + + yield* TestClock.adjust("249 millis"); + expect(requests).toHaveLength(1); + yield* TestClock.adjust("1 millis"); + + const updated = Option.getOrThrow(yield* Fiber.join(catalogUpdate)); + expect(requests).toHaveLength(2); + expect(updated.models.map((model) => model.name)).toContain("anthropic/claude-opus-4"); + + yield* TestClock.adjust("5 seconds"); + expect(requests).toHaveLength(2); + }).pipe(Effect.provide(testLayer()), Effect.scoped), +); + +it.effect("fences a pending retry when the Hermes connection generation changes", () => + Effect.gen(function* () { + let status = connectedStatus; + const requestGenerations: Array = []; + const defaultBroker = yield* HermesGatewayBroker; + const broker = { + ...defaultBroker, + getInstanceStatus: () => Effect.succeed(status), + isConnected: () => Effect.succeed(true), + request: (_requestedInstanceId, message) => + Effect.gen(function* () { + if (message.type !== "models.list.request") { + return yield* Effect.die( + new Error(`Unexpected Hermes gateway request '${message.type}'.`), + ); + } + requestGenerations.push(status.connectionGeneration); + if (status.connectionGeneration === connectedStatus.connectionGeneration) { + return yield* new ProviderAdapterRequestError({ + provider: "hermes", + method: message.type, + detail: "old connection failed", + }); + } + return catalogResponse(message, "anthropic/claude-generation-8"); + }), + streamStatuses: Stream.empty, + } satisfies typeof defaultBroker; + + const provider = yield* HermesDriver.create({ + instanceId, + displayName: "Catalog Hermes", + environment: [], + enabled: true, + config: HermesDriver.defaultConfig(), + }).pipe(Effect.provideService(HermesGatewayBroker, broker)); + yield* provider.snapshot.getSnapshot; + yield* Effect.yieldNow; + expect(requestGenerations).toEqual([7]); + + status = { + ...connectedStatus, + model: "anthropic/claude-generation-8", + connectionGeneration: 8, + }; + const catalogUpdate = yield* Stream.runHead(provider.snapshot.streamChanges).pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* Effect.yieldNow; + yield* provider.snapshot.getSnapshot; + + const updated = Option.getOrThrow(yield* Fiber.join(catalogUpdate)); + expect(updated.models.map((model) => model.name)).toContain("anthropic/claude-generation-8"); + yield* TestClock.adjust("5 seconds"); + expect(requestGenerations).toEqual([7, 8]); + }).pipe(Effect.provide(testLayer()), Effect.scoped), +); + +it.effect("ignores an older same-generation response after a forced refresh", () => + Effect.gen(function* () { + const releaseFirstRequest = yield* Deferred.make(); + const requests: Array = []; + const defaultBroker = yield* HermesGatewayBroker; + const broker = { + ...defaultBroker, + getInstanceStatus: () => Effect.succeed(connectedStatus), + isConnected: () => Effect.succeed(true), + request: (_requestedInstanceId, message) => + Effect.gen(function* () { + if (message.type !== "models.list.request") { + return yield* Effect.die( + new Error(`Unexpected Hermes gateway request '${message.type}'.`), + ); + } + requests.push(message); + if (requests.length === 1) { + yield* Deferred.await(releaseFirstRequest); + return catalogResponse(message, "anthropic/stale-model"); + } + return catalogResponse(message, "anthropic/refreshed-model"); + }), + streamStatuses: Stream.empty, + } satisfies typeof defaultBroker; + + const provider = yield* HermesDriver.create({ + instanceId, + displayName: "Catalog Hermes", + environment: [], + enabled: true, + config: HermesDriver.defaultConfig(), + }).pipe(Effect.provideService(HermesGatewayBroker, broker)); + yield* provider.snapshot.getSnapshot; + yield* Effect.yieldNow; + expect(requests).toHaveLength(1); + + const catalogUpdate = yield* Stream.runHead(provider.snapshot.streamChanges).pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* Effect.yieldNow; + yield* provider.snapshot.refresh; + + const refreshed = Option.getOrThrow(yield* Fiber.join(catalogUpdate)); + expect(requests).toHaveLength(2); + expect(refreshed.models.map((model) => model.name)).toContain("anthropic/refreshed-model"); + + yield* Deferred.succeed(releaseFirstRequest, undefined); + yield* Effect.yieldNow; + const afterStaleResponse = yield* provider.snapshot.getSnapshot; + expect(afterStaleResponse.models.map((model) => model.name)).toContain( + "anthropic/refreshed-model", + ); + expect(afterStaleResponse.models.map((model) => model.name)).not.toContain( + "anthropic/stale-model", + ); + }).pipe(Effect.provide(testLayer()), Effect.scoped), +); + +it.effect("uses a distinct catalog request namespace after an instance rebuild", () => + Effect.gen(function* () { + const requests: Array = []; + const firstRequestSeen = yield* Deferred.make(); + const secondRequestSeen = yield* Deferred.make(); + const defaultBroker = yield* HermesGatewayBroker; + const broker = { + ...defaultBroker, + getInstanceStatus: () => Effect.succeed(connectedStatus), + isConnected: () => Effect.succeed(true), + request: (_requestedInstanceId, message) => + Effect.gen(function* () { + if (message.type !== "models.list.request") { + return yield* Effect.die( + new Error(`Unexpected Hermes gateway request '${message.type}'.`), + ); + } + requests.push(message); + yield* Deferred.succeed( + requests.length === 1 ? firstRequestSeen : secondRequestSeen, + undefined, + ); + return catalogResponse(message, "anthropic/claude-sonnet-4"); + }), + streamStatuses: Stream.empty, + } satisfies typeof defaultBroker; + + const firstScope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(firstScope, Exit.void)); + const firstProvider = yield* HermesDriver.create({ + instanceId, + displayName: "Catalog Hermes", + environment: [], + enabled: true, + config: HermesDriver.defaultConfig(), + }).pipe( + Effect.provideService(HermesGatewayBroker, broker), + Effect.provideService(Scope.Scope, firstScope), + ); + yield* firstProvider.snapshot.getSnapshot; + yield* Deferred.await(firstRequestSeen); + yield* Scope.close(firstScope, Exit.void); + + const secondScope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(secondScope, Exit.void)); + const secondProvider = yield* HermesDriver.create({ + instanceId, + displayName: "Catalog Hermes rebuilt", + environment: [], + enabled: true, + config: HermesDriver.defaultConfig(), + }).pipe( + Effect.provideService(HermesGatewayBroker, broker), + Effect.provideService(Scope.Scope, secondScope), + ); + yield* secondProvider.snapshot.getSnapshot; + yield* Deferred.await(secondRequestSeen); + + expect(requests).toHaveLength(2); + expect(requests[0]?.requestId).not.toBe(requests[1]?.requestId); + }).pipe(Effect.provide(testLayer()), Effect.scoped), +); + +it.effect("ends the old status stream when an instance scope closes", () => + Effect.gen(function* () { + const firstUpdateSeen = yield* Deferred.make(); + let updateCount = 0; + const defaultBroker = yield* HermesGatewayBroker; + const broker = { + ...defaultBroker, + getInstanceStatus: () => Effect.succeed(connectedStatus), + isConnected: () => Effect.succeed(false), + request: () => Effect.die(new Error("offline snapshots must not request a catalog")), + // Model the broker's process-lifetime stream: emit one status so the old + // subscription is demonstrably active, then remain open forever. + streamStatuses: Stream.concat(Stream.make(connectedStatus), Stream.never), + } satisfies typeof defaultBroker; + const instanceScope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(instanceScope, Exit.void)); + const provider = yield* HermesDriver.create({ + instanceId, + displayName: "Old Catalog Hermes", + environment: [], + enabled: true, + config: HermesDriver.defaultConfig(), + }).pipe( + Effect.provideService(HermesGatewayBroker, broker), + Effect.provideService(Scope.Scope, instanceScope), + ); + const oldSubscription = yield* Stream.runForEach(provider.snapshot.streamChanges, () => + Effect.sync(() => { + updateCount += 1; + }).pipe(Effect.andThen(Deferred.succeed(firstUpdateSeen, undefined)), Effect.ignore), + ).pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(firstUpdateSeen); + yield* Scope.close(instanceScope, Exit.void); + yield* Fiber.join(oldSubscription); + // A registry rebuild reuses this process-wide source. Completion here + // proves the old driver's consumer cannot later overwrite its replacement. + expect(updateCount).toBe(1); + }).pipe(Effect.provide(testLayer()), Effect.scoped), +); diff --git a/apps/server/src/provider/Drivers/HermesDriver.ts b/apps/server/src/provider/Drivers/HermesDriver.ts index 3dce0f83276..eb996cf1e39 100644 --- a/apps/server/src/provider/Drivers/HermesDriver.ts +++ b/apps/server/src/provider/Drivers/HermesDriver.ts @@ -1,23 +1,31 @@ import { - DEFAULT_HERMES_MODEL, HERMES_DRIVER_KIND, + HERMES_GATEWAY_PROTOCOL_VERSION, + HermesGatewayRequestId, HermesSettings, TextGenerationError, + type HermesGatewayInstanceStatus, + type HermesGatewayModelsListResponse, type ServerProvider, type ServerSettings, } from "@t3tools/contracts"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; 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 Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import { ServerConfig } from "../../config.ts"; import { readHomeThreadId } from "../../orchestration/homeThreads.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import type { TextGenerationShape } from "../../textGeneration/TextGeneration.ts"; +import { ProviderDriverError } from "../Errors.ts"; import { makeHermesAdapter } from "../Layers/HermesAdapter.ts"; import { defaultProviderContinuationIdentity, @@ -25,10 +33,35 @@ import { type ProviderInstance, } from "../ProviderDriver.ts"; import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; +import { hermesServerModels } from "../hermesModels.ts"; import { HermesGatewayBroker } from "../Services/HermesGatewayBroker.ts"; const decodeHermesSettings = Schema.decodeSync(HermesSettings); +const HERMES_CATALOG_RETRY_DELAYS = ["250 millis", "1 second"] as const; + +interface HermesCatalogRequest { + readonly generation: number; + readonly cycleId: number; + readonly requestId: HermesGatewayRequestId; +} + +interface HermesCatalogState { + readonly nextRequestEpoch: number; + readonly observedGeneration: number | null; + readonly activeRequest: HermesCatalogRequest | null; + readonly catalogGeneration: number | null; + readonly catalog: HermesGatewayModelsListResponse | undefined; +} + +const isSameCatalogRequest = ( + active: HermesCatalogRequest | null, + expected: HermesCatalogRequest, +) => + active?.generation === expected.generation && + active.cycleId === expected.cycleId && + active.requestId === expected.requestId; + export type HermesDriverEnv = | Crypto.Crypto | FileSystem.FileSystem @@ -67,6 +100,35 @@ export const HermesDriver: ProviderDriver = { create: ({ instanceId, displayName, accentColor, enabled }) => Effect.gen(function* () { const broker = yield* HermesGatewayBroker; + const instanceScope = yield* Scope.Scope; + const crypto = yield* Crypto.Crypto; + const requestNamespace = yield* crypto.randomUUIDv4.pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: HERMES_DRIVER_KIND, + instanceId, + detail: "Failed to generate a Hermes catalog request namespace.", + cause, + }), + ), + ); + const instanceClosed = yield* Deferred.make(); + const catalogChanges = yield* PubSub.unbounded(); + yield* Scope.addFinalizer( + instanceScope, + Deferred.succeed(instanceClosed, undefined).pipe( + Effect.andThen(PubSub.shutdown(catalogChanges)), + Effect.ignore, + ), + ); + const catalogState = yield* Ref.make({ + nextRequestEpoch: 0, + observedGeneration: null, + activeRequest: null, + catalogGeneration: null, + catalog: undefined, + }); // Captured once at construction: `getSnapshot` must be context-free // (`R = never`) because the registry calls it outside this scope. const settings = yield* ServerSettingsService; @@ -79,75 +141,250 @@ export const HermesDriver: ProviderDriver = { 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.catchTags({ HermesGatewayManagementError: () => Effect.succeed(undefined) }), + + /** + * Catalog discovery can touch cached provider inventories on the remote + * Hermes host, so it never blocks a snapshot read. One request is + * launched per connection generation; transient failures retry with a + * short bounded backoff, and completion publishes a second snapshot with + * the real picker choices. + */ + const scheduleCatalogRefresh = ( + status: HermesGatewayInstanceStatus | undefined, + connected: boolean, + force: boolean, + ) => + Effect.gen(function* () { + const generation = connected ? (status?.connectionGeneration ?? null) : null; + if (generation === null) { + yield* Ref.update(catalogState, (current) => ({ + ...current, + observedGeneration: null, + activeRequest: null, + })); + return; + } + + const claimed = yield* Ref.modify(catalogState, (current) => { + // Connection generations are monotonic for the lifetime of this + // driver. A snapshot that read the previous status must not + // displace a request already claimed by the replacement socket. + if (current.observedGeneration !== null && generation < current.observedGeneration) { + return [undefined, current] as const; + } + if ( + !force && + current.observedGeneration === generation && + (current.activeRequest?.generation === generation || + current.catalogGeneration === generation) + ) { + return [undefined, current] as const; + } + const candidate: HermesCatalogRequest = { + generation, + cycleId: current.nextRequestEpoch, + requestId: HermesGatewayRequestId.make( + `t3-models-${instanceId}-${generation}-${requestNamespace}-${current.nextRequestEpoch}`, + ), + }; + return [ + candidate, + { + ...current, + nextRequestEpoch: current.nextRequestEpoch + 1, + observedGeneration: generation, + activeRequest: candidate, + }, + ] as const; + }); + if (claimed === undefined) return; + + const requestIsCurrent = (request: HermesCatalogRequest) => + Effect.gen(function* () { + const stillConnected = yield* broker.isConnected(instanceId); + const latestStatus = Option.getOrUndefined( + yield* broker.getInstanceStatus(instanceId).pipe(Effect.option), + ); + const latestGeneration = stillConnected + ? (latestStatus?.connectionGeneration ?? null) + : null; + + return yield* Ref.modify(catalogState, (current) => { + if (!isSameCatalogRequest(current.activeRequest, request)) { + return [false, current] as const; + } + if (latestGeneration !== request.generation) { + return [ + false, + { + ...current, + observedGeneration: latestGeneration, + activeRequest: null, + }, + ] as const; + } + return [true, current] as const; + }); + }); + + const load = Effect.gen(function* () { + let request = claimed; + + for (let attempt = 0; ; attempt += 1) { + if (!(yield* requestIsCurrent(request))) return; + + const response = Option.getOrUndefined( + yield* broker + .request(instanceId, { + type: "models.list.request", + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + requestId: request.requestId, + }) + .pipe(Effect.option), + ); + + if ( + response?.type === "models.list.response" && + response.requestId === request.requestId && + (yield* requestIsCurrent(request)) + ) { + const stored = yield* Ref.modify(catalogState, (current) => + isSameCatalogRequest(current.activeRequest, request) + ? [ + true, + { + ...current, + activeRequest: null, + catalogGeneration: request.generation, + catalog: response, + }, + ] + : [false, current], + ); + if (stored) yield* PubSub.publish(catalogChanges, undefined); + return; + } + + const delay = HERMES_CATALOG_RETRY_DELAYS[attempt]; + if (delay === undefined) return; + yield* Effect.sleep(delay); + if (!(yield* requestIsCurrent(request))) return; + + const retry = yield* Ref.modify(catalogState, (current) => { + if (!isSameCatalogRequest(current.activeRequest, request)) { + return [undefined, current] as const; + } + const next: HermesCatalogRequest = { + generation: request.generation, + cycleId: request.cycleId, + requestId: HermesGatewayRequestId.make( + `t3-models-${instanceId}-${request.generation}-${requestNamespace}-${current.nextRequestEpoch}`, + ), + }; + return [ + next, + { + ...current, + nextRequestEpoch: current.nextRequestEpoch + 1, + activeRequest: next, + }, + ] as const; + }); + if (retry === undefined) return; + request = retry; + } + }).pipe( + // Scope interruption must release this cycle without clearing a + // newer request claimed by refresh or a replacement connection. + Effect.ensuring( + Ref.update(catalogState, (current) => + current.activeRequest?.generation === claimed.generation && + current.activeRequest.cycleId === claimed.cycleId + ? { ...current, activeRequest: null } + : current, + ), + ), ); - // Read-only: a snapshot must never create the thread as a side effect - // (this runs on every status tick). The handshake owns creation. - // A settings read that fails degrades to "no designation" rather than - // failing the whole snapshot — the pin is cosmetic, the status is not. - const currentSettings = yield* settings.getSettings.pipe( - Effect.map(Option.some), - Effect.orElseSucceed(() => Option.none()), - ); - const homeThreadId = Option.isSome(currentSettings) - ? readHomeThreadId(currentSettings.value.providerInstances[instanceId]) - : undefined; - return { - ...(homeThreadId !== undefined ? { homeThreadId } : {}), - instanceId, - driver: HERMES_DRIVER_KIND, - ...(displayName ? { displayName } : {}), - ...(accentColor ? { accentColor } : {}), - continuation: { groupKey: continuationIdentity.continuationKey }, - showInteractionModeToggle: false, - requiresNewThreadForModelChange: true, - requiresWorkspace: false, - 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", - // The slug stays `DEFAULT_HERMES_MODEL` no matter what the plugin - // reports: threads bind to it, and letting it follow Hermes' current - // config would orphan every thread whose model changed on the Hermes - // side. The reported model is used only as the human-facing name, so - // the picker says "gpt-5.6-terra" instead of a generic placeholder. - // Falls back to "Hermes" when no plugin has connected yet or the - // plugin predates the `model` field on `connection.hello`. - models: [ - { - slug: DEFAULT_HERMES_MODEL, - name: status?.model ?? "Hermes", - isCustom: false, - isDefault: true, - capabilities: null, + yield* load.pipe(Effect.forkIn(instanceScope)); + }); + + const snapshotFromStatus = ( + connected: boolean, + status: HermesGatewayInstanceStatus | undefined, + catalog: HermesGatewayModelsListResponse | undefined, + ) => + Effect.gen(function* () { + // Read-only: a snapshot must never create the thread as a side effect + // (this runs on every status tick). The handshake owns creation. + // A settings read that fails degrades to "no designation" rather than + // failing the whole snapshot — the pin is cosmetic, the status is not. + const currentSettings = yield* settings.getSettings.pipe( + Effect.map(Option.some), + Effect.orElseSucceed(() => Option.none()), + ); + const homeThreadId = Option.isSome(currentSettings) + ? readHomeThreadId(currentSettings.value.providerInstances[instanceId]) + : undefined; + return { + ...(homeThreadId !== undefined ? { homeThreadId } : {}), + instanceId, + driver: HERMES_DRIVER_KIND, + ...(displayName ? { displayName } : {}), + ...(accentColor ? { accentColor } : {}), + continuation: { groupKey: continuationIdentity.continuationKey }, + showInteractionModeToggle: false, + requiresNewThreadForModelChange: false, + requiresWorkspace: false, + 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", }, - ], - slashCommands: [], - skills: [], - } satisfies ServerProvider; - }); + checkedAt: DateTime.formatIso(DateTime.nowUnsafe()), + ...(!connected && enabled + ? { message: "Hermes is offline. Reconnect its T3 Code gateway plugin." } + : {}), + availability: "available", + // Keep the stable `hermes` sentinel for every existing thread, then + // add provider-qualified catalog entries once the live plugin has + // answered the asynchronous inventory request. + models: hermesServerModels({ reportedModel: status?.model, catalog }), + slashCommands: [], + skills: [], + } satisfies ServerProvider; + }); + + const readSnapshot = (forceCatalogRefresh: boolean) => + Effect.gen(function* () { + const connected = yield* broker.isConnected(instanceId); + const status = Option.getOrUndefined( + yield* broker.getInstanceStatus(instanceId).pipe(Effect.option), + ); + yield* scheduleCatalogRefresh(status, connected, forceCatalogRefresh); + const state = yield* Ref.get(catalogState); + const generation = connected ? (status?.connectionGeneration ?? null) : null; + const catalog = + generation !== null && state.catalogGeneration === generation + ? state.catalog + : undefined; + return yield* snapshotFromStatus(connected, status, catalog); + }); + const getSnapshot = readSnapshot(false); const snapshot = { maintenanceCapabilities, getSnapshot, - refresh: getSnapshot, - streamChanges: broker.streamStatuses.pipe( - Stream.filter((status) => status.instanceId === instanceId), - Stream.mapEffect(() => getSnapshot), + refresh: readSnapshot(true), + streamChanges: Stream.merge( + broker.streamStatuses.pipe( + Stream.filter((status) => status.instanceId === instanceId), + Stream.mapEffect(() => getSnapshot), + Stream.interruptWhen(Deferred.await(instanceClosed)), + ), + Stream.fromPubSub(catalogChanges).pipe(Stream.mapEffect(() => getSnapshot)), ), }; diff --git a/apps/server/src/provider/Layers/HermesAdapter.test.ts b/apps/server/src/provider/Layers/HermesAdapter.test.ts index a7956e4d6b7..6436a407190 100644 --- a/apps/server/src/provider/Layers/HermesAdapter.test.ts +++ b/apps/server/src/provider/Layers/HermesAdapter.test.ts @@ -34,6 +34,7 @@ import { type HermesGatewayBrokerShape, type HermesGatewayEnvelope, } from "../Services/HermesGatewayBroker.ts"; +import { encodeHermesModelSlug } from "../hermesModels.ts"; import { makeHermesAdapter, sanitizeHermesItemData, @@ -1472,7 +1473,13 @@ it.effect("reads a skill body and passes a null body through unchanged", () => * A broker fake that answers session.ensure and turn.start/steer, recording * everything sent — the minimum surface `sendTurn` touches. */ -const makeTurnBroker = () => { +const makeTurnBroker = ( + options: { + readonly acknowledgeSelection?: boolean; + readonly acknowledgeReasoning?: boolean; + readonly failSend?: boolean; + } = {}, +) => { const sent: Array = []; const broker: HermesGatewayBrokerShape = { createEnrollment: () => Effect.die(new Error("unused")), @@ -1504,11 +1511,37 @@ const makeTurnBroker = () => { threadId: message.threadId, sessionId: message.sessionId, turnId: message.turnId, + ...(message.type === "turn.start" && + options.acknowledgeSelection && + message.modelSelection?.mode === "specific" + ? { + appliedModelSelection: { + provider: message.modelSelection.provider, + model: message.modelSelection.model, + }, + ...(message.reasoningEffort !== undefined && options.acknowledgeReasoning !== false + ? { appliedReasoningEffort: message.reasoningEffort } + : {}), + } + : {}), }); } return Effect.die(new Error(`unexpected request ${message.type}`)); }, - send: (_instanceId, message) => Effect.sync(() => sent.push(message)).pipe(Effect.asVoid), + send: (_instanceId, message) => + Effect.sync(() => sent.push(message)).pipe( + Effect.flatMap(() => + options.failSend + ? Effect.fail( + new ProviderAdapterRequestError({ + provider: ProviderDriverKind.make("hermes"), + method: message.type, + detail: "synthetic send failure", + }), + ) + : Effect.void, + ), + ), isConnected: () => Effect.succeed(true), stream: Stream.empty, streamStatuses: Stream.empty, @@ -1516,6 +1549,189 @@ const makeTurnBroker = () => { return { sent, broker } as const; }; +it.effect("applies model and reasoning selections only at a fresh turn boundary", () => + Effect.gen(function* () { + const instanceId = ProviderInstanceId.make("hermes_model_selection"); + const threadId = ThreadId.make("thread-model-selection"); + const selectedModel = encodeHermesModelSlug({ + provider: "openrouter", + model: "anthropic/claude-sonnet-4.5", + }); + const { sent, broker } = makeTurnBroker({ acknowledgeSelection: true }); + const adapter = yield* makeHermesAdapter({ instanceId }).pipe( + Effect.provideService(HermesGatewayBroker, broker), + ); + + yield* adapter.startSession({ + threadId, + providerInstanceId: instanceId, + runtimeMode: "full-access", + }); + const started = yield* adapter.sendTurn({ + threadId, + input: "use the selected model", + modelSelection: { + instanceId, + model: selectedModel, + options: [{ id: "reasoningEffort", value: "high" }], + }, + }); + + const turnStart = sent.find((message) => message.type === "turn.start"); + if (!turnStart || turnStart.type !== "turn.start") { + return yield* Effect.die(new Error("turn.start was not sent")); + } + assert.deepEqual(turnStart.modelSelection, { + mode: "specific", + provider: "openrouter", + model: "anthropic/claude-sonnet-4.5", + }); + assert.equal(turnStart.reasoningEffort, "high"); + + const sessionAfterStart = (yield* adapter.listSessions())[0]; + assert.equal(sessionAfterStart?.model, selectedModel); + assert.equal(sessionAfterStart?.activeTurnId, started.turnId); + + yield* adapter.sendTurn({ + threadId, + input: "continue without switching mid-turn", + modelSelection: { + instanceId, + model: encodeHermesModelSlug({ provider: "openai", model: "gpt-5" }), + options: [{ id: "reasoningEffort", value: "ultra" }], + }, + }); + + 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")); + } + assert.isFalse("modelSelection" in turnSteer); + assert.isFalse("reasoningEffort" in turnSteer); + + const sessionAfterSteer = (yield* adapter.listSessions())[0]; + assert.equal(sessionAfterSteer?.model, selectedModel); + assert.equal(sessionAfterSteer?.activeTurnId, started.turnId); + }).pipe(Effect.scoped, Effect.provide(testEnvLayer)), +); + +it.effect("interrupts a started turn when Hermes does not acknowledge its model", () => + Effect.gen(function* () { + const instanceId = ProviderInstanceId.make("hermes_model_mismatch"); + const threadId = ThreadId.make("thread-model-mismatch"); + const selectedModel = encodeHermesModelSlug({ provider: "openai", model: "gpt-5" }); + // The interrupt transport failure is intentional: the acknowledgement + // error remains authoritative even when best-effort compensation fails. + const { sent, broker } = makeTurnBroker({ failSend: true }); + const adapter = yield* makeHermesAdapter({ instanceId }).pipe( + Effect.provideService(HermesGatewayBroker, broker), + ); + + const sessionBefore = yield* adapter.startSession({ + threadId, + providerInstanceId: instanceId, + runtimeMode: "full-access", + }); + const sessionSnapshot = { ...sessionBefore }; + const error = yield* Effect.flip( + adapter.sendTurn({ + threadId, + input: "use the selected model", + modelSelection: { + instanceId, + model: selectedModel, + options: [{ id: "reasoningEffort", value: "high" }], + }, + }), + ); + + assert.equal(error._tag, "ProviderAdapterRequestError"); + if (error._tag === "ProviderAdapterRequestError") { + assert.equal(error.detail, "Hermes did not confirm the requested model selection."); + } + const turnStart = sent.find((message) => message.type === "turn.start"); + const interrupts = sent.filter((message) => message.type === "turn.interrupt"); + assert.lengthOf(interrupts, 1); + if ( + !turnStart || + turnStart.type !== "turn.start" || + !interrupts[0] || + interrupts[0].type !== "turn.interrupt" + ) { + return yield* Effect.die(new Error("missing turn.start or compensating turn.interrupt")); + } + assert.equal(interrupts[0].threadId, turnStart.threadId); + assert.equal(interrupts[0].sessionId, turnStart.sessionId); + assert.equal(interrupts[0].turnId, turnStart.turnId); + assert.notEqual(interrupts[0].requestId, turnStart.requestId); + + const sessionAfter = (yield* adapter.listSessions())[0]; + assert.deepEqual(sessionAfter, sessionSnapshot); + + // A retry without a selection also proves the rejected selection was not + // written into the adapter's private session context. + yield* adapter.sendTurn({ threadId, input: "retry with the session default" }); + const turnStarts = sent.filter((message) => message.type === "turn.start"); + assert.lengthOf(turnStarts, 2); + assert.isFalse("modelSelection" in turnStarts[1]!); + }).pipe(Effect.scoped, Effect.provide(testEnvLayer)), +); + +it.effect("interrupts only once when Hermes does not acknowledge reasoning", () => + Effect.gen(function* () { + const instanceId = ProviderInstanceId.make("hermes_reasoning_mismatch"); + const threadId = ThreadId.make("thread-reasoning-mismatch"); + const selectedModel = encodeHermesModelSlug({ provider: "openai", model: "gpt-5" }); + const { sent, broker } = makeTurnBroker({ + acknowledgeSelection: true, + acknowledgeReasoning: false, + }); + const adapter = yield* makeHermesAdapter({ instanceId }).pipe( + Effect.provideService(HermesGatewayBroker, broker), + ); + + const sessionBefore = yield* adapter.startSession({ + threadId, + providerInstanceId: instanceId, + runtimeMode: "full-access", + }); + const sessionSnapshot = { ...sessionBefore }; + const error = yield* Effect.flip( + adapter.sendTurn({ + threadId, + input: "use high reasoning", + modelSelection: { + instanceId, + model: selectedModel, + options: [{ id: "reasoningEffort", value: "high" }], + }, + }), + ); + + assert.equal(error._tag, "ProviderAdapterRequestError"); + if (error._tag === "ProviderAdapterRequestError") { + assert.equal(error.detail, "Hermes did not confirm the requested reasoning effort."); + } + const turnStart = sent.find((message) => message.type === "turn.start"); + const interrupts = sent.filter((message) => message.type === "turn.interrupt"); + assert.lengthOf(interrupts, 1); + if ( + !turnStart || + turnStart.type !== "turn.start" || + !interrupts[0] || + interrupts[0].type !== "turn.interrupt" + ) { + return yield* Effect.die(new Error("missing turn.start or compensating turn.interrupt")); + } + assert.equal(interrupts[0].threadId, turnStart.threadId); + assert.equal(interrupts[0].sessionId, turnStart.sessionId); + assert.equal(interrupts[0].turnId, turnStart.turnId); + + const sessionAfter = (yield* adapter.listSessions())[0]; + assert.deepEqual(sessionAfter, sessionSnapshot); + }).pipe(Effect.scoped, Effect.provide(testEnvLayer)), +); + /** Write attachment bytes where the adapter will look for them. */ const writeAttachmentFixture = (attachment: ChatAttachment, bytes: Uint8Array) => Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/HermesAdapter.ts b/apps/server/src/provider/Layers/HermesAdapter.ts index d6742854f10..a35f49e17e5 100644 --- a/apps/server/src/provider/Layers/HermesAdapter.ts +++ b/apps/server/src/provider/Layers/HermesAdapter.ts @@ -2,6 +2,7 @@ import { EventId, HERMES_GATEWAY_PROTOCOL_VERSION, HERMES_MEDIA_MAX_BYTES, + HermesGatewayReasoningEffort, HermesGatewayRequestId, HermesGatewayResumeCursor, HermesGatewaySessionId, @@ -12,7 +13,9 @@ import { TurnId, type CanonicalRequestType, type HermesGatewayPluginToT3Message, + type HermesGatewayRequestedModelSelection, type HermesGatewayTurnAttachment, + type ModelSelection, type ProviderInstanceId, type ProviderRuntimeEvent, type ProviderSession, @@ -26,6 +29,8 @@ import * as PubSub from "effect/PubSub"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; +import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; + import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; import { @@ -35,9 +40,11 @@ import { } from "../Errors.ts"; import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; import { HermesGatewayBroker } from "../Services/HermesGatewayBroker.ts"; +import { decodeHermesModelSlug } from "../hermesModels.ts"; const PROVIDER = ProviderDriverKind.make("hermes"); const isResumeCursor = Schema.is(HermesGatewayResumeCursor); +const isHermesReasoningEffort = Schema.is(HermesGatewayReasoningEffort); /** * Hermes never queues. When the gateway socket is down there is nothing on the @@ -72,6 +79,7 @@ interface PendingInteraction { interface SessionContext { hermesSessionId: HermesGatewaySessionId; + modelSelection: ModelSelection | undefined; readonly turns: Array<{ readonly id: TurnId; readonly items: Array }>; /** * Approval and user-input requests T3 has shown but Hermes has not resolved. @@ -280,7 +288,12 @@ export const makeHermesAdapter = Effect.fn("makeHermesAdapter")(function* (input return { ...(yield* eventBase(message)), type: "turn.started", - payload: {}, + payload: { + ...(message.appliedModelSelection + ? { model: message.appliedModelSelection.model } + : {}), + ...(message.appliedReasoningEffort ? { effort: message.appliedReasoningEffort } : {}), + }, }; } case "content.delta": { @@ -710,6 +723,7 @@ export const makeHermesAdapter = Effect.fn("makeHermesAdapter")(function* (input sessions.set(sessionInput.threadId, { session, hermesSessionId: response.sessionId, + modelSelection: sessionInput.modelSelection, turns: activeTurnId === undefined ? [] : [{ id: activeTurnId, items: [] }], pendingApprovals: new Map(), pendingUserInputs: new Map(), @@ -809,16 +823,68 @@ export const makeHermesAdapter = Effect.fn("makeHermesAdapter")(function* (input yield* requireConnected(method); const turnId = activeTurnId ?? TurnId.make(`hermes-turn-${yield* randomId}`); const outboundRequestId = yield* requestId; - 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, - ...(attachments !== undefined ? { attachments } : {}), - }); + const selectedModel = + turnInput.modelSelection ?? + context.modelSelection ?? + (context.session.model + ? { instanceId: input.instanceId, model: context.session.model } + : undefined); + let requestedModel: HermesGatewayRequestedModelSelection | undefined; + let requestedReasoning: HermesGatewayReasoningEffort | undefined; + // A steer belongs to the already-running agent. Selections are applied + // only at the next fresh turn boundary, never in the middle of a run. + if (activeTurnId === undefined && selectedModel !== undefined) { + if (selectedModel.instanceId !== input.instanceId) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: `Hermes model selection targets instance '${selectedModel.instanceId}', expected '${input.instanceId}'.`, + }); + } + requestedModel = decodeHermesModelSlug(selectedModel.model); + if (requestedModel === undefined) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: `Unknown Hermes model selection '${selectedModel.model}'. Refresh the model list and choose an available model.`, + }); + } + const rawReasoning = getModelSelectionStringOptionValue(selectedModel, "reasoningEffort"); + if (rawReasoning !== undefined && !isHermesReasoningEffort(rawReasoning)) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: `Unsupported Hermes reasoning effort '${rawReasoning}'.`, + }); + } + requestedReasoning = rawReasoning; + } + const response = yield* broker.request( + input.instanceId, + activeTurnId + ? { + type: "turn.steer", + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + requestId: outboundRequestId, + threadId: turnInput.threadId, + sessionId: context.hermesSessionId, + turnId, + text, + ...(attachments !== undefined ? { attachments } : {}), + } + : { + type: "turn.start", + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + requestId: outboundRequestId, + threadId: turnInput.threadId, + sessionId: context.hermesSessionId, + turnId, + text, + ...(attachments !== undefined ? { attachments } : {}), + ...(requestedModel !== undefined ? { modelSelection: requestedModel } : {}), + ...(requestedReasoning !== undefined ? { reasoningEffort: requestedReasoning } : {}), + }, + ); if (response.type !== "turn.started") { return yield* new ProviderAdapterRequestError({ provider: PROVIDER, @@ -829,7 +895,61 @@ export const makeHermesAdapter = Effect.fn("makeHermesAdapter")(function* (input : `Expected turn.started, received '${response.type}'.`, }); } - updateSession(context, { status: "running", activeTurnId: turnId }); + + let selectionAcknowledgementError: string | undefined; + if (activeTurnId === undefined && requestedModel !== undefined) { + const applied = response.appliedModelSelection; + const modelMatches = + applied !== undefined && + (requestedModel.mode === "default" || + (applied.provider === requestedModel.provider && applied.model === requestedModel.model)); + if (!modelMatches) { + selectionAcknowledgementError = "Hermes did not confirm the requested model selection."; + } + } + if ( + selectionAcknowledgementError === undefined && + activeTurnId === undefined && + requestedReasoning !== undefined && + response.appliedReasoningEffort !== requestedReasoning + ) { + selectionAcknowledgementError = "Hermes did not confirm the requested reasoning effort."; + } + if (selectionAcknowledgementError !== undefined) { + // `turn.started` means Hermes has already launched the agent. If its + // acknowledgement does not match the requested configuration, reject + // the send without leaving that now-untracked turn running remotely. + // Compensation is deliberately best-effort: a transport failure must + // not replace the more useful selection acknowledgement error. + yield* requestId.pipe( + Effect.flatMap((interruptRequestId) => + broker.send(input.instanceId, { + type: "turn.interrupt", + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + requestId: interruptRequestId, + threadId: turnInput.threadId, + sessionId: response.sessionId, + turnId: response.turnId, + }), + ), + Effect.ignore, + ); + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method, + detail: selectionAcknowledgementError, + }); + } + if (activeTurnId === undefined && selectedModel !== undefined) { + context.modelSelection = selectedModel; + } + updateSession(context, { + status: "running", + activeTurnId: turnId, + ...(activeTurnId === undefined && selectedModel !== undefined + ? { model: selectedModel.model } + : {}), + }); trackTurn(context, turnId); return { threadId: turnInput.threadId, @@ -933,7 +1053,7 @@ export const makeHermesAdapter = Effect.fn("makeHermesAdapter")(function* (input const adapter: HermesAdapterShape = { provider: PROVIDER, - capabilities: { sessionModelSwitch: "unsupported" }, + capabilities: { sessionModelSwitch: "in-session" }, describe, getSkillBody, startSession, diff --git a/apps/server/src/provider/Layers/HermesGatewayBroker.test.ts b/apps/server/src/provider/Layers/HermesGatewayBroker.test.ts index 5a39fc86a45..a9412766798 100644 --- a/apps/server/src/provider/Layers/HermesGatewayBroker.test.ts +++ b/apps/server/src/provider/Layers/HermesGatewayBroker.test.ts @@ -605,7 +605,9 @@ it.effect("shares one live broker between the gateway route and Hermes provider }) .pipe(Effect.forkChild({ startImmediately: true })); yield* Effect.yieldNow; - const ensure = sent.at(-1); + const ensure = sent.findLast( + (message) => message.type === "session.ensure" && message.threadId === threadId, + ); if (!ensure || ensure.type !== "session.ensure") { return yield* Effect.die(new Error("session.ensure did not reach the gateway transport")); } @@ -624,7 +626,9 @@ it.effect("shares one live broker between the gateway route and Hermes provider .sendTurn({ threadId, input: "hello through the shared broker" }) .pipe(Effect.forkChild({ startImmediately: true })); yield* Effect.yieldNow; - const turnStart = sent.at(-1); + const turnStart = sent.findLast( + (message) => message.type === "turn.start" && message.threadId === threadId, + ); if (!turnStart || turnStart.type !== "turn.start") { return yield* Effect.die(new Error("turn.start did not reach the gateway transport")); } diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index ae69664301d..a1f7a48990b 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -16,6 +16,7 @@ import { ClaudeSettings, CodexSettings, DEFAULT_SERVER_SETTINGS, + HERMES_DRIVER_KIND, ProviderDriverKind, ProviderInstanceId, ServerSettings, @@ -786,6 +787,95 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ]); }); + it("retains pending Hermes catalogs and treats completed refreshes as authoritative", () => { + const previousProvider = { + instanceId: ProviderInstanceId.make("hermes-mac-mini"), + driver: HERMES_DRIVER_KIND, + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-07-30T00:00:00.000Z", + version: "0.19.0", + models: [ + { + slug: "hermes", + name: "gpt-5.6-sol (Hermes default)", + isCustom: false, + isDefault: true, + capabilities: createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("reasoningEffort", "Reasoning", [ + { id: "high", label: "High", isDefault: true }, + ]), + ], + }), + }, + { + slug: "hermes-model:openai-codex:gpt-5.4", + name: "gpt-5.4", + isCustom: false, + capabilities: null, + }, + ], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const pendingReconnectProvider = { + ...previousProvider, + checkedAt: "2026-07-30T00:01:00.000Z", + models: [ + { + slug: "hermes", + name: "gpt-5.6-sol (Hermes default)", + isCustom: false, + isDefault: true, + capabilities: createModelCapabilities({ optionDescriptors: [] }), + }, + ], + } satisfies ServerProvider; + const exhaustedDiscoveryProvider = { + ...pendingReconnectProvider, + checkedAt: "2026-07-30T00:02:00.000Z", + } satisfies ServerProvider; + const refreshedProvider = { + ...pendingReconnectProvider, + checkedAt: "2026-07-30T00:03:00.000Z", + models: [ + { + slug: "hermes", + name: "gpt-5.6-sol (Hermes default)", + isCustom: false, + isDefault: true, + // A completed v5 response with a genuinely empty model list + // still carries the unresolved reasoning choices. That makes it + // authoritative without inventing a user-selected default. + capabilities: createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("reasoningEffort", "Reasoning", [ + { id: "none", label: "None" }, + { id: "high", label: "High" }, + ]), + ], + }), + }, + ], + } satisfies ServerProvider; + + assert.deepStrictEqual( + mergeProviderSnapshot(previousProvider, pendingReconnectProvider).models, + [...previousProvider.models], + ); + assert.deepStrictEqual( + mergeProviderSnapshot(previousProvider, exhaustedDiscoveryProvider).models, + [...previousProvider.models], + ); + + assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [ + ...refreshedProvider.models, + ]); + }); + it.effect("does not run provider probes during layer construction", () => Effect.gen(function* () { const codexDriver = ProviderDriverKind.make("codex"); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index e96f62d74b6..1ad8f6b093d 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -24,6 +24,7 @@ */ import { defaultInstanceIdForDriver, + HERMES_DRIVER_KIND, ProviderDescribeError, ProviderDriverKind, type ProviderInstanceId, @@ -81,6 +82,18 @@ const makeManualProviderMaintenanceCapabilities = (provider: ProviderDriverKind) const hasModelCapabilities = (model: ServerProvider["models"][number]): boolean => (model.capabilities?.optionDescriptors?.length ?? 0) > 0; +/** + * A pending Hermes snapshot contains only its stable default sentinel with no + * controls. A completed v5 catalog always adds at least one provider-qualified + * model, or (for a genuinely empty inventory) advertises the reasoning selector + * on that sentinel. That distinction lets the registry keep the last good + * catalog through startup, reconnect, and exhausted discovery without making a + * real empty response unable to remove stale models or capabilities. + */ +const hasDiscoveredHermesCatalog = ( + models: ReadonlyArray, +): boolean => models.length > 1 || models.some(hasModelCapabilities); + const shouldRetainMissingProviderModels = (provider: ServerProvider): boolean => { if (provider.driver !== ProviderDriverKind.make("opencode")) { return true; @@ -103,6 +116,15 @@ const mergeProviderModels = ( previousModels: ReadonlyArray, nextModels: ReadonlyArray, ): ReadonlyArray => { + if (provider.driver === HERMES_DRIVER_KIND) { + // Only a completed catalog response is authoritative. The driver's first + // snapshot for a new connection generation is intentionally non-blocking, + // so replacing a cached catalog with that sentinel would make model and + // reasoning controls disappear whenever discovery is slow or exhausted. + if (hasDiscoveredHermesCatalog(nextModels)) return nextModels; + return hasDiscoveredHermesCatalog(previousModels) ? previousModels : nextModels; + } + const shouldRetainMissingModels = shouldRetainMissingProviderModels(provider); if (shouldRetainMissingModels && nextModels.length === 0 && previousModels.length > 0) { diff --git a/apps/server/src/provider/hermesModels.test.ts b/apps/server/src/provider/hermesModels.test.ts new file mode 100644 index 00000000000..df943c6c393 --- /dev/null +++ b/apps/server/src/provider/hermesModels.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "@effect/vitest"; +import { + DEFAULT_HERMES_MODEL, + HERMES_GATEWAY_PROTOCOL_VERSION, + HermesGatewayRequestId, +} from "@t3tools/contracts"; + +import { + buildProviderOptionSelectionsFromDescriptors, + getProviderOptionDescriptors, +} from "@t3tools/shared/model"; + +import { + decodeHermesModelSlug, + encodeHermesModelSlug, + hermesServerModels, +} from "./hermesModels.ts"; + +describe("Hermes model slugs", () => { + it("round-trips provider-qualified ids without delimiter collisions", () => { + const slug = encodeHermesModelSlug({ + provider: "custom:local/proxy", + model: "anthropic/claude-sonnet-4:extended", + }); + + expect(decodeHermesModelSlug(slug)).toEqual({ + mode: "specific", + provider: "custom:local/proxy", + model: "anthropic/claude-sonnet-4:extended", + }); + expect(decodeHermesModelSlug(DEFAULT_HERMES_MODEL)).toEqual({ mode: "default" }); + expect(decodeHermesModelSlug("unknown-model")).toBeUndefined(); + expect(decodeHermesModelSlug("hermes-model:provider")).toBeUndefined(); + expect(decodeHermesModelSlug("hermes-model:%E0%A4%A:model")).toBeUndefined(); + }); +}); + +describe("hermesServerModels", () => { + it("keeps the legacy default and exposes provider-qualified catalog choices", () => { + const models = hermesServerModels({ + reportedModel: "fallback-model", + catalog: { + type: "models.list.response", + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + requestId: HermesGatewayRequestId.make("models-1"), + currentProvider: "openrouter", + currentModel: "anthropic/claude-sonnet-4", + currentReasoningEffort: "high", + reasoningEfforts: ["none", "low", "high"], + models: [ + { + provider: "openrouter", + providerName: "OpenRouter", + model: "anthropic/claude-sonnet-4", + supportsReasoning: true, + }, + { + provider: "anthropic", + providerName: "Anthropic", + model: "claude-haiku-4-5", + supportsReasoning: false, + }, + { + provider: "openrouter", + providerName: "OpenRouter", + model: "openai/gpt-5.4", + supportsReasoning: true, + }, + ], + }, + }); + + expect(models.map((model) => model.name)).toEqual([ + "anthropic/claude-sonnet-4 (Hermes default)", + "anthropic/claude-sonnet-4", + "claude-haiku-4-5", + "openai/gpt-5.4", + ]); + expect(models[0]?.slug).toBe(DEFAULT_HERMES_MODEL); + const reasoningDescriptor = models[0]?.capabilities?.optionDescriptors?.[0]; + expect(reasoningDescriptor).toMatchObject({ + id: "reasoningEffort", + options: [ + { id: "none", label: "None" }, + { id: "low", label: "Low" }, + { id: "high", label: "High" }, + ], + }); + expect(reasoningDescriptor).not.toHaveProperty("currentValue"); + expect( + reasoningDescriptor?.type === "select" + ? reasoningDescriptor.options.some((option) => option.isDefault) + : true, + ).toBe(false); + expect( + buildProviderOptionSelectionsFromDescriptors(models[0]?.capabilities?.optionDescriptors), + ).toBeUndefined(); + const explicitDescriptors = getProviderOptionDescriptors({ + caps: models[0]!.capabilities!, + selections: [{ id: "reasoningEffort", value: "low" }], + }); + expect(buildProviderOptionSelectionsFromDescriptors(explicitDescriptors)).toEqual([ + { id: "reasoningEffort", value: "low" }, + ]); + expect(decodeHermesModelSlug(models[1]?.slug ?? "")).toEqual({ + mode: "specific", + provider: "openrouter", + model: "anthropic/claude-sonnet-4", + }); + expect(models[2]?.capabilities?.optionDescriptors).toEqual([]); + expect(decodeHermesModelSlug(models[3]?.slug ?? "")).toEqual({ + mode: "specific", + provider: "openrouter", + model: "openai/gpt-5.4", + }); + }); + + it("still exposes reasoning on the default when inventory degrades", () => { + const [fallback] = hermesServerModels({ reportedModel: "gpt-5", catalog: undefined }); + expect(fallback?.slug).toBe(DEFAULT_HERMES_MODEL); + expect(fallback?.capabilities?.optionDescriptors).toEqual([]); + }); +}); diff --git a/apps/server/src/provider/hermesModels.ts b/apps/server/src/provider/hermesModels.ts new file mode 100644 index 00000000000..1235b709dae --- /dev/null +++ b/apps/server/src/provider/hermesModels.ts @@ -0,0 +1,132 @@ +import { + DEFAULT_HERMES_MODEL, + type HermesGatewayCatalogModel, + type HermesGatewayModelsListResponse, + type HermesGatewayRequestedModelSelection, + type HermesGatewayReasoningEffort, + type ModelCapabilities, + type ServerProviderModel, +} from "@t3tools/contracts"; +import { createModelCapabilities } from "@t3tools/shared/model"; + +const HERMES_MODEL_SLUG_PREFIX = "hermes-model:"; + +const REASONING_LABELS: Readonly> = { + none: "None", + minimal: "Minimal", + low: "Low", + medium: "Medium", + high: "High", + xhigh: "Extra High", + max: "Max", + ultra: "Ultra", +}; + +/** + * T3 model slugs are opaque UI/persistence identifiers. Hermes model ids are + * only unique inside a provider, so both components are encoded rather than + * exposing the ambiguous bare model id as the slug. + */ +export function encodeHermesModelSlug(input: { + readonly provider: string; + readonly model: string; +}) { + return `${HERMES_MODEL_SLUG_PREFIX}${encodeURIComponent(input.provider)}:${encodeURIComponent(input.model)}`; +} + +/** Translate a persisted T3 slug into the structured gateway selection. */ +export function decodeHermesModelSlug( + slug: string, +): HermesGatewayRequestedModelSelection | undefined { + if (slug === DEFAULT_HERMES_MODEL) return { mode: "default" }; + if (!slug.startsWith(HERMES_MODEL_SLUG_PREFIX)) return undefined; + const encoded = slug.slice(HERMES_MODEL_SLUG_PREFIX.length); + const separator = encoded.indexOf(":"); + if (separator <= 0 || separator === encoded.length - 1) return undefined; + try { + const provider = decodeURIComponent(encoded.slice(0, separator)).trim(); + const model = decodeURIComponent(encoded.slice(separator + 1)).trim(); + return provider && model ? { mode: "specific", provider, model } : undefined; + } catch { + return undefined; + } +} + +function reasoningCapabilities(input: { + readonly catalog: HermesGatewayModelsListResponse | undefined; + readonly enabled: boolean; +}): ModelCapabilities { + const efforts = input.catalog?.reasoningEfforts ?? []; + if (!input.enabled || efforts.length === 0) { + return createModelCapabilities({ optionDescriptors: [] }); + } + // The catalog's currentReasoningEffort is Hermes' global setting, not the + // effective default for every model: per-model reasoning_overrides may win. + // Leave the descriptor unresolved so an untouched T3 control delegates to + // Hermes; only an explicit user selection becomes a session override. + return createModelCapabilities({ + optionDescriptors: [ + { + id: "reasoningEffort", + label: "Reasoning", + type: "select", + options: efforts.map((effort) => ({ + id: effort, + label: REASONING_LABELS[effort], + })), + }, + ], + }); +} + +function isCurrentCatalogModel( + entry: HermesGatewayCatalogModel, + catalog: HermesGatewayModelsListResponse, +) { + return entry.provider === catalog.currentProvider && entry.model === catalog.currentModel; +} + +/** Build the normal T3 picker models from one Hermes catalog response. */ +export function hermesServerModels(input: { + readonly reportedModel: string | null | undefined; + readonly catalog: HermesGatewayModelsListResponse | undefined; +}): ReadonlyArray { + const currentModel = input.catalog?.currentModel ?? input.reportedModel ?? undefined; + const currentEntry = input.catalog?.models.find((entry) => + isCurrentCatalogModel(entry, input.catalog!), + ); + const defaultCapabilities = reasoningCapabilities({ + catalog: input.catalog, + // Hermes treats uncatalogued models as reasoning-capable. Preserve that + // graceful fallback so a partial/older inventory does not hide the dial. + enabled: currentEntry?.supportsReasoning ?? true, + }); + const models: ServerProviderModel[] = [ + { + slug: DEFAULT_HERMES_MODEL, + name: currentModel ? `${currentModel} (Hermes default)` : "Hermes default", + isCustom: false, + isDefault: true, + capabilities: defaultCapabilities, + }, + ]; + if (!input.catalog) return models; + + const seen = new Set([DEFAULT_HERMES_MODEL]); + for (const entry of input.catalog.models) { + const slug = encodeHermesModelSlug(entry); + if (seen.has(slug)) continue; + seen.add(slug); + models.push({ + slug, + name: entry.model, + subProvider: entry.providerName, + isCustom: false, + capabilities: reasoningCapabilities({ + catalog: input.catalog, + enabled: entry.supportsReasoning, + }), + }); + } + return models; +} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 46623f5bde4..390be99db16 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -41,6 +41,8 @@ import { detectComposerTrigger, expandCollapsedComposerCursor, replaceTextRange, + resolveComposerModelPickerContinuationGroupKey, + shouldRenderStaticComposerModelLabel, shouldSubmitComposerOnEnter, } from "../../composer-logic"; import { deriveComposerSendState, readFileAsDataUrl } from "../ChatView.logic"; @@ -884,21 +886,26 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) providerInstanceEntries[0]?.driverKind ?? ProviderDriverKind.make("unconfigured"); const requestedDriverKind: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider; - const lockedContinuationGroupKey = useMemo((): string | null => { - if (!lockedProvider || !activeThread) return null; - const lockedInstanceId = - activeThread.session?.providerInstanceId ?? activeThreadModelSelection?.instanceId; - if (!lockedInstanceId) return null; - return ( - providerInstanceEntries.find((entry) => entry.instanceId === lockedInstanceId) - ?.continuationGroupKey ?? null - ); - }, [ - activeThread, - activeThreadModelSelection?.instanceId, - lockedProvider, - providerInstanceEntries, - ]); + const isHomeThread = isHomeThreadId(providerStatuses, activeThread?.id); + const lockedContinuationGroupKey = useMemo( + () => + resolveComposerModelPickerContinuationGroupKey({ + isProviderLocked: lockedProvider !== null, + isHomeThread, + projectAgentInstanceId: activeProjectAgentInstanceId, + threadInstanceId: activeThread?.session?.providerInstanceId, + threadModelInstanceId: activeThreadModelSelection?.instanceId, + instanceEntries: providerInstanceEntries, + }), + [ + activeProjectAgentInstanceId, + activeThread?.session?.providerInstanceId, + activeThreadModelSelection?.instanceId, + isHomeThread, + lockedProvider, + providerInstanceEntries, + ], + ); // Resolve which configured instance the composer is currently targeting. // Priority: @@ -976,13 +983,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // keeps the image-only intake it has always had. const allowsFileAttachments = selectedProvider === "hermes"; - // A Home thread is permanently bound to one instance and one model slug — - // `thread.create` fixes both, and Hermes sets - // `requiresNewThreadForModelChange`. A picker there would offer a choice - // that cannot be taken, so the chip renders as a static label instead. - // The same is true for every thread in an agent's synthetic project: the - // project itself is the binding, so drafts there are equally choiceless. - const isHomeThread = isHomeThreadId(providerStatuses, activeThread?.id); + // Home threads and agent projects stay bound to one Hermes instance. The + // model chip remains a static label while that instance has no choice to + // offer; a dynamic catalog can open the picker without relaxing the + // instance binding. const isAgentBoundComposer = activeProjectAgentInstanceId !== null; const { modelOptions: composerModelOptions, selectedModel } = useEffectiveComposerModelState({ @@ -1077,6 +1081,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } return out; }, [providerInstanceEntries, settings]); + const isBoundComposerModelPickerStatic = shouldRenderStaticComposerModelLabel({ + isBoundComposer: isHomeThread || isAgentBoundComposer, + modelOptionCount: modelOptionsByInstance.get(selectedInstanceId)?.length ?? 0, + }); + const modelPickerLockedProvider = + lockedProvider ?? (isHomeThread || isAgentBoundComposer ? selectedProvider : null); const selectedModelForPickerWithCustomFallback = useMemo(() => { const currentOptions = modelOptionsByInstance.get(selectedInstanceId) ?? []; return currentOptions.some((option) => option.slug === selectedModelForPicker) @@ -3343,8 +3353,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) compact={isComposerFooterCompact} activeInstanceId={selectedInstanceId} model={selectedModelForPickerWithCustomFallback} - staticLabel={isHomeThread || isAgentBoundComposer} - lockedProvider={lockedProvider} + staticLabel={isBoundComposerModelPickerStatic} + lockedProvider={modelPickerLockedProvider} lockedContinuationGroupKey={lockedContinuationGroupKey} instanceEntries={providerInstanceEntries} keybindings={keybindings} diff --git a/apps/web/src/composer-logic.test.ts b/apps/web/src/composer-logic.test.ts index b8ef7443611..8e039a21060 100644 --- a/apps/web/src/composer-logic.test.ts +++ b/apps/web/src/composer-logic.test.ts @@ -8,6 +8,8 @@ import { isCollapsedCursorAdjacentToInlineToken, parseStandaloneComposerSlashCommand, replaceTextRange, + resolveComposerModelPickerContinuationGroupKey, + shouldRenderStaticComposerModelLabel, shouldSubmitComposerOnEnter, } from "./composer-logic"; import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext"; @@ -26,6 +28,59 @@ describe("shouldSubmitComposerOnEnter", () => { }); }); +describe("shouldRenderStaticComposerModelLabel", () => { + it("keeps a bound composer static when its instance has no model choice", () => { + expect( + shouldRenderStaticComposerModelLabel({ isBoundComposer: true, modelOptionCount: 1 }), + ).toBe(true); + }); + + it("makes a bound composer interactive when its instance advertises multiple models", () => { + expect( + shouldRenderStaticComposerModelLabel({ isBoundComposer: true, modelOptionCount: 2 }), + ).toBe(false); + }); + + it("preserves the interactive picker for ordinary composers", () => { + expect( + shouldRenderStaticComposerModelLabel({ isBoundComposer: false, modelOptionCount: 1 }), + ).toBe(false); + }); +}); + +describe("resolveComposerModelPickerContinuationGroupKey", () => { + const instanceEntries = [ + { instanceId: "hermes-mac-mini", continuationGroupKey: "hermes:mac-mini" }, + { instanceId: "hermes-laptop", continuationGroupKey: "hermes:laptop" }, + ]; + + it("locks an agent-project draft to its exact bound instance", () => { + expect( + resolveComposerModelPickerContinuationGroupKey({ + isProviderLocked: false, + isHomeThread: false, + projectAgentInstanceId: "hermes-mac-mini", + threadInstanceId: undefined, + threadModelInstanceId: undefined, + instanceEntries, + }), + ).toBe("hermes:mac-mini"); + }); + + it("does not lock an ordinary draft from its current model selection alone", () => { + expect( + resolveComposerModelPickerContinuationGroupKey({ + isProviderLocked: false, + isHomeThread: false, + projectAgentInstanceId: null, + threadInstanceId: undefined, + threadModelInstanceId: "hermes-mac-mini", + instanceEntries, + }), + ).toBeNull(); + }); +}); + describe("detectComposerTrigger", () => { it("detects @path trigger at cursor", () => { const text = "Please check @src/com"; diff --git a/apps/web/src/composer-logic.ts b/apps/web/src/composer-logic.ts index 2d1d3aed3b1..9ebfe2485b7 100644 --- a/apps/web/src/composer-logic.ts +++ b/apps/web/src/composer-logic.ts @@ -18,6 +18,36 @@ export function shouldSubmitComposerOnEnter(input: { return !input.isMobileViewport && !input.shiftKey; } +export function shouldRenderStaticComposerModelLabel(input: { + isBoundComposer: boolean; + modelOptionCount: number; +}): boolean { + return input.isBoundComposer && input.modelOptionCount <= 1; +} + +export function resolveComposerModelPickerContinuationGroupKey(input: { + isProviderLocked: boolean; + isHomeThread: boolean; + projectAgentInstanceId: string | null; + threadInstanceId: string | undefined; + threadModelInstanceId: string | undefined; + instanceEntries: ReadonlyArray<{ + instanceId: string; + continuationGroupKey?: string | undefined; + }>; +}): string | null { + if (!input.isProviderLocked && !input.isHomeThread && input.projectAgentInstanceId === null) { + return null; + } + const lockedInstanceId = + input.threadInstanceId ?? input.threadModelInstanceId ?? input.projectAgentInstanceId; + if (!lockedInstanceId) return null; + return ( + input.instanceEntries.find((entry) => entry.instanceId === lockedInstanceId) + ?.continuationGroupKey ?? null + ); +} + const isInlineTokenSegment = ( segment: | { type: "text"; text: string } diff --git a/integrations/hermes-t3-gateway/COMPATIBILITY.md b/integrations/hermes-t3-gateway/COMPATIBILITY.md index 79a334f2633..ae238252d9e 100644 --- a/integrations/hermes-t3-gateway/COMPATIBILITY.md +++ b/integrations/hermes-t3-gateway/COMPATIBILITY.md @@ -66,14 +66,16 @@ Home-channel surfaces, added for protocol v3: | `HERMES_SESSION_USER_ID` binding | `gateway/run.py:17372` | | Session-context lifetime around a turn | `gateway/run.py:12972` → `:14626` | -This inventory describes gateway wire protocol v4. Protocol v2 added active-turn +This inventory describes gateway wire protocol v5. Protocol v2 added active-turn recovery in `session.ready` and authoritative `content.snapshot` replacement; v3 added `role` on `connection.hello`, `homeThreadId` on `connection.accepted`, and the `home.deliver` / `home.deliver.ack` pair; v4 adds media — optional inline `attachments` on `turn.start` / `turn.steer`, the `media.deliver` / `media.deliver.ack` pair, and the `attachments` capability flipping to the -literal `true`. Older server/plugin pairs are rejected during the handshake — -the version policy stays fail-closed. +literal `true`; v5 adds on-demand `models.list.request` / +`models.list.response`, requested model/reasoning fields on `turn.start`, and +verified applied selections on `turn.started`. Older server/plugin pairs are +rejected during the handshake — the version policy stays fail-closed. ## Mapped in the initial scope @@ -89,6 +91,8 @@ the version policy stays fail-closed. | `/steer` gateway command | `turn.steer` | | Adapter interrupt event | `turn.interrupt` | | `load_config_readonly()["agent"]["reasoning_effort"]` | Optional `reasoningEffort` on `describe.response` | +| `inventory.build_models_payload(...)` | `models.list.response` on explicit request | +| Session-scoped `/model` and `/reasoning` commands | v5 `turn.start` selection fields | | `skills_list()` metadata | `skills` on `describe.response` | | `skill_view(name, preprocess=False)` | `markdown` on `skill.body.response` | | Cron `deliver=t3`, `send_message t3`, lifecycle | `home.deliver` / `home.deliver.ack` | @@ -180,7 +184,12 @@ the version policy stays fail-closed. 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. + from `connection.hello` rather than sending null or empty. On an explicit + `models.list.request`, the plugin also calls `load_picker_context()` and + `build_models_payload()` off the event loop, requesting only explicitly + configured providers and no refresh or custom-provider probes. Inventory + failures return an empty model list while preserving the readable current + selection. - Hermes' configured reasoning effort is read from `load_config_readonly()["agent"]["reasoning_effort"]` on every `describe.request`, with the same discipline as the model read above: a @@ -190,6 +199,16 @@ the version policy stays fail-closed. `agent.reasoning_overrides` (per-model) and `delegation.reasoning_effort` (subagents); neither is resolved here, so a user with a per-model override active sees the global value on the Agent page. +- A v5 `turn.start` may request a default or specific model plus one of + `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`, or `ultra`. + Before registering the turn, the adapter dispatches synthetic command + events directly to Hermes' registered runner handler: `/model ... --session` + first, then `/reasoning ...`. Control acknowledgements are discarded rather + than entering the transcript, and the runner's effective session overrides + are verified before `turn.started` is emitted. A model failure prevents the + reasoning command and the user message from running. Repeated selections + are cached per T3 thread but skipped only while the runner's live override + still matches; no global config is written. - Skills are enumerated through the registered `skills_list()` tool surface (`tools/skills_tool.py:785`), not the private `_find_all_skills()` scanner behind it. Consequences of that choice, all verified at 62e07223: @@ -231,7 +250,8 @@ the version policy stays fail-closed. `skillName` cannot be answered, because the response echoes the name back and the wire type is non-empty. That takes the ordinary correlated `protocol.error` path. -- Attachments are part of protocol v4; the capability is fixed to `true` +- Attachments have been part of the protocol since v4; the capability is fixed + to `true` (T3's schema pins the literal, so a plugin that cannot handle them is a v3 plugin and is rejected at the version gate). Inbound, `turn.start` / `turn.steer` may carry inline base64 files (≤25MB each): turn-start files diff --git a/integrations/hermes-t3-gateway/README.md b/integrations/hermes-t3-gateway/README.md index 16c125afcdb..a8e1c7e5409 100644 --- a/integrations/hermes-t3-gateway/README.md +++ b/integrations/hermes-t3-gateway/README.md @@ -4,7 +4,7 @@ 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 v4. The T3 server and Hermes plugin must be updated +The gateway wire protocol is v5. 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 @@ -50,9 +50,11 @@ 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. +T3 loads the selectable model catalog on demand from Hermes' explicitly +configured providers. Changing model or reasoning in T3 applies Hermes' +official `/model ... --session` and `/reasoning ...` commands before the next +turn starts. These are per-session overrides: they never rewrite Hermes' +global `config.yaml`, and each T3 thread remains isolated from the others. ## The Home thread @@ -97,6 +99,7 @@ Every send result also reports `media_count`, `acked_count`, and `delivery_ids`. - Text input and live assistant streaming - Authoritative text replacement when Hermes revises cumulative streamed output - Multiple concurrent Hermes sessions in one process +- On-demand model catalog plus per-session model and reasoning selection - Active-turn steering and interrupt - Dangerous-command approvals - Structured `clarify` questions @@ -114,10 +117,11 @@ Every send result also reports `media_count`, `acked_count`, and `delivery_ids`. Non-home T3 threads remain session-only: Hermes cannot message them unprompted, and an unsolicited send to one still fails with `no active T3 turn`. -Attachments are pinned to `true`. It is part of the v4 contract rather than a -negotiated option — T3's schema fixes the capability at that literal, so a plugin -that cannot handle attachments is by definition a v3 plugin and is rejected at -the version gate. Inbound files arrive on `turn.start` / `turn.steer` and are +Attachments are pinned to `true`. It has been part of the contract since v4 +rather than a negotiated option — T3's schema fixes the capability at that +literal, so a plugin that cannot handle attachments is by definition a v3 +plugin and is rejected at the version gate. Inbound files arrive on +`turn.start` / `turn.steer` and are materialized to private temp files before the turn starts; outbound files leave as `media.deliver` frames. diff --git a/integrations/hermes-t3-gateway/adapter.py b/integrations/hermes-t3-gateway/adapter.py index cf5c58d667b..5db05abf111 100644 --- a/integrations/hermes-t3-gateway/adapter.py +++ b/integrations/hermes-t3-gateway/adapter.py @@ -3,15 +3,17 @@ from __future__ import annotations import asyncio +import copy import contextvars import logging import os import re +import shlex import tempfile import time import uuid import weakref -from collections.abc import Coroutine +from collections.abc import Callable, Coroutine from dataclasses import dataclass, field from pathlib import Path from typing import Any @@ -41,11 +43,15 @@ PROTOCOL_VERSION, canonical_tool_data, canonical_tool_item_type, + configured_model_selection, describe_response, frame, iso_now, item_id, + models_catalog, + models_list_response, protocol_error, + REASONING_EFFORTS, skill_body, skill_body_response, turn_attachments, @@ -170,6 +176,52 @@ class _TurnState: completed_at: float | None = None +@dataclass +class _TurnStartReservation: + """Fence one thread's pre-start configuration from concurrent lifecycle calls.""" + + session_id: str + cancelled: bool = False + + +@dataclass(frozen=True) +class _MappingEntrySnapshot: + """One session-scoped mapping value captured before a control command.""" + + existed: bool + value: Any = None + + +def _snapshot_mapping_entry( + mapping: Any, + key: str, + *, + deep: bool = True, +) -> _MappingEntrySnapshot: + if not isinstance(mapping, dict): + return _MappingEntrySnapshot(existed=False) + if key not in mapping: + return _MappingEntrySnapshot(existed=False) + value = mapping[key] + return _MappingEntrySnapshot( + existed=True, + value=copy.deepcopy(value) if deep else value, + ) + + +def _restore_mapping_entry( + mapping: Any, + key: str, + snapshot: _MappingEntrySnapshot, +) -> None: + if not isinstance(mapping, dict): + return + if snapshot.existed: + mapping[key] = snapshot.value + else: + mapping.pop(key, None) + + @dataclass class _SteerControlResponse: thread_id: str @@ -193,6 +245,59 @@ def control_message_id(self) -> str: ) +class _TurnConfigurationError(ValueError): + """A requested session configuration could not be applied safely.""" + + +def _model_selection_from_turn( + message: dict[str, Any], +) -> tuple[tuple[str, ...], dict[str, str]] | None: + """Validate and resolve the optional v5 ``modelSelection`` request. + + The returned tuple contains a stable cache key and the explicit + provider/model pair Hermes' session command needs. ``default`` is resolved + to the current global config now, before any session state is changed. + """ + raw = message.get("modelSelection") + if raw is None: + return None + if not isinstance(raw, dict): + raise _TurnConfigurationError("modelSelection must be an object") + mode = str(raw.get("mode") or "").strip().lower() + if mode == "default": + configured = configured_model_selection() + if configured is None: + raise _TurnConfigurationError( + "Hermes has no configured default model to select" + ) + return (("default",), configured) + if mode != "specific": + raise _TurnConfigurationError( + "modelSelection.mode must be 'default' or 'specific'" + ) + provider = raw.get("provider") + model = raw.get("model") + if not isinstance(provider, str) or not provider.strip(): + raise _TurnConfigurationError( + "A specific modelSelection requires a provider" + ) + if not isinstance(model, str) or not model.strip(): + raise _TurnConfigurationError("A specific modelSelection requires a model") + normalized = {"provider": provider.strip(), "model": model.strip()} + return (("specific", normalized["provider"], normalized["model"]), normalized) + + +def _reasoning_effort_from_turn(message: dict[str, Any]) -> str | None: + raw = message.get("reasoningEffort") + if raw is None: + return None + if not isinstance(raw, str) or raw.strip().lower() not in REASONING_EFFORTS: + raise _TurnConfigurationError( + "reasoningEffort must be one of " + ", ".join(REASONING_EFFORTS) + ) + return raw.strip().lower() + + class T3PlatformAdapter(BasePlatformAdapter): """One process-level T3 connection serving many isolated thread sessions.""" @@ -226,6 +331,18 @@ def __init__(self, config: PlatformConfig): self._active_session_threads: set[str] = set() self._thread_by_session: dict[str, str] = {} self._active_turns: dict[str, _TurnState] = {} + # A turn is not active until its requested model/reasoning has been + # verified. Reserve that pre-start window separately so a second + # turn.start cannot pass the active-turn guard while the first one is + # awaiting Hermes' command handler. session.stop marks the reservation + # cancelled; the first start then rolls its configuration back and can + # never register or run, even if session.ensure races in afterward. + self._turn_start_reservations: dict[str, _TurnStartReservation] = {} + # Last requested and verified session-local model/reasoning state per + # T3 thread. This is only an idempotency cache: every skip also checks + # the runner's live override, and a plugin restart naturally starts + # empty so the first v5 turn reapplies its requested configuration. + self._applied_turn_configuration: dict[str, dict[str, Any]] = {} # The most recently COMPLETED turn per thread. The base adapter's # delivery pipeline sends the final text (notify-marked, which # completes the turn here) BEFORE it sends the reply's media files @@ -514,8 +631,8 @@ async def _flush_home_queue(self) -> None: entry queued by an older plugin carries the version it was built under, and T3's strict-lockstep decoder closes the socket on any other version — turning one stale queued frame into a reconnect loop that outlives - the upgrade. The delivery fields themselves are version-stable (the - v3→v4 change only added frame types), so restamping is honest. + the upgrade. The delivery schemas themselves are unchanged in v5, and + v3 home deliveries remain a valid subset, so restamping is honest. """ pending = self._home_queue.entries() if not pending: @@ -991,6 +1108,8 @@ async def _handle_server_frame(self, raw: dict[str, Any]) -> None: ) elif frame_type == "describe.request": await self._describe(message) + elif frame_type == "models.list.request": + await self._list_models(message) elif frame_type == "skill.body.request": await self._send_skill_body(message) elif frame_type in {"home.deliver.ack", "media.deliver.ack"}: @@ -1031,6 +1150,22 @@ async def _describe(self, message: dict[str, Any]) -> None: ) ) + async def _list_models(self, message: dict[str, Any]) -> None: + """Enumerate selectable models only when T3 explicitly asks. + + Hermes' inventory may consult a stale disk cache through synchronous + provider code, so it must not occupy the gateway event loop. The + protocol helper degrades inventory failures to a truthful empty list + plus whatever current config remains readable. + """ + catalog = await asyncio.to_thread(models_catalog) + await self._send_frame( + models_list_response( + request_id_value=str(message["requestId"]), + catalog=catalog, + ) + ) + async def _send_skill_body(self, message: dict[str, Any]) -> None: """Answer `skill.body.request` with one skill's markdown. @@ -1058,7 +1193,7 @@ async def _send_skill_body(self, message: dict[str, Any]) -> 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) + session_id = self._session_id_for_source(source) resume_id = str(message.get("resumeSessionId") or "") self._sessions[thread_id] = session_id self._active_session_threads.add(thread_id) @@ -1080,10 +1215,502 @@ async def _ensure_session(self, message: dict[str, Any]) -> None: ) await self._send_status() + def _session_id_for_source(self, source: Any) -> str: + """Resolve the same profile-aware key Hermes' command handlers use.""" + runner = getattr(self, "gateway_runner", None) + resolver = getattr(runner, "_session_key_for_source", None) + session_id = ( + resolver(source) if callable(resolver) else build_session_key(source) + ) + resolved = str(session_id or "").strip() + if not resolved: + raise ValueError("Hermes could not resolve a session key for this thread") + return resolved + + def _configuration_surfaces( + self, + *, + needs_model: bool, + needs_reasoning: bool, + ) -> tuple[Any, Any]: + """Validate every Hermes surface before the first state mutation.""" + handler = self._message_handler + runner = getattr(self, "gateway_runner", None) + if not callable(handler) or runner is None: + raise _TurnConfigurationError( + "Hermes cannot apply session configuration on this gateway" + ) + if needs_model or needs_reasoning: + if not isinstance( + getattr(runner, "_session_model_overrides", None), dict + ) or not callable( + getattr(runner, "_resolve_session_agent_runtime", None) + ): + raise _TurnConfigurationError( + "This Hermes version cannot resolve session model state" + ) + if needs_reasoning: + if not isinstance( + getattr(runner, "_session_reasoning_overrides", None), dict + ) or not callable( + getattr(runner, "_resolve_session_reasoning_config", None) + ): + raise _TurnConfigurationError( + "This Hermes version does not support session reasoning selection" + ) + return runner, handler + + @staticmethod + def _effective_model_selection(runner: Any, session_id: str) -> dict[str, str]: + override = runner._session_model_overrides.get(session_id) + if not isinstance(override, dict): + raise _TurnConfigurationError( + "Hermes did not install the requested session model override" + ) + override_model = str(override.get("model") or "").strip() + override_provider = str(override.get("provider") or "").strip() + if not override_model or not override_provider: + raise _TurnConfigurationError( + "Hermes installed an incomplete session model override" + ) + try: + effective_model, runtime = runner._resolve_session_agent_runtime( + session_key=session_id + ) + except Exception as exc: + raise _TurnConfigurationError( + "Hermes could not resolve the requested session model" + ) from exc + resolved_model = str(effective_model or "").strip() + resolved_provider = "" + if isinstance(runtime, dict): + resolved_provider = str(runtime.get("provider") or "").strip() + resolved_provider = resolved_provider or override_provider + if resolved_model != override_model or resolved_provider != override_provider: + raise _TurnConfigurationError( + "Hermes did not make the requested session model effective" + ) + return {"provider": resolved_provider, "model": resolved_model} + + @staticmethod + def _effective_reasoning_effort( + runner: Any, + session_id: str, + *, + model: str, + ) -> str: + override = runner._session_reasoning_overrides.get(session_id) + if not isinstance(override, dict): + raise _TurnConfigurationError( + "Hermes did not install the requested session reasoning override" + ) + try: + effective = runner._resolve_session_reasoning_config( + session_key=session_id, + model=model, + ) + except Exception as exc: + raise _TurnConfigurationError( + "Hermes could not resolve the requested reasoning effort" + ) from exc + if not isinstance(effective, dict) or effective != override: + raise _TurnConfigurationError( + "Hermes did not make the requested reasoning effort effective" + ) + if effective.get("enabled") is False: + return "none" + effort = str(effective.get("effort") or "").strip().lower() + if effort not in REASONING_EFFORTS: + raise _TurnConfigurationError( + "Hermes installed an invalid session reasoning override" + ) + return effort + + @staticmethod + def _current_effective_model(runner: Any, session_id: str) -> str: + try: + model, _runtime = runner._resolve_session_agent_runtime( + session_key=session_id + ) + except Exception as exc: + raise _TurnConfigurationError( + "Hermes could not resolve the session model for reasoning" + ) from exc + resolved = str(model or "").strip() + if not resolved: + raise _TurnConfigurationError( + "Hermes has no effective session model for reasoning" + ) + return resolved + + async def _apply_turn_configuration( + self, + message: dict[str, Any], + *, + thread_id: str, + session_id: str, + can_commit: Callable[[], bool] | None = None, + ) -> dict[str, Any]: + """Apply v5 model/reasoning requests through Hermes' command surface. + + Commands go straight to the runner's registered message handler, not + ``handle_message``. Their control acknowledgements are therefore never + sent through this adapter or leaked into the T3 transcript. + """ + model_request = _model_selection_from_turn(message) + reasoning_effort = _reasoning_effort_from_turn(message) + if model_request is None and reasoning_effort is None: + if can_commit is not None and not can_commit(): + raise _TurnConfigurationError( + "The Hermes session stopped while its turn was starting" + ) + return {} + + runner, handler = self._configuration_surfaces( + needs_model=model_request is not None, + needs_reasoning=reasoning_effort is not None, + ) + source = self._source(thread_id, str(message["requestId"])) + command_session_id = self._session_id_for_source(source) + if command_session_id != session_id: + raise _TurnConfigurationError( + "The Hermes session profile changed; call session.ensure again" + ) + + model_overrides = runner._session_model_overrides + reasoning_overrides = getattr(runner, "_session_reasoning_overrides", None) + model_override_snapshot = _snapshot_mapping_entry( + model_overrides, session_id + ) + reasoning_override_snapshot = _snapshot_mapping_entry( + reasoning_overrides, session_id + ) + had_pending_notes_attribute = hasattr(runner, "_pending_model_notes") + pending_notes_snapshot = _snapshot_mapping_entry( + getattr(runner, "_pending_model_notes", None), session_id + ) + one_turn_restore_snapshot = _snapshot_mapping_entry( + getattr(runner, "_pending_one_turn_model_restores", None), session_id + ) + ephemeral_pin_snapshot = _snapshot_mapping_entry( + getattr(runner, "_session_ephemeral_pin", None), session_id + ) + voice_channel_snapshot = _snapshot_mapping_entry( + getattr(runner, "_session_vc_last", None), session_id + ) + had_reasoning_config = hasattr(runner, "_reasoning_config") + prior_reasoning_config = copy.deepcopy( + getattr(runner, "_reasoning_config", None) + ) + + durable_store = None + durable_entry = None + durable_override_snapshot: dict[str, Any] | None = None + durable_entry_was_auto_reset: bool | None = None + session_db = None + session_db_id = "" + session_db_row: dict[str, Any] | None = None + if model_request is not None: + durable_store = getattr(runner, "async_session_store", None) + if durable_store is not None: + get_or_create = getattr( + durable_store, "get_or_create_session", None + ) + get_override = getattr(durable_store, "get_model_override", None) + set_override = getattr(durable_store, "set_model_override", None) + if not all( + callable(method) + for method in (get_or_create, get_override, set_override) + ): + raise _TurnConfigurationError( + "This Hermes version cannot transact session model state" + ) + try: + durable_entry = await get_or_create(source) + durable_override_snapshot = copy.deepcopy( + await get_override(session_id) + ) + except Exception as exc: + raise _TurnConfigurationError( + "Hermes could not snapshot its persisted session model" + ) from exc + durable_entry_was_auto_reset = bool( + getattr(durable_entry, "was_auto_reset", False) + ) + session_db_id = str( + getattr(durable_entry, "session_id", "") or "" + ) + + session_db = getattr(runner, "_session_db", None) + if session_db is not None and session_db_id: + get_session = getattr(session_db, "get_session", None) + restore_methods = ( + getattr(session_db, "update_session_model", None), + getattr(session_db, "update_session_meta", None), + getattr(session_db, "update_system_prompt", None), + ) + if not callable(get_session) or not all( + callable(method) for method in restore_methods + ): + raise _TurnConfigurationError( + "This Hermes version cannot transact its session database" + ) + try: + row = await get_session(session_db_id) + except Exception as exc: + raise _TurnConfigurationError( + "Hermes could not snapshot its session database" + ) from exc + if isinstance(row, dict): + session_db_row = copy.deepcopy(row) + + had_cache = thread_id in self._applied_turn_configuration + prior_cache = copy.deepcopy( + self._applied_turn_configuration.get(thread_id) + ) + cache = self._applied_turn_configuration.setdefault(thread_id, {}) + applied: dict[str, Any] = {} + + # Hermes' /model handler switches a cached agent in place and then + # releases it. Keep the prior agent outside that command transaction: + # a failed /reasoning verification can then put the untouched cache + # entry back, while a successful transaction still disposes it through + # Hermes' own eviction path. + agent_cache = getattr(runner, "_agent_cache", None) + agent_cache_lock = getattr(runner, "_agent_cache_lock", None) + detached_agent_cache = _MappingEntrySnapshot(existed=False) + if isinstance(agent_cache, dict): + if agent_cache_lock is not None: + with agent_cache_lock: + detached_agent_cache = _snapshot_mapping_entry( + agent_cache, session_id, deep=False + ) + agent_cache.pop(session_id, None) + else: + detached_agent_cache = _snapshot_mapping_entry( + agent_cache, session_id, deep=False + ) + agent_cache.pop(session_id, None) + + def restore_prior_memory_configuration() -> None: + _restore_mapping_entry( + model_overrides, session_id, model_override_snapshot + ) + _restore_mapping_entry( + reasoning_overrides, session_id, reasoning_override_snapshot + ) + _restore_mapping_entry( + getattr(runner, "_pending_model_notes", None), + session_id, + pending_notes_snapshot, + ) + current_pending_notes = getattr(runner, "_pending_model_notes", None) + if ( + not had_pending_notes_attribute + and isinstance(current_pending_notes, dict) + and not current_pending_notes + ): + delattr(runner, "_pending_model_notes") + _restore_mapping_entry( + getattr(runner, "_pending_one_turn_model_restores", None), + session_id, + one_turn_restore_snapshot, + ) + _restore_mapping_entry( + getattr(runner, "_session_ephemeral_pin", None), + session_id, + ephemeral_pin_snapshot, + ) + _restore_mapping_entry( + getattr(runner, "_session_vc_last", None), + session_id, + voice_channel_snapshot, + ) + if had_reasoning_config: + runner._reasoning_config = prior_reasoning_config + elif hasattr(runner, "_reasoning_config"): + delattr(runner, "_reasoning_config") + if had_cache: + self._applied_turn_configuration[thread_id] = prior_cache + else: + self._applied_turn_configuration.pop(thread_id, None) + + def restore_detached_agent_cache() -> None: + if not isinstance(agent_cache, dict): + return + if agent_cache_lock is not None: + with agent_cache_lock: + _restore_mapping_entry( + agent_cache, session_id, detached_agent_cache + ) + else: + _restore_mapping_entry( + agent_cache, session_id, detached_agent_cache + ) + + async def restore_prior_durable_configuration() -> None: + if durable_store is not None: + await durable_store.set_model_override( + session_id, durable_override_snapshot + ) + if ( + durable_entry is not None + and durable_entry_was_auto_reset is not None + ): + durable_entry.was_auto_reset = durable_entry_was_auto_reset + if session_db is not None and session_db_id and session_db_row is not None: + prior_db_model = session_db_row.get("model") + await session_db.update_session_model( + session_db_id, prior_db_model + ) + await session_db.update_session_meta( + session_db_id, + session_db_row.get("model_config"), + prior_db_model, + ) + await session_db.update_system_prompt( + session_db_id, session_db_row.get("system_prompt") + ) + + def release_detached_agent_cache() -> None: + if not detached_agent_cache.existed: + return + restore_detached_agent_cache() + evict = getattr(runner, "_evict_cached_agent", None) + if not callable(evict): + raise _TurnConfigurationError( + "This Hermes version cannot retire its prior cached agent" + ) + evict(session_id) + + dispatched_configuration = False + + async def dispatch(command: str) -> None: + nonlocal dispatched_configuration + dispatched_configuration = True + await handler( + MessageEvent( + text=command, + message_type=MessageType.COMMAND, + source=source, + message_id=str(message["requestId"]), + metadata={"t3_control": "turn-configuration"}, + ) + ) + + try: + effective_model: dict[str, str] | None = None + if model_request is not None: + request_key, target = model_request + if cache.get("modelRequest") == request_key: + try: + cached_effective = self._effective_model_selection( + runner, session_id + ) + except _TurnConfigurationError: + cached_effective = None + if ( + cached_effective == cache.get("modelEffective") + and cached_effective == target + ): + effective_model = cached_effective + + if effective_model is None: + command = ( + f"/model {shlex.quote(target['model'])} " + f"--provider {shlex.quote(target['provider'])} --session" + ) + try: + await dispatch(command) + effective_model = self._effective_model_selection( + runner, session_id + ) + except Exception as exc: + if isinstance(exc, _TurnConfigurationError): + raise + raise _TurnConfigurationError( + "Hermes could not apply the requested session model" + ) from exc + if effective_model != target: + raise _TurnConfigurationError( + "Hermes resolved the requested model to a different selection" + ) + cache["modelRequest"] = request_key + cache["modelEffective"] = dict(effective_model) + applied["appliedModelSelection"] = dict(effective_model) + + if reasoning_effort is not None: + reasoning_model = ( + effective_model["model"] + if effective_model is not None + else self._current_effective_model(runner, session_id) + ) + if cache.get("reasoningRequest") == reasoning_effort: + try: + cached_effort = self._effective_reasoning_effort( + runner, + session_id, + model=reasoning_model, + ) + except _TurnConfigurationError: + cached_effort = None + if cached_effort == cache.get("reasoningEffective"): + applied_effort = cached_effort + else: + applied_effort = None + else: + applied_effort = None + + if applied_effort is None: + try: + await dispatch(f"/reasoning {reasoning_effort}") + applied_effort = self._effective_reasoning_effort( + runner, + session_id, + model=reasoning_model, + ) + except Exception as exc: + if isinstance(exc, _TurnConfigurationError): + raise + raise _TurnConfigurationError( + "Hermes could not apply the requested reasoning effort" + ) from exc + if applied_effort != reasoning_effort: + raise _TurnConfigurationError( + "Hermes resolved a different reasoning effort than requested" + ) + cache["reasoningRequest"] = reasoning_effort + cache["reasoningEffective"] = applied_effort + applied["appliedReasoningEffort"] = applied_effort + + if can_commit is not None and not can_commit(): + raise _TurnConfigurationError( + "The Hermes session stopped while its turn was starting" + ) + if dispatched_configuration: + release_detached_agent_cache() + else: + restore_detached_agent_cache() + return applied + except BaseException: + # Hermes' slash handlers update several durable and in-memory + # surfaces. If verification fails (or startup is cancelled), roll + # all of them back before exposing the failed turn to T3. + restore_prior_memory_configuration() + try: + await restore_prior_durable_configuration() + finally: + restore_detached_agent_cache() + raise + 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"]): + if ( + not session_id + or session_id != str(message["sessionId"]) + or thread_id not in self._active_session_threads + ): await self._send_frame( protocol_error( "session-not-found", @@ -1093,16 +1720,32 @@ async def _start_turn(self, message: dict[str, Any]) -> None: ) ) return - if thread_id in self._active_turns: + if ( + thread_id in self._active_turns + or thread_id in self._turn_start_reservations + ): await self._send_frame( protocol_error( "invalid-message", - "This Hermes session already has an active turn; use turn.steer.", + "This Hermes session already has an active or starting turn; " + "use turn.steer.", recoverable=True, related_request_id=str(message["requestId"]), ) ) return + reservation = _TurnStartReservation(session_id=session_id) + self._turn_start_reservations[thread_id] = reservation + + def can_commit() -> bool: + return ( + self._turn_start_reservations.get(thread_id) is reservation + and not reservation.cancelled + and self._sessions.get(thread_id) == session_id + and thread_id in self._active_session_threads + and thread_id not in self._active_turns + ) + # Decode and materialize attachments BEFORE any turn state exists: a # malformed attachment raises ValueError into the correlated # `protocol.error` path with no half-started turn to clean up. @@ -1114,16 +1757,39 @@ async def _start_turn(self, message: dict[str, Any]) -> None: # vision routing for images, STT for voice, and path-pointing context # notes for documents (`gateway/run.py:12420+`). No prompt-text # injection is needed on this path. - media_paths, media_types = _materialize_attachments( - turn_attachments(message) - ) - 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 + try: + media_paths, media_types = _materialize_attachments( + turn_attachments(message) + ) + try: + applied_configuration = await self._apply_turn_configuration( + message, + thread_id=thread_id, + session_id=session_id, + can_commit=can_commit, + ) + except _TurnConfigurationError as exc: + await self._send_frame( + protocol_error( + "invalid-message", + str(exc), + 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"]), + ) + # No await separates the final fence check in + # _apply_turn_configuration from this registration. + self._active_turns[thread_id] = turn + finally: + if self._turn_start_reservations.get(thread_id) is reservation: + self._turn_start_reservations.pop(thread_id, None) # Roll the registration back if starting the turn raises. Without this # a failed `turn.started` send (a socket that dropped between the # decode and the write) leaves a phantom turn no completion path will @@ -1140,9 +1806,20 @@ async def _start_turn(self, message: dict[str, Any]) -> None: threadId=thread_id, sessionId=session_id, turnId=turn.turn_id, + **applied_configuration, ) ) await self._send_status() + # session.stop can run while either frame above is awaiting I/O. + # It owns the turn after popping it and must prevent a late call + # into Hermes' agent pipeline. + if ( + self._active_turns.get(thread_id) is not turn + or thread_id not in self._active_session_threads + ): + if self._active_turns.get(thread_id) is turn: + self._active_turns.pop(thread_id, None) + return await self.handle_message( MessageEvent( text=str(message["text"]), @@ -1399,6 +2076,9 @@ async def _stop_session(self, message: dict[str, Any]) -> None: ) ) return + reservation = self._turn_start_reservations.get(thread_id) + if reservation is not None and reservation.session_id == session_id: + reservation.cancelled = True turn = self._active_turns.pop(thread_id, None) if turn is not None: await self.interrupt_session_activity(session_id, thread_id) diff --git a/integrations/hermes-t3-gateway/connection.py b/integrations/hermes-t3-gateway/connection.py index afa8649bf87..4aed0144e0b 100644 --- a/integrations/hermes-t3-gateway/connection.py +++ b/integrations/hermes-t3-gateway/connection.py @@ -63,7 +63,7 @@ async def _open_socket(url: str): ping_interval=20, ping_timeout=20, close_timeout=5, - # Protocol v4 turn frames may carry inline base64 attachments up to + # Protocol v4+ turn frames may carry inline base64 attachments up to # 25MB raw (~34MB encoded, `protocol.MAX_MEDIA_BYTES`). 64MB leaves # room for the JSON envelope and T3's per-turn total while still # bounding a pathological frame. diff --git a/integrations/hermes-t3-gateway/home.py b/integrations/hermes-t3-gateway/home.py index 293fafa895b..693a3014824 100644 --- a/integrations/hermes-t3-gateway/home.py +++ b/integrations/hermes-t3-gateway/home.py @@ -604,7 +604,7 @@ async def standalone_send( disk before the socket is opened, so the job reports success-with-queued and the live gateway flushes it on its next `connection.accepted`. - `media_files` rides the v4 wire as one `media.deliver` frame per file + `media_files` rides the v4+ wire as one `media.deliver` frame per file (upstream passes `(path, is_voice)` tuples; bare path strings are accepted too). A file that cannot be read or exceeds the 25MB frame ceiling is reported in `detail` and skipped — never queued, because a queued frame diff --git a/integrations/hermes-t3-gateway/plugin.yaml b/integrations/hermes-t3-gateway/plugin.yaml index 71eac115827..6dfa32626f8 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.4.0 +version: 0.5.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 f76c3b0fe4c..42771176973 100644 --- a/integrations/hermes-t3-gateway/protocol.py +++ b/integrations/hermes-t3-gateway/protocol.py @@ -9,10 +9,21 @@ from datetime import datetime, timezone from typing import Any -PROTOCOL_VERSION = 4 -PLUGIN_VERSION = "0.4.0" +PROTOCOL_VERSION = 5 +PLUGIN_VERSION = "0.5.0" WEBSOCKET_PATH = "/api/hermes-gateway/ws" +REASONING_EFFORTS = ( + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", + "ultra", +) + # What a connecting socket intends to be. `gateway` is the instance's one live # plugin connection; `delivery` is a short-lived socket (an out-of-process cron # run) that hands over a `home.deliver` and leaves. T3 never registers a @@ -26,7 +37,7 @@ "activity": True, "approvals": True, "userInput": True, - # Part of the v4 contract itself, not a negotiated option: the T3 schema + # Part of the v4+ contract itself, not a negotiated option: the T3 schema # pins `attachments` to the literal `true`, so a plugin that cannot handle # them is a v3 plugin and is rejected at the version gate. "attachments": True, @@ -43,6 +54,7 @@ "session.stop", "ping", "describe.request", + "models.list.request", "skill.body.request", "home.deliver.ack", "media.deliver.ack", @@ -111,14 +123,14 @@ def frame(frame_type: str, **payload: Any) -> dict[str, Any]: } -def configured_model() -> str | None: - """Return Hermes' configured default model, or None when unavailable. +def configured_model_selection() -> dict[str, str] | None: + """Return Hermes' configured default provider/model pair when available. 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. + dict, so nothing here mutates it or hands a nested structure to a caller. + Older scalar `model:` configs are supported and an omitted provider uses + Hermes' own gateway default, `openrouter`. 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 @@ -127,13 +139,28 @@ def configured_model() -> str | None: try: from hermes_cli.config import load_config_readonly - model = load_config_readonly().get("model", {}).get("default") + raw_model = load_config_readonly().get("model", {}) except Exception: # noqa: BLE001 - model reporting must never break the handshake return None - if not isinstance(model, str): + if isinstance(raw_model, dict): + model = raw_model.get("default", raw_model.get("name")) + provider = raw_model.get("provider") or "openrouter" + else: + model = raw_model + provider = "openrouter" + if not isinstance(model, str) or not isinstance(provider, str): return None - trimmed = model.strip() - return trimmed or None + trimmed_model = model.strip() + trimmed_provider = provider.strip() or "openrouter" + if not trimmed_model: + return None + return {"provider": trimmed_provider, "model": trimmed_model} + + +def configured_model() -> str | None: + """Return Hermes' configured default model, or None when unavailable.""" + selection = configured_model_selection() + return selection["model"] if selection else None def configured_reasoning_effort() -> str | None: @@ -162,6 +189,128 @@ def configured_reasoning_effort() -> str | None: return trimmed or None +def models_catalog() -> dict[str, Any]: + """Build the explicit, authenticated provider/model catalog for T3. + + This intentionally performs the potentially-blocking Hermes inventory + work synchronously. The adapter calls it through ``asyncio.to_thread`` and + only in response to ``models.list.request``; connecting and describing a + gateway therefore never trigger model discovery or network-backed cache + refreshes. + + Any inventory failure degrades to an empty list plus the read-only current + config when available. A broken picker must not take the gateway offline. + """ + configured = configured_model_selection() + current_provider = configured["provider"] if configured else None + current_model = configured["model"] if configured else None + models: list[dict[str, Any]] = [] + + try: + from hermes_cli.inventory import build_models_payload, load_picker_context + + context = load_picker_context() + payload = build_models_payload( + context, + explicit_only=True, + include_unconfigured=False, + picker_hints=False, + canonical_order=True, + pricing=False, + capabilities=True, + refresh=False, + probe_custom_providers=False, + probe_current_custom_provider=False, + max_models=100, + ) + if isinstance(payload, dict): + payload_provider = payload.get("provider") + payload_model = payload.get("model") + if isinstance(payload_provider, str) and payload_provider.strip(): + current_provider = payload_provider.strip() + if isinstance(payload_model, str) and payload_model.strip(): + current_model = payload_model.strip() + + seen: set[tuple[str, str]] = set() + rows = payload.get("providers") + if isinstance(rows, list): + for row in rows: + if not isinstance(row, dict): + continue + provider = str(row.get("slug") or "").strip() + if not provider: + continue + provider_name = str(row.get("name") or provider).strip() + capabilities = row.get("capabilities") + if not isinstance(capabilities, dict): + capabilities = {} + row_models = row.get("models") + if not isinstance(row_models, list): + continue + for raw_model in row_models: + if not isinstance(raw_model, str) or not raw_model.strip(): + continue + model = raw_model.strip() + key = (provider, model) + if key in seen: + continue + seen.add(key) + model_capabilities = capabilities.get(raw_model) + if not isinstance(model_capabilities, dict): + model_capabilities = capabilities.get(model) + supports_reasoning = True + if isinstance(model_capabilities, dict): + value = model_capabilities.get("reasoning") + if isinstance(value, bool): + supports_reasoning = value + models.append( + { + "provider": provider, + "providerName": provider_name or provider, + "model": model, + "supportsReasoning": supports_reasoning, + } + ) + except Exception: # noqa: BLE001 - catalog discovery is best-effort + models = [] + + reasoning_effort = configured_reasoning_effort() + if reasoning_effort: + reasoning_effort = reasoning_effort.strip().lower() + if reasoning_effort in {"false", "disabled", "off", "no"}: + reasoning_effort = "none" + elif reasoning_effort not in REASONING_EFFORTS: + reasoning_effort = None + + return { + "models": models, + "currentProvider": current_provider, + "currentModel": current_model, + "currentReasoningEffort": reasoning_effort, + } + + +def models_list_response( + *, + request_id_value: str, + catalog: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Build the correlated response to ``models.list.request``.""" + resolved = models_catalog() if catalog is None else catalog + payload: dict[str, Any] = { + "type": "models.list.response", + "protocolVersion": PROTOCOL_VERSION, + "requestId": request_id_value, + "reasoningEfforts": list(REASONING_EFFORTS), + "models": [dict(model) for model in resolved.get("models", [])], + } + for key in ("currentProvider", "currentModel", "currentReasoningEffort"): + value = resolved.get(key) + if isinstance(value, str) and value.strip(): + payload[key] = value.strip() + return payload + + def installed_skills() -> list[dict[str, Any]]: """Return metadata for the skills Hermes currently exposes. diff --git a/integrations/hermes-t3-gateway/tests/test_adapter.py b/integrations/hermes-t3-gateway/tests/test_adapter.py index f75c8c3bae2..a54348ac753 100644 --- a/integrations/hermes-t3-gateway/tests/test_adapter.py +++ b/integrations/hermes-t3-gateway/tests/test_adapter.py @@ -6,6 +6,7 @@ import enum import importlib.util import pathlib +import shlex import sys import tempfile import types @@ -64,6 +65,28 @@ class Source: message_id: str +class FakeGatewayRunner: + def __init__(self): + self._session_model_overrides = {} + self._session_reasoning_overrides = {} + self.default_model = "default-model" + self.default_provider = "openrouter" + self.reasoning_models = [] + + def _session_key_for_source(self, source): + return build_session_key(source) + + def _resolve_session_agent_runtime(self, *, session_key): + override = self._session_model_overrides.get(session_key, {}) + return override.get("model", self.default_model), { + "provider": override.get("provider", self.default_provider) + } + + def _resolve_session_reasoning_config(self, *, session_key, model=""): + self.reasoning_models.append(model) + return self._session_reasoning_overrides.get(session_key) + + class BasePlatformAdapter: def __init__(self, config, platform): self.config = config @@ -223,7 +246,7 @@ async def _start_turn(self, thread_id: str, turn_id: str): await self.adapter._handle_server_frame( { "type": "session.ensure", - "protocolVersion": 4, + "protocolVersion": protocol_module.PROTOCOL_VERSION, "requestId": f"ensure-{thread_id}", "threadId": thread_id, } @@ -232,7 +255,7 @@ async def _start_turn(self, thread_id: str, turn_id: str): await self.adapter._handle_server_frame( { "type": "turn.start", - "protocolVersion": 4, + "protocolVersion": protocol_module.PROTOCOL_VERSION, "requestId": f"start-{thread_id}", "threadId": thread_id, "sessionId": session_id, @@ -242,11 +265,57 @@ async def _start_turn(self, thread_id: str, turn_id: str): ) return session_id + async def _ensure_thread(self, thread_id: str) -> str: + await self.adapter._handle_server_frame( + { + "type": "session.ensure", + "protocolVersion": protocol_module.PROTOCOL_VERSION, + "requestId": f"ensure-{thread_id}", + "threadId": thread_id, + } + ) + return self.adapter._sessions[thread_id] + + def _install_configuration_handler(self, *, apply_model=True): + runner = FakeGatewayRunner() + commands = [] + observations = [] + + async def handler(event): + commands.append(event.text) + observations.append( + { + "active": event.source.chat_id in self.adapter._active_turns, + "frames": [message["type"] for message in self.connection.messages], + } + ) + tokens = shlex.split(event.text) + session_id = runner._session_key_for_source(event.source) + if tokens[0] == "/model" and apply_model: + runner._session_model_overrides[session_id] = { + "model": tokens[1], + "provider": tokens[tokens.index("--provider") + 1], + } + elif tokens[0] == "/reasoning": + effort = tokens[1] + runner._session_reasoning_overrides[session_id] = ( + {"enabled": False} + if effort == "none" + else {"enabled": True, "effort": effort} + ) + elif tokens[0] == "/steer": + return "⏩ Steer queued for the active session" + return "control acknowledgement that must stay hidden" + + self.adapter.gateway_runner = runner + self.adapter._message_handler = handler + return runner, commands, observations + async def test_thread_ensure_start_stream_and_complete(self): await self.adapter._handle_server_frame( { "type": "session.ensure", - "protocolVersion": 4, + "protocolVersion": protocol_module.PROTOCOL_VERSION, "requestId": "ensure-1", "threadId": "thread-1", } @@ -259,7 +328,7 @@ async def test_thread_ensure_start_stream_and_complete(self): await self.adapter._handle_server_frame( { "type": "turn.start", - "protocolVersion": 4, + "protocolVersion": protocol_module.PROTOCOL_VERSION, "requestId": "start-1", "threadId": "thread-1", "sessionId": ready["sessionId"], @@ -288,6 +357,854 @@ async def test_thread_ensure_start_stream_and_complete(self): ] self.assertEqual(deltas, ["Hello", " world"]) + async def test_turn_configuration_is_applied_before_start_and_ack_is_hidden(self): + session_id = await self._ensure_thread("thread-configured") + runner, commands, observations = self._install_configuration_handler() + cached_agent = types.SimpleNamespace(model="prior-model") + cached_entry = (cached_agent, "prior-signature") + runner._agent_cache = {session_id: cached_entry} + runner._agent_cache_lock = None + evicted = [] + + def evict_cached_agent(key): + evicted.append(runner._agent_cache.pop(key, None)) + + runner._evict_cached_agent = evict_cached_agent + frames_before = len(self.connection.messages) + + await self.adapter._handle_server_frame( + { + "type": "turn.start", + "protocolVersion": protocol_module.PROTOCOL_VERSION, + "requestId": "start-configured", + "threadId": "thread-configured", + "sessionId": session_id, + "turnId": "turn-configured", + "text": "Run the tests", + "modelSelection": { + "mode": "specific", + "provider": "openai-codex", + "model": "gpt-5.4", + }, + "reasoningEffort": "high", + } + ) + + self.assertEqual( + commands, + [ + "/model gpt-5.4 --provider openai-codex --session", + "/reasoning high", + ], + ) + self.assertTrue(all(not seen["active"] for seen in observations)) + self.assertTrue( + all("turn.started" not in seen["frames"] for seen in observations) + ) + started = self.connection.messages[frames_before] + self.assertEqual(started["type"], "turn.started") + self.assertEqual( + started["appliedModelSelection"], + {"provider": "openai-codex", "model": "gpt-5.4"}, + ) + self.assertEqual(started["appliedReasoningEffort"], "high") + self.assertEqual( + runner._session_model_overrides[session_id]["model"], "gpt-5.4" + ) + self.assertEqual(evicted, [cached_entry]) + self.assertNotIn(session_id, runner._agent_cache) + self.assertEqual(cached_agent.model, "prior-model") + # Direct control dispatch never entered BasePlatformAdapter, so its + # textual acknowledgements could not become T3 transcript messages. + self.assertEqual( + [event.text for event in self.adapter.messages], ["Run the tests"] + ) + + async def test_invalid_reasoning_is_rejected_before_model_mutation(self): + session_id = await self._ensure_thread("thread-invalid-config") + runner, commands, _ = self._install_configuration_handler() + + await self.adapter._handle_server_frame( + { + "type": "turn.start", + "protocolVersion": protocol_module.PROTOCOL_VERSION, + "requestId": "start-invalid-config", + "threadId": "thread-invalid-config", + "sessionId": session_id, + "turnId": "turn-invalid-config", + "text": "Do not run", + "modelSelection": { + "mode": "specific", + "provider": "openai-codex", + "model": "gpt-5.4", + }, + "reasoningEffort": "impossible", + } + ) + + self.assertEqual(commands, []) + self.assertEqual(runner._session_model_overrides, {}) + self.assertEqual(self.connection.messages[-1]["type"], "protocol.error") + self.assertEqual( + self.connection.messages[-1]["requestId"], "start-invalid-config" + ) + self.assertNotIn("thread-invalid-config", self.adapter._active_turns) + + async def test_default_model_mode_uses_an_explicit_session_switch(self): + session_id = await self._ensure_thread("thread-default-model") + runner, commands, _ = self._install_configuration_handler() + with unittest.mock.patch.object( + adapter_module, + "configured_model_selection", + return_value={ + "provider": "custom provider", + "model": "model with spaces", + }, + ): + await self.adapter._handle_server_frame( + { + "type": "turn.start", + "protocolVersion": protocol_module.PROTOCOL_VERSION, + "requestId": "start-default-model", + "threadId": "thread-default-model", + "sessionId": session_id, + "turnId": "turn-default-model", + "text": "Run", + "modelSelection": {"mode": "default"}, + } + ) + + self.assertEqual( + commands, + [ + "/model 'model with spaces' --provider 'custom provider' --session" + ], + ) + started = next( + frame + for frame in reversed(self.connection.messages) + if frame["type"] == "turn.started" + ) + self.assertEqual( + started["appliedModelSelection"], + {"provider": "custom provider", "model": "model with spaces"}, + ) + self.assertEqual(runner._session_reasoning_overrides, {}) + + async def test_reasoning_only_turn_uses_the_current_effective_model(self): + session_id = await self._ensure_thread("thread-reasoning-only") + runner, commands, _ = self._install_configuration_handler() + + await self.adapter._handle_server_frame( + { + "type": "turn.start", + "protocolVersion": protocol_module.PROTOCOL_VERSION, + "requestId": "start-reasoning-only", + "threadId": "thread-reasoning-only", + "sessionId": session_id, + "turnId": "turn-reasoning-only", + "text": "Run", + "reasoningEffort": "ultra", + } + ) + + self.assertEqual(commands, ["/reasoning ultra"]) + started = next( + frame + for frame in reversed(self.connection.messages) + if frame["type"] == "turn.started" + ) + self.assertNotIn("appliedModelSelection", started) + self.assertEqual(started["appliedReasoningEffort"], "ultra") + self.assertEqual( + runner._session_reasoning_overrides[session_id], + {"enabled": True, "effort": "ultra"}, + ) + self.assertEqual(runner.reasoning_models, [runner.default_model]) + + async def test_failed_model_switch_does_not_apply_reasoning_or_start(self): + session_id = await self._ensure_thread("thread-model-failure") + runner, commands, _ = self._install_configuration_handler( + apply_model=False + ) + + await self.adapter._handle_server_frame( + { + "type": "turn.start", + "protocolVersion": protocol_module.PROTOCOL_VERSION, + "requestId": "start-model-failure", + "threadId": "thread-model-failure", + "sessionId": session_id, + "turnId": "turn-model-failure", + "text": "Do not run", + "modelSelection": { + "mode": "specific", + "provider": "openai-codex", + "model": "gpt-5.4", + }, + "reasoningEffort": "high", + } + ) + + self.assertEqual( + commands, ["/model gpt-5.4 --provider openai-codex --session"] + ) + self.assertEqual(runner._session_reasoning_overrides, {}) + self.assertEqual(self.connection.messages[-1]["type"], "protocol.error") + self.assertNotIn("thread-model-failure", self.adapter._active_turns) + self.assertNotIn( + "thread-model-failure", self.adapter._applied_turn_configuration + ) + + async def test_concurrent_starts_reserve_the_preconfiguration_window(self): + thread_id = "thread-concurrent-start" + session_id = await self._ensure_thread(thread_id) + runner, commands, _ = self._install_configuration_handler() + original_handler = self.adapter._message_handler + configuration_entered = asyncio.Event() + release_configuration = asyncio.Event() + + async def blocking_handler(event): + configuration_entered.set() + await release_configuration.wait() + return await original_handler(event) + + self.adapter._message_handler = blocking_handler + + def start_message(request_id, turn_id, model): + return { + "type": "turn.start", + "protocolVersion": protocol_module.PROTOCOL_VERSION, + "requestId": request_id, + "threadId": thread_id, + "sessionId": session_id, + "turnId": turn_id, + "text": f"Run {turn_id}", + "modelSelection": { + "mode": "specific", + "provider": "openai-codex", + "model": model, + }, + } + + first = asyncio.create_task( + self.adapter._handle_server_frame( + start_message("start-concurrent-1", "turn-concurrent-1", "gpt-5.4") + ) + ) + await asyncio.wait_for(configuration_entered.wait(), timeout=1) + + await self.adapter._handle_server_frame( + start_message("start-concurrent-2", "turn-concurrent-2", "gpt-5.5") + ) + + second_error = next( + message + for message in self.connection.messages + if message.get("requestId") == "start-concurrent-2" + ) + self.assertEqual(second_error["type"], "protocol.error") + self.assertEqual(second_error["code"], "invalid-message") + self.assertEqual(commands, []) + + release_configuration.set() + await first + + self.assertEqual( + commands, ["/model gpt-5.4 --provider openai-codex --session"] + ) + self.assertEqual( + runner._session_model_overrides[session_id]["model"], "gpt-5.4" + ) + self.assertEqual( + [event.text for event in self.adapter.messages], ["Run turn-concurrent-1"] + ) + self.assertEqual( + self.adapter._active_turns[thread_id].turn_id, "turn-concurrent-1" + ) + self.assertNotIn(thread_id, self.adapter._turn_start_reservations) + + async def test_stop_racing_configuration_fences_and_rolls_back_the_start(self): + thread_id = "thread-stop-start" + session_id = await self._ensure_thread(thread_id) + runner = FakeGatewayRunner() + configuration_mutated = asyncio.Event() + release_configuration = asyncio.Event() + + async def handler(event): + tokens = shlex.split(event.text) + command_session_id = runner._session_key_for_source(event.source) + runner._session_model_overrides[command_session_id] = { + "model": tokens[1], + "provider": tokens[tokens.index("--provider") + 1], + } + configuration_mutated.set() + await release_configuration.wait() + return "hidden" + + self.adapter.gateway_runner = runner + self.adapter._message_handler = handler + start = asyncio.create_task( + self.adapter._handle_server_frame( + { + "type": "turn.start", + "protocolVersion": protocol_module.PROTOCOL_VERSION, + "requestId": "start-stop-race", + "threadId": thread_id, + "sessionId": session_id, + "turnId": "turn-stop-race", + "text": "Must not run", + "modelSelection": { + "mode": "specific", + "provider": "openai-codex", + "model": "gpt-5.4", + }, + } + ) + ) + await asyncio.wait_for(configuration_mutated.wait(), timeout=1) + + await self.adapter._handle_server_frame( + { + "type": "session.stop", + "protocolVersion": protocol_module.PROTOCOL_VERSION, + "requestId": "stop-start-race", + "threadId": thread_id, + "sessionId": session_id, + } + ) + self.assertTrue( + self.adapter._turn_start_reservations[thread_id].cancelled + ) + # A fast re-ensure must not revive the already-stopped start. + self.assertEqual(await self._ensure_thread(thread_id), session_id) + + release_configuration.set() + await start + + self.assertEqual(runner._session_model_overrides, {}) + self.assertNotIn(thread_id, self.adapter._applied_turn_configuration) + self.assertNotIn(thread_id, self.adapter._active_turns) + self.assertNotIn(thread_id, self.adapter._turn_start_reservations) + self.assertEqual(self.adapter.messages, []) + self.assertFalse( + any( + message["type"] == "turn.started" + and message.get("requestId") == "start-stop-race" + for message in self.connection.messages + ) + ) + start_error = next( + message + for message in self.connection.messages + if message.get("requestId") == "start-stop-race" + ) + self.assertEqual(start_error["type"], "protocol.error") + self.assertIn("stopped", start_error["message"]) + + async def test_profile_aware_session_key_is_used_for_config_and_verification(self): + thread_id = "thread-multiplex" + profile_session_id = f"agent:work:t3:dm:{thread_id}" + runner, commands, _ = self._install_configuration_handler() + runner._session_key_for_source = lambda source: ( + f"agent:work:t3:dm:{source.chat_id}" + ) + + session_id = await self._ensure_thread(thread_id) + self.assertEqual(session_id, profile_session_id) + + await self.adapter._handle_server_frame( + { + "type": "turn.start", + "protocolVersion": protocol_module.PROTOCOL_VERSION, + "requestId": "start-multiplex", + "threadId": thread_id, + "sessionId": session_id, + "turnId": "turn-multiplex", + "text": "Run", + "modelSelection": { + "mode": "specific", + "provider": "openai-codex", + "model": "gpt-5.4", + }, + "reasoningEffort": "xhigh", + } + ) + + self.assertEqual( + commands, + [ + "/model gpt-5.4 --provider openai-codex --session", + "/reasoning xhigh", + ], + ) + self.assertEqual( + set(runner._session_model_overrides), {profile_session_id} + ) + self.assertEqual( + set(runner._session_reasoning_overrides), {profile_session_id} + ) + started = next( + message + for message in self.connection.messages + if message.get("requestId") == "start-multiplex" + ) + self.assertEqual(started["type"], "turn.started") + self.assertEqual(started["appliedReasoningEffort"], "xhigh") + + async def test_reasoning_failure_restores_model_reasoning_and_cache(self): + thread_id = "thread-atomic-config" + session_id = await self._ensure_thread(thread_id) + runner = FakeGatewayRunner() + prior_model = {"provider": "old-provider", "model": "old-model"} + prior_reasoning = {"enabled": True, "effort": "low"} + prior_cache = { + "modelRequest": ("specific", "old-provider", "old-model"), + "modelEffective": dict(prior_model), + "reasoningRequest": "low", + "reasoningEffective": "low", + } + runner._session_model_overrides[session_id] = dict(prior_model) + runner._session_reasoning_overrides[session_id] = dict(prior_reasoning) + runner._pending_model_notes = {session_id: "prior model note"} + runner._pending_one_turn_model_restores = { + session_id: {"had_override": True, "override": dict(prior_model)} + } + runner._reasoning_config = dict(prior_reasoning) + runner._session_ephemeral_pin = {session_id: "prior prompt pin"} + runner._session_vc_last = {session_id: "prior voice channel"} + cached_agent = types.SimpleNamespace(model="old-model") + cached_entry = (cached_agent, "prior-cache-signature") + runner._agent_cache = {session_id: cached_entry} + runner._agent_cache_lock = None + + session_entry = types.SimpleNamespace( + session_id="durable-session-id", + was_auto_reset=True, + ) + + class FakeAsyncSessionStore: + def __init__(self): + self.model_overrides = {session_id: dict(prior_model)} + + async def get_or_create_session(self, source): + del source + return session_entry + + async def get_model_override(self, key): + value = self.model_overrides.get(key) + return dict(value) if value is not None else None + + async def set_model_override(self, key, value): + if value is None: + self.model_overrides.pop(key, None) + else: + self.model_overrides[key] = dict(value) + + prior_db_row = { + "model": "old-model", + "model_config": '{"browser_model_lock":"old-model"}', + "system_prompt": "prior system prompt", + } + + class FakeSessionDB: + def __init__(self): + self.rows = {session_entry.session_id: dict(prior_db_row)} + + async def get_session(self, key): + row = self.rows.get(key) + return dict(row) if row is not None else None + + async def update_session_model(self, key, model): + row = self.rows[key] + row["model"] = model + row["model_config"] = None + row["system_prompt"] = None + + async def update_session_meta(self, key, model_config, model=None): + row = self.rows[key] + row["model_config"] = model_config + if model is not None: + row["model"] = model + + async def update_system_prompt(self, key, system_prompt): + self.rows[key]["system_prompt"] = system_prompt + + runner.async_session_store = FakeAsyncSessionStore() + runner._session_db = FakeSessionDB() + self.adapter._applied_turn_configuration[thread_id] = dict(prior_cache) + commands = [] + + async def handler(event): + commands.append(event.text) + tokens = shlex.split(event.text) + command_session_id = runner._session_key_for_source(event.source) + if tokens[0] == "/model": + cached = runner._agent_cache.get(command_session_id) + if cached is not None: + cached[0].model = tokens[1] + durable_entry = await runner.async_session_store.get_or_create_session( + event.source + ) + durable_entry.was_auto_reset = False + await runner._session_db.update_session_model( + durable_entry.session_id, tokens[1] + ) + runner._pending_model_notes[command_session_id] = "new model note" + runner._session_model_overrides[command_session_id] = { + "model": tokens[1], + "provider": tokens[tokens.index("--provider") + 1], + } + await runner.async_session_store.set_model_override( + command_session_id, + runner._session_model_overrides[command_session_id], + ) + runner._pending_one_turn_model_restores.pop( + command_session_id, None + ) + runner._session_ephemeral_pin.pop(command_session_id, None) + runner._session_vc_last.pop(command_session_id, None) + runner._agent_cache.pop(command_session_id, None) + return "hidden" + runner._reasoning_config = {"enabled": True, "effort": tokens[1]} + runner._session_reasoning_overrides[command_session_id] = { + "enabled": True, + "effort": tokens[1], + } + raise RuntimeError("reasoning command failed after mutation") + + self.adapter.gateway_runner = runner + self.adapter._message_handler = handler + await self.adapter._handle_server_frame( + { + "type": "turn.start", + "protocolVersion": protocol_module.PROTOCOL_VERSION, + "requestId": "start-atomic-config", + "threadId": thread_id, + "sessionId": session_id, + "turnId": "turn-atomic-config", + "text": "Must not run", + "modelSelection": { + "mode": "specific", + "provider": "openai-codex", + "model": "gpt-5.4", + }, + "reasoningEffort": "high", + } + ) + + self.assertEqual( + commands, + [ + "/model gpt-5.4 --provider openai-codex --session", + "/reasoning high", + ], + ) + self.assertEqual( + runner._session_model_overrides, {session_id: prior_model} + ) + self.assertEqual( + runner._session_reasoning_overrides, {session_id: prior_reasoning} + ) + self.assertEqual( + runner._pending_model_notes, {session_id: "prior model note"} + ) + self.assertEqual( + runner._pending_one_turn_model_restores, + {session_id: {"had_override": True, "override": prior_model}}, + ) + self.assertEqual(runner._reasoning_config, prior_reasoning) + self.assertEqual( + runner.async_session_store.model_overrides, + {session_id: prior_model}, + ) + self.assertTrue(session_entry.was_auto_reset) + self.assertEqual( + runner._session_db.rows, + {session_entry.session_id: prior_db_row}, + ) + self.assertIs(runner._agent_cache[session_id], cached_entry) + self.assertEqual(cached_agent.model, "old-model") + self.assertEqual( + runner._session_ephemeral_pin, {session_id: "prior prompt pin"} + ) + self.assertEqual( + runner._session_vc_last, {session_id: "prior voice channel"} + ) + self.assertEqual( + self.adapter._applied_turn_configuration, {thread_id: prior_cache} + ) + self.assertNotIn(thread_id, self.adapter._active_turns) + self.assertEqual(self.adapter.messages, []) + error = self.connection.messages[-1] + self.assertEqual(error["type"], "protocol.error") + self.assertEqual(error["requestId"], "start-atomic-config") + + async def test_cancelled_configuration_restores_prior_state(self): + thread_id = "thread-cancelled-config" + session_id = await self._ensure_thread(thread_id) + runner = FakeGatewayRunner() + configuration_mutated = asyncio.Event() + session_entry = types.SimpleNamespace( + session_id="cancelled-durable-session", + was_auto_reset=True, + ) + + class FakeAsyncSessionStore: + def __init__(self): + self.model_overrides = {} + + async def get_or_create_session(self, source): + del source + return session_entry + + async def get_model_override(self, key): + return self.model_overrides.get(key) + + async def set_model_override(self, key, value): + if value is None: + self.model_overrides.pop(key, None) + else: + self.model_overrides[key] = dict(value) + + prior_db_row = { + "model": None, + "model_config": '{"browser_model_lock":"old-browser-model"}', + "system_prompt": "prior nullable-model prompt", + } + + class FakeSessionDB: + def __init__(self): + self.rows = {session_entry.session_id: dict(prior_db_row)} + + async def get_session(self, key): + return dict(self.rows[key]) + + async def update_session_model(self, key, model): + row = self.rows[key] + row["model"] = model + row["model_config"] = None + row["system_prompt"] = None + + async def update_session_meta(self, key, model_config, model=None): + row = self.rows[key] + row["model_config"] = model_config + if model is not None: + row["model"] = model + + async def update_system_prompt(self, key, system_prompt): + self.rows[key]["system_prompt"] = system_prompt + + runner.async_session_store = FakeAsyncSessionStore() + runner._session_db = FakeSessionDB() + cached_agent = types.SimpleNamespace(model="old-model") + cached_entry = (cached_agent, "prior-cache-signature") + runner._agent_cache = {session_id: cached_entry} + runner._agent_cache_lock = None + + async def handler(event): + tokens = shlex.split(event.text) + command_session_id = runner._session_key_for_source(event.source) + cached = runner._agent_cache.get(command_session_id) + if cached is not None: + cached[0].model = tokens[1] + durable_entry = await runner.async_session_store.get_or_create_session( + event.source + ) + durable_entry.was_auto_reset = False + await runner._session_db.update_session_model( + durable_entry.session_id, tokens[1] + ) + runner._session_model_overrides[command_session_id] = { + "model": tokens[1], + "provider": tokens[tokens.index("--provider") + 1], + } + if not hasattr(runner, "_pending_model_notes"): + runner._pending_model_notes = {} + runner._pending_model_notes[command_session_id] = "new model note" + await runner.async_session_store.set_model_override( + command_session_id, + runner._session_model_overrides[command_session_id], + ) + runner._agent_cache.pop(command_session_id, None) + configuration_mutated.set() + await asyncio.Event().wait() + + self.adapter.gateway_runner = runner + self.adapter._message_handler = handler + start = asyncio.create_task( + self.adapter._handle_server_frame( + { + "type": "turn.start", + "protocolVersion": protocol_module.PROTOCOL_VERSION, + "requestId": "start-cancelled-config", + "threadId": thread_id, + "sessionId": session_id, + "turnId": "turn-cancelled-config", + "text": "Must not run", + "modelSelection": { + "mode": "specific", + "provider": "openai-codex", + "model": "gpt-5.4", + }, + } + ) + ) + await asyncio.wait_for(configuration_mutated.wait(), timeout=1) + + start.cancel() + with self.assertRaises(asyncio.CancelledError): + await start + + self.assertEqual(runner._session_model_overrides, {}) + self.assertEqual(runner._session_reasoning_overrides, {}) + self.assertFalse(hasattr(runner, "_pending_model_notes")) + self.assertEqual(runner.async_session_store.model_overrides, {}) + self.assertTrue(session_entry.was_auto_reset) + self.assertEqual( + runner._session_db.rows, + {session_entry.session_id: prior_db_row}, + ) + self.assertIs(runner._agent_cache[session_id], cached_entry) + self.assertEqual(cached_agent.model, "old-model") + self.assertNotIn(thread_id, self.adapter._applied_turn_configuration) + self.assertNotIn(thread_id, self.adapter._turn_start_reservations) + self.assertNotIn(thread_id, self.adapter._active_turns) + + async def test_configuration_cache_is_idempotent_and_isolated_per_thread(self): + runner, commands, _ = self._install_configuration_handler() + + async def start(thread_id: str, turn_id: str) -> str: + session_id = self.adapter._sessions.get(thread_id) + if session_id is None: + session_id = await self._ensure_thread(thread_id) + await self.adapter._handle_server_frame( + { + "type": "turn.start", + "protocolVersion": protocol_module.PROTOCOL_VERSION, + "requestId": f"start-{turn_id}", + "threadId": thread_id, + "sessionId": session_id, + "turnId": turn_id, + "text": "Run", + "modelSelection": { + "mode": "specific", + "provider": "openai-codex", + "model": "gpt-5.4", + }, + "reasoningEffort": "medium", + } + ) + return session_id + + first_session = await start("thread-cache-a", "turn-a1") + await self.adapter.send( + "thread-cache-a", "done", metadata={"notify": True} + ) + cached_agent = types.SimpleNamespace(model="gpt-5.4") + cached_entry = (cached_agent, "verified-cache-signature") + runner._agent_cache = {first_session: cached_entry} + runner._agent_cache_lock = None + evicted = [] + + def evict_cached_agent(key): + evicted.append(runner._agent_cache.pop(key, None)) + + runner._evict_cached_agent = evict_cached_agent + await start("thread-cache-a", "turn-a2") + # The runner still matches the cached verified state: no repeat + # control commands or agent eviction for another turn in the same + # session. + self.assertEqual(len(commands), 2) + self.assertIs(runner._agent_cache[first_session], cached_entry) + self.assertEqual(evicted, []) + await self.adapter.send( + "thread-cache-a", "done again", metadata={"notify": True} + ) + + second_session = await start("thread-cache-b", "turn-b1") + self.assertEqual(len(commands), 4) + self.assertNotEqual(first_session, second_session) + self.assertEqual( + set(runner._session_model_overrides), + {first_session, second_session}, + ) + + async def test_busy_turn_rejects_configuration_without_mutating_it(self): + session_id = await self._ensure_thread("thread-busy-config") + runner, commands, _ = self._install_configuration_handler() + await self.adapter._handle_server_frame( + { + "type": "turn.start", + "protocolVersion": protocol_module.PROTOCOL_VERSION, + "requestId": "start-busy-first", + "threadId": "thread-busy-config", + "sessionId": session_id, + "turnId": "turn-busy-first", + "text": "First", + } + ) + + await self.adapter._handle_server_frame( + { + "type": "turn.start", + "protocolVersion": protocol_module.PROTOCOL_VERSION, + "requestId": "start-busy-second", + "threadId": "thread-busy-config", + "sessionId": session_id, + "turnId": "turn-busy-second", + "text": "Second", + "modelSelection": { + "mode": "specific", + "provider": "openai-codex", + "model": "gpt-5.4", + }, + "reasoningEffort": "high", + } + ) + + self.assertEqual(commands, []) + self.assertEqual(runner._session_model_overrides, {}) + error = self.connection.messages[-1] + self.assertEqual(error["type"], "protocol.error") + self.assertEqual(error["requestId"], "start-busy-second") + + async def test_steer_ignores_model_and_reasoning_fields(self): + session_id = await self._ensure_thread("thread-steer-config") + runner, commands, _ = self._install_configuration_handler() + await self.adapter._handle_server_frame( + { + "type": "turn.start", + "protocolVersion": protocol_module.PROTOCOL_VERSION, + "requestId": "start-steer-config", + "threadId": "thread-steer-config", + "sessionId": session_id, + "turnId": "turn-steer-config", + "text": "Start", + } + ) + await self.adapter._handle_server_frame( + { + "type": "turn.steer", + "protocolVersion": protocol_module.PROTOCOL_VERSION, + "requestId": "steer-config", + "threadId": "thread-steer-config", + "sessionId": session_id, + "turnId": "turn-steer-config", + "text": "Focus", + "modelSelection": { + "mode": "specific", + "provider": "openai-codex", + "model": "gpt-5.4", + }, + "reasoningEffort": "high", + } + ) + + self.assertEqual(commands, ["/steer Focus"]) + self.assertEqual(runner._session_model_overrides, {}) + self.assertEqual(runner._session_reasoning_overrides, {}) + async def test_tool_progress_bubble_edits_never_complete_the_turn(self): """Regression: the gateway's progress loop must not end a T3 turn. @@ -839,7 +1756,7 @@ async def test_session_ready_reports_an_active_turn_on_reconnect(self): await self.adapter._handle_server_frame( { "type": "session.ensure", - "protocolVersion": 4, + "protocolVersion": protocol_module.PROTOCOL_VERSION, "requestId": "ensure-reconnect", "threadId": "thread-reconnect", "resumeSessionId": session_id, @@ -855,7 +1772,7 @@ async def test_steer_uses_official_hermes_command(self): await self.adapter._handle_server_frame( { "type": "session.ensure", - "protocolVersion": 4, + "protocolVersion": protocol_module.PROTOCOL_VERSION, "requestId": "ensure-2", "threadId": "thread-2", } @@ -864,7 +1781,7 @@ async def test_steer_uses_official_hermes_command(self): await self.adapter._handle_server_frame( { "type": "turn.start", - "protocolVersion": 4, + "protocolVersion": protocol_module.PROTOCOL_VERSION, "requestId": "start-2", "threadId": "thread-2", "sessionId": session_id, @@ -883,7 +1800,7 @@ async def accept_steer(_event): await self.adapter._handle_server_frame( { "type": "turn.steer", - "protocolVersion": 4, + "protocolVersion": protocol_module.PROTOCOL_VERSION, "requestId": "steer-2", "threadId": "thread-2", "sessionId": session_id, @@ -955,7 +1872,7 @@ async def stream_while_steering(event): await self.adapter._handle_server_frame( { "type": "turn.steer", - "protocolVersion": 4, + "protocolVersion": protocol_module.PROTOCOL_VERSION, "requestId": "steer-race", "threadId": "thread-steer-race", "sessionId": session_id, @@ -1004,7 +1921,7 @@ async def edit_own_acknowledgement(event): await self.adapter._handle_server_frame( { "type": "turn.steer", - "protocolVersion": 4, + "protocolVersion": protocol_module.PROTOCOL_VERSION, "requestId": "steer-edit", "threadId": "thread-steer-edit", "sessionId": session_id, @@ -1023,7 +1940,7 @@ async def test_rejected_steer_emits_error_without_completing_active_turn(self): await self.adapter._handle_server_frame( { "type": "session.ensure", - "protocolVersion": 4, + "protocolVersion": protocol_module.PROTOCOL_VERSION, "requestId": "ensure-rejected-steer", "threadId": "thread-rejected-steer", } @@ -1032,7 +1949,7 @@ async def test_rejected_steer_emits_error_without_completing_active_turn(self): await self.adapter._handle_server_frame( { "type": "turn.start", - "protocolVersion": 4, + "protocolVersion": protocol_module.PROTOCOL_VERSION, "requestId": "start-rejected-steer", "threadId": "thread-rejected-steer", "sessionId": session_id, @@ -1049,7 +1966,7 @@ async def reject_steer(_event): await self.adapter._handle_server_frame( { "type": "turn.steer", - "protocolVersion": 4, + "protocolVersion": protocol_module.PROTOCOL_VERSION, "requestId": "steer-rejected", "threadId": "thread-rejected-steer", "sessionId": session_id, @@ -1096,7 +2013,7 @@ async def test_failed_steer_emits_correlated_internal_error(self): await self.adapter._handle_server_frame( { "type": "session.ensure", - "protocolVersion": 4, + "protocolVersion": protocol_module.PROTOCOL_VERSION, "requestId": "ensure-failed-steer", "threadId": "thread-failed-steer", } @@ -1105,7 +2022,7 @@ async def test_failed_steer_emits_correlated_internal_error(self): await self.adapter._handle_server_frame( { "type": "turn.start", - "protocolVersion": 4, + "protocolVersion": protocol_module.PROTOCOL_VERSION, "requestId": "start-failed-steer", "threadId": "thread-failed-steer", "sessionId": session_id, @@ -1122,7 +2039,7 @@ async def fail_steer(_event): await self.adapter._handle_server_frame( { "type": "turn.steer", - "protocolVersion": 4, + "protocolVersion": protocol_module.PROTOCOL_VERSION, "requestId": "steer-failed", "threadId": "thread-failed-steer", "sessionId": session_id, @@ -1210,7 +2127,7 @@ async def test_session_status_counts_ready_sessions_and_stop_decrements(self): await self.adapter._handle_server_frame( { "type": "session.ensure", - "protocolVersion": 4, + "protocolVersion": protocol_module.PROTOCOL_VERSION, "requestId": "ensure-3", "threadId": "thread-3", } @@ -1220,7 +2137,7 @@ async def test_session_status_counts_ready_sessions_and_stop_decrements(self): await self.adapter._handle_server_frame( { "type": "session.stop", - "protocolVersion": 4, + "protocolVersion": protocol_module.PROTOCOL_VERSION, "requestId": "stop-3", "threadId": "thread-3", "sessionId": session_id, @@ -1241,7 +2158,7 @@ async def test_describe_request_replies_with_the_requests_own_id(self): await self.adapter._handle_server_frame( { "type": "describe.request", - "protocolVersion": 4, + "protocolVersion": protocol_module.PROTOCOL_VERSION, "requestId": "describe-1", } ) @@ -1250,12 +2167,50 @@ async def test_describe_request_replies_with_the_requests_own_id(self): self.assertEqual(reply["type"], "describe.response") # Correlation, exactly like ping -> pong. self.assertEqual(reply["requestId"], "describe-1") - self.assertEqual(reply["protocolVersion"], 4) + self.assertEqual( + reply["protocolVersion"], protocol_module.PROTOCOL_VERSION + ) self.assertEqual(reply["hermesVersion"], "0.19.0") self.assertIsInstance(reply["skills"], list) self.assertIn("capabilities", reply) self.assertEqual(describe.call_count, 1) + async def test_models_list_request_builds_catalog_off_the_event_loop(self): + catalog = { + "currentProvider": "openai-codex", + "currentModel": "gpt-5.4", + "currentReasoningEffort": "high", + "models": [ + { + "provider": "openai-codex", + "providerName": "OpenAI Codex", + "model": "gpt-5.4", + "supportsReasoning": True, + } + ], + } + with unittest.mock.patch.object( + adapter_module, "models_catalog" + ) as build_catalog, unittest.mock.patch.object( + adapter_module.asyncio, + "to_thread", + new=unittest.mock.AsyncMock(return_value=catalog), + ) as to_thread: + await self.adapter._handle_server_frame( + { + "type": "models.list.request", + "protocolVersion": protocol_module.PROTOCOL_VERSION, + "requestId": "models-1", + } + ) + + to_thread.assert_awaited_once_with(build_catalog) + reply = self.connection.messages[-1] + self.assertEqual(reply["type"], "models.list.response") + self.assertEqual(reply["requestId"], "models-1") + self.assertEqual(reply["models"], catalog["models"]) + self.assertEqual(reply["currentReasoningEffort"], "high") + async def test_describe_request_survives_hermes_being_unreadable(self): # An older Hermes whose modules exist but export none of the accessors # the plugin reads. The reply gets thinner; it never becomes an error @@ -1264,7 +2219,7 @@ async def test_describe_request_survives_hermes_being_unreadable(self): await self.adapter._handle_server_frame( { "type": "describe.request", - "protocolVersion": 4, + "protocolVersion": protocol_module.PROTOCOL_VERSION, "requestId": "describe-degraded", } ) @@ -1281,7 +2236,7 @@ async def test_skill_body_request_survives_hermes_being_unreadable(self): await self.adapter._handle_server_frame( { "type": "skill.body.request", - "protocolVersion": 4, + "protocolVersion": protocol_module.PROTOCOL_VERSION, "requestId": "body-degraded", "skillName": "codex", } @@ -1299,7 +2254,7 @@ async def test_skill_body_request_replies_with_correlated_markdown(self): await self.adapter._handle_server_frame( { "type": "skill.body.request", - "protocolVersion": 4, + "protocolVersion": protocol_module.PROTOCOL_VERSION, "requestId": "body-1", "skillName": "codex", } @@ -1316,7 +2271,7 @@ async def test_skill_body_request_replies_null_for_an_unknown_skill(self): await self.adapter._handle_server_frame( { "type": "skill.body.request", - "protocolVersion": 4, + "protocolVersion": protocol_module.PROTOCOL_VERSION, "requestId": "body-2", "skillName": "does-not-exist", } @@ -1336,7 +2291,7 @@ async def test_skill_body_request_without_a_name_is_a_correlated_error(self): await self.adapter._handle_server_frame( { "type": "skill.body.request", - "protocolVersion": 4, + "protocolVersion": protocol_module.PROTOCOL_VERSION, "requestId": "body-3", } ) @@ -1350,12 +2305,12 @@ async def test_describe_frames_never_emit_a_protocol_error(self): for message in ( { "type": "describe.request", - "protocolVersion": 4, + "protocolVersion": protocol_module.PROTOCOL_VERSION, "requestId": "describe-no-error", }, { "type": "skill.body.request", - "protocolVersion": 4, + "protocolVersion": protocol_module.PROTOCOL_VERSION, "requestId": "body-no-error", "skillName": "codex", }, @@ -1601,7 +2556,7 @@ async def test_a_failed_turn_start_leaves_no_phantom_turn_behind(self): await self.adapter._handle_server_frame( { "type": "session.ensure", - "protocolVersion": 4, + "protocolVersion": protocol_module.PROTOCOL_VERSION, "requestId": "ensure-wedged", "threadId": "thread-wedged", } @@ -1621,7 +2576,7 @@ async def drop_the_turn_started(message): await self.adapter._handle_server_frame( { "type": "turn.start", - "protocolVersion": 4, + "protocolVersion": protocol_module.PROTOCOL_VERSION, "requestId": "start-wedged", "threadId": "thread-wedged", "sessionId": session_id, @@ -1639,7 +2594,7 @@ async def drop_the_turn_started(message): await self.adapter._handle_server_frame( { "type": "turn.start", - "protocolVersion": 4, + "protocolVersion": protocol_module.PROTOCOL_VERSION, "requestId": "start-recovered", "threadId": "thread-wedged", "sessionId": session_id, @@ -1698,7 +2653,7 @@ async def _start_turn(self, thread_id: str, turn_id: str) -> str: await self.adapter._handle_server_frame( { "type": "session.ensure", - "protocolVersion": 4, + "protocolVersion": protocol_module.PROTOCOL_VERSION, "requestId": f"ensure-{thread_id}", "threadId": thread_id, } @@ -1707,7 +2662,7 @@ async def _start_turn(self, thread_id: str, turn_id: str) -> str: await self.adapter._handle_server_frame( { "type": "turn.start", - "protocolVersion": 4, + "protocolVersion": protocol_module.PROTOCOL_VERSION, "requestId": f"start-{thread_id}", "threadId": thread_id, "sessionId": session_id, @@ -1729,7 +2684,9 @@ async def test_a_proactive_send_to_home_emits_home_deliver(self): frames = self.connection.messages[frames_before:] self.assertEqual([frame["type"] for frame in frames], ["home.deliver"]) delivery = frames[0] - self.assertEqual(delivery["protocolVersion"], 4) + self.assertEqual( + delivery["protocolVersion"], protocol_module.PROTOCOL_VERSION + ) self.assertEqual(delivery["threadId"], self.HOME) self.assertEqual(delivery["kind"], "cron") self.assertEqual(delivery["label"], "Cron: nightly") @@ -1878,7 +2835,7 @@ async def test_a_delivery_is_queued_before_it_is_sent_and_purged_on_ack(self): await self.adapter._handle_server_frame( { "type": "home.deliver.ack", - "protocolVersion": 4, + "protocolVersion": protocol_module.PROTOCOL_VERSION, "deliveryId": delivery_id, } ) @@ -1908,7 +2865,7 @@ async def send(self, message): await self.adapter._handle_connection_accepted( { "type": "connection.accepted", - "protocolVersion": 4, + "protocolVersion": protocol_module.PROTOCOL_VERSION, "requestId": "hello-1", "instanceId": "instance", "nickname": "Hermes", @@ -1926,7 +2883,7 @@ async def send(self, message): await self.adapter._handle_server_frame( { "type": "home.deliver.ack", - "protocolVersion": 4, + "protocolVersion": protocol_module.PROTOCOL_VERSION, "deliveryId": offline.message_id, } ) @@ -2026,7 +2983,7 @@ async def test_connection_accepted_reconciles_the_home_designation(self): await self.adapter._handle_connection_accepted( { "type": "connection.accepted", - "protocolVersion": 4, + "protocolVersion": protocol_module.PROTOCOL_VERSION, "requestId": "hello-1", "instanceId": "instance", "nickname": "Hermes", @@ -2043,7 +3000,7 @@ async def test_an_accepted_frame_without_a_home_thread_changes_nothing(self): await self.adapter._handle_connection_accepted( { "type": "connection.accepted", - "protocolVersion": 4, + "protocolVersion": protocol_module.PROTOCOL_VERSION, "requestId": "hello-1", "instanceId": "instance", "nickname": "Hermes", @@ -2054,7 +3011,11 @@ async def test_an_accepted_frame_without_a_home_thread_changes_nothing(self): async def test_a_nameless_ack_is_a_correlated_protocol_error(self): await self.adapter._handle_server_frame( - {"type": "home.deliver.ack", "protocolVersion": 4, "requestId": "ack-1"} + { + "type": "home.deliver.ack", + "protocolVersion": protocol_module.PROTOCOL_VERSION, + "requestId": "ack-1", + } ) reply = self.connection.messages[-1] self.assertEqual(reply["type"], "protocol.error") @@ -2062,7 +3023,7 @@ async def test_a_nameless_ack_is_a_correlated_protocol_error(self): class InboundAttachmentTests(unittest.IsolatedAsyncioTestCase): - """v4 turn attachments: base64 on the frame → temp files → media_urls.""" + """v4+ turn attachments: base64 on the frame → temp files → media_urls.""" async def asyncSetUp(self): self.adapter = adapter_module.T3PlatformAdapter( @@ -2081,7 +3042,7 @@ async def _ensure(self, thread_id: str) -> str: await self.adapter._handle_server_frame( { "type": "session.ensure", - "protocolVersion": 4, + "protocolVersion": protocol_module.PROTOCOL_VERSION, "requestId": f"ensure-{thread_id}", "threadId": thread_id, } @@ -2095,7 +3056,7 @@ async def test_turn_attachments_land_as_local_files_on_the_message_event(self): await self.adapter._handle_server_frame( { "type": "turn.start", - "protocolVersion": 4, + "protocolVersion": protocol_module.PROTOCOL_VERSION, "requestId": "start-attach", "threadId": "thread-attach", "sessionId": session_id, @@ -2145,7 +3106,7 @@ async def test_a_hostile_attachment_name_cannot_escape_the_temp_directory(self): await self.adapter._handle_server_frame( { "type": "turn.start", - "protocolVersion": 4, + "protocolVersion": protocol_module.PROTOCOL_VERSION, "requestId": "start-hostile", "threadId": "thread-hostile", "sessionId": session_id, @@ -2175,7 +3136,7 @@ async def test_a_turn_without_attachments_carries_no_media(self): await self.adapter._handle_server_frame( { "type": "turn.start", - "protocolVersion": 4, + "protocolVersion": protocol_module.PROTOCOL_VERSION, "requestId": "start-plain", "threadId": "thread-plain", "sessionId": session_id, @@ -2193,7 +3154,7 @@ async def test_a_malformed_attachment_errors_before_any_turn_starts(self): await self.adapter._handle_server_frame( { "type": "turn.start", - "protocolVersion": 4, + "protocolVersion": protocol_module.PROTOCOL_VERSION, "requestId": "start-bad", "threadId": "thread-bad-attach", "sessionId": session_id, @@ -2220,7 +3181,7 @@ async def test_steer_attachments_ride_the_injected_text_as_path_notes(self): await self.adapter._handle_server_frame( { "type": "turn.start", - "protocolVersion": 4, + "protocolVersion": protocol_module.PROTOCOL_VERSION, "requestId": "start-steer-attach", "threadId": "thread-steer-attach", "sessionId": session_id, @@ -2236,7 +3197,7 @@ async def accept_steer(_event): await self.adapter._handle_server_frame( { "type": "turn.steer", - "protocolVersion": 4, + "protocolVersion": protocol_module.PROTOCOL_VERSION, "requestId": "steer-attach", "threadId": "thread-steer-attach", "sessionId": session_id, @@ -2308,7 +3269,7 @@ async def _start_turn(self, thread_id: str, turn_id: str) -> str: await self.adapter._handle_server_frame( { "type": "session.ensure", - "protocolVersion": 4, + "protocolVersion": protocol_module.PROTOCOL_VERSION, "requestId": f"ensure-{thread_id}", "threadId": thread_id, } @@ -2317,7 +3278,7 @@ async def _start_turn(self, thread_id: str, turn_id: str) -> str: await self.adapter._handle_server_frame( { "type": "turn.start", - "protocolVersion": 4, + "protocolVersion": protocol_module.PROTOCOL_VERSION, "requestId": f"start-{thread_id}", "threadId": thread_id, "sessionId": session_id, @@ -2338,7 +3299,9 @@ async def test_turn_media_is_delivered_turn_scoped(self): frames = self.connection.messages[frames_before:] self.assertEqual([frame["type"] for frame in frames], ["media.deliver"]) delivery = frames[0] - self.assertEqual(delivery["protocolVersion"], 4) + self.assertEqual( + delivery["protocolVersion"], protocol_module.PROTOCOL_VERSION + ) self.assertEqual(delivery["threadId"], "thread-media") self.assertEqual(delivery["turnId"], "turn-media") self.assertEqual(delivery["name"], "chart.png") @@ -2632,7 +3595,7 @@ async def test_media_is_queued_before_it_is_sent_and_purged_only_on_ack(self): await self.adapter._handle_server_frame( { "type": "media.deliver.ack", - "protocolVersion": 4, + "protocolVersion": protocol_module.PROTOCOL_VERSION, "deliveryId": "unrelated", } ) @@ -2641,7 +3604,7 @@ async def test_media_is_queued_before_it_is_sent_and_purged_only_on_ack(self): await self.adapter._handle_server_frame( { "type": "media.deliver.ack", - "protocolVersion": 4, + "protocolVersion": protocol_module.PROTOCOL_VERSION, "deliveryId": delivery_id, } ) @@ -2665,7 +3628,7 @@ async def send(self, message): await self.adapter._handle_connection_accepted( { "type": "connection.accepted", - "protocolVersion": 4, + "protocolVersion": protocol_module.PROTOCOL_VERSION, "requestId": "hello-1", "instanceId": "instance", "nickname": "Hermes", @@ -2682,7 +3645,7 @@ async def send(self, message): await self.adapter._handle_server_frame( { "type": "media.deliver.ack", - "protocolVersion": 4, + "protocolVersion": protocol_module.PROTOCOL_VERSION, "deliveryId": offline.message_id, } ) diff --git a/integrations/hermes-t3-gateway/tests/test_connection.py b/integrations/hermes-t3-gateway/tests/test_connection.py index 852a539536c..97faa36e2b9 100644 --- a/integrations/hermes-t3-gateway/tests/test_connection.py +++ b/integrations/hermes-t3-gateway/tests/test_connection.py @@ -64,7 +64,7 @@ async def test_enrollment_handshake_returns_credential(self): socket = FakeSocket( { "type": "connection.accepted", - "protocolVersion": 4, + "protocolVersion": connection.PROTOCOL_VERSION, "instanceId": "provider-instance", "nickname": "Research", "credential": "persistent-secret", @@ -85,9 +85,9 @@ async def test_accepted_handshake_rejects_an_incompatible_protocol(self): socket = FakeSocket( { "type": "connection.accepted", - # A v3 server: the version policy stays fail-closed across the - # v4 bump, so this must not be silently accepted. - "protocolVersion": 3, + # A v4 server: the version policy stays fail-closed across the + # v5 bump, so this must not be silently accepted. + "protocolVersion": 4, "instanceId": "provider-instance", "nickname": "Research", } @@ -149,14 +149,14 @@ async def recv(self): json.dumps( { "type": "ping", - "protocolVersion": 4, + "protocolVersion": connection.PROTOCOL_VERSION, "requestId": "server-ping-1", } ), json.dumps( { "type": "connection.accepted", - "protocolVersion": 4, + "protocolVersion": connection.PROTOCOL_VERSION, "requestId": hello_id, "instanceId": "provider-instance", "nickname": "Hermes", @@ -207,7 +207,7 @@ async def recv(self): return json.dumps( { "type": "connection.accepted", - "protocolVersion": 4, + "protocolVersion": connection.PROTOCOL_VERSION, "requestId": self.sent[0]["requestId"], "instanceId": "provider-instance", "nickname": "Hermes", @@ -221,7 +221,11 @@ def __aiter__(self): async def frames(): yield json.dumps({"type": "turn.start", "requestId": "turn-1"}) yield json.dumps( - {"type": "ping", "protocolVersion": 4, "requestId": "ping-1"} + { + "type": "ping", + "protocolVersion": connection.PROTOCOL_VERSION, + "requestId": "ping-1", + } ) await released.wait() diff --git a/integrations/hermes-t3-gateway/tests/test_home.py b/integrations/hermes-t3-gateway/tests/test_home.py index 4850014de9d..065f187fe16 100644 --- a/integrations/hermes-t3-gateway/tests/test_home.py +++ b/integrations/hermes-t3-gateway/tests/test_home.py @@ -285,7 +285,7 @@ def test_home_deliver_applies_every_wire_bound(self): created_at="2026-07-26T00:00:00Z", ) self.assertEqual(frame["type"], "home.deliver") - self.assertEqual(frame["protocolVersion"], 4) + self.assertEqual(frame["protocolVersion"], protocol.PROTOCOL_VERSION) self.assertEqual(frame["deliveryId"], "delivery-1") self.assertEqual(frame["threadId"], "home-thread") self.assertEqual(frame["kind"], "cron") @@ -310,7 +310,10 @@ def test_home_deliver_never_emits_an_invalid_kind_or_empty_label(self): self.assertTrue(len(frame["text"]) >= 1) def test_home_deliver_ack_is_an_accepted_server_command(self): - message = {"type": "home.deliver.ack", "protocolVersion": 4} + message = { + "type": "home.deliver.ack", + "protocolVersion": protocol.PROTOCOL_VERSION, + } self.assertEqual(protocol.validate_server_frame(message), message) def test_build_media_delivery_reads_the_file_and_guesses_the_mime(self): @@ -476,7 +479,7 @@ async def test_standalone_send_hellos_as_delivery_then_acks_and_closes(self): ) hello, delivery = server.sent self.assertEqual(hello["role"], "delivery") - self.assertEqual(hello["protocolVersion"], 4) + self.assertEqual(hello["protocolVersion"], protocol.PROTOCOL_VERSION) self.assertEqual( hello["authentication"], { @@ -539,7 +542,7 @@ async def test_standalone_send_falls_back_to_the_designated_home_thread(self): self.assertEqual(server.sent[1]["threadId"], "home-thread") async def test_standalone_send_delivers_media_files_as_media_frames(self): - """`deliver=t3` cron output with files rides the v4 media framing.""" + """`deliver=t3` cron output with files rides the v4+ media framing.""" chart = pathlib.Path(self._tmp.name) / "chart.png" chart.write_bytes(b"\x89PNG fake bytes") server = MockDeliveryServer() diff --git a/integrations/hermes-t3-gateway/tests/test_protocol.py b/integrations/hermes-t3-gateway/tests/test_protocol.py index 081b98fb1f5..90c54a16a6e 100644 --- a/integrations/hermes-t3-gateway/tests/test_protocol.py +++ b/integrations/hermes-t3-gateway/tests/test_protocol.py @@ -65,8 +65,51 @@ def fake_hermes_skills(skills_list=None, skill_view=None): sys.modules[name] = module +@contextmanager +def fake_hermes_inventory(*, config, payload=None, error=None, calls=None): + """Install the documented Hermes inventory/config surfaces.""" + names = ("hermes_cli", "hermes_cli.config", "hermes_cli.inventory") + saved = {name: sys.modules.get(name) for name in names} + package = types.ModuleType("hermes_cli") + package.__path__ = [] + config_module = types.ModuleType("hermes_cli.config") + config_module.load_config_readonly = lambda: config + inventory = types.ModuleType("hermes_cli.inventory") + context = object() + + def load_picker_context(): + return context + + def build_models_payload(received_context, **kwargs): + if calls is not None: + calls.append((received_context, kwargs)) + if error is not None: + raise error + return payload + + inventory.load_picker_context = load_picker_context + inventory.build_models_payload = build_models_payload + package.config = config_module + package.inventory = inventory + sys.modules.update( + { + "hermes_cli": package, + "hermes_cli.config": config_module, + "hermes_cli.inventory": inventory, + } + ) + try: + yield context + 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_v4_contract(self): + def test_hello_matches_v5_contract(self): hello = protocol.connection_hello( hermes_version="0.19.0", authentication={"type": "enrollment-token", "token": "once"}, @@ -75,8 +118,8 @@ def test_hello_matches_v4_contract(self): ) self.assertEqual(hello["type"], "connection.hello") self.assertEqual(hello["requestId"], "request-1") - self.assertEqual(hello["protocolVersion"], 4) - # v4 pins `attachments` to the literal true — it is part of the + self.assertEqual(hello["protocolVersion"], protocol.PROTOCOL_VERSION) + # v5 retains v4's literal attachment capability. # contract, not a negotiated option. self.assertTrue(hello["capabilities"]["attachments"]) self.assertTrue(hello["capabilities"]["streaming"]) @@ -127,15 +170,27 @@ def test_configured_model_ignores_blank_and_non_string_values(self): def test_server_frame_validation_is_closed(self): with self.assertRaisesRegex(ValueError, "unsupported"): - protocol.validate_server_frame({"type": "made.up", "protocolVersion": 4}) + protocol.validate_server_frame( + { + "type": "made.up", + "protocolVersion": protocol.PROTOCOL_VERSION, + } + ) with self.assertRaisesRegex(ValueError, "version"): - # Protocol v3 peers must upgrade before sending runtime frames. - protocol.validate_server_frame({"type": "ping", "protocolVersion": 3}) + # Protocol v4 peers must upgrade before sending runtime frames. + protocol.validate_server_frame({"type": "ping", "protocolVersion": 4}) def test_describe_frames_are_accepted_server_commands(self): - for frame_type in ("describe.request", "skill.body.request"): + for frame_type in ( + "describe.request", + "models.list.request", + "skill.body.request", + ): with self.subTest(frame_type=frame_type): - message = {"type": frame_type, "protocolVersion": 4} + message = { + "type": frame_type, + "protocolVersion": protocol.PROTOCOL_VERSION, + } self.assertEqual(protocol.validate_server_frame(message), message) # ── describe.response ────────────────────────────────────────────── @@ -153,7 +208,7 @@ def test_describe_response_round_trips_every_reported_field(self): ) self.assertEqual(response["type"], "describe.response") self.assertEqual(response["requestId"], "describe-1") - self.assertEqual(response["protocolVersion"], 4) + self.assertEqual(response["protocolVersion"], protocol.PROTOCOL_VERSION) self.assertEqual(response["pluginVersion"], protocol.PLUGIN_VERSION) self.assertEqual(response["hermesVersion"], "0.19.0") self.assertEqual(response["model"], "gpt-5.6-terra") @@ -212,6 +267,107 @@ def test_configured_reasoning_effort_ignores_blank_and_non_string_values(self): ): self.assertIsNone(protocol.configured_reasoning_effort()) + # ── models.list.response ────────────────────────────────────────── + + def test_models_catalog_projects_the_explicit_inventory_shape(self): + calls = [] + config = { + "model": {"provider": "openai-codex", "default": "gpt-5.4"}, + "agent": {"reasoning_effort": "high"}, + } + payload = { + "provider": "openai-codex", + "model": "gpt-5.4", + "providers": [ + { + "slug": "openai-codex", + "name": "OpenAI Codex", + "models": ["gpt-5.4", "gpt-5.3-codex"], + "capabilities": { + "gpt-5.4": {"fast": True, "reasoning": True}, + "gpt-5.3-codex": {"fast": False, "reasoning": False}, + }, + } + ], + } + + with fake_hermes_inventory( + config=config, + payload=payload, + calls=calls, + ) as context: + catalog = protocol.models_catalog() + response = protocol.models_list_response( + request_id_value="models-1", + catalog=catalog, + ) + + self.assertEqual( + calls, + [ + ( + context, + { + "explicit_only": True, + "include_unconfigured": False, + "picker_hints": False, + "canonical_order": True, + "pricing": False, + "capabilities": True, + "refresh": False, + "probe_custom_providers": False, + "probe_current_custom_provider": False, + "max_models": 100, + }, + ) + ], + ) + self.assertEqual(response["type"], "models.list.response") + self.assertEqual(response["protocolVersion"], protocol.PROTOCOL_VERSION) + self.assertEqual(response["requestId"], "models-1") + self.assertEqual(response["currentProvider"], "openai-codex") + self.assertEqual(response["currentModel"], "gpt-5.4") + self.assertEqual(response["currentReasoningEffort"], "high") + self.assertEqual( + response["reasoningEfforts"], + ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"], + ) + self.assertEqual( + response["models"], + [ + { + "provider": "openai-codex", + "providerName": "OpenAI Codex", + "model": "gpt-5.4", + "supportsReasoning": True, + }, + { + "provider": "openai-codex", + "providerName": "OpenAI Codex", + "model": "gpt-5.3-codex", + "supportsReasoning": False, + }, + ], + ) + + def test_models_catalog_degrades_to_current_config_and_an_empty_list(self): + config = { + "model": {"default": "anthropic/claude-sonnet-4.6"}, + "agent": {"reasoning_effort": "off"}, + } + with fake_hermes_inventory( + config=config, + error=RuntimeError("inventory unavailable"), + ): + response = protocol.models_list_response( + request_id_value="models-fallback" + ) + + self.assertEqual(response["models"], []) + self.assertEqual(response["currentProvider"], "openrouter") + self.assertEqual(response["currentModel"], "anthropic/claude-sonnet-4.6") + self.assertEqual(response["currentReasoningEffort"], "none") + # ── skills enumeration ───────────────────────────────────────────── def test_installed_skills_projects_only_documented_fields(self): @@ -284,7 +440,7 @@ def test_skill_body_response_round_trips(self): ) self.assertEqual(response["type"], "skill.body.response") self.assertEqual(response["requestId"], "body-1") - self.assertEqual(response["protocolVersion"], 4) + self.assertEqual(response["protocolVersion"], protocol.PROTOCOL_VERSION) self.assertEqual(response["skillName"], "codex") self.assertEqual(response["markdown"], "# Codex\n\nDelegate coding.") @@ -394,7 +550,7 @@ def test_media_deliver_encodes_the_payload_and_applies_every_wire_bound(self): created_at="2026-07-27T00:00:00Z", ) self.assertEqual(frame["type"], "media.deliver") - self.assertEqual(frame["protocolVersion"], 4) + self.assertEqual(frame["protocolVersion"], protocol.PROTOCOL_VERSION) self.assertEqual(frame["deliveryId"], "media-1") self.assertEqual(frame["threadId"], "home-thread") self.assertEqual(frame["turnId"], "turn-9") @@ -471,7 +627,10 @@ def test_media_deliver_rejects_an_empty_or_oversized_payload(self): ) def test_media_deliver_ack_is_an_accepted_server_command(self): - message = {"type": "media.deliver.ack", "protocolVersion": 4} + message = { + "type": "media.deliver.ack", + "protocolVersion": protocol.PROTOCOL_VERSION, + } self.assertEqual(protocol.validate_server_frame(message), message) # ── inbound turn attachments ─────────────────────────────────────── diff --git a/packages/contracts/src/hermesGateway.test.ts b/packages/contracts/src/hermesGateway.test.ts index 3f4f90bd3ef..a66428a91e6 100644 --- a/packages/contracts/src/hermesGateway.test.ts +++ b/packages/contracts/src/hermesGateway.test.ts @@ -104,6 +104,10 @@ describe("Hermes gateway management contracts", () => { }); describe("Hermes gateway handshake", () => { + it("uses protocol v5 for model and reasoning selection", () => { + expect(HERMES_GATEWAY_PROTOCOL_VERSION).toBe(5); + }); + it("accepts one-time enrollment authentication", () => { const hello = decodeHello({ type: "connection.hello", @@ -210,9 +214,9 @@ describe("Hermes gateway handshake", () => { expect(hello.capabilities.protocolVersion).toBe(3); }); - it("requires attachments as part of the v4 contract itself", () => { - // Not a negotiated option: a v4 plugin that cannot handle attachments is - // a v3 plugin, and belongs at the version gate instead. + it("requires attachments as part of the current contract", () => { + // Not a negotiated option: a v5 plugin that cannot handle attachments is + // a pre-v4 plugin, and belongs at the version gate instead. expect(() => decodeCapabilities({ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, @@ -246,7 +250,7 @@ describe("T3 to Hermes messages", () => { ).toBe("opaque/hermes/session/value"); }); - it("decodes start and steering as distinct turn operations", () => { + it("decodes model and reasoning selection only on a turn start", () => { const context = { protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, requestId: "turn-command-1", @@ -256,8 +260,71 @@ describe("T3 to Hermes messages", () => { 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"); + const start = decodeT3Message({ + type: "turn.start", + ...context, + modelSelection: { + mode: "specific", + provider: " openrouter ", + model: " anthropic/claude-sonnet-4 ", + }, + reasoningEffort: "high", + }); + expect(start.type).toBe("turn.start"); + if (start.type !== "turn.start") { + throw new Error("expected turn.start"); + } + expect(start.modelSelection).toEqual({ + mode: "specific", + provider: "openrouter", + model: "anthropic/claude-sonnet-4", + }); + expect(start.reasoningEffort).toBe("high"); + + const steer = decodeT3Message({ type: "turn.steer", ...context }); + expect(steer.type).toBe("turn.steer"); + if (steer.type !== "turn.steer") { + throw new Error("expected turn.steer"); + } + expect("modelSelection" in steer).toBe(false); + expect("reasoningEffort" in steer).toBe(false); + }); + + it("decodes the default model request and rejects invalid selections", () => { + const context = { + type: "turn.start", + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + requestId: "turn-command-2", + threadId: "thread-1", + sessionId: "session-1", + turnId: "turn-2", + text: "Use the configured default.", + } as const; + + const start = decodeT3Message({ + ...context, + modelSelection: { mode: "default" }, + reasoningEffort: "none", + }); + expect(start.type).toBe("turn.start"); + + expect(() => + decodeT3Message({ + ...context, + modelSelection: { mode: "specific", provider: " ", model: "gpt-5" }, + }), + ).toThrow(); + expect(() => decodeT3Message({ ...context, reasoningEffort: "extreme" })).toThrow(); + }); + + it("decodes model catalog requests", () => { + expect( + decodeT3Message({ + type: "models.list.request", + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + requestId: "models-1", + }).type, + ).toBe("models.list.request"); }); it("decodes interrupt, approval, structured input, stop, and ping", () => { @@ -501,13 +568,25 @@ describe("Hermes to T3 events", () => { throw new Error("expected session.ready"); } expect(activeReady.activeTurnId).toBe("turn-1"); - expect( - decodePluginMessage({ - type: "turn.started", - requestId: "turn-command-1", - ...turnContext, - }).type, - ).toBe("turn.started"); + const started = decodePluginMessage({ + type: "turn.started", + requestId: "turn-command-1", + appliedModelSelection: { + provider: "openrouter", + model: "anthropic/claude-sonnet-4", + }, + appliedReasoningEffort: "high", + ...turnContext, + }); + expect(started.type).toBe("turn.started"); + if (started.type !== "turn.started") { + throw new Error("expected turn.started"); + } + expect(started.appliedModelSelection).toEqual({ + provider: "openrouter", + model: "anthropic/claude-sonnet-4", + }); + expect(started.appliedReasoningEffort).toBe("high"); expect( decodePluginMessage({ type: "content.delta", @@ -604,10 +683,59 @@ describe("Hermes to T3 events", () => { }).type, ).toBe("user-input.resolved"); }); + + it("decodes the selectable model catalog and reasoning efforts", () => { + const catalog = decodePluginMessage({ + type: "models.list.response", + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + requestId: "models-1", + currentProvider: " openrouter ", + currentModel: " anthropic/claude-sonnet-4 ", + currentReasoningEffort: "high", + reasoningEfforts: ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"], + models: [ + { + provider: " openrouter ", + providerName: " OpenRouter ", + model: " anthropic/claude-sonnet-4 ", + supportsReasoning: true, + }, + { + provider: "anthropic", + providerName: "Anthropic", + model: "claude-haiku-4-5", + supportsReasoning: false, + }, + ], + }); + + expect(catalog.type).toBe("models.list.response"); + if (catalog.type !== "models.list.response") { + throw new Error("expected models.list.response"); + } + expect(catalog.currentProvider).toBe("openrouter"); + expect(catalog.currentModel).toBe("anthropic/claude-sonnet-4"); + expect(catalog.reasoningEfforts).toEqual([ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", + "ultra", + ]); + expect(catalog.models[0]).toEqual({ + provider: "openrouter", + providerName: "OpenRouter", + model: "anthropic/claude-sonnet-4", + supportsReasoning: true, + }); + }); }); describe("Hermes provider integration constants", () => { - it("exposes Hermes as a single opaque model in the normal provider picker", () => { + it("keeps Hermes's stable fallback model slug", () => { expect(DEFAULT_MODEL_BY_PROVIDER[HERMES_DRIVER_KIND]).toBe(DEFAULT_HERMES_MODEL); expect(PROVIDER_DISPLAY_NAMES[HERMES_DRIVER_KIND]).toBe("Hermes"); }); diff --git a/packages/contracts/src/hermesGateway.ts b/packages/contracts/src/hermesGateway.ts index d4888f7a189..c2da7fb67b0 100644 --- a/packages/contracts/src/hermesGateway.ts +++ b/packages/contracts/src/hermesGateway.ts @@ -22,7 +22,7 @@ import { ProviderApprovalDecision, ProviderUserInputAnswers } from "./orchestrat import { ProviderInstanceId } from "./providerInstance.ts"; import { CanonicalItemType, CanonicalRequestType, UserInputQuestion } from "./providerRuntime.ts"; -export const HERMES_GATEWAY_PROTOCOL_VERSION = 4 as const; +export const HERMES_GATEWAY_PROTOCOL_VERSION = 5 as const; /** * Base64 payload ceiling for a single media frame, both directions. @@ -91,9 +91,9 @@ export const HermesGatewayCapabilities = Schema.Struct({ activity: Schema.Boolean, approvals: Schema.Boolean, userInput: Schema.Boolean, - // Literal by design: attachments are part of the v4 contract itself, not a - // negotiated option. A plugin speaking v4 must handle them; one that cannot - // is a v3 plugin and is rejected at the version gate. + // Literal by design: attachments are part of the current contract, not a + // negotiated option. A plugin speaking v5 must handle them; one that cannot + // is a pre-v4 plugin and is rejected at the version gate. attachments: Schema.Literal(true), }); export type HermesGatewayCapabilities = typeof HermesGatewayCapabilities.Type; @@ -128,8 +128,9 @@ export type HermesGatewayConnectionState = typeof HermesGatewayConnectionState.T /** * Public instance state used by settings and provider-picker surfaces. * - * `protocolVersion` is not restricted to v2 here so the UI can report the - * unsupported version observed from a plugin that needs an upgrade. + * `protocolVersion` is not restricted to the current version here so the UI + * can report the unsupported version observed from a plugin that needs an + * upgrade. */ export const HermesGatewayInstanceStatus = Schema.Struct({ instanceId: ProviderInstanceId, @@ -299,9 +300,8 @@ export const HermesGatewayConnectionHello = Schema.Struct({ 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"`. + * The model Hermes is configured to run, reported as a lightweight handshake + * summary. T3 requests the selectable catalog after connecting. * * Optional so a plugin that predates this field still connects: an absent * value degrades to the generic label rather than failing the handshake. @@ -309,8 +309,9 @@ export const HermesGatewayConnectionHello = Schema.Struct({ model: Schema.optional(TrimmedNonEmptyString), /** * Defaults to `"gateway"` on decode so the field stays honest about intent - * rather than making every caller repeat the common case. v3 requires both - * sides updated regardless, so this default is ergonomics, not tolerance. + * rather than making every caller repeat the common case. Protocol changes + * require both sides updated regardless, so this default is ergonomics, not + * tolerance. */ role: HermesGatewayConnectionRole.pipe(Schema.withDecodingDefault(Effect.succeed("gateway"))), }); @@ -362,6 +363,51 @@ export const HermesGatewayConnectionStatus = Schema.Struct({ }); export type HermesGatewayConnectionStatus = typeof HermesGatewayConnectionStatus.Type; +export const HermesGatewayReasoningEffort = Schema.Literals([ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", + "ultra", +]); +export type HermesGatewayReasoningEffort = typeof HermesGatewayReasoningEffort.Type; + +const HermesGatewayDefaultModelSelection = Schema.Struct({ + mode: Schema.Literal("default"), +}); + +const HermesGatewaySpecificModelSelection = Schema.Struct({ + mode: Schema.Literal("specific"), + provider: TrimmedNonEmptyString, + model: TrimmedNonEmptyString, +}); + +/** A turn's requested model, before Hermes resolves its configured default. */ +export const HermesGatewayRequestedModelSelection = Schema.Union([ + HermesGatewayDefaultModelSelection, + HermesGatewaySpecificModelSelection, +]); +export type HermesGatewayRequestedModelSelection = typeof HermesGatewayRequestedModelSelection.Type; + +/** The concrete provider and model Hermes applied to a turn. */ +export const HermesGatewayEffectiveModel = Schema.Struct({ + provider: TrimmedNonEmptyString, + model: TrimmedNonEmptyString, +}); +export type HermesGatewayEffectiveModel = typeof HermesGatewayEffectiveModel.Type; + +/** One selectable model in the catalog reported by the connected Hermes process. */ +export const HermesGatewayCatalogModel = Schema.Struct({ + provider: TrimmedNonEmptyString, + providerName: TrimmedNonEmptyString, + model: TrimmedNonEmptyString, + supportsReasoning: Schema.Boolean, +}); +export type HermesGatewayCatalogModel = typeof HermesGatewayCatalogModel.Type; + const HermesGatewaySessionContext = Schema.Struct({ threadId: ThreadId, sessionId: HermesGatewaySessionId, @@ -410,6 +456,8 @@ export const HermesGatewayTurnStart = Schema.Struct({ ...HermesGatewayTurnContext.fields, text: HermesGatewayTurnText, attachments: Schema.optional(Schema.Array(HermesGatewayTurnAttachment)), + modelSelection: Schema.optional(HermesGatewayRequestedModelSelection), + reasoningEffort: Schema.optional(HermesGatewayReasoningEffort), }); export type HermesGatewayTurnStart = typeof HermesGatewayTurnStart.Type; @@ -468,6 +516,14 @@ export const HermesGatewayDescribeRequest = Schema.Struct({ }); export type HermesGatewayDescribeRequest = typeof HermesGatewayDescribeRequest.Type; +/** Ask the connected Hermes process for its current selectable model catalog. */ +export const HermesGatewayModelsListRequest = Schema.Struct({ + type: Schema.Literal("models.list.request"), + protocolVersion: HermesGatewayProtocolVersion, + requestId: HermesGatewayRequestId, +}); +export type HermesGatewayModelsListRequest = typeof HermesGatewayModelsListRequest.Type; + /** Ask for one skill's markdown body. Fired on row expand, never eagerly. */ export const HermesGatewaySkillBodyRequest = Schema.Struct({ type: Schema.Literal("skill.body.request"), @@ -501,6 +557,8 @@ export const HermesGatewayTurnStarted = Schema.Struct({ protocolVersion: HermesGatewayProtocolVersion, requestId: HermesGatewayRequestId, ...HermesGatewayTurnContext.fields, + appliedModelSelection: Schema.optional(HermesGatewayEffectiveModel), + appliedReasoningEffort: Schema.optional(HermesGatewayReasoningEffort), }); export type HermesGatewayTurnStarted = typeof HermesGatewayTurnStarted.Type; @@ -661,6 +719,18 @@ export const HermesGatewayDescribedSkill = Schema.Struct({ }); export type HermesGatewayDescribedSkill = typeof HermesGatewayDescribedSkill.Type; +export const HermesGatewayModelsListResponse = Schema.Struct({ + type: Schema.Literal("models.list.response"), + protocolVersion: HermesGatewayProtocolVersion, + requestId: HermesGatewayRequestId, + currentProvider: Schema.optional(TrimmedNonEmptyString), + currentModel: Schema.optional(TrimmedNonEmptyString), + currentReasoningEffort: Schema.optional(HermesGatewayReasoningEffort), + reasoningEfforts: Schema.Array(HermesGatewayReasoningEffort), + models: Schema.Array(HermesGatewayCatalogModel), +}); +export type HermesGatewayModelsListResponse = typeof HermesGatewayModelsListResponse.Type; + export const HermesGatewayDescribeResponse = Schema.Struct({ type: Schema.Literal("describe.response"), protocolVersion: HermesGatewayProtocolVersion, @@ -843,6 +913,7 @@ export const HermesGatewayT3ToPluginMessage = Schema.Union([ HermesGatewayUserInputResponse, HermesGatewaySessionStop, HermesGatewayDescribeRequest, + HermesGatewayModelsListRequest, HermesGatewaySkillBodyRequest, HermesGatewayPing, HermesGatewayHomeDeliverAck, @@ -868,6 +939,7 @@ export const HermesGatewayPluginToT3Message = Schema.Union([ HermesGatewayTurnAborted, HermesGatewaySessionExited, HermesGatewayDescribeResponse, + HermesGatewayModelsListResponse, HermesGatewaySkillBodyResponse, HermesGatewayPong, HermesGatewayProtocolError, From 8a9bc24d0f0e3adebeb65b219279dfbb4ced97c7 Mon Sep 17 00:00:00 2001 From: Nicky Molina Date: Thu, 30 Jul 2026 12:09:02 -0400 Subject: [PATCH 2/4] fix(hermes): address review races --- .../src/provider/Drivers/HermesDriver.test.ts | 46 +++++++ .../src/provider/Drivers/HermesDriver.ts | 2 +- .../src/provider/Layers/HermesAdapter.test.ts | 68 ++++++++++ .../src/provider/Layers/HermesAdapter.ts | 127 +++++++++++------- integrations/hermes-t3-gateway/adapter.py | 28 ++++ .../hermes-t3-gateway/tests/test_adapter.py | 66 +++++++++ 6 files changed, 291 insertions(+), 46 deletions(-) diff --git a/apps/server/src/provider/Drivers/HermesDriver.test.ts b/apps/server/src/provider/Drivers/HermesDriver.test.ts index d5c4b404101..ea5e2d599c3 100644 --- a/apps/server/src/provider/Drivers/HermesDriver.test.ts +++ b/apps/server/src/provider/Drivers/HermesDriver.test.ts @@ -185,6 +185,52 @@ it.effect("discovers the Hermes model catalog without blocking snapshots", () => }).pipe(Effect.provide(testLayer()), Effect.scoped), ); +it.effect("replays a catalog completion to a late snapshot subscriber", () => + Effect.gen(function* () { + const releaseCatalog = yield* Deferred.make(); + const defaultBroker = yield* HermesGatewayBroker; + const broker = { + ...defaultBroker, + getInstanceStatus: () => Effect.succeed(connectedStatus), + isConnected: () => Effect.succeed(true), + request: (_requestedInstanceId, message) => + Effect.gen(function* () { + if (message.type !== "models.list.request") { + return yield* Effect.die( + new Error(`Unexpected Hermes gateway request '${message.type}'.`), + ); + } + yield* Deferred.await(releaseCatalog); + return catalogResponse(message, "anthropic/claude-sonnet-4"); + }), + streamStatuses: Stream.empty, + } satisfies typeof defaultBroker; + + const provider = yield* HermesDriver.create({ + instanceId, + displayName: "Catalog Hermes", + environment: [], + enabled: true, + config: HermesDriver.defaultConfig(), + }).pipe(Effect.provideService(HermesGatewayBroker, broker)); + const liveSubscriber = yield* Stream.runHead(provider.snapshot.streamChanges).pipe( + Effect.forkChild({ startImmediately: true }), + ); + + const initial = yield* provider.snapshot.getSnapshot; + expect(initial.models).toHaveLength(1); + yield* Deferred.succeed(releaseCatalog, undefined); + + const published = Option.getOrThrow(yield* Fiber.join(liveSubscriber)); + expect(published.models).toHaveLength(2); + + // The registry subscribes after reading its initial snapshot. A catalog + // that finishes in that gap must still be the first change it observes. + const replayed = Option.getOrThrow(yield* Stream.runHead(provider.snapshot.streamChanges)); + expect(replayed.models).toHaveLength(2); + }).pipe(Effect.provide(testLayer()), Effect.scoped), +); + it.effect("retries a transient Hermes model catalog failure with bounded backoff", () => Effect.gen(function* () { const requests: Array = []; diff --git a/apps/server/src/provider/Drivers/HermesDriver.ts b/apps/server/src/provider/Drivers/HermesDriver.ts index eb996cf1e39..2b1ee3af3dd 100644 --- a/apps/server/src/provider/Drivers/HermesDriver.ts +++ b/apps/server/src/provider/Drivers/HermesDriver.ts @@ -114,7 +114,7 @@ export const HermesDriver: ProviderDriver = { ), ); const instanceClosed = yield* Deferred.make(); - const catalogChanges = yield* PubSub.unbounded(); + const catalogChanges = yield* PubSub.unbounded({ replay: 1 }); yield* Scope.addFinalizer( instanceScope, Deferred.succeed(instanceClosed, undefined).pipe( diff --git a/apps/server/src/provider/Layers/HermesAdapter.test.ts b/apps/server/src/provider/Layers/HermesAdapter.test.ts index 6436a407190..13fe5d200eb 100644 --- a/apps/server/src/provider/Layers/HermesAdapter.test.ts +++ b/apps/server/src/provider/Layers/HermesAdapter.test.ts @@ -1677,6 +1677,74 @@ it.effect("interrupts a started turn when Hermes does not acknowledge its model" }).pipe(Effect.scoped, Effect.provide(testEnvLayer)), ); +it.effect("suppresses a streamed start when Hermes does not acknowledge its model", () => + Effect.gen(function* () { + const instanceId = ProviderInstanceId.make("hermes_streamed_model_mismatch"); + const threadId = ThreadId.make("thread-streamed-model-mismatch"); + const selectedModel = encodeHermesModelSlug({ provider: "openai", model: "gpt-5" }); + const brokerEvents = yield* PubSub.unbounded(); + const { sent, broker: turnBroker } = makeTurnBroker({ failSend: true }); + const broker: HermesGatewayBrokerShape = { + ...turnBroker, + request: (requestedInstanceId, message) => + turnBroker.request(requestedInstanceId, message).pipe( + Effect.tap((response) => + response.type === "turn.started" + ? PubSub.publish(brokerEvents, { + instanceId: requestedInstanceId, + message: response, + }).pipe(Effect.asVoid) + : Effect.void, + ), + ), + stream: Stream.fromPubSub(brokerEvents), + }; + const adapter = yield* makeHermesAdapter({ instanceId }).pipe( + Effect.provideService(HermesGatewayBroker, broker), + ); + const { seen, fiber } = yield* collectEvents(adapter); + yield* drain; + + yield* adapter.startSession({ + threadId, + providerInstanceId: instanceId, + runtimeMode: "full-access", + }); + yield* Effect.flip( + adapter.sendTurn({ + threadId, + input: "use the selected model", + modelSelection: { + instanceId, + model: selectedModel, + options: [{ id: "reasoningEffort", value: "high" }], + }, + }), + ); + yield* drain; + + const rejectedStart = sent.find((message) => message.type === "turn.start"); + if (!rejectedStart || rejectedStart.type !== "turn.start") { + return yield* Effect.die(new Error("missing rejected turn.start")); + } + assert.isUndefined( + seen.find((event) => event.type === "turn.started" && event.turnId === rejectedStart.turnId), + ); + assert.isUndefined((yield* adapter.listSessions())[0]?.activeTurnId); + + yield* adapter.sendTurn({ threadId, input: "retry with the session default" }); + assert.lengthOf( + sent.filter((message) => message.type === "turn.start"), + 2, + ); + assert.lengthOf( + sent.filter((message) => message.type === "turn.steer"), + 0, + ); + yield* Fiber.interrupt(fiber); + }).pipe(Effect.scoped, Effect.provide(testEnvLayer)), +); + it.effect("interrupts only once when Hermes does not acknowledge reasoning", () => Effect.gen(function* () { const instanceId = ProviderInstanceId.make("hermes_reasoning_mismatch"); diff --git a/apps/server/src/provider/Layers/HermesAdapter.ts b/apps/server/src/provider/Layers/HermesAdapter.ts index a35f49e17e5..690acfb95c4 100644 --- a/apps/server/src/provider/Layers/HermesAdapter.ts +++ b/apps/server/src/provider/Layers/HermesAdapter.ts @@ -77,6 +77,11 @@ interface PendingInteraction { readonly requestType: CanonicalRequestType; } +interface PendingTurnAcknowledgement { + readonly requestedModel: HermesGatewayRequestedModelSelection | undefined; + readonly requestedReasoning: HermesGatewayReasoningEffort | undefined; +} + interface SessionContext { hermesSessionId: HermesGatewaySessionId; modelSelection: ModelSelection | undefined; @@ -94,6 +99,28 @@ interface SessionContext { type PluginMessage = Exclude; +const getSelectionAcknowledgementError = ( + response: Extract, + pending: PendingTurnAcknowledgement, +) => { + if (pending.requestedModel !== undefined) { + const applied = response.appliedModelSelection; + const modelMatches = + applied !== undefined && + (pending.requestedModel.mode === "default" || + (applied.provider === pending.requestedModel.provider && + applied.model === pending.requestedModel.model)); + if (!modelMatches) return "Hermes did not confirm the requested model selection."; + } + if ( + pending.requestedReasoning !== undefined && + response.appliedReasoningEffort !== pending.requestedReasoning + ) { + return "Hermes did not confirm the requested reasoning effort."; + } + return undefined; +}; + const nowIso = () => DateTime.formatIso(DateTime.nowUnsafe()); const MAX_PERSISTED_GATEWAY_FIELD_CHARS = 4_096; @@ -166,6 +193,7 @@ export const makeHermesAdapter = Effect.fn("makeHermesAdapter")(function* (input const serverConfig = yield* ServerConfig; const events = yield* PubSub.unbounded(); const sessions = new Map(); + const pendingTurnAcknowledgements = new Map(); const randomId = crypto.randomUUIDv4.pipe( Effect.mapError( @@ -280,6 +308,13 @@ export const makeHermesAdapter = Effect.fn("makeHermesAdapter")(function* (input return undefined; case "turn.started": { if (!contextMatchesSession(context, message)) return undefined; + const pendingAcknowledgement = pendingTurnAcknowledgements.get(message.requestId); + if (pendingAcknowledgement !== undefined) { + pendingTurnAcknowledgements.delete(message.requestId); + if (getSelectionAcknowledgementError(message, pendingAcknowledgement) !== undefined) { + return undefined; + } + } updateSession(context, { status: "running", activeTurnId: TurnId.make(message.turnId), @@ -859,33 +894,50 @@ export const makeHermesAdapter = Effect.fn("makeHermesAdapter")(function* (input } requestedReasoning = rawReasoning; } - const response = yield* broker.request( - input.instanceId, - activeTurnId - ? { - type: "turn.steer", - protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, - requestId: outboundRequestId, - threadId: turnInput.threadId, - sessionId: context.hermesSessionId, - turnId, - text, - ...(attachments !== undefined ? { attachments } : {}), - } - : { - type: "turn.start", - protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, - requestId: outboundRequestId, - threadId: turnInput.threadId, - sessionId: context.hermesSessionId, - turnId, - text, - ...(attachments !== undefined ? { attachments } : {}), - ...(requestedModel !== undefined ? { modelSelection: requestedModel } : {}), - ...(requestedReasoning !== undefined ? { reasoningEffort: requestedReasoning } : {}), - }, - ); + const pendingAcknowledgement = + activeTurnId === undefined && + (requestedModel !== undefined || requestedReasoning !== undefined) + ? { requestedModel, requestedReasoning } + : undefined; + if (pendingAcknowledgement !== undefined) { + pendingTurnAcknowledgements.set(outboundRequestId, pendingAcknowledgement); + } + const response = yield* broker + .request( + input.instanceId, + activeTurnId + ? { + type: "turn.steer", + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + requestId: outboundRequestId, + threadId: turnInput.threadId, + sessionId: context.hermesSessionId, + turnId, + text, + ...(attachments !== undefined ? { attachments } : {}), + } + : { + type: "turn.start", + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + requestId: outboundRequestId, + threadId: turnInput.threadId, + sessionId: context.hermesSessionId, + turnId, + text, + ...(attachments !== undefined ? { attachments } : {}), + ...(requestedModel !== undefined ? { modelSelection: requestedModel } : {}), + ...(requestedReasoning !== undefined ? { reasoningEffort: requestedReasoning } : {}), + }, + ) + .pipe( + Effect.tapCause(() => + Effect.sync(() => { + pendingTurnAcknowledgements.delete(outboundRequestId); + }), + ), + ); if (response.type !== "turn.started") { + pendingTurnAcknowledgements.delete(outboundRequestId); return yield* new ProviderAdapterRequestError({ provider: PROVIDER, method, @@ -896,25 +948,10 @@ export const makeHermesAdapter = Effect.fn("makeHermesAdapter")(function* (input }); } - let selectionAcknowledgementError: string | undefined; - if (activeTurnId === undefined && requestedModel !== undefined) { - const applied = response.appliedModelSelection; - const modelMatches = - applied !== undefined && - (requestedModel.mode === "default" || - (applied.provider === requestedModel.provider && applied.model === requestedModel.model)); - if (!modelMatches) { - selectionAcknowledgementError = "Hermes did not confirm the requested model selection."; - } - } - if ( - selectionAcknowledgementError === undefined && - activeTurnId === undefined && - requestedReasoning !== undefined && - response.appliedReasoningEffort !== requestedReasoning - ) { - selectionAcknowledgementError = "Hermes did not confirm the requested reasoning effort."; - } + const selectionAcknowledgementError = + pendingAcknowledgement === undefined + ? undefined + : getSelectionAcknowledgementError(response, pendingAcknowledgement); if (selectionAcknowledgementError !== undefined) { // `turn.started` means Hermes has already launched the agent. If its // acknowledgement does not match the requested configuration, reject diff --git a/integrations/hermes-t3-gateway/adapter.py b/integrations/hermes-t3-gateway/adapter.py index 5db05abf111..96ab38d567e 100644 --- a/integrations/hermes-t3-gateway/adapter.py +++ b/integrations/hermes-t3-gateway/adapter.py @@ -343,6 +343,11 @@ def __init__(self, config: PlatformConfig): # the runner's live override, and a plugin restart naturally starts # empty so the first v5 turn reapplies its requested configuration. self._applied_turn_configuration: dict[str, dict[str, Any]] = {} + # Hermes' slash handlers also update runner-global compatibility state + # such as `_reasoning_config`. Serialize configuration transactions + # across T3 threads so one failed rollback cannot overwrite a later + # session's successful selection. + self._turn_configuration_lock = asyncio.Lock() # The most recently COMPLETED turn per thread. The base adapter's # delivery pipeline sends the final text (notify-marked, which # completes the turn here) BEFORE it sends the reply's media files @@ -1350,6 +1355,29 @@ async def _apply_turn_configuration( thread_id: str, session_id: str, can_commit: Callable[[], bool] | None = None, + ) -> dict[str, Any]: + async with self._turn_configuration_lock: + # A different thread's configuration may have held the lock while + # this session was stopped. Fence it before any slash command or + # durable snapshot can mutate Hermes state. + if can_commit is not None and not can_commit(): + raise _TurnConfigurationError( + "The Hermes session stopped while its turn was starting" + ) + return await self._apply_turn_configuration_locked( + message, + thread_id=thread_id, + session_id=session_id, + can_commit=can_commit, + ) + + async def _apply_turn_configuration_locked( + self, + message: dict[str, Any], + *, + thread_id: str, + session_id: str, + can_commit: Callable[[], bool] | None = None, ) -> dict[str, Any]: """Apply v5 model/reasoning requests through Hermes' command surface. diff --git a/integrations/hermes-t3-gateway/tests/test_adapter.py b/integrations/hermes-t3-gateway/tests/test_adapter.py index a54348ac753..147b45e6d4b 100644 --- a/integrations/hermes-t3-gateway/tests/test_adapter.py +++ b/integrations/hermes-t3-gateway/tests/test_adapter.py @@ -940,6 +940,72 @@ async def handler(event): self.assertEqual(error["type"], "protocol.error") self.assertEqual(error["requestId"], "start-atomic-config") + async def test_failed_reasoning_does_not_rollback_a_later_session(self): + first_thread = "thread-overlap-first" + second_thread = "thread-overlap-second" + first_session = await self._ensure_thread(first_thread) + second_session = await self._ensure_thread(second_thread) + runner = FakeGatewayRunner() + runner._reasoning_config = {"enabled": True, "effort": "medium"} + first_mutated = asyncio.Event() + release_first = asyncio.Event() + + async def handler(event): + effort = shlex.split(event.text)[1] + session_id = runner._session_key_for_source(event.source) + selected = {"enabled": True, "effort": effort} + runner._reasoning_config = dict(selected) + runner._session_reasoning_overrides[session_id] = dict(selected) + if event.source.chat_id == first_thread: + first_mutated.set() + await release_first.wait() + raise RuntimeError("first reasoning command failed") + return "hidden" + + self.adapter.gateway_runner = runner + self.adapter._message_handler = handler + + def start_message(thread_id, session_id, effort): + return { + "type": "turn.start", + "protocolVersion": protocol_module.PROTOCOL_VERSION, + "requestId": f"start-{thread_id}", + "threadId": thread_id, + "sessionId": session_id, + "turnId": f"turn-{thread_id}", + "text": "Run", + "reasoningEffort": effort, + } + + first = asyncio.create_task( + self.adapter._handle_server_frame( + start_message(first_thread, first_session, "high") + ) + ) + await asyncio.wait_for(first_mutated.wait(), timeout=1) + + second = asyncio.create_task( + self.adapter._handle_server_frame( + start_message(second_thread, second_session, "low") + ) + ) + # The second task is queued before the first is released. Without a + # cross-thread configuration fence it commits `low` first, then the + # first task's rollback incorrectly overwrites that success. + asyncio.get_running_loop().call_soon(release_first.set) + await asyncio.gather(first, second) + + self.assertEqual( + runner._reasoning_config, {"enabled": True, "effort": "low"} + ) + self.assertNotIn(first_session, runner._session_reasoning_overrides) + self.assertEqual( + runner._session_reasoning_overrides[second_session], + {"enabled": True, "effort": "low"}, + ) + self.assertNotIn(first_thread, self.adapter._active_turns) + self.assertIn(second_thread, self.adapter._active_turns) + async def test_cancelled_configuration_restores_prior_state(self): thread_id = "thread-cancelled-config" session_id = await self._ensure_thread(thread_id) From 43febf5ad0d9233f9ec72e45ca2ab9adba1d60cf Mon Sep 17 00:00:00 2001 From: Nicky Molina Date: Thu, 30 Jul 2026 12:15:15 -0400 Subject: [PATCH 3/4] fix(hermes): complete durable rollback --- integrations/hermes-t3-gateway/adapter.py | 51 +++++-- .../hermes-t3-gateway/tests/test_adapter.py | 137 ++++++++++++++++++ 2 files changed, 177 insertions(+), 11 deletions(-) diff --git a/integrations/hermes-t3-gateway/adapter.py b/integrations/hermes-t3-gateway/adapter.py index 96ab38d567e..55387684ed5 100644 --- a/integrations/hermes-t3-gateway/adapter.py +++ b/integrations/hermes-t3-gateway/adapter.py @@ -1577,28 +1577,57 @@ def restore_detached_agent_cache() -> None: ) async def restore_prior_durable_configuration() -> None: + failures: list[tuple[str, Exception]] = [] + + async def attempt( + label: str, + operation: Callable[[], Coroutine[Any, Any, Any]], + ) -> None: + try: + await operation() + except Exception as exc: + failures.append((label, exc)) + if durable_store is not None: - await durable_store.set_model_override( - session_id, durable_override_snapshot - ) if ( durable_entry is not None and durable_entry_was_auto_reset is not None ): durable_entry.was_auto_reset = durable_entry_was_auto_reset + await attempt( + "session routing override", + lambda: durable_store.set_model_override( + session_id, durable_override_snapshot + ), + ) if session_db is not None and session_db_id and session_db_row is not None: prior_db_model = session_db_row.get("model") - await session_db.update_session_model( - session_db_id, prior_db_model + await attempt( + "session database model", + lambda: session_db.update_session_model( + session_db_id, prior_db_model + ), ) - await session_db.update_session_meta( - session_db_id, - session_db_row.get("model_config"), - prior_db_model, + await attempt( + "session database metadata", + lambda: session_db.update_session_meta( + session_db_id, + session_db_row.get("model_config"), + prior_db_model, + ), ) - await session_db.update_system_prompt( - session_db_id, session_db_row.get("system_prompt") + await attempt( + "session database system prompt", + lambda: session_db.update_system_prompt( + session_db_id, session_db_row.get("system_prompt") + ), ) + if failures: + failed_surfaces = ", ".join(label for label, _error in failures) + raise _TurnConfigurationError( + "Hermes could not fully roll back session configuration " + f"({failed_surfaces})" + ) from failures[0][1] def release_detached_agent_cache() -> None: if not detached_agent_cache.existed: diff --git a/integrations/hermes-t3-gateway/tests/test_adapter.py b/integrations/hermes-t3-gateway/tests/test_adapter.py index 147b45e6d4b..c16eaeb48a8 100644 --- a/integrations/hermes-t3-gateway/tests/test_adapter.py +++ b/integrations/hermes-t3-gateway/tests/test_adapter.py @@ -940,6 +940,143 @@ async def handler(event): self.assertEqual(error["type"], "protocol.error") self.assertEqual(error["requestId"], "start-atomic-config") + async def test_store_rollback_failure_still_restores_session_database(self): + thread_id = "thread-partial-durable-rollback" + session_id = await self._ensure_thread(thread_id) + runner = FakeGatewayRunner() + prior_model = {"provider": "old-provider", "model": "old-model"} + runner._session_model_overrides[session_id] = dict(prior_model) + session_entry = types.SimpleNamespace( + session_id="partial-rollback-session", + was_auto_reset=False, + ) + + class FailingRestoreStore: + def __init__(self): + self.persisted = dict(prior_model) + self.fail_restore = False + self.restore_attempts = 0 + + async def get_or_create_session(self, source): + del source + return session_entry + + async def get_model_override(self, key): + self.assert_session(key) + return dict(self.persisted) + + async def set_model_override(self, key, value): + self.assert_session(key) + if self.fail_restore: + self.restore_attempts += 1 + raise RuntimeError("routing store restore failed") + self.persisted = dict(value) + + @staticmethod + def assert_session(key): + if key != session_id: + raise AssertionError(f"unexpected session key: {key}") + + prior_db_row = { + "model": "old-model", + "model_config": '{"browser_model_lock":"old-model"}', + "system_prompt": "prior system prompt", + } + + class RecordingSessionDB: + def __init__(self): + self.row = dict(prior_db_row) + self.calls = [] + + async def get_session(self, key): + self.assert_session(key) + return dict(self.row) + + async def update_session_model(self, key, model): + self.assert_session(key) + self.calls.append(("model", model)) + self.row["model"] = model + self.row["model_config"] = None + self.row["system_prompt"] = None + + async def update_session_meta(self, key, model_config, model=None): + self.assert_session(key) + self.calls.append(("meta", model_config, model)) + self.row["model_config"] = model_config + if model is not None: + self.row["model"] = model + + async def update_system_prompt(self, key, system_prompt): + self.assert_session(key) + self.calls.append(("prompt", system_prompt)) + self.row["system_prompt"] = system_prompt + + @staticmethod + def assert_session(key): + if key != session_entry.session_id: + raise AssertionError(f"unexpected database session: {key}") + + store = FailingRestoreStore() + session_db = RecordingSessionDB() + runner.async_session_store = store + runner._session_db = session_db + + async def handler(event): + tokens = shlex.split(event.text) + command_session_id = runner._session_key_for_source(event.source) + if tokens[0] == "/model": + selected = { + "model": tokens[1], + "provider": tokens[tokens.index("--provider") + 1], + } + await session_db.update_session_model( + session_entry.session_id, selected["model"] + ) + runner._session_model_overrides[command_session_id] = selected + await store.set_model_override(command_session_id, selected) + store.fail_restore = True + return "hidden" + raise RuntimeError("reasoning command failed") + + self.adapter.gateway_runner = runner + self.adapter._message_handler = handler + await self.adapter._handle_server_frame( + { + "type": "turn.start", + "protocolVersion": protocol_module.PROTOCOL_VERSION, + "requestId": "start-partial-durable-rollback", + "threadId": thread_id, + "sessionId": session_id, + "turnId": "turn-partial-durable-rollback", + "text": "Must not run", + "modelSelection": { + "mode": "specific", + "provider": "openai-codex", + "model": "gpt-5.4", + }, + "reasoningEffort": "high", + } + ) + + self.assertEqual(store.restore_attempts, 1) + self.assertEqual(session_db.row, prior_db_row) + self.assertEqual( + session_db.calls, + [ + ("model", "gpt-5.4"), + ("model", "old-model"), + ("meta", prior_db_row["model_config"], "old-model"), + ("prompt", prior_db_row["system_prompt"]), + ], + ) + self.assertEqual( + runner._session_model_overrides, {session_id: prior_model} + ) + error = self.connection.messages[-1] + self.assertEqual(error["type"], "protocol.error") + self.assertEqual(error["code"], "invalid-message") + self.assertIn("session routing override", error["message"]) + async def test_failed_reasoning_does_not_rollback_a_later_session(self): first_thread = "thread-overlap-first" second_thread = "thread-overlap-second" From f780d05e0fcf1e06b3d0fb0869b4d0cc6d02b41b Mon Sep 17 00:00:00 2001 From: Nicky Molina Date: Fri, 31 Jul 2026 21:35:39 -0400 Subject: [PATCH 4/4] fix(hermes): avoid clobbering foreign reasoning config Restore runner-global `_reasoning_config` only while this turn still owns the value it last observed after dispatch. Session-scoped overrides remain the transactional source of truth; a later writer is no longer overwritten on rollback. --- integrations/hermes-t3-gateway/README.md | 6 +- integrations/hermes-t3-gateway/adapter.py | 60 +++++++++++++++---- .../hermes-t3-gateway/tests/test_adapter.py | 59 ++++++++++++++++++ 3 files changed, 112 insertions(+), 13 deletions(-) diff --git a/integrations/hermes-t3-gateway/README.md b/integrations/hermes-t3-gateway/README.md index a8e1c7e5409..6b8f4b35536 100644 --- a/integrations/hermes-t3-gateway/README.md +++ b/integrations/hermes-t3-gateway/README.md @@ -54,7 +54,11 @@ T3 loads the selectable model catalog on demand from Hermes' explicitly configured providers. Changing model or reasoning in T3 applies Hermes' official `/model ... --session` and `/reasoning ...` commands before the next turn starts. These are per-session overrides: they never rewrite Hermes' -global `config.yaml`, and each T3 thread remains isolated from the others. +global `config.yaml`, and each T3 thread remains isolated from the others. A +failed or cancelled start rolls those session surfaces back transactionally; +configuration across threads is serialized, and Hermes' runner-wide +`_reasoning_config` compatibility mirror is restored only while this turn still +owns the value it last observed. ## The Home thread diff --git a/integrations/hermes-t3-gateway/adapter.py b/integrations/hermes-t3-gateway/adapter.py index 55387684ed5..15d6e02c221 100644 --- a/integrations/hermes-t3-gateway/adapter.py +++ b/integrations/hermes-t3-gateway/adapter.py @@ -1430,6 +1430,14 @@ async def _apply_turn_configuration_locked( prior_reasoning_config = copy.deepcopy( getattr(runner, "_reasoning_config", None) ) + # Track the runner-global reasoning mirror this transaction last + # observed. Session correctness lives in the per-session override map; + # `_reasoning_config` is only a process-wide compatibility field that + # Hermes' slash handler also updates. On rollback we restore it only + # while it still matches this ownership snapshot so a later writer is + # never clobbered (the configuration lock already serializes T3 turns). + owned_reasoning_config_present = had_reasoning_config + owned_reasoning_config = copy.deepcopy(prior_reasoning_config) durable_store = None durable_entry = None @@ -1554,10 +1562,24 @@ def restore_prior_memory_configuration() -> None: session_id, voice_channel_snapshot, ) - if had_reasoning_config: - runner._reasoning_config = prior_reasoning_config - elif hasattr(runner, "_reasoning_config"): - delattr(runner, "_reasoning_config") + currently_has_reasoning_config = hasattr(runner, "_reasoning_config") + current_reasoning_config = ( + getattr(runner, "_reasoning_config", None) + if currently_has_reasoning_config + else None + ) + still_owns_reasoning_config = ( + currently_has_reasoning_config == owned_reasoning_config_present + and ( + not currently_has_reasoning_config + or current_reasoning_config == owned_reasoning_config + ) + ) + if still_owns_reasoning_config: + if had_reasoning_config: + runner._reasoning_config = prior_reasoning_config + elif currently_has_reasoning_config: + delattr(runner, "_reasoning_config") if had_cache: self._applied_turn_configuration[thread_id] = prior_cache else: @@ -1642,18 +1664,32 @@ def release_detached_agent_cache() -> None: dispatched_configuration = False + def note_owned_reasoning_config() -> None: + nonlocal owned_reasoning_config_present, owned_reasoning_config + owned_reasoning_config_present = hasattr(runner, "_reasoning_config") + owned_reasoning_config = ( + copy.deepcopy(getattr(runner, "_reasoning_config", None)) + if owned_reasoning_config_present + else None + ) + async def dispatch(command: str) -> None: nonlocal dispatched_configuration dispatched_configuration = True - await handler( - MessageEvent( - text=command, - message_type=MessageType.COMMAND, - source=source, - message_id=str(message["requestId"]), - metadata={"t3_control": "turn-configuration"}, + try: + await handler( + MessageEvent( + text=command, + message_type=MessageType.COMMAND, + source=source, + message_id=str(message["requestId"]), + metadata={"t3_control": "turn-configuration"}, + ) ) - ) + finally: + # Capture ownership even when the handler mutates then raises so + # rollback can tell our write apart from a later foreign write. + note_owned_reasoning_config() try: effective_model: dict[str, str] | None = None diff --git a/integrations/hermes-t3-gateway/tests/test_adapter.py b/integrations/hermes-t3-gateway/tests/test_adapter.py index c16eaeb48a8..5a77064d3b4 100644 --- a/integrations/hermes-t3-gateway/tests/test_adapter.py +++ b/integrations/hermes-t3-gateway/tests/test_adapter.py @@ -1143,6 +1143,65 @@ def start_message(thread_id, session_id, effort): self.assertNotIn(first_thread, self.adapter._active_turns) self.assertIn(second_thread, self.adapter._active_turns) + async def test_reasoning_rollback_does_not_clobber_foreign_global_config(self): + """Global `_reasoning_config` is restored only while this turn owns it. + + Session-scoped overrides still roll back. If something else rewrites the + runner-wide mirror after our dispatch (and before we restore), leave that + newer value alone instead of replaying this turn's preimage. + """ + thread_id = "thread-foreign-reasoning-config" + session_id = await self._ensure_thread(thread_id) + runner = FakeGatewayRunner() + prior_reasoning = {"enabled": True, "effort": "medium"} + runner._reasoning_config = dict(prior_reasoning) + runner._session_reasoning_overrides[session_id] = dict(prior_reasoning) + foreign_reasoning = {"enabled": True, "effort": "low"} + + async def handler(event): + del event + selected = {"enabled": True, "effort": "high"} + runner._reasoning_config = dict(selected) + runner._session_reasoning_overrides[session_id] = dict(selected) + return "hidden" + + original_effective = self.adapter._effective_reasoning_effort + + def effective_then_foreign(runner_arg, key, *, model=""): + original_effective(runner_arg, key, model=model) + # A later writer lands between ownership capture and rollback. + runner_arg._reasoning_config = dict(foreign_reasoning) + raise adapter_module._TurnConfigurationError( + "verification failed after a foreign reasoning write" + ) + + self.adapter.gateway_runner = runner + self.adapter._message_handler = handler + self.adapter._effective_reasoning_effort = effective_then_foreign + + await self.adapter._handle_server_frame( + { + "type": "turn.start", + "protocolVersion": protocol_module.PROTOCOL_VERSION, + "requestId": "start-foreign-reasoning", + "threadId": thread_id, + "sessionId": session_id, + "turnId": "turn-foreign-reasoning", + "text": "Must not run", + "reasoningEffort": "high", + } + ) + + # Session-scoped state rolls back; the foreign global write is preserved. + self.assertEqual( + runner._session_reasoning_overrides, {session_id: prior_reasoning} + ) + self.assertEqual(runner._reasoning_config, foreign_reasoning) + self.assertNotIn(thread_id, self.adapter._active_turns) + error = self.connection.messages[-1] + self.assertEqual(error["type"], "protocol.error") + self.assertIn("foreign reasoning write", error["message"]) + async def test_cancelled_configuration_restores_prior_state(self): thread_id = "thread-cancelled-config" session_id = await self._ensure_thread(thread_id)