From ac89107a5a39045ccc644f48875bb5fd2d677cfb Mon Sep 17 00:00:00 2001 From: Dani Akash Date: Thu, 7 May 2026 11:15:55 +0530 Subject: [PATCH 1/2] feat(runtime): expose session models on getStatus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an optional `models` field to `AcpRuntimeStatus` that surfaces the model ids the agent advertised at session-creation time. The data already lands in `NewSessionResponse.models` and is partly persisted on `record.acpx.{available_models, current_model_id}` — the public runtime API just never exposed it. The CLI's status command reaches into the private record directly today; this gives every host the same capability. Strictly additive at every layer: - `AcpRuntimeStatus.models?: AcpRuntimeSessionModels` (new optional field on an existing type) - `AcpRuntimeSessionModels = { currentModelId?, availableModelIds }` (new exported type) - No persisted-state schema changes — `available_models` stays `string[]` - No wire-protocol changes - No CLI changes — its existing private-record path keeps working Display names are deliberately not surfaced here. The ACP SDK provides `{ modelId, name }` but `name` is dropped at persistence time today (`mode-preference.ts:137`). Surfacing names would either require schema changes or a parallel optional field — worth a separate follow-up if there's user demand. Drive-by fix: `manager.ensureSession` was already capturing `sessionResult.configOptions` via `applyConfigOptionsToRecord` but silently dropping `sessionResult.models` — only the reconnect path called `syncAdvertisedModelState`. Added one line to capture models on first session creation too, mirroring the reconnect path. Driving consumer: `acpx-ai-provider` (the Vercel AI SDK adapter) needs this to expose `getAvailableModelIds()` so chat / picker UIs built on AI SDK can discover what each agent supports without reaching into private state. Tests: - `getStatus` populates `models` from `NewSessionResponse.models` - `getStatus` omits `models` when the agent didn't advertise any - `getStatus.models` survives a save/reload cycle `pnpm check` (format + typecheck + lint + build + viewer + test:coverage) passes locally; 605 tests, 0 failures. --- src/runtime.ts | 1 + src/runtime/engine/manager.ts | 28 +++++++- src/runtime/public/contract.ts | 22 ++++++ test/runtime-manager.test.ts | 128 ++++++++++++++++++++++++++++++++- 4 files changed, 177 insertions(+), 2 deletions(-) diff --git a/src/runtime.ts b/src/runtime.ts index fe7acdd..4be64d9 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -38,6 +38,7 @@ export type { AcpRuntimeOptions, AcpRuntimePromptMode, AcpRuntimeSessionMode, + AcpRuntimeSessionModels, AcpRuntimeStatus, AcpRuntimeTurn, AcpRuntimeTurnAttachment, diff --git a/src/runtime/engine/manager.ts b/src/runtime/engine/manager.ts index 73ecf05..8b65b01 100644 --- a/src/runtime/engine/manager.ts +++ b/src/runtime/engine/manager.ts @@ -16,13 +16,18 @@ import { trimConversationForRuntime, } from "../../session/conversation-model.js"; import { defaultSessionEventLog } from "../../session/event-log.js"; -import { setDesiredConfigOption, setDesiredModeId } from "../../session/mode-preference.js"; +import { + setDesiredConfigOption, + setDesiredModeId, + syncAdvertisedModelState, +} from "../../session/mode-preference.js"; import type { ClientOperation, SessionRecord, SessionResumePolicy } from "../../types.js"; import type { AcpRuntimeEvent, AcpRuntimeHandle, AcpRuntimeOptions, AcpRuntimePromptMode, + AcpRuntimeSessionModels, AcpRuntimeStatus, AcpRuntimeTurnAttachment, AcpRuntimeTurn, @@ -239,6 +244,25 @@ function statusSummary(record: SessionRecord): string { return parts.join(" "); } +function buildModelsField(record: SessionRecord): { models?: AcpRuntimeSessionModels } { + const available = record.acpx?.available_models; + const currentModelId = record.acpx?.current_model_id; + if (!available || available.length === 0) { + if (currentModelId === undefined) { + return {}; + } + // Edge case: have a current id but no advertised list. Surface what + // we have so hosts that already know the registry can use it. + return { models: { currentModelId, availableModelIds: [] } }; + } + return { + models: { + ...(currentModelId !== undefined ? { currentModelId } : {}), + availableModelIds: [...available], + }, + }; +} + export class AcpRuntimeManager { private readonly activeControllers = new Map(); private readonly pendingPersistentClients = new Map(); @@ -419,6 +443,7 @@ export class AcpRuntimeManager { record.protocolVersion = client.initializeResult?.protocolVersion; record.agentCapabilities = client.initializeResult?.agentCapabilities; applyConfigOptionsToRecord(record, sessionResult); + syncAdvertisedModelState(record, sessionResult.models); applyLifecycleSnapshotToRecord(record, client.getAgentLifecycleSnapshot()); await this.options.sessionStore.save(record); if (input.mode === "persistent") { @@ -787,6 +812,7 @@ export class AcpRuntimeManager { acpxRecordId: record.acpxRecordId, backendSessionId: record.acpSessionId, agentSessionId: record.agentSessionId, + ...buildModelsField(record), details: { cwd: record.cwd, lastUsedAt: record.lastUsedAt, diff --git a/src/runtime/public/contract.ts b/src/runtime/public/contract.ts index 4ce602a..77cbf53 100644 --- a/src/runtime/public/contract.ts +++ b/src/runtime/public/contract.ts @@ -62,11 +62,33 @@ export type AcpRuntimeCapabilities = { configOptionKeys?: string[]; }; +/** + * Models advertised by the agent at session-creation time, plus the id + * of the currently selected model. + * + * `currentModelId` will match an entry in `availableModelIds` when the + * agent advertised a current selection. It may be undefined if the agent + * didn't advertise one (some adapters omit it on first connect). + * + * Display names are not currently surfaced — see the runtime's release + * notes for the follow-up that adds them. + */ +export type AcpRuntimeSessionModels = { + currentModelId?: string; + availableModelIds: string[]; +}; + export type AcpRuntimeStatus = { summary?: string; acpxRecordId?: string; backendSessionId?: string; agentSessionId?: string; + /** + * Models advertised by the agent for this session. Undefined when the + * agent has not advertised any (Gemini CLI, custom adapters that don't + * populate `NewSessionResponse.models`). + */ + models?: AcpRuntimeSessionModels; details?: Record; }; diff --git a/test/runtime-manager.test.ts b/test/runtime-manager.test.ts index ee219a3..435f310 100644 --- a/test/runtime-manager.test.ts +++ b/test/runtime-manager.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; -import type { SetSessionConfigOptionResponse } from "@agentclientprotocol/sdk"; +import type { SessionModelState, SetSessionConfigOptionResponse } from "@agentclientprotocol/sdk"; import { AcpxOperationalError } from "../src/errors.js"; import { AcpRuntimeManager } from "../src/runtime/engine/manager.js"; import type { @@ -31,6 +31,7 @@ type FakeClient = { sessionId: string; agentSessionId?: string; configOptions?: SetSessionConfigOptionResponse["configOptions"]; + models?: SessionModelState; }>; loadSession: ( sessionId: string, @@ -38,6 +39,7 @@ type FakeClient = { ) => Promise<{ agentSessionId?: string; configOptions?: SetSessionConfigOptionResponse["configOptions"]; + models?: SessionModelState; }>; hasReusableSession: (sessionId: string) => boolean; supportsLoadSession: () => boolean; @@ -1997,3 +1999,127 @@ test("AcpRuntimeManager reuses a kept-open persistent client for controls before assert.equal(closeCalls, 1); }); + +function createModelsClientFactory(options: { + models?: SessionModelState; + onSetSessionModel?: (sessionId: string, modelId: string) => void; +}): () => FakeClient { + return (): FakeClient => + ({ + initializeResult: { protocolVersion: 1 }, + start: async () => {}, + close: async () => {}, + createSession: async () => ({ + sessionId: "models-session", + agentSessionId: "models-agent", + ...(options.models !== undefined ? { models: options.models } : {}), + }), + loadSession: async () => ({ agentSessionId: "models-agent" }), + hasReusableSession: () => false, + supportsLoadSession: () => true, + loadSessionWithOptions: async () => ({ agentSessionId: "models-agent" }), + getAgentLifecycleSnapshot: () => ({ pid: 1, startedAt: "now", running: true }), + prompt: async () => ({ stopReason: "end_turn" }), + requestCancelActivePrompt: async () => false, + hasActivePrompt: () => false, + setSessionMode: async () => {}, + setSessionConfigOption: async () => {}, + setSessionModel: async (sessionId: string, modelId: string) => { + options.onSetSessionModel?.(sessionId, modelId); + }, + clearEventHandlers: () => {}, + setEventHandlers: () => {}, + }) as unknown as FakeClient; +} + +test("AcpRuntimeManager getStatus surfaces models advertised by the agent", async () => { + const store = new InMemorySessionStore(); + const manager = new AcpRuntimeManager( + createRuntimeOptions({ cwd: "/tmp", sessionStore: store }), + { + clientFactory: createModelsClientFactory({ + models: { + currentModelId: "opus", + availableModels: [ + { modelId: "opus", name: "Opus" }, + { modelId: "sonnet", name: "Sonnet" }, + ], + }, + }) as never, + }, + ); + + const record = await manager.ensureSession({ + sessionKey: "models-key", + agent: "claude", + mode: "persistent", + }); + const handle = createHandle(record.acpxRecordId); + const status = await manager.getStatus(handle); + + assert.deepEqual(status.models, { + currentModelId: "opus", + availableModelIds: ["opus", "sonnet"], + }); +}); + +test("AcpRuntimeManager getStatus omits models when the agent did not advertise any", async () => { + const store = new InMemorySessionStore(); + const manager = new AcpRuntimeManager( + createRuntimeOptions({ cwd: "/tmp", sessionStore: store }), + { + clientFactory: createModelsClientFactory({}) as never, + }, + ); + + const record = await manager.ensureSession({ + sessionKey: "no-models-key", + agent: "claude", + mode: "persistent", + }); + const handle = createHandle(record.acpxRecordId); + const status = await manager.getStatus(handle); + + assert.equal(status.models, undefined); +}); + +test("AcpRuntimeManager getStatus.models survives a save/reload cycle", async () => { + const store = new InMemorySessionStore(); + const factory = createModelsClientFactory({ + models: { + currentModelId: "opus", + availableModels: [ + { modelId: "opus", name: "Opus" }, + { modelId: "sonnet", name: "Sonnet" }, + ], + }, + }) as never; + + const initial = new AcpRuntimeManager( + createRuntimeOptions({ cwd: "/tmp", sessionStore: store }), + { + clientFactory: factory, + }, + ); + const record = await initial.ensureSession({ + sessionKey: "persisted-models-key", + agent: "claude", + mode: "persistent", + }); + const handle = createHandle(record.acpxRecordId); + const beforeStatus = await initial.getStatus(handle); + assert.deepEqual(beforeStatus.models, { + currentModelId: "opus", + availableModelIds: ["opus", "sonnet"], + }); + + // Reload from the same persisted store with a fresh manager — proves the + // models field is read out of the persisted record, not just from + // in-process state. + const reloaded = new AcpRuntimeManager( + createRuntimeOptions({ cwd: "/tmp", sessionStore: store }), + { clientFactory: factory }, + ); + const afterStatus = await reloaded.getStatus(handle); + assert.deepEqual(afterStatus.models, beforeStatus.models); +}); From 9af7b18810f0acfbfd80dde89ba0b6519a19c19e Mon Sep 17 00:00:00 2001 From: DaniAkash Date: Thu, 7 May 2026 11:25:25 +0530 Subject: [PATCH 2/2] docs: trim type comments and add changelog entry --- CHANGELOG.md | 2 ++ src/runtime/engine/manager.ts | 2 -- src/runtime/public/contract.ts | 16 ---------------- 3 files changed, 2 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d779d1..6744323 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ Repo: https://github.com/openclaw/acpx ### Changes +- Runtime/embedding: surface advertised models on `AcpRuntimeStatus.models` (a new optional `{ currentModelId?, availableModelIds }` field) so embedders can build model pickers without reaching into the private session record. Also captures `sessionResult.models` on first `ensureSession`, mirroring the existing reconnect-path behavior. + ### Breaking ### Fixes diff --git a/src/runtime/engine/manager.ts b/src/runtime/engine/manager.ts index 8b65b01..38f4e35 100644 --- a/src/runtime/engine/manager.ts +++ b/src/runtime/engine/manager.ts @@ -251,8 +251,6 @@ function buildModelsField(record: SessionRecord): { models?: AcpRuntimeSessionMo if (currentModelId === undefined) { return {}; } - // Edge case: have a current id but no advertised list. Surface what - // we have so hosts that already know the registry can use it. return { models: { currentModelId, availableModelIds: [] } }; } return { diff --git a/src/runtime/public/contract.ts b/src/runtime/public/contract.ts index 77cbf53..5129c89 100644 --- a/src/runtime/public/contract.ts +++ b/src/runtime/public/contract.ts @@ -62,17 +62,6 @@ export type AcpRuntimeCapabilities = { configOptionKeys?: string[]; }; -/** - * Models advertised by the agent at session-creation time, plus the id - * of the currently selected model. - * - * `currentModelId` will match an entry in `availableModelIds` when the - * agent advertised a current selection. It may be undefined if the agent - * didn't advertise one (some adapters omit it on first connect). - * - * Display names are not currently surfaced — see the runtime's release - * notes for the follow-up that adds them. - */ export type AcpRuntimeSessionModels = { currentModelId?: string; availableModelIds: string[]; @@ -83,11 +72,6 @@ export type AcpRuntimeStatus = { acpxRecordId?: string; backendSessionId?: string; agentSessionId?: string; - /** - * Models advertised by the agent for this session. Undefined when the - * agent has not advertised any (Gemini CLI, custom adapters that don't - * populate `NewSessionResponse.models`). - */ models?: AcpRuntimeSessionModels; details?: Record; };