| author | |
| committer | |
| log | df78cda8bf9c0971300e1bf35251774d9fbc833a |
| tree | 9d267cf4505f84e80406ee23d73dd89c1563af18 |
| parent | 605c2bb734bbebed0b19fc1ff6b514f41bd3659b |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>6 files changed, 165 insertions(+), 5 deletions(-)
packages/client-runtime/src/rpc/session.test.ts+34| ... | ... | @@ -287,6 +287,40 @@ describe("RpcSessionFactory", () => { |
| 287 | 287 | }), |
| 288 | 288 | ); |
| 289 | 289 | |
| 290 | it.effect("reaches ready when a newer server sends unknown config members", () => | |
| 291 | Effect.gen(function* () { | |
| 292 | const { factory, sockets } = yield* makeFactory(); | |
| 293 | const session = yield* factory.connect(PREPARED); | |
| 294 | const readyFiber = yield* Effect.forkChild(session.ready); | |
| 295 | const socket = yield* awaitSocket(sockets); | |
| 296 | socket.open(); | |
| 297 | ||
| 298 | const shortcut = { | |
| 299 | key: "p", | |
| 300 | metaKey: false, | |
| 301 | ctrlKey: false, | |
| 302 | shiftKey: false, | |
| 303 | altKey: false, | |
| 304 | modKey: true, | |
| 305 | }; | |
| 306 | yield* completeInitialConfig(socket, { | |
| 307 | ...ENCODED_SERVER_CONFIG, | |
| 308 | keybindings: [ | |
| 309 | { command: "someFuture.toggle", shortcut }, | |
| 310 | { command: "terminal.toggle", shortcut }, | |
| 311 | ], | |
| 312 | issues: [{ kind: "keybindings.future-issue", message: "From a newer server" }], | |
| 313 | availableEditors: ["some-future-editor", "zed"], | |
| 314 | }); | |
| 315 | yield* Fiber.join(readyFiber); | |
| 316 | ||
| 317 | const config = yield* session.initialConfig; | |
| 318 | expect(config.keybindings).toEqual([{ command: "terminal.toggle", shortcut }]); | |
| 319 | expect(config.issues).toEqual([]); | |
| 320 | expect(config.availableEditors).toEqual(["zed"]); | |
| 321 | }), | |
| 322 | ); | |
| 323 | ||
| 290 | 324 | it.effect("uses the legacy config RPC for probes when the server lacks the capability", () => |
| 291 | 325 | Effect.scoped( |
| 292 | 326 | Effect.gen(function* () { |
packages/contracts/src/baseSchemas.ts+25| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import * as Effect from "effect/Effect"; |
| 2 | import * as Option from "effect/Option"; | |
| 2 | 3 | import * as Schema from "effect/Schema"; |
| 3 | 4 | import * as SchemaTransformation from "effect/SchemaTransformation"; |
| 4 | 5 | |
| ... | ... | @@ -20,6 +21,30 @@ export const PortSchema = Schema.Int.check(Schema.isBetween({ minimum: 1, maximu |
| 20 | 21 | export const IsoDateTime = Schema.String; |
| 21 | 22 | export type IsoDateTime = typeof IsoDateTime.Type; |
| 22 | 23 | |
| 24 | /** | |
| 25 | * Wire codec for server→client arrays whose element unions grow over time | |
| 26 | * (new literal members, new struct variants). Decoding drops elements the | |
| 27 | * current build cannot decode instead of failing the whole payload — a client | |
| 28 | * has to keep decoding configs sent by servers newer than itself, and | |
| 29 | * rejecting the payload would take down the connection over data the client | |
| 30 | * couldn't act on anyway. Encoding is the plain array encoding. | |
| 31 | */ | |
| 32 | export const ForwardCompatibleArray = <Element extends Schema.Top>(element: Element) => { | |
| 33 | const decodeElement = Schema.decodeUnknownOption(element as never); | |
| 34 | return Schema.Array(Schema.Unknown).pipe( | |
| 35 | Schema.decodeTo( | |
| 36 | Schema.Array(element), | |
| 37 | SchemaTransformation.transform<ReadonlyArray<Element["Encoded"]>, ReadonlyArray<unknown>>({ | |
| 38 | decode: (values) => | |
| 39 | values.filter((value) => Option.isSome(decodeElement(value))) as ReadonlyArray< | |
| 40 | Element["Encoded"] | |
| 41 | >, | |
| 42 | encode: (values) => values, | |
| 43 | }), | |
| 44 | ), | |
| 45 | ); | |
| 46 | }; | |
| 47 | ||
| 23 | 48 | /** |
| 24 | 49 | * Construct a branded identifier. Enforces non-empty trimmed strings |
| 25 | 50 | */ |
packages/contracts/src/keybindings.test.ts+65| ... | ... | @@ -20,6 +20,7 @@ const decode = <S extends Schema.Top>( |
| 20 | 20 | >; |
| 21 | 21 | |
| 22 | 22 | const decodeResolvedRule = Schema.decodeUnknownEffect(ResolvedKeybindingRule as never); |
| 23 | const encodeResolvedKeybindings = Schema.encodeEffect(ResolvedKeybindingsConfig); | |
| 23 | 24 | |
| 24 | 25 | it.effect("parses keybinding rules", () => |
| 25 | 26 | Effect.gen(function* () { |
| ... | ... | @@ -185,6 +186,70 @@ it.effect("parses resolved keybindings arrays", () => |
| 185 | 186 | }), |
| 186 | 187 | ); |
| 187 | 188 | |
| 189 | const shortcut = { | |
| 190 | key: "p", | |
| 191 | metaKey: false, | |
| 192 | ctrlKey: false, | |
| 193 | shiftKey: false, | |
| 194 | altKey: false, | |
| 195 | modKey: true, | |
| 196 | }; | |
| 197 | ||
| 198 | it.effect("drops resolved rules with commands this build does not know", () => | |
| 199 | Effect.gen(function* () { | |
| 200 | const parsed = yield* decode(ResolvedKeybindingsConfig, [ | |
| 201 | { command: "terminal.toggle", shortcut }, | |
| 202 | { command: "someFuture.toggle", shortcut }, | |
| 203 | { command: "filePicker.toggle", shortcut }, | |
| 204 | ]); | |
| 205 | assert.deepEqual( | |
| 206 | parsed.map((rule) => rule.command), | |
| 207 | ["terminal.toggle", "filePicker.toggle"], | |
| 208 | ); | |
| 209 | }), | |
| 210 | ); | |
| 211 | ||
| 212 | it.effect("drops resolved rules with unknown when-node types", () => | |
| 213 | Effect.gen(function* () { | |
| 214 | const parsed = yield* decode(ResolvedKeybindingsConfig, [ | |
| 215 | { | |
| 216 | command: "terminal.toggle", | |
| 217 | shortcut, | |
| 218 | whenAst: { type: "xor", left: 1, right: 2 }, | |
| 219 | }, | |
| 220 | { command: "terminal.split", shortcut }, | |
| 221 | ]); | |
| 222 | assert.deepEqual( | |
| 223 | parsed.map((rule) => rule.command), | |
| 224 | ["terminal.split"], | |
| 225 | ); | |
| 226 | }), | |
| 227 | ); | |
| 228 | ||
| 229 | it.effect("drops malformed resolved rule entries", () => | |
| 230 | Effect.gen(function* () { | |
| 231 | const parsed = yield* decode(ResolvedKeybindingsConfig, [ | |
| 232 | "garbage", | |
| 233 | { command: "terminal.toggle", shortcut }, | |
| 234 | null, | |
| 235 | ]); | |
| 236 | assert.deepEqual( | |
| 237 | parsed.map((rule) => rule.command), | |
| 238 | ["terminal.toggle"], | |
| 239 | ); | |
| 240 | }), | |
| 241 | ); | |
| 242 | ||
| 243 | it.effect("encodes resolved keybindings to the plain wire shape", () => | |
| 244 | Effect.gen(function* () { | |
| 245 | const rules = [{ command: "terminal.toggle" as const, shortcut }]; | |
| 246 | const encoded = yield* encodeResolvedKeybindings(rules); | |
| 247 | assert.deepEqual(encoded, rules); | |
| 248 | const roundTripped = yield* decode(ResolvedKeybindingsConfig, encoded); | |
| 249 | assert.deepEqual(roundTripped, rules); | |
| 250 | }), | |
| 251 | ); | |
| 252 | ||
| 188 | 253 | it.effect("drops unknown fields in resolved keybinding rules", () => |
| 189 | 254 | decodeResolvedRule({ |
| 190 | 255 | command: "terminal.toggle", |
packages/contracts/src/keybindings.ts+9-2| ... | ... | @@ -1,5 +1,5 @@ |
| 1 | 1 | import * as Schema from "effect/Schema"; |
| 2 | import { TrimmedString } from "./baseSchemas.ts"; | |
| 2 | import { ForwardCompatibleArray, TrimmedString } from "./baseSchemas.ts"; | |
| 3 | 3 | |
| 4 | 4 | export const MAX_KEYBINDING_VALUE_LENGTH = 64; |
| 5 | 5 | export const MAX_KEYBINDING_WHEN_LENGTH = 256; |
| ... | ... | @@ -155,7 +155,14 @@ export const ResolvedKeybindingRule = Schema.Struct({ |
| 155 | 155 | }).annotate({ parseOptions: { onExcessProperty: "ignore" } }); |
| 156 | 156 | export type ResolvedKeybindingRule = typeof ResolvedKeybindingRule.Type; |
| 157 | 157 | |
| 158 | export const ResolvedKeybindingsConfig = Schema.Array(ResolvedKeybindingRule).check( | |
| 158 | /** | |
| 159 | * The command set grows over time, so a client may receive rules it cannot | |
| 160 | * represent (a command or `when` node added after that client shipped). | |
| 161 | * Decoding drops those rules instead of failing the whole payload — | |
| 162 | * rejecting the config would take down the connection over a shortcut the | |
| 163 | * client couldn't dispatch anyway. | |
| 164 | */ | |
| 165 | export const ResolvedKeybindingsConfig = ForwardCompatibleArray(ResolvedKeybindingRule).check( | |
| 159 | 166 | Schema.isMaxLength(MAX_KEYBINDINGS_COUNT), |
| 160 | 167 | ); |
| 161 | 168 | export type ResolvedKeybindingsConfig = typeof ResolvedKeybindingsConfig.Type; |
packages/contracts/src/server.test.ts+25-1| ... | ... | @@ -1,9 +1,11 @@ |
| 1 | 1 | import * as Schema from "effect/Schema"; |
| 2 | 2 | import { describe, expect, it } from "vite-plus/test"; |
| 3 | 3 | |
| 4 | import { ServerProvider } from "./server.ts"; | |
| 4 | import { ServerConfig, ServerProvider, ServerUpsertKeybindingResult } from "./server.ts"; | |
| 5 | 5 | |
| 6 | 6 | const decodeServerProvider = Schema.decodeUnknownSync(ServerProvider); |
| 7 | const decodeUpsertKeybindingResult = Schema.decodeUnknownSync(ServerUpsertKeybindingResult); | |
| 8 | const decodeAvailableEditors = Schema.decodeUnknownSync(ServerConfig.fields.availableEditors); | |
| 7 | 9 | |
| 8 | 10 | describe("ServerProvider", () => { |
| 9 | 11 | it("defaults capability arrays when decoding provider snapshots", () => { |
| ... | ... | @@ -72,3 +74,25 @@ describe("ServerProvider", () => { |
| 72 | 74 | expect(parsed.continuation?.groupKey).toBe("codex:home:/Users/julius/.codex"); |
| 73 | 75 | }); |
| 74 | 76 | }); |
| 77 | ||
| 78 | describe("server config forward compatibility", () => { | |
| 79 | it("drops config issues with kinds this build does not know", () => { | |
| 80 | const parsed = decodeUpsertKeybindingResult({ | |
| 81 | keybindings: [], | |
| 82 | issues: [ | |
| 83 | { kind: "keybindings.invalid-entry", message: "Bad entry", index: 2 }, | |
| 84 | { kind: "keybindings.future-issue", message: "From a newer server" }, | |
| 85 | ], | |
| 86 | }); | |
| 87 | ||
| 88 | expect(parsed.issues).toEqual([ | |
| 89 | { kind: "keybindings.invalid-entry", message: "Bad entry", index: 2 }, | |
| 90 | ]); | |
| 91 | }); | |
| 92 | ||
| 93 | it("drops editor ids this build does not know", () => { | |
| 94 | const parsed = decodeAvailableEditors(["zed", "some-future-editor", "vscode"]); | |
| 95 | ||
| 96 | expect(parsed).toEqual(["zed", "vscode"]); | |
| 97 | }); | |
| 98 | }); |
packages/contracts/src/server.ts+7-2| ... | ... | @@ -3,6 +3,7 @@ import * as Schema from "effect/Schema"; |
| 3 | 3 | import { ExecutionEnvironmentDescriptor, ServerSelfUpdateMethod } from "./environment.ts"; |
| 4 | 4 | import { ServerAuthDescriptor } from "./auth.ts"; |
| 5 | 5 | import { |
| 6 | ForwardCompatibleArray, | |
| 6 | 7 | IsoDateTime, |
| 7 | 8 | NonNegativeInt, |
| 8 | 9 | PositiveInt, |
| ... | ... | @@ -38,7 +39,9 @@ export const ServerConfigIssue = Schema.Union([ |
| 38 | 39 | ]); |
| 39 | 40 | export type ServerConfigIssue = typeof ServerConfigIssue.Type; |
| 40 | 41 | |
| 41 | const ServerConfigIssues = Schema.Array(ServerConfigIssue); | |
| 42 | // Issue kinds grow over time; older clients must not fail the whole config | |
| 43 | // decode over a kind they cannot render. | |
| 44 | const ServerConfigIssues = ForwardCompatibleArray(ServerConfigIssue); | |
| 42 | 45 | |
| 43 | 46 | export const ServerProviderState = Schema.Literals(["ready", "warning", "error", "disabled"]); |
| 44 | 47 | export type ServerProviderState = typeof ServerProviderState.Type; |
| ... | ... | @@ -417,7 +420,9 @@ export const ServerConfig = Schema.Struct({ |
| 417 | 420 | keybindings: ResolvedKeybindingsConfig, |
| 418 | 421 | issues: ServerConfigIssues, |
| 419 | 422 | providers: ServerProviders, |
| 420 | availableEditors: Schema.Array(EditorId), | |
| 423 | // Editor ids grow over time; drop ones this build does not know rather than | |
| 424 | // failing the whole config decode. | |
| 425 | availableEditors: ForwardCompatibleArray(EditorId), | |
| 421 | 426 | observability: ServerObservability, |
| 422 | 427 | settings: ServerSettings, |
| 423 | 428 | /** Whether shell subscriptions can emit an opt-in catch-up completion marker. */ |