| author | |
| committer | |
| log | 27ab45593fdcaffe718c1fa54dcbb2df4bd769a2 |
| tree | 34886c62a786e6beecda02bd904e20ae47312b00 |
| parent | 3da82d5d384a5f45e3e4a9c5bdb8336eb54e49ff |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>2 files changed, 65 insertions(+), 34 deletions(-)
apps/server/src/provider/acp/AcpRuntimeModel.test.ts+58| ... | @@ -13,6 +13,7 @@ import { | ... | @@ -13,6 +13,7 @@ import { |
| 13 | parsePermissionRequest, | 13 | parsePermissionRequest, |
| 14 | parseSessionModeState, | 14 | parseSessionModeState, |
| 15 | parseSessionUpdateEvent, | 15 | parseSessionUpdateEvent, |
| 16 | sessionModelStateFromInitialize, | ||
| 16 | sessionUpdateCountsAsLoadReplayActivity, | 17 | sessionUpdateCountsAsLoadReplayActivity, |
| 17 | sessionUpdateIsReplay, | 18 | sessionUpdateIsReplay, |
| 18 | syntheticLoadSessionResponseFromInitialize, | 19 | syntheticLoadSessionResponseFromInitialize, |
| ... | @@ -216,6 +217,63 @@ describe("AcpRuntimeModel", () => { | ... | @@ -216,6 +217,63 @@ describe("AcpRuntimeModel", () => { |
| 216 | expect(response.modes?.availableModes).toHaveLength(2); | 217 | expect(response.modes?.availableModes).toHaveLength(2); |
| 217 | }); | 218 | }); |
| 218 | 219 | ||
| 220 | it("keeps initialize mode state whose modes have null descriptions", () => { | ||
| 221 | const response = syntheticLoadSessionResponseFromInitialize({ | ||
| 222 | protocolVersion: 1, | ||
| 223 | _meta: { | ||
| 224 | modeState: { | ||
| 225 | currentModeId: "code", | ||
| 226 | availableModes: [ | ||
| 227 | { id: "ask", name: "Ask", description: null }, | ||
| 228 | { id: "code", name: "Code", description: "Edit files", _meta: null }, | ||
| 229 | ], | ||
| 230 | _meta: null, | ||
| 231 | }, | ||
| 232 | }, | ||
| 233 | } satisfies EffectAcpSchema.InitializeResponse); | ||
| 234 | |||
| 235 | expect(parseSessionModeState(response)).toEqual({ | ||
| 236 | currentModeId: "code", | ||
| 237 | availableModes: [ | ||
| 238 | { id: "ask", name: "Ask" }, | ||
| 239 | { id: "code", name: "Code", description: "Edit files" }, | ||
| 240 | ], | ||
| 241 | }); | ||
| 242 | }); | ||
| 243 | |||
| 244 | it("reads initialize model state and ignores malformed model state", () => { | ||
| 245 | const modelState = { | ||
| 246 | currentModelId: "grok-4.7", | ||
| 247 | availableModels: [ | ||
| 248 | { | ||
| 249 | modelId: "grok-4.7", | ||
| 250 | name: "Grok 4.7", | ||
| 251 | description: null, | ||
| 252 | _meta: { reasoningEfforts: [{ id: "high", default: true }] }, | ||
| 253 | }, | ||
| 254 | { modelId: "grok-4.6", name: "Grok 4.6" }, | ||
| 255 | ], | ||
| 256 | }; | ||
| 257 | |||
| 258 | expect(sessionModelStateFromInitialize({ protocolVersion: 1, _meta: { modelState } })).toEqual( | ||
| 259 | modelState, | ||
| 260 | ); | ||
| 261 | expect( | ||
| 262 | sessionModelStateFromInitialize({ | ||
| 263 | protocolVersion: 1, | ||
| 264 | _meta: { | ||
| 265 | modelState: { currentModelId: "grok-4.7", availableModels: [{ modelId: "grok-4.7" }] }, | ||
| 266 | }, | ||
| 267 | }), | ||
| 268 | ).toBeUndefined(); | ||
| 269 | expect( | ||
| 270 | sessionModelStateFromInitialize({ | ||
| 271 | protocolVersion: 1, | ||
| 272 | _meta: { modelState: { ...modelState, _meta: "not an object" } }, | ||
| 273 | }), | ||
| 274 | ).toBeUndefined(); | ||
| 275 | }); | ||
| 276 | |||
| 219 | it("projects typed ACP tool call updates into runtime events", () => { | 277 | it("projects typed ACP tool call updates into runtime events", () => { |
| 220 | const created = parseSessionUpdateEvent({ | 278 | const created = parseSessionUpdateEvent({ |
| 221 | sessionId: "session-1", | 279 | sessionId: "session-1", |
apps/server/src/provider/acp/AcpRuntimeModel.ts+7-34| ... | @@ -3,7 +3,9 @@ import * as Duration from "effect/Duration"; | ... | @@ -3,7 +3,9 @@ import * as Duration from "effect/Duration"; |
| 3 | import * as Effect from "effect/Effect"; | 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Option from "effect/Option"; | 4 | import * as Option from "effect/Option"; |
| 5 | import * as Ref from "effect/Ref"; | 5 | import * as Ref from "effect/Ref"; |
| 6 | import type * as EffectAcpSchema from "effect-acp/compat"; | 6 | import * as Schema from "effect/Schema"; |
| 7 | import * as EffectAcpSchema from "effect-acp/compat"; | ||
| 8 | import * as EffectAcpSchemaV1 from "effect-acp/schema-v1"; | ||
| 7 | import { | 9 | import { |
| 8 | deriveToolActivityPresentation, | 10 | deriveToolActivityPresentation, |
| 9 | mergeToolActivityData, | 11 | mergeToolActivityData, |
| ... | @@ -19,39 +21,10 @@ function isRecord(value: unknown): value is Record<string, unknown> { | ... | @@ -19,39 +21,10 @@ function isRecord(value: unknown): value is Record<string, unknown> { |
| 19 | return typeof value === "object" && value !== null && !Array.isArray(value); | 21 | return typeof value === "object" && value !== null && !Array.isArray(value); |
| 20 | } | 22 | } |
| 21 | 23 | ||
| 22 | function isSessionModelState(value: unknown): value is EffectAcpSchema.SessionModelState { | 24 | // Guards for the untyped `initialize._meta` states some agents (Grok) advertise. |
| 23 | if (!isRecord(value) || typeof value.currentModelId !== "string") { | 25 | // Modes were removed from ACP v2, so the v1 wire schema is the source of truth. |
| 24 | return false; | 26 | const isSessionModelState = Schema.is(EffectAcpSchema.SessionModelState); |
| 25 | } | 27 | const isSessionModeState = Schema.is(EffectAcpSchemaV1.SessionModeState); |
| 26 | if (!Array.isArray(value.availableModels)) { | ||
| 27 | return false; | ||
| 28 | } | ||
| 29 | return value.availableModels.every( | ||
| 30 | (model) => | ||
| 31 | isRecord(model) && | ||
| 32 | typeof model.modelId === "string" && | ||
| 33 | typeof model.name === "string" && | ||
| 34 | (model.description === undefined || | ||
| 35 | model.description === null || | ||
| 36 | typeof model.description === "string"), | ||
| 37 | ); | ||
| 38 | } | ||
| 39 | |||
| 40 | function isSessionModeState(value: unknown): value is EffectAcpSchema.SessionModeState { | ||
| 41 | if (!isRecord(value) || typeof value.currentModeId !== "string") { | ||
| 42 | return false; | ||
| 43 | } | ||
| 44 | if (!Array.isArray(value.availableModes)) { | ||
| 45 | return false; | ||
| 46 | } | ||
| 47 | return value.availableModes.every( | ||
| 48 | (mode) => | ||
| 49 | isRecord(mode) && | ||
| 50 | typeof mode.id === "string" && | ||
| 51 | typeof mode.name === "string" && | ||
| 52 | (mode.description === undefined || typeof mode.description === "string"), | ||
| 53 | ); | ||
| 54 | } | ||
| 55 | 28 | ||
| 56 | export interface AcpSessionMode { | 29 | export interface AcpSessionMode { |
| 57 | readonly id: string; | 30 | readonly id: string; |