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.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..38f4e35 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,23 @@ 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 {}; + } + 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 +441,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 +810,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..5129c89 100644 --- a/src/runtime/public/contract.ts +++ b/src/runtime/public/contract.ts @@ -62,11 +62,17 @@ export type AcpRuntimeCapabilities = { configOptionKeys?: string[]; }; +export type AcpRuntimeSessionModels = { + currentModelId?: string; + availableModelIds: string[]; +}; + export type AcpRuntimeStatus = { summary?: string; acpxRecordId?: string; backendSessionId?: string; agentSessionId?: string; + 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); +});