From 2523f3e036c3b7cb9dd3909ebcce2c206690f3ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 10 Sep 2026 03:16:41 +0200 Subject: [PATCH] fix(sdk): accept CLI interleaved fields in the remote model catalog https://github.com/Kilo-Org/cloud/pull/6029 --- .../src/lib/use-session-model-options.test.ts | 28 +++++++++++ .../src/cli-live-transport.test.ts | 32 +++++++++++++ .../src/remote-model-catalog.test.ts | 48 +++++++++++++++---- packages/cloud-agent-sdk/src/schemas.ts | 5 +- 4 files changed, 101 insertions(+), 12 deletions(-) diff --git a/apps/mobile/src/lib/use-session-model-options.test.ts b/apps/mobile/src/lib/use-session-model-options.test.ts index 9b162381f9..c53c1869ce 100644 --- a/apps/mobile/src/lib/use-session-model-options.test.ts +++ b/apps/mobile/src/lib/use-session-model-options.test.ts @@ -374,6 +374,34 @@ describe('buildSessionModelOptions', () => { expect(result.options.some(option => option.name === 'Use session model')).toBe(false); }); + it('locks the chip to the observed model id when catalog parse never becomes v1', () => { + const result = buildSessionModelOptions({ + activeSessionType: 'remote', + remoteModelState: { + ownerConnectionId: 'cli-owner', + protocol: 'unknown', + refresh: 'error', + error: 'Invalid remote model catalog', + }, + observedModel: { + model: { providerID: 'kilo', modelID: 'muse-spark-1.3-contributor' }, + }, + remoteModelOverride: null, + gatewayModels, + gatewayModelsLoading: false, + organizationId: 'org-persisted', + }); + + expect(result.source).toBe('remote-unavailable'); + expect(result.pickerDisabled).toBe(true); + expect(result.options).toEqual([ + expect.objectContaining({ + name: 'muse-spark-1.3-contributor', + unavailable: true, + }), + ]); + }); + it('disables model changes when remote discovery fails without exposing Gateway rows', () => { const result = buildSessionModelOptions({ activeSessionType: 'remote', diff --git a/packages/cloud-agent-sdk/src/cli-live-transport.test.ts b/packages/cloud-agent-sdk/src/cli-live-transport.test.ts index ecb17447c2..8f75ee3376 100644 --- a/packages/cloud-agent-sdk/src/cli-live-transport.test.ts +++ b/packages/cloud-agent-sdk/src/cli-live-transport.test.ts @@ -312,6 +312,38 @@ describe('CliLiveTransport unified user web connection', () => { transport.destroy(); }); + it('publishes v1 when the CLI catalog uses interleaved.field=reasoning_text', async () => { + const catalog: RemoteModelCatalogWireV1 = structuredClone(WIRE_CATALOG); + catalog.all[0]!.models['claude-sonnet-4']!.capabilities.interleaved = { + field: 'reasoning_text', + }; + const connection = createConnection(); + jest + .mocked(connection.sendCommand) + .mockImplementation((_sessionId, command) => + Promise.resolve(command === 'list_models' ? catalog : { ok: true }) + ); + const states: RemoteModelState[] = []; + const { transport } = createTransportWithSinks({ + connection, + onRemoteModelStateChange: state => states.push(state), + }); + + transport.connect(); + emitOwner(connection); + await Promise.resolve(); + await Promise.resolve(); + + expect(states.at(-1)).toEqual({ + ownerConnectionId: 'owner', + protocol: 'v1', + catalog: REMOTE_CATALOG, + refresh: 'idle', + }); + expect(transport.canSend?.()).toBe(true); + transport.destroy(); + }); + it('keeps protocol unknown and owner send capability after a malformed initial catalog', async () => { const connection = createConnection(); jest.mocked(connection.sendCommand).mockResolvedValueOnce({ diff --git a/packages/cloud-agent-sdk/src/remote-model-catalog.test.ts b/packages/cloud-agent-sdk/src/remote-model-catalog.test.ts index 42093f29a2..9e25fbef2f 100644 --- a/packages/cloud-agent-sdk/src/remote-model-catalog.test.ts +++ b/packages/cloud-agent-sdk/src/remote-model-catalog.test.ts @@ -1,3 +1,5 @@ +import { z } from 'zod'; + import { REMOTE_MODEL_CATALOG_MAX_SERIALIZED_BYTES, REMOTE_MODEL_IDENTITY_MAX_LENGTH, @@ -9,9 +11,19 @@ import { modelRefsEqual, remoteModelCatalogV1Schema, remoteModelCatalogWireV1Schema, + type RemoteModelCatalogWireV1, } from './remote-model-catalog'; -function createSdkModel(providerID: string, id: string, variants: string[] = [], name = id) { +// SdkModelFixture mirrors the wire schema's model shape, so interleaved keeps +// the boolean | { field } union the schema accepts. +type SdkModelFixture = RemoteModelCatalogWireV1['all'][number]['models'][string]; + +function createSdkModel( + providerID: string, + id: string, + variants: string[] = [], + name = id +): SdkModelFixture { return { id, providerID, @@ -36,13 +48,6 @@ function createSdkModel(providerID: string, id: string, variants: string[] = [], }; } -type SdkModelFixture = ReturnType & { - recommendedIndex?: number; - isFree?: boolean; - mayTrainOnYourPrompts?: boolean; - hasUserByokAvailable?: boolean; -}; - function createSdkProvider( id: string, models: SdkModelFixture[] = [createSdkModel(id, `model-${id}`)] @@ -146,6 +151,33 @@ function createUtf8OversizedCatalog() { } describe('remoteModelCatalogV1Schema', () => { + it('reproduces the old interleaved enum rejecting CLI catalogs', () => { + const oldInterleaved = z.union([ + z.boolean(), + z.object({ field: z.enum(['reasoning_content', 'reasoning_details']) }).strict(), + ]); + + expect(oldInterleaved.safeParse({ field: 'reasoning_text' }).success).toBe(false); + expect(oldInterleaved.safeParse({ field: 'reasoning' }).success).toBe(false); + }); + + it('accepts CLI interleaved field names the picker does not use', () => { + const fields = ['reasoning', 'reasoning_content', 'reasoning_details', 'reasoning_text']; + + for (const field of fields) { + const model = createSdkModel('kilo', 'muse-spark-1.3-contributor'); + model.capabilities = { ...model.capabilities, interleaved: { field } }; + const parsed = remoteModelCatalogV1Schema.safeParse( + createWireCatalog([createSdkProvider('kilo', [model])]) + ); + + expect(parsed.success).toBe(true); + if (parsed.success) { + expect(parsed.data.providers[0]?.models[0]?.id).toBe('muse-spark-1.3-contributor'); + } + } + }); + it('normalizes the SDK ProviderListResponse shape without rewriting model identities', () => { const model: SdkModelFixture = createSdkModel( 'custom/provider:v1', diff --git a/packages/cloud-agent-sdk/src/schemas.ts b/packages/cloud-agent-sdk/src/schemas.ts index c46e0c83c0..45d62fb93b 100644 --- a/packages/cloud-agent-sdk/src/schemas.ts +++ b/packages/cloud-agent-sdk/src/schemas.ts @@ -152,10 +152,7 @@ const remoteSdkModelSchema = z toolcall: z.boolean(), input: remoteModelModalitiesSchema, output: remoteModelModalitiesSchema, - interleaved: z.union([ - z.boolean(), - z.object({ field: z.enum(['reasoning_content', 'reasoning_details']) }).strict(), - ]), + interleaved: z.union([z.boolean(), z.object({ field: z.string().min(1) }).passthrough()]), }) .strict(), cost: z