diff --git a/backend/__tests__/service/agent-manage-settings.test.js b/backend/__tests__/service/agent-manage-settings.test.js index 8fd8ae1996..935fd74430 100644 --- a/backend/__tests__/service/agent-manage-settings.test.js +++ b/backend/__tests__/service/agent-manage-settings.test.js @@ -8,6 +8,8 @@ const { AgentInstallation } = require('../../models/AgentRegistry'); const AgentMemory = require('../../models/AgentMemory'); const manageSettings = require('../../routes/registry/manage-settings'); const podAgents = require('../../routes/registry/pod-agents'); +const agentConfig = require('../../routes/registry/agent-config'); +const { projectSeatEnvironments, seatEnvironmentKey } = require('../../services/seatEnvironmentProjection'); const SECRET = 'agent-manage-settings-test'; const token = (user) => jwt.sign({ id: String(user._id) }, SECRET); @@ -23,6 +25,7 @@ describe('per-installation agent settings', () => { app.use(express.json()); app.use('/api/registry', manageSettings); app.use('/api/registry', podAgents); + app.use('/api/registry', agentConfig); owner = await User.create({ username: 'settings-owner', email: 'settings-owner@test.com', password: 'password123' }); other = await User.create({ username: 'settings-other', email: 'settings-other@test.com', password: 'password123' }); admin = await User.create({ username: 'settings-admin', email: 'settings-admin@test.com', password: 'password123', role: 'admin' }); @@ -42,6 +45,10 @@ describe('per-installation agent settings', () => { ]); }); + afterEach(async () => { + await User.deleteMany({ username: 'settings-runtime-bot' }); + }); + it('lists only installations the caller may manage', async () => { const response = await request(app).get('/api/registry/agents/settings-agent/manage-settings?instanceId=same') .set('Authorization', `Bearer ${token(owner)}`); @@ -60,6 +67,70 @@ describe('per-installation agent settings', () => { expect(otherResponse.body.writablePodId).toBe(String(podB._id)); }); + it('reads and writes the same effort declaration the daemon selects', async () => { + await User.create({ + username: 'settings-runtime-bot', email: 'settings-runtime-bot@test.com', password: 'password123', + isBot: true, botMetadata: { agentName: 'settings-agent', instanceId: 'same', machineId: 'machine-a' }, + }); + await AgentInstallation.updateOne({ podId: podA._id, agentName: 'settings-agent' }, { + $set: { config: {} }, + }); + await AgentInstallation.updateOne({ podId: podB._id, agentName: 'settings-agent' }, { + $set: { installedBy: owner._id, 'config.environment': { model: 'opus', effort: 'high' } }, + }); + const url = '/api/registry/agents/settings-agent/manage-settings?instanceId=same'; + const before = await request(app).get(url).set('Authorization', `Bearer ${token(owner)}`); + expect(before.status).toBe(200); + expect(before.body.writablePodId).toBe(String(podA._id)); + expect(before.body.runtimeSourcePodId).toBe(String(podB._id)); + expect(before.body.runtimeEditable).toBe(true); + expect(before.body.runtimeManaged).toBe(true); + expect(before.body.runtimeControls).toEqual(expect.objectContaining({ effort: 'high' })); + + const saved = await request(app) + .patch(`/api/registry/pods/${before.body.runtimeSourcePodId}/agents/settings-agent`) + .set('Authorization', `Bearer ${token(owner)}`) + .send({ instanceId: 'same', config: { environment: { effort: 'xhigh' } } }); + expect(saved.status).toBe(200); + const seat = (await projectSeatEnvironments({ installedBy: owner._id })) + .get(seatEnvironmentKey('settings-agent', 'same')); + expect(seat.environment.effort).toBe('xhigh'); + expect(seat.sourcePodId).toBe(String(podA._id)); + const after = await request(app).get(url).set('Authorization', `Bearer ${token(owner)}`); + expect(after.body.runtimeManaged).toBe(true); + expect(after.body.runtimeControls.runtimeType).toBe('wrapper'); + expect(after.body.runtimeControls.effort).toBe('xhigh'); + }); + + it('does not present a standalone CLI preference as a running effort', async () => { + await AgentInstallation.updateOne({ podId: podA._id, agentName: 'settings-agent' }, { + $set: { 'config.environment': { model: 'opus', effort: 'max' } }, + }); + const response = await request(app) + .get('/api/registry/agents/settings-agent/manage-settings?instanceId=same') + .set('Authorization', `Bearer ${token(owner)}`); + expect(response.status).toBe(200); + expect(response.body.runtimeManaged).toBe(false); + expect(response.body.runtimeEditable).toBe(false); + expect(response.body.runtimeControls.effort).toBe(''); + }); + + it('does not present a hosted runtime preference as its running model or effort', async () => { + await AgentInstallation.updateOne({ podId: podA._id, agentName: 'settings-agent' }, { + $set: { 'config.runtime': { runtimeType: 'internal' }, + 'config.environment': { model: 'opus', effort: 'max' } }, + }); + const response = await request(app) + .get('/api/registry/agents/settings-agent/manage-settings?instanceId=same') + .set('Authorization', `Bearer ${token(owner)}`); + expect(response.status).toBe(200); + expect(response.body.runtimeManaged).toBe(false); + expect(response.body.runtimeEditable).toBe(false); + expect(response.body.runtimeSourcePodId).toBeNull(); + expect(response.body.runtimeControls.model).toBe(''); + expect(response.body.runtimeControls.effort).toBe(''); + }); + it('shows the installation owner’s quota when an admin opens settings', async () => { await User.updateOne({ _id: owner._id }, { $set: { 'entitlements.pro': true } }); const response = await request(app).get('/api/registry/agents/settings-agent/manage-settings?instanceId=same') diff --git a/backend/__tests__/unit/services/seatEnvironmentProjection.test.js b/backend/__tests__/unit/services/seatEnvironmentProjection.test.js index ecb95e29b6..4f505ea124 100644 --- a/backend/__tests__/unit/services/seatEnvironmentProjection.test.js +++ b/backend/__tests__/unit/services/seatEnvironmentProjection.test.js @@ -81,6 +81,7 @@ describe('projectSeatEnvironments', () => { // The pair comes from the OLDEST active declaration (`_id` ascending), // which for a duplicated identity is a rule rather than the stored order. expect(entry.runtime).toEqual({ adapter: 'claude' }); + expect(entry.sourcePodId).toBe(String(podOne)); const theirs = await projectSeatEnvironments({ installedBy: b._id }); expect(theirs.get(seatEnvironmentKey('proj-seat', 'default')).runtime).toEqual({ adapter: 'pi' }); diff --git a/backend/routes/registry/manage-settings.ts b/backend/routes/registry/manage-settings.ts index c8bfad673b..27198039c7 100644 --- a/backend/routes/registry/manage-settings.ts +++ b/backend/routes/registry/manage-settings.ts @@ -2,6 +2,7 @@ import rateLimit from 'express-rate-limit'; import { cloudflareIpRateLimitKeyGenerator } from '../../middleware/ipRateLimit'; import { hostedCapsForOwner, userRunCeilingForOwner } from '../../services/hostedRuntimeService'; +import { projectSeatEnvironments, seatEnvironmentKey } from '../../services/seatEnvironmentProjection'; const express = require('express'); const auth = require('../../middleware/auth'); const { AgentInstallation } = require('../../models/AgentRegistry'); @@ -57,14 +58,55 @@ router.get('/agents/:name/manage-settings', settingsRateLimit, auth, async (req: .select('name').lean(); const names = new Map(pods.map((pod: any) => [String(pod._id), String(pod.name)])); const visible = manageable.filter((entry: any) => names.has(String(entry.podId))); - const writable = visible.find((entry: any) => entry.status === 'active') || visible[0]; - const quotaOwner = writable?.installedBy || userId; + const fallback = visible.find((entry: any) => entry.status === 'active') || visible[0]; + const quotaOwner = fallback?.installedBy || userId; + // The daemon runs the oldest active declaration, not whichever pod this + // page happened to load first. Use its projection for both the displayed + // controls and the installation selected for edits. + const seat = (await projectSeatEnvironments({ installedBy: quotaOwner, + agentName: req.params.name })) + .get(seatEnvironmentKey(req.params.name, req.query.instanceId)); + const source = visible.find((entry: any) => String(entry.podId) === seat?.sourcePodId); + const writable = fallback; + const runtime = seat?.runtime && typeof seat.runtime === 'object' ? seat.runtime as Record : {}; + const environment = seat?.environment || {}; + const runtimeControls = Object.fromEntries(['model', 'effort'] + .map((key) => [key, environment[key] ?? runtime[key]]) + .filter(([, value]) => typeof value === 'string')); + for (const key of ['runtimeType', 'adapter']) { + if (typeof runtime[key] === 'string') runtimeControls[key] = runtime[key]; + } + if (runtime.host === 'cloud' || runtime.host === 'byo') runtimeControls.host = runtime.host; + // The identity binding, rather than this pod's runtime declaration, tells + // us whether the daemon owns the next spawn. A Settings write can make an + // older pod the projection source even when it has no runtimeType field. + const binding = await User.findOne({ + isBot: true, + 'botMetadata.agentName': String(req.params.name).toLowerCase(), + 'botMetadata.instanceId': normalizeInstanceId(req.query.instanceId), + }).select('botMetadata.machineId').lean(); + const runtimeManaged = Boolean(binding?.botMetadata?.machineId); + if (runtimeManaged && !runtimeControls.runtimeType) runtimeControls.runtimeType = 'wrapper'; + // A standalone `agent run` reads its local token record. Hosted/native + // turns read their own run config (installation.config.model or the service + // default), not the daemon projection. Neither path can be attested by an + // installation runtime preference, so display only daemon-managed values. + if (!runtimeManaged) { + runtimeControls.model = ''; + runtimeControls.effort = ''; + } const [{ turnsPerDay }, userRunCeiling] = await Promise.all([ hostedCapsForOwner(quotaOwner), userRunCeilingForOwner(quotaOwner), ]); return res.json({ turnCap: turnsPerDay, userRunCeiling, podCount: installations.length, activePodCount: installations.filter((entry: any) => entry.status === 'active').length, writablePodId: writable ? String(writable.podId) : null, + runtimeSourcePodId: !runtimeManaged ? null + : source ? String(source.podId) + : (!seat?.sourcePodId && writable ? String(writable.podId) : null), + runtimeControls, + runtimeManaged, + runtimeEditable: runtimeManaged && (!seat?.sourcePodId || Boolean(source)), installations: visible .map((entry: any) => settings(entry, names.get(String(entry.podId)) || 'Pod')) }); } catch (error) { diff --git a/backend/services/seatEnvironmentProjection.ts b/backend/services/seatEnvironmentProjection.ts index 6927b1c923..049d64016e 100644 --- a/backend/services/seatEnvironmentProjection.ts +++ b/backend/services/seatEnvironmentProjection.ts @@ -122,6 +122,7 @@ export type SeatEnvironmentEntry = { agentName: string; instanceId: string; podIds: string[]; + sourcePodId: string | null; runtime: unknown; environment: Record | null; }; @@ -135,6 +136,7 @@ export type SeatEnvironmentQuery = { * Mongo happened to order first, which is a guess dressed as a verdict. */ installedBy?: unknown; + agentName?: string; }; /** @@ -164,6 +166,7 @@ export const projectSeatEnvironments = async ( ): Promise> => { const filter: Record = { status: 'active' }; if (query.installedBy) filter.installedBy = query.installedBy; + if (query.agentName) filter.agentName = normalizeIdentityPart(query.agentName); const installs = await AgentInstallation.find(filter) .select('agentName instanceId podId config') .sort({ _id: 1 }) @@ -175,7 +178,7 @@ export const projectSeatEnvironments = async ( const instanceId = normalizeIdentityPart(install.instanceId) || 'default'; const key = seatEnvironmentKey(agentName, instanceId); const entry = byIdentity.get(key) || { - agentName, instanceId, podIds: [], runtime: null, environment: null, + agentName, instanceId, podIds: [], sourcePodId: null, runtime: null, environment: null, }; if (install.podId) entry.podIds.push(String(install.podId)); // AgentInstallation.config is a Mongoose Map; lean() yields a plain @@ -189,6 +192,7 @@ export const projectSeatEnvironments = async ( // empty row shadow a newer row's real pair (Vera, on 268370c1). const environment = config.environment ? projectEnvironment(config.environment) : null; if (!sourced.has(key) && (runtime || environment)) { + entry.sourcePodId = install.podId ? String(install.podId) : null; entry.runtime = runtime; entry.environment = environment; sourced.add(key); diff --git a/cli/__tests__/daemon-supervisor.test.mjs b/cli/__tests__/daemon-supervisor.test.mjs index 18f97106c5..5e88bc59eb 100644 --- a/cli/__tests__/daemon-supervisor.test.mjs +++ b/cli/__tests__/daemon-supervisor.test.mjs @@ -2,6 +2,9 @@ // effect injected — no processes, no network, no real timers. import { jest } from '@jest/globals'; import { EventEmitter } from 'events'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; import { backoffMs, @@ -10,6 +13,7 @@ import { createDaemonSupervisor, } from '../src/lib/daemon-supervisor.js'; import { assertNoSandboxDeclared } from '../src/lib/adapters/pi.js'; +import claude from '../src/lib/adapters/claude.js'; const record = { machineDbId: '507f1f77bcf86cd799439011', @@ -450,6 +454,32 @@ describe('tick', () => { expect(supervisor.agentStates()).toEqual([ expect.objectContaining({ adapter: 'claude', model: 'sonnet', effort: 'high' }), ]); + + // The environment value selected from the Settings declaration must be + // the value the actual adapter places in argv, not the runtime overlay. + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'runtime-effort-argv-')); + const calls = []; + try { + await claude.spawn('check', { + cwd, sessionId: null, + environment: { + model: tokens['wren-test'].environment.model, + effort: tokens['wren-test'].environment.effort, + }, + _spawnImpl: (command, args) => { + calls.push(args); + const child = new EventEmitter(); + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + setImmediate(() => child.emit('close', 0)); + return child; + }, + }); + expect(calls[0][calls[0].indexOf('--effort') + 1]).toBe('high'); + expect(calls[0][calls[0].indexOf('--model') + 1]).toBe('sonnet'); + } finally { + fs.rmSync(cwd, { recursive: true, force: true }); + } }); test('a refused adapter change is reported as the adapter that kept running (TASK-065)', async () => { diff --git a/cli/package-lock.json b/cli/package-lock.json index df4dd4df33..3a3444d4be 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "@commonlyai/cli", - "version": "0.1.139", + "version": "0.1.142", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@commonlyai/cli", - "version": "0.1.139", + "version": "0.1.142", "license": "Apache-2.0", "dependencies": { "commander": "^12.0.0" diff --git a/cli/package.json b/cli/package.json index a7d7f5bedc..66b0a46cf5 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@commonlyai/cli", - "version": "0.1.139", + "version": "0.1.142", "license": "Apache-2.0", "description": "The Commonly CLI — connect agents, manage pods, iterate fast", "type": "module", diff --git a/docs/development/agent-experience-audit.md b/docs/development/agent-experience-audit.md index 336a706b4b..8096552c87 100644 --- a/docs/development/agent-experience-audit.md +++ b/docs/development/agent-experience-audit.md @@ -4200,3 +4200,13 @@ Gates that never appear in metadata decided several more, including the client a **Repair:** after a post, resolve known mentioned handles against pod membership and return a sender-only notice for each known non-member. V2 shows the sentence below the sent message; the MCP post result carries the same sentence and a structured `MENTION_NOT_IN_POD` field. Unknown handles stay silent. The notice names only the handle the sender typed and says nothing about other pods. **Lesson:** accepting a message is not proof its address resolved. A tool that lets an agent address someone must report a known delivery failure in the same turn. + +## 84. An installation preference appeared as the running effort (2026-10-23, kite) + +*Origin: TASK-434. Settings showed Effort `max` while a verified CLI spawn used `high`.* + +Settings read one writable pod's `config.environment`, while the daemon selected the oldest active runtime declaration across the owner's installations. A standalone CLI seat read its local token record instead of either server row. The same label therefore presented different sources as if each were the running value. + +**Repair:** Settings now uses the daemon's environment projection for model and effort, saves through that projection's source pod, and shows standalone CLI controls as locally set and read-only. Focused route, UI and adapter tests connect the saved effort to the spawn argv. + +**Lesson:** a displayed runtime setting must identify the source the runner reads. A server preference cannot attest a local process that does not consume it. diff --git a/frontend/src/v2/__tests__/V2AgentRuntimeGrants.test.tsx b/frontend/src/v2/__tests__/V2AgentRuntimeGrants.test.tsx index b6e44cf7a9..42650af4d4 100644 --- a/frontend/src/v2/__tests__/V2AgentRuntimeGrants.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentRuntimeGrants.test.tsx @@ -42,6 +42,8 @@ beforeEach(() => { test('runtime labels only recognize walked adapters and distinguish daemon from a single CLI', () => { expect(describeRuntime({ runtimeType: 'wrapper', adapter: 'claude' })).toEqual({ mode: 'daemon', adapter: 'Claude Code' }); + expect(describeRuntime({ runtimeType: 'wrapper', adapter: 'claude', managed: false })) + .toEqual({ mode: 'cli', adapter: 'Claude Code' }); expect(describeRuntime({ runtimeType: 'webhook', host: 'byo', adapter: 'dsh' })).toEqual({ mode: 'cli', adapter: 'DeepSeek Harness' }); expect(describeRuntime({ runtimeType: 'wrapper', adapter: 'unwalked' })).toEqual({ mode: 'daemon', adapter: null }); expect(describeRuntime({ runtimeType: 'hosted' })).toEqual({ mode: 'hosted', adapter: null }); diff --git a/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx b/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx index 8a60e13dd2..7ef96b0156 100644 --- a/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx @@ -403,3 +403,52 @@ test('runtime editor keeps a wrapper model when no environment override exists', expect(await screen.findByLabelText('Model')).toHaveValue('selected-model'); expect(screen.getByLabelText('Effort')).toHaveValue('medium'); }); + +test('runtime editor shows the daemon-selected effort instead of a different pod config', async () => { + axios.patch.mockResolvedValue({ data: { updated: true } }); + axios.get.mockImplementation((url) => { + if (url.includes('/manage-settings')) return Promise.resolve({ data: { + writablePodId: 'skills-pod', runtimeSourcePodId: 'source-pod', + runtimeControls: { runtimeType: 'wrapper', adapter: 'claude', model: 'opus', effort: 'high' }, + runtimeEditable: true, installations: [ + { podId: 'skills-pod', podName: 'Work', status: 'active' }, + { podId: 'source-pod', podName: 'Source', status: 'active' }, + ], + } }); + if (url.startsWith('/api/registry/pods/')) return Promise.resolve({ data: { agent: { + name: 'maya-claude', instanceId: 'default', displayName: 'Maya', + runtime: { runtimeType: 'wrapper', adapter: 'claude' }, + config: { environment: { model: 'opus', effort: 'max' } }, + } } }); + return Promise.resolve({ data: [] }); + }); + renderPage(); + const effort = await screen.findByLabelText('Effort'); + expect(effort).toHaveValue('high'); + fireEvent.change(effort, { target: { value: 'xhigh' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save runtime' })); + await waitFor(() => expect(axios.patch).toHaveBeenCalledWith( + '/api/registry/pods/source-pod/agents/maya-claude', + { config: { environment: { effort: 'xhigh' } }, instanceId: 'default' }, + )); +}); + +test('standalone CLI settings do not claim a local effort or offer a remote save', async () => { + axios.get.mockImplementation((url) => { + if (url.includes('/manage-settings')) return Promise.resolve({ data: { + writablePodId: 'pod-1', runtimeSourcePodId: null, runtimeManaged: false, + runtimeEditable: false, runtimeControls: { runtimeType: 'wrapper', adapter: 'claude', model: '', effort: '' }, + installations: [{ podId: 'pod-1', podName: 'Work', status: 'active' }], + } }); + if (url.startsWith('/api/registry/pods/')) return Promise.resolve({ data: { agent: { + name: 'maya-claude', instanceId: 'default', displayName: 'Maya', + runtime: { runtimeType: 'wrapper', adapter: 'claude' }, + config: { environment: { model: 'opus', effort: 'max' } }, + } } }); + return Promise.resolve({ data: [] }); + }); + renderPage(); + expect(await screen.findByText('Single CLI')).toBeInTheDocument(); + expect(screen.queryByText('max')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Save runtime' })).not.toBeInTheDocument(); +}); diff --git a/frontend/src/v2/agents/V2AgentSettingsPage.tsx b/frontend/src/v2/agents/V2AgentSettingsPage.tsx index 6d801a7619..cd5e162549 100644 --- a/frontend/src/v2/agents/V2AgentSettingsPage.tsx +++ b/frontend/src/v2/agents/V2AgentSettingsPage.tsx @@ -37,7 +37,8 @@ const errorLabel = (error: unknown, fallback: string): string => ( const V2AgentSettingsPage: React.FC = () => { const { t } = useTranslation(); const { agentName = '', instanceId = '' } = useParams<{ agentName: string; instanceId: string }>(); - const { access, installation, podId, loading, error: loadError, refresh, update, updatePod } = useAgentSettings(agentName, instanceId); + const { access, installation, podId, runtimeControls, runtimeEditable, runtimeManaged, loading, + error: loadError, refresh, update, updatePod, updateRuntime } = useAgentSettings(agentName, instanceId); const [removedLast, setRemovedLast] = useState(false); const [keys, setKeys] = useState([]); const [keysReadyFor, setKeysReadyFor] = useState(null); @@ -173,6 +174,8 @@ const V2AgentSettingsPage: React.FC = () => { ...(typeof controls.adapter === 'string' ? { adapter: controls.adapter } : {}), ...(typeof controls.model === 'string' ? { model: controls.model } : {}), ...(typeof controls.effort === 'string' ? { effort: controls.effort } : {}), + ...runtimeControls, + ...(runtimeManaged !== undefined ? { managed: runtimeManaged } : {}), }; return ( @@ -252,9 +255,9 @@ const V2AgentSettingsPage: React.FC = () => { name={agentName} instanceId={instanceId} runtime={runtime} - onSaveRuntime={async (changes) => { - await update({ config: { environment: changes } }); - }} + onSaveRuntime={runtimeEditable ? async (changes) => { + await updateRuntime(changes); + } : undefined} /> diff --git a/frontend/src/v2/agents/useAgentSettings.ts b/frontend/src/v2/agents/useAgentSettings.ts index 27a621345a..6939c5f0b1 100644 --- a/frontend/src/v2/agents/useAgentSettings.ts +++ b/frontend/src/v2/agents/useAgentSettings.ts @@ -46,6 +46,10 @@ interface AgentAccess { interface ManageSettingsResponse { writablePodId: string | null; + runtimeSourcePodId?: string | null; + runtimeControls?: { runtimeType?: string; host?: 'cloud' | 'byo'; adapter?: string; model?: string; effort?: string }; + runtimeEditable?: boolean; + runtimeManaged?: boolean; installations: Array<{ podId: string; podName: string; status: string }>; } @@ -54,6 +58,10 @@ export const useAgentSettings = (agentName: string, instanceId: string) => { const [access, setAccess] = useState(null); const [installation, setInstallation] = useState(null); const [podId, setPodId] = useState(null); + const [runtimeSourcePodId, setRuntimeSourcePodId] = useState(null); + const [runtimeControls, setRuntimeControls] = useState({}); + const [runtimeEditable, setRuntimeEditable] = useState(true); + const [runtimeManaged, setRuntimeManaged] = useState(undefined); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const requestId = useRef(0); @@ -85,6 +93,11 @@ export const useAgentSettings = (agentName: string, instanceId: string) => { pods: rows.map((row) => ({ id: row.podId, name: row.podName })) }); setInstallation(response.data.agent); setPodId(pod.podId); + setRuntimeSourcePodId(settingsResponse.data.runtimeSourcePodId === undefined + ? settingsResponse.data.writablePodId : settingsResponse.data.runtimeSourcePodId); + setRuntimeControls(settingsResponse.data.runtimeControls || {}); + setRuntimeEditable(settingsResponse.data.runtimeEditable !== false); + setRuntimeManaged(settingsResponse.data.runtimeManaged); setError(null); } return response.data.agent; @@ -103,6 +116,10 @@ export const useAgentSettings = (agentName: string, instanceId: string) => { setAccess(null); setInstallation(null); setPodId(null); + setRuntimeSourcePodId(null); + setRuntimeControls({}); + setRuntimeEditable(true); + setRuntimeManaged(undefined); void refresh().catch((requestError) => { if (active) setError(requestError); }).finally(() => { @@ -127,5 +144,14 @@ export const useAgentSettings = (agentName: string, instanceId: string) => { return response.data; }, [agentName, instanceId]); - return { access, installation, podId, loading, error, refresh, update, updatePod }; + const updateRuntime = useCallback(async (changes: { model?: string; effort?: string }) => { + if (!runtimeSourcePodId) throw new Error('No writable runtime source'); + await axios.patch(installationUrl(runtimeSourcePodId), { + config: { environment: changes }, instanceId, + }); + await refresh(); + }, [instanceId, installationUrl, refresh, runtimeSourcePodId]); + + return { access, installation, podId, runtimeControls, runtimeEditable, runtimeManaged, loading, + error, refresh, update, updatePod, updateRuntime }; }; diff --git a/frontend/src/v2/components/V2AgentRuntimeGrants.tsx b/frontend/src/v2/components/V2AgentRuntimeGrants.tsx index 4bf289789c..413f0b7bc7 100644 --- a/frontend/src/v2/components/V2AgentRuntimeGrants.tsx +++ b/frontend/src/v2/components/V2AgentRuntimeGrants.tsx @@ -9,6 +9,7 @@ export interface Runtime { adapter?: string; model?: string; effort?: string; + managed?: boolean; } interface AgentGrant { @@ -47,7 +48,7 @@ const WALKED_ADAPTERS: Record = { export const describeRuntime = (runtime: Runtime): { mode: 'hosted' | 'daemon' | 'cli' | 'unknown'; adapter: string | null } => { const type = String(runtime.runtimeType || '').toLowerCase(); const adapterKey = String(runtime.adapter || type).toLowerCase(); - const mode = type === 'wrapper' ? 'daemon' + const mode = type === 'wrapper' ? (runtime.managed === false ? 'cli' : 'daemon') : type === 'hosted' || type === 'native' || type === 'internal' || runtime.host === 'cloud' ? 'hosted' : runtime.host === 'byo' && WALKED_ADAPTERS[adapterKey] ? 'cli' : 'unknown'; return { mode, adapter: WALKED_ADAPTERS[adapterKey] || null };