diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ed2b96c..dd6bd035 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ Repo: https://github.com/openclaw/acpx ### Changes - Runtime/embedding: `AcpRuntime.ensureSession` now accepts `sessionOptions` (`systemPrompt`, `model`, `allowedTools`, `maxTurns`) for fresh sessions, threading the values into `_meta.systemPrompt` (and `_meta.claudeCode.options.*`) on the underlying `session/new` request and persisting them onto the new record. Reusing an existing persistent record continues to ignore `sessionOptions` since system prompts are fixed at `newSession` time. `SessionAgentOptions` and `SystemPromptOption` are now re-exported from `acpx/runtime`. Thanks @DaniAkash. +- Runtime/embedding: surface advertised models on `AcpRuntimeStatus.models` so embedders can build model pickers without reaching into private session records. Thanks @DaniAkash. ### Breaking diff --git a/src/runtime.ts b/src/runtime.ts index 2eab8b9c..e5bc6553 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 b277e105..5a3f191c 100644 --- a/src/runtime/engine/manager.ts +++ b/src/runtime/engine/manager.ts @@ -30,6 +30,7 @@ import type { AcpRuntimeHandle, AcpRuntimeOptions, AcpRuntimePromptMode, + AcpRuntimeSessionModels, AcpRuntimeStatus, AcpRuntimeTurnAttachment, AcpRuntimeTurn, @@ -247,6 +248,22 @@ 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) { + return currentModelId === undefined + ? {} + : { models: { currentModelId, availableModelIds: [] } }; + } + return { + models: { + ...(currentModelId !== undefined ? { currentModelId } : {}), + availableModelIds: [...available], + }, + }; +} + export class AcpRuntimeManager { private readonly activeControllers = new Map(); private readonly pendingPersistentClients = new Map(); @@ -826,6 +843,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 38c2efe1..e43ce890 100644 --- a/src/runtime/public/contract.ts +++ b/src/runtime/public/contract.ts @@ -75,11 +75,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 8bb99891..6e1e987b 100644 --- a/test/runtime-manager.test.ts +++ b/test/runtime-manager.test.ts @@ -2089,6 +2089,125 @@ 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"], + }); + + const reloaded = new AcpRuntimeManager( + createRuntimeOptions({ cwd: "/tmp", sessionStore: store }), + { clientFactory: factory }, + ); + const afterStatus = await reloaded.getStatus(handle); + assert.deepEqual(afterStatus.models, beforeStatus.models); +}); + test("AcpRuntimeManager forwards sessionOptions to createClient on fresh session", async () => { const store = new InMemorySessionStore(); const factoryCalls: Array> = [];