`connection.hello` gained an optional `model` field, but the value died in the broker: `HermesGatewayInstanceStatus` carried pluginVersion and hermesVersion with nowhere for the model to go, so the provider picker had no way to name the model Hermes actually runs. Add `model` to the status struct as a required nullable field. Null covers both "no plugin has connected yet" and "the connected plugin predates the field" — the plugin ships separately from the server, so a plugin that never sends it must still complete the handshake rather than failing the frame decoder. Tests cover the present and absent paths on both the hello frame and the status projection. The model is volatile liveness, not durable config: it belongs in broker memory alongside pluginVersion/hermesVersion/capabilities and is never persisted to settings.
Unified · Split
| @@ -69,6 +69,7 @@ describe("Hermes gateway management contracts", () => { · 68 unmodified lines | ||
| 69 | 69 | lastConnectedAt: "2026-07-23T12:00:00.000Z", |
| 70 | 70 | pluginVersion: "0.2.0", |
| 71 | 71 | hermesVersion: "1.2.3", |
| 72 | + model: "gpt-5.6-terra", | |
| 72 | 73 | activeSessionCount: 2, |
| 73 | 74 | protocolVersion: 2, |
| 74 | 75 | capabilities: { |
| @@ -82,6 +83,12 @@ describe("Hermes gateway management contracts", () => { · 7 unmodified lines | ||
| 82 | 83 | }); |
| 83 | 84 | expect(connected.status).toBe("connected"); |
| 84 | 85 | expect(connected.activeSessionCount).toBe(2); |
| 86 | + expect(connected.model).toBe("gpt-5.6-terra"); | |
| 87 | + | |
| 88 | + // A plugin that predates the `model` field still produces a valid status; | |
| 89 | + // the picker falls back to the generic label rather than failing to decode. | |
| 90 | + const withoutModel = decodeInstanceStatus({ ...connected, model: null }); | |
| 91 | + expect(withoutModel.model).toBeNull(); | |
| 85 | 92 | |
| 86 | 93 | const upgradeRequired = decodeInstanceStatus({ |
| 87 | 94 | ...connected, |
| @@ -134,6 +141,7 @@ describe("Hermes gateway handshake", () => { · 46 unmodified lines | ||
| 134 | 141 | userInput: true, |
| 135 | 142 | attachments: false, |
| 136 | 143 | }, |
| 144 | + model: "gpt-5.6-terra", | |
| 137 | 145 | authentication: { |
| 138 | 146 | type: "instance-credential", |
| 139 | 147 | instanceId: "hermes-research", |
| @@ -142,6 +150,35 @@ describe("Hermes gateway handshake", () => { · 2 unmodified lines | ||
| 142 | 150 | }); |
| 143 | 151 | |
| 144 | 152 | expect(hello.authentication.type).toBe("instance-credential"); |
| 153 | + expect(hello.model).toBe("gpt-5.6-terra"); | |
| 154 | + }); | |
| 155 | + | |
| 156 | + // The plugin ships separately from the server, so a plugin that predates the | |
| 157 | + // `model` field must still complete the handshake rather than failing the | |
| 158 | + // frame decoder. T3 falls back to the generic model label. | |
| 159 | + it("accepts a hello from a plugin that reports no model", () => { | |
| 160 | + const hello = decodeHello({ | |
| 161 | + type: "connection.hello", | |
| 162 | + requestId: "hello-no-model", | |
| 163 | + protocolVersion: 2, | |
| 164 | + pluginVersion: "0.2.0", | |
| 165 | + hermesVersion: "1.2.3", | |
| 166 | + capabilities: { | |
| 167 | + protocolVersion: 2, | |
| 168 | + streaming: true, | |
| 169 | + activity: true, | |
| 170 | + approvals: true, | |
| 171 | + userInput: true, | |
| 172 | + attachments: false, | |
| 173 | + }, | |
| 174 | + authentication: { | |
| 175 | + type: "instance-credential", | |
| 176 | + instanceId: "hermes-research", | |
| 177 | + credential: "persistent-secret", | |
| 178 | + }, | |
| 179 | + }); | |
| 180 | + | |
| 181 | + expect(hello.model).toBeUndefined(); | |
| 145 | 182 | }); |
| 146 | 183 | |
| 147 | 184 | it("decodes a future-version hello so the broker can reject it explicitly", () => { |
| @@ -123,6 +123,13 @@ export const HermesGatewayInstanceStatus = Schema.Struct({ · 122 unmodified lines | ||
| 123 | 123 | lastConnectedAt: Schema.NullOr(IsoDateTime), |
| 124 | 124 | pluginVersion: Schema.NullOr(TrimmedNonEmptyString), |
| 125 | 125 | hermesVersion: Schema.NullOr(TrimmedNonEmptyString), |
| 126 | + /** | |
| 127 | + * The model the connected plugin reported at handshake, surfaced so the | |
| 128 | + * provider picker can name the model Hermes actually runs instead of a | |
| 129 | + * placeholder. Null when no plugin has connected yet, or when the connected | |
| 130 | + * plugin predates the `model` field on `connection.hello`. | |
| 131 | + */ | |
| 132 | + model: Schema.NullOr(TrimmedNonEmptyString), | |
| 126 | 133 | activeSessionCount: NonNegativeInt, |
| 127 | 134 | protocolVersion: Schema.NullOr(PositiveInt), |
| 128 | 135 | capabilities: Schema.NullOr(HermesGatewayCapabilities), |