authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-05 16:20:26-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-05 16:20:26-07:00
log27ab45593fdcaffe718c1fa54dcbb2df4bd769a2
tree34886c62a786e6beecda02bd904e20ae47312b00
parent3da82d5d384a5f45e3e4a9c5bdb8336eb54e49ff
signature Signed by PGP key B5690EEEBB952194

fix(server): ACP mode states with null descriptions are no longer dropped (#16218)

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 });
218219
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";
3import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
4import * as Option from "effect/Option";4import * as Option from "effect/Option";
5import * as Ref from "effect/Ref";5import * as Ref from "effect/Ref";
6import type * as EffectAcpSchema from "effect-acp/compat";6import * as Schema from "effect/Schema";
7import * as EffectAcpSchema from "effect-acp/compat";
8import * as EffectAcpSchemaV1 from "effect-acp/schema-v1";
7import {9import {
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}
2123
22function 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;26const isSessionModelState = Schema.is(EffectAcpSchema.SessionModelState);
25 }27const 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
40function 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}
5528
56export interface AcpSessionMode {29export interface AcpSessionMode {
57 readonly id: string;30 readonly id: string;