diff --git a/backend/__tests__/unit/models/Session.contextSettings.test.js b/backend/__tests__/unit/models/Session.contextSettings.test.js new file mode 100644 index 0000000000..e1924847b8 --- /dev/null +++ b/backend/__tests__/unit/models/Session.contextSettings.test.js @@ -0,0 +1,11 @@ +const Session = require('../../../models/Session'); + +test('session schema has no unused context settings or compaction predicate', () => { + expect(Session.schema.path('contextSettings')).toBeUndefined(); + expect(Session.schema.methods.shouldCompact).toBeUndefined(); + const session = new Session({ + sessionKey: 'agent:pod:session', + podId: '111111111111111111111111', + }); + expect(session.toObject()).not.toHaveProperty('contextSettings'); +}); diff --git a/backend/__tests__/unit/routes/registry.agent-config-installer-gate.test.js b/backend/__tests__/unit/routes/registry.agent-config-installer-gate.test.js index adfb1395bf..1fcc01fbc4 100644 --- a/backend/__tests__/unit/routes/registry.agent-config-installer-gate.test.js +++ b/backend/__tests__/unit/routes/registry.agent-config-installer-gate.test.js @@ -42,6 +42,11 @@ jest.mock('../../../services/runtimeIdentityOwnership', () => ({ ...jest.requireActual('../../../services/runtimeIdentityOwnership'), ownsRuntimeIdentity: jest.fn().mockResolvedValue({ owned: true }), })); +jest.mock('../../../services/agentProvisionerService', () => ({ + ...jest.requireActual('../../../services/agentProvisionerService'), + readOpenClawIdentityFile: jest.fn(), + writeWorkspaceIdentityFile: jest.fn().mockResolvedValue(undefined), +})); const Pod = require('../../../models/Pod'); const { AgentInstallation } = require('../../../models/AgentRegistry'); @@ -50,6 +55,7 @@ const User = require('../../../models/User'); const AgentIdentityService = require('../../../services/agentIdentityService'); const { ownsRuntimeIdentity } = require('../../../services/runtimeIdentityOwnership'); const { podHasOutsiderWritableGrant } = require('../../../services/nativeOutsiderGrantPolicy'); +const { readOpenClawIdentityFile, writeWorkspaceIdentityFile } = require('../../../services/agentProvisionerService'); const registryRoutes = require('../../../routes/registry'); const app = express(); @@ -98,6 +104,8 @@ describe('agent config PATCH — installer gate', () => { jest.spyOn(AgentIdentityService, 'renameAgentDisplayName').mockImplementation(async (_name, _instanceId, label) => ({ displayName: label, mirrorSynced: true })); ownsRuntimeIdentity.mockResolvedValue({ owned: true }); podHasOutsiderWritableGrant.mockResolvedValue(false); + readOpenClawIdentityFile.mockResolvedValue(''); + writeWorkspaceIdentityFile.mockResolvedValue(undefined); setCallerRole('user'); }); afterEach(() => jest.restoreAllMocks()); @@ -233,6 +241,198 @@ describe('agent config PATCH — installer gate', () => { ); }); + it('keeps the existing OpenClaw persona in IDENTITY.md on a name-only edit', async () => { + const primary = installation({ installedBy: MEMBER, displayName: 'Old name' }); + setPod(); + AgentInstallation.findOne.mockResolvedValue(primary); + AgentInstallation.find.mockResolvedValue([primary]); + readOpenClawIdentityFile.mockResolvedValue('# IDENTITY.md\n\n- **Name:** Old name\n- **Vibe:** Precise\n- **Domain:** Research\n\n## Notes\n\nKeep detail\n'); + + const res = await request(app) + .patch('/api/registry/pods/pod-1/agents/openclaw') + .send({ instanceId: 'curator', displayName: 'New name' }); + + expect(res.status).toBe(200); + await new Promise((resolve) => setImmediate(resolve)); + expect(readOpenClawIdentityFile).toHaveBeenCalledWith('curator'); + expect(writeWorkspaceIdentityFile).toHaveBeenCalledWith('curator', '# IDENTITY.md\n\n- **Name:** New name\n- **Vibe:** Precise\n- **Domain:** Research\n\n## Notes\n\nKeep detail\n'); + expect(AgentProfile.updateMany).toHaveBeenCalledWith( + expect.any(Object), + { $set: { name: 'New name' } }, + { runValidators: true }, + ); + }); + + it('keeps the runtime Name on an OpenClaw Persona-only edit', async () => { + const primary = installation({ installedBy: MEMBER, displayName: 'Scout the researcher' }); + setPod(); + AgentInstallation.findOne.mockResolvedValue(primary); + AgentInstallation.find.mockResolvedValue([primary]); + readOpenClawIdentityFile.mockResolvedValue('# IDENTITY.md\n\n- **Name:** Scout the researcher\n- **Vibe:** Warm\n'); + + const persona = { tone: 'professional', specialties: ['research'], boundaries: [], customInstructions: 'Be precise' }; + const res = await request(app) + .patch('/api/registry/pods/pod-1/agents/openclaw') + .send({ instanceId: 'curator', persona }); + + expect(res.status).toBe(200); + await new Promise((resolve) => setImmediate(resolve)); + expect(AgentIdentityService.renameAgentDisplayName).not.toHaveBeenCalled(); + expect(primary.displayName).toBe('Scout the researcher'); + expect(writeWorkspaceIdentityFile).toHaveBeenCalledWith( + 'curator', + expect.stringContaining('- **Name:** Scout the researcher\n'), + ); + expect(writeWorkspaceIdentityFile).toHaveBeenCalledWith( + 'curator', + expect.stringContaining('- **Domain:** research\n'), + ); + }); + + it('awaits a name write and serializes an overlapping persona edit', async () => { + const primary = installation({ installedBy: MEMBER, displayName: 'Old name' }); + setPod(); + AgentInstallation.findOne.mockResolvedValue(primary); + AgentInstallation.find.mockResolvedValue([primary]); + let content = '# IDENTITY.md\n\n- **Name:** Old name\n- **Vibe:** Warm\n'; + readOpenClawIdentityFile.mockImplementation(async () => content); + let releaseRename; + let markRenameStarted; + const renameStarted = new Promise((resolve) => { markRenameStarted = resolve; }); + const renameGate = new Promise((resolve) => { releaseRename = resolve; }); + writeWorkspaceIdentityFile.mockImplementationOnce(async (_id, next) => { + markRenameStarted(); + await renameGate; + content = next; + }).mockImplementation(async (_id, next) => { content = next; }); + let renameFinished = false; + const rename = request(app).patch('/api/registry/pods/pod-1/agents/openclaw') + .send({ instanceId: 'curator', displayName: 'New name' }) + .then((res) => { renameFinished = true; return res; }); + await renameStarted; + const persona = { tone: 'professional', specialties: ['research'], boundaries: [], customInstructions: 'Be precise' }; + let markPersonaSaved; + const personaSaved = new Promise((resolve) => { markPersonaSaved = resolve; }); + AgentProfile.updateMany.mockImplementationOnce(async () => { markPersonaSaved(); }); + const edit = request(app).patch('/api/registry/pods/pod-1/agents/openclaw') + .send({ instanceId: 'curator', persona }).then((res) => res); + await personaSaved; + await new Promise((resolve) => setImmediate(resolve)); + expect(renameFinished).toBe(false); + expect(readOpenClawIdentityFile).toHaveBeenCalledTimes(1); + releaseRename(); + expect((await rename).status).toBe(200); + expect((await edit).status).toBe(200); + expect(content).toContain('- **Name:** New name\n'); + expect(content).toContain('- **Domain:** research\n'); + expect(AgentProfile.updateMany).toHaveBeenLastCalledWith( + { agentId: 'openclaw:curator', podId: { $in: ['pod-1'] } }, + { $set: { persona } }, + { runValidators: true }, + ); + }); + + it('reports a failed identity write and permits a subsequent retry', async () => { + const primary = installation({ installedBy: MEMBER, displayName: 'Old name' }); + setPod(); + AgentInstallation.findOne.mockResolvedValue(primary); + AgentInstallation.find.mockResolvedValue([primary]); + readOpenClawIdentityFile.mockResolvedValue('- **Name:** Old name\n'); + writeWorkspaceIdentityFile.mockRejectedValueOnce(new Error('write unavailable')); + const save = () => request(app).patch('/api/registry/pods/pod-1/agents/openclaw') + .send({ instanceId: 'curator', displayName: 'New name' }); + const failed = await save(); + expect(failed.status).toBe(503); + expect(failed.body.code).toBe('identity_projection_retryable'); + expect((await save()).status).toBe(200); + expect(writeWorkspaceIdentityFile).toHaveBeenCalledTimes(2); + }); + + it('propagates a real identity reader permission failure into a retryable rename response', async () => { + const fs = require('fs'); + const actualReader = jest.requireActual('../../../services/agentProvisionerService').readOpenClawIdentityFile; + const previousMode = process.env.AGENT_PROVISIONER_K8S; + process.env.AGENT_PROVISIONER_K8S = '0'; + const primary = installation({ installedBy: MEMBER, displayName: 'Old name' }); + setPod(); + AgentInstallation.findOne.mockResolvedValue(primary); + AgentInstallation.find.mockResolvedValue([primary]); + const originalRead = fs.readFileSync; + const originalExists = fs.existsSync; + const existsSpy = jest.spyOn(fs, 'existsSync').mockImplementation((file) => ( + String(file).endsWith('/curator/IDENTITY.md') || originalExists(file) + )); + let reachedReader = false; + const readSpy = jest.spyOn(fs, 'readFileSync').mockImplementation((file, ...args) => { + if (String(file).endsWith('/curator/IDENTITY.md')) { + reachedReader = true; + throw Object.assign(new Error('Permission denied'), { code: 'EACCES' }); + } + return originalRead(file, ...args); + }); + readOpenClawIdentityFile.mockImplementation(actualReader); + try { + const res = await request(app).patch('/api/registry/pods/pod-1/agents/openclaw') + .send({ instanceId: 'curator', displayName: 'New name' }); + expect(reachedReader).toBe(true); + expect(res.status).toBe(503); + expect(res.body.code).toBe('identity_projection_retryable'); + expect(writeWorkspaceIdentityFile).not.toHaveBeenCalled(); + } finally { + readSpy.mockRestore(); + existsSpy.mockRestore(); + if (previousMode === undefined) delete process.env.AGENT_PROVISIONER_K8S; + else process.env.AGENT_PROVISIONER_K8S = previousMode; + } + }); + + it.each(['OPENCLAW_WORKSPACE_ROOT', 'CLAWDBOT_WORKSPACE_DIR'])( + 'uses the real %s identity file for name and persona edits', async (rootKey) => { + const fs = require('fs'); + const os = require('os'); + const path = require('path'); + const actual = jest.requireActual('../../../services/agentProvisionerService'); + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'identity-projection-')); + const envKeys = ['AGENT_PROVISIONER_K8S', 'OPENCLAW_WORKSPACE_ROOT', 'CLAWDBOT_WORKSPACE_DIR', 'OPENCLAW_WORKSPACE_PATH']; + const previous = Object.fromEntries(envKeys.map((key) => [key, process.env[key]])); + envKeys.forEach((key) => { delete process.env[key]; }); + process.env.AGENT_PROVISIONER_K8S = '0'; + process.env[rootKey] = root; + const primary = installation({ installedBy: MEMBER, displayName: 'Database label' }); + setPod(); + AgentInstallation.findOne.mockResolvedValue(primary); + AgentInstallation.find.mockResolvedValue([primary]); + readOpenClawIdentityFile.mockImplementation(actual.readOpenClawIdentityFile); + writeWorkspaceIdentityFile.mockImplementation(actual.writeWorkspaceIdentityFile); + const identityPath = path.join(root, 'curator', 'IDENTITY.md'); + const original = '# IDENTITY.md\n\n- **Name:** Runtime name\n- **Vibe:** Warm\n- **Domain:** Research\n\n## Notes\nKeep detail\n'; + try { + await actual.writeWorkspaceIdentityFile('curator', original); + const renamed = await request(app).patch('/api/registry/pods/pod-1/agents/openclaw') + .send({ instanceId: 'curator', displayName: 'New name' }); + expect(renamed.status).toBe(200); + expect(fs.readFileSync(identityPath, 'utf8')).toBe(original.replace('Runtime name', 'New name')); + + // Distinct runtime and database labels prove Name came from the file. + await actual.writeWorkspaceIdentityFile('curator', original); + primary.displayName = 'Database label'; + const persona = { tone: 'professional', specialties: ['research'], boundaries: [], customInstructions: 'Be precise' }; + const edited = await request(app).patch('/api/registry/pods/pod-1/agents/openclaw') + .send({ instanceId: 'curator', persona }); + expect(edited.status).toBe(200); + const saved = fs.readFileSync(identityPath, 'utf8'); + expect(saved).toContain('- **Name:** Runtime name\n'); + expect(saved).toContain('- **Domain:** research\n'); + } finally { + envKeys.forEach((key) => { + if (previous[key] === undefined) delete process.env[key]; + else process.env[key] = previous[key]; + }); + fs.rmSync(root, { recursive: true, force: true }); + } + }, + ); + it('refuses an identity rename by an installer who shares that identity', async () => { const primary = installation({ installedBy: MEMBER }); const peer = installation({ podId: 'pod-2', installedBy: 'other-installer' }); diff --git a/backend/__tests__/unit/services/agentProvisionerServiceK8s.test.js b/backend/__tests__/unit/services/agentProvisionerServiceK8s.test.js index 270c3ced40..6e9fcd2e67 100644 --- a/backend/__tests__/unit/services/agentProvisionerServiceK8s.test.js +++ b/backend/__tests__/unit/services/agentProvisionerServiceK8s.test.js @@ -34,8 +34,10 @@ const mock = { // these, so this is the only place a test can assert on the delivered file // rather than on the source that composes it. execCommands: [], + execArgv: [], // What the next exec writes to its stdout sink; empty = no output. execStdout: '', + execStatus: { status: 'Success' }, }; class KubeConfig { @@ -71,13 +73,14 @@ const mock = { statusCallback, ) { mock.execCommands.push(Array.isArray(_command) ? _command.join(' ') : String(_command)); + mock.execArgv.push(_command); if (mock.execStdout && _stdout && typeof _stdout.write === 'function') { _stdout.write(mock.execStdout); if (typeof statusCallback === 'function') setImmediate(() => statusCallback({ status: 'Success' })); return Promise.resolve(); } if (typeof statusCallback === 'function') { - statusCallback({ status: 'Success' }); + statusCallback(mock.execStatus); } return Promise.resolve(); } @@ -94,6 +97,7 @@ const mock = { const k8s = require('@kubernetes/client-node'); const { restartAgentRuntime, provisionAgentRuntime, buildPvcVirtualKeyReadScript, issueLiteLLMVirtualKey, + readOpenClawIdentityFile, } = require('../../../services/agentProvisionerServiceK8s'); const { CYCLES_DIRECTIVE_MARKER } = require('../../../routes/registry/presets'); @@ -103,6 +107,41 @@ describe('agentProvisionerServiceK8s', () => { k8s.__mock.replaceNamespacedDeployment.mockClear(); }); + it('distinguishes missing identities from read and parent-access failures', async () => { + k8s.__mock.execStatus = { status: 'Failure', message: 'Permission denied' }; + try { + await expect(readOpenClawIdentityFile('curator')).rejects.toThrow('Permission denied'); + const command = k8s.__mock.execArgv.at(-1); + const fs = require('fs'); + const os = require('os'); + const path = require('path'); + const { spawnSync } = require('child_process'); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'identity-reader-')); + const identity = path.join(dir, 'IDENTITY.md'); + const run = () => spawnSync(process.execPath, [...command.slice(1, -1), identity], { encoding: 'utf8' }); + try { + expect(run().status).toBe(0); + fs.writeFileSync(identity, 'Name: Curator'); + const read = run(); + expect(read.status).toBe(0); + expect(read.stdout).toBe('Name: Curator'); + fs.chmodSync(dir, 0o000); + const inaccessible = run(); + expect(inaccessible.status).not.toBe(0); + expect(inaccessible.stderr).toContain('EACCES'); + fs.chmodSync(dir, 0o700); + fs.unlinkSync(identity); + fs.mkdirSync(identity); + expect(run().status).not.toBe(0); + } finally { + fs.chmodSync(dir, 0o700); + fs.rmSync(dir, { recursive: true, force: true }); + } + } finally { + k8s.__mock.execStatus = { status: 'Success' }; + } + }); + it('restarts the shared gateway deployment for OpenClaw runtimes', async () => { const result = await restartAgentRuntime('moltbot', 'cuz'); diff --git a/backend/models/Session.ts b/backend/models/Session.ts index dffd61ee0f..1499316ec3 100644 --- a/backend/models/Session.ts +++ b/backend/models/Session.ts @@ -41,16 +41,9 @@ export interface ISession extends Document { }; state: SessionState; lastActivityAt: Date; - contextSettings: { - maxTokens: number; - compactionThreshold: number; - includeMemory: boolean; - includeSkills: boolean; - }; createdAt: Date; updatedAt: Date; appendTranscript(entry: ITranscriptEntry): Promise; - shouldCompact(): boolean; shouldReset(): boolean; compact(summaryContent: string): Promise; reset(): Promise; @@ -114,12 +107,6 @@ const SessionSchema = new Schema( default: 'active', }, lastActivityAt: { type: Date, default: Date.now }, - contextSettings: { - maxTokens: { type: Number, default: 8000 }, - compactionThreshold: { type: Number, default: 6000 }, - includeMemory: { type: Boolean, default: true }, - includeSkills: { type: Boolean, default: true }, - }, }, { timestamps: true }, ); @@ -159,10 +146,6 @@ SessionSchema.methods.appendTranscript = async function (entry: ITranscriptEntry return this.save(); }; -SessionSchema.methods.shouldCompact = function (): boolean { - return this.tokenUsage.context >= this.contextSettings.compactionThreshold; -}; - SessionSchema.methods.shouldReset = function (): boolean { const now = new Date(); diff --git a/backend/routes/registry/agent-config.ts b/backend/routes/registry/agent-config.ts index f9d6ef407b..25e6599396 100644 --- a/backend/routes/registry/agent-config.ts +++ b/backend/routes/registry/agent-config.ts @@ -7,6 +7,7 @@ const AgentProfile = require('../../models/AgentProfile'); const AgentIdentityService = require('../../services/agentIdentityService'); const Pod = require('../../models/Pod'); const { + readOpenClawIdentityFile, writeWorkspaceIdentityFile, syncOpenClawSkills, } = require('../../services/agentProvisionerService'); @@ -32,6 +33,9 @@ const { assertAgentRuntimeMayJoinPod } = require('../../services/outsiderGrantAg const { hostedEngineInEffect } = require('../../services/hostedEngine'); const agentConfigRouter = express.Router(); +// Serialize this route's IDENTITY.md read/modify/write operations per account. +// This is process-local; it does not coordinate independent backend replicas. +const identityWrites = new Map>(); /** * Every field this handler writes, and so every field the installer gate below @@ -421,10 +425,38 @@ agentConfigRouter.patch('/pods/:podId/agents/:name', auth, async (req: any, res: ); if ((persona !== undefined || displayName) && normalizedInstanceId && name.toLowerCase() === 'openclaw') { - const identityContent = buildIdentityContent(effectiveDisplayName || normalizedInstanceId, persona || {}); - writeWorkspaceIdentityFile(normalizedInstanceId, identityContent).catch((err: any) => { - console.warn('[registry] Failed to sync IDENTITY.md for', normalizedInstanceId, err.message); - }); + const previousWrite = identityWrites.get(normalizedInstanceId) || Promise.resolve(); + let releaseWrite!: () => void; + const currentWrite = new Promise((resolve) => { releaseWrite = resolve; }); + identityWrites.set(normalizedInstanceId, currentWrite); + try { + await previousWrite; + const identityContent = await readOpenClawIdentityFile(normalizedInstanceId); + if (persona !== undefined) { + // Persona-only edits retain the runtime Name. A missing file uses + // the installation label instead of renaming it to the instance id. + const existingName = identityContent.match(/^- \*\*Name:\*\* ([^\r\n]*)/m)?.[1]?.trim(); + const identityName = effectiveDisplayName || existingName || installation.displayName + || normalizedInstanceId; + await writeWorkspaceIdentityFile( + normalizedInstanceId, buildIdentityContent(identityName, persona), + ); + } else { + // Keep manually edited persona content on a name-only save. + const renamed = identityContent.replace( + /^(- \*\*Name:\*\* )[^\r\n]*/m, + (_line: string, prefix: string) => `${prefix}${effectiveDisplayName || normalizedInstanceId}`, + ); + if (renamed !== identityContent) { + await writeWorkspaceIdentityFile(normalizedInstanceId, renamed); + } + } + } finally { + releaseWrite(); + if (identityWrites.get(normalizedInstanceId) === currentWrite) { + identityWrites.delete(normalizedInstanceId); + } + } } } } catch (projectionError) { diff --git a/backend/services/agentProvisionerService.ts b/backend/services/agentProvisionerService.ts index 565111b711..5d7ab98db0 100644 --- a/backend/services/agentProvisionerService.ts +++ b/backend/services/agentProvisionerService.ts @@ -1550,16 +1550,14 @@ const readOpenClawIdentityFile = async (accountId: any, options = {}) => { const k8sProvisioner = require('./agentProvisionerServiceK8s'); return k8sProvisioner.readOpenClawIdentityFile(accountId, options); } - // Local: read from filesystem + const workspacePath = resolveOpenClawWorkspacePath(accountId); try { - const workspacePath = process.env.OPENCLAW_WORKSPACE_PATH || '/workspace'; - const filePath = `${workspacePath}/${accountId}/IDENTITY.md`; - // eslint-disable-next-line global-require - const fs = require('fs'); - if (!fs.existsSync(filePath)) return ''; - return fs.readFileSync(filePath, 'utf8'); - } catch { - return ''; + return fs.readFileSync(path.join(workspacePath, 'IDENTITY.md'), 'utf8'); + } catch (error: any) { + // Only absence is empty content. Read/modify/write callers must not + // report success or replace an unreadable identity with a blank one. + if (error.code === 'ENOENT') return ''; + throw error; } }; diff --git a/backend/services/agentProvisionerServiceK8s.ts b/backend/services/agentProvisionerServiceK8s.ts index 1367f4817a..a02d96e030 100644 --- a/backend/services/agentProvisionerServiceK8s.ts +++ b/backend/services/agentProvisionerServiceK8s.ts @@ -362,18 +362,20 @@ const readOpenClawHeartbeatFile = async (accountId: any, { gateway } : any = {}) }; const readOpenClawIdentityFile = async (accountId: any, { gateway } : any = {}) => { - try { - const podName = await resolveGatewayPodNameWithRetry(gateway); - const identityPath = `/workspace/${accountId}/IDENTITY.md`; - const result = await execInPod({ - podName, - containerName: 'clawdbot-gateway', - command: ['sh', '-lc', `[ -f "${identityPath}" ] && cat "${identityPath}" || echo ""`], - }); - return result.stdout || ''; - } catch { - return ''; - } + const podName = await resolveGatewayPodNameWithRetry(gateway); + const identityPath = `/workspace/${accountId}/IDENTITY.md`; + const result = await execInPod({ + podName, + containerName: 'clawdbot-gateway', + // stat/test -e cannot distinguish an inaccessible parent from absence. + // Use the gateway's Node runtime to preserve every error except ENOENT. + command: ['node', '-e', `try { + process.stdout.write(require('fs').readFileSync(process.argv[1], 'utf8')); + } catch (error) { + if (error.code !== 'ENOENT') throw error; + }`, identityPath], + }); + return result.stdout || ''; }; // Cross-cutting behavioral invariants appended to every SOUL.md the diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 552be1f348..2fd230163a 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -2364,7 +2364,6 @@ "agentSettings": { "identityTitle": "Identity", "displayName": "Display name", - "instructions": "Instructions", "persona": "Persona", "tone": "Tone", "toneOptions": { "friendly": "Friendly", "professional": "Professional", "casual": "Casual", "formal": "Formal", "technical": "Technical" }, @@ -2448,19 +2447,6 @@ }, "agentPolicy": { "title": "Policy", - "approvals": "Tool approvals", - "commaHint": "Separate tool names with commas.", - "allowed": "Allowed tools", - "blocked": "Blocked tools", - "requireApproval": "Require approval", - "context": "Context", - "maxTokens": "Maximum tokens", - "compactionThreshold": "Compact after", - "summaryHours": "Summary window (hours)", - "includeMemory": "Include memory", - "includeSkills": "Include skills", - "includeSummaries": "Include summaries", - "savePolicy": "Save policy", "trust": "Trust mode", "repoAccess": "Repository access", "chooseAccess": "Choose access", @@ -2475,9 +2461,6 @@ "quotas": "Quotas", "turnsPerDay": "Hosted turns per day", "userRunCeiling": "Runs per day", - "contextInvalid": "Check the context limits.", - "profileUnavailable": "This agent's policy can't be changed in this pod yet.", - "profileUnavailableSave": "This agent's policy can't be changed in this pod yet, so nothing was saved.", "loadError": "Could not load policy data.", "saveError": "Could not save changes.", "saved": "Saved" diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index a390d68913..0cd2cb0782 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -2355,7 +2355,6 @@ "agentSettings": { "identityTitle": "身份", "displayName": "显示名称", - "instructions": "指令", "persona": "角色设定", "tone": "语气", "toneOptions": { "friendly": "友好", "professional": "专业", "casual": "随和", "formal": "正式", "technical": "技术型" }, @@ -2439,19 +2438,6 @@ }, "agentPolicy": { "title": "策略", - "approvals": "工具审批", - "commaHint": "用逗号分隔工具名称。", - "allowed": "允许的工具", - "blocked": "禁用的工具", - "requireApproval": "需要审批", - "context": "上下文", - "maxTokens": "最大 Token 数", - "compactionThreshold": "压缩阈值", - "summaryHours": "摘要范围(小时)", - "includeMemory": "包含记忆", - "includeSkills": "包含技能", - "includeSummaries": "包含摘要", - "savePolicy": "保存策略", "trust": "信任模式", "repoAccess": "仓库访问权限", "chooseAccess": "选择权限", @@ -2466,9 +2452,6 @@ "quotas": "配额", "turnsPerDay": "托管运行次数/天", "userRunCeiling": "运行次数/天", - "contextInvalid": "请检查上下文限制。", - "profileUnavailable": "此智能体在这个 Pod 中的策略暂时无法修改。", - "profileUnavailableSave": "此智能体在这个 Pod 中的策略暂时无法修改,更改未保存。", "loadError": "无法加载策略数据。", "saveError": "无法保存更改。", "saved": "已保存" diff --git a/frontend/src/v2/__tests__/V2AgentPolicy.test.tsx b/frontend/src/v2/__tests__/V2AgentPolicy.test.tsx index a14de9f2b7..505d1df6ef 100644 --- a/frontend/src/v2/__tests__/V2AgentPolicy.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentPolicy.test.tsx @@ -40,39 +40,19 @@ beforeEach(() => { axios.patch.mockResolvedValue({ data: { section: 'long_term', content: 'Changed' } }); }); -it('writes approval and context policy through the shared settings hook', async () => { +it('hides stored approval and context settings that no runtime enforces', async () => { renderPolicy(); await screen.findByDisplayValue('Original'); - fireEvent.change(screen.getByLabelText('Allowed tools'), { target: { value: 'commonly, Read, Read' } }); - fireEvent.click(screen.getByLabelText('Include summaries')); - fireEvent.click(screen.getByRole('button', { name: 'Save policy' })); - await waitFor(() => expect(update).toHaveBeenCalledWith({ - toolPolicy: { allowed: ['commonly', 'Read'], blocked: [], requireApproval: ['Bash'] }, - contextPolicy: { maxTokens: 8000, compactionThreshold: 6000, summaryHours: 24, - includeMemory: true, includeSkills: true, includeSummaries: false }, - })); -}); - -it('saves an edit immediately on the first click and confirms it', async () => { - renderPolicy(); - fireEvent.change(screen.getByLabelText('Allowed tools'), { target: { value: 'commonly, Read' } }); - fireEvent.click(screen.getByRole('button', { name: 'Save policy' })); - - expect(update).toHaveBeenCalledTimes(1); - expect(update).toHaveBeenCalledWith(expect.objectContaining({ - toolPolicy: { allowed: ['commonly', 'Read'], blocked: [], requireApproval: ['Bash'] }, - })); - expect(await screen.findByRole('status')).toHaveTextContent('Saved'); -}); - -it('explains why an installation without a profile cannot save policy', async () => { - renderPolicy({ ...installation, profile: null }); - expect(screen.getByRole('alert')).toHaveTextContent("This agent's policy can't be changed in this pod yet."); - fireEvent.change(screen.getByLabelText('Allowed tools'), { target: { value: 'Read' } }); - fireEvent.click(screen.getByRole('button', { name: 'Save policy' })); - + expect(screen.queryByText('Tool approvals')).not.toBeInTheDocument(); + expect(screen.queryByText('Context')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Maximum tokens')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Compact after')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Summary window (hours)')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Include memory')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Include skills')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Include summaries')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Save policy' })).not.toBeInTheDocument(); expect(update).not.toHaveBeenCalled(); - expect(await screen.findByRole('alert')).toHaveTextContent('nothing was saved'); }); it('writes only the chosen trust mode and edits memory with its revision', async () => { diff --git a/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx b/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx index 95e209d7df..619ebbec1a 100644 --- a/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx @@ -140,23 +140,78 @@ test('identity edits go through the shared installation PATCH with the exact ins } }); if (url.startsWith('/api/registry/pods/')) return Promise.resolve({ data: { agent: { name: 'maya-claude', instanceId: 'default', displayName: 'Maya', - profile: { instructions: 'Old', persona: { tone: 'friendly', specialties: ['writing'] } }, + profile: { + instructions: 'Old', persona: { tone: 'friendly', specialties: ['writing'] }, + toolPolicy: { requireApproval: ['search'] }, contextPolicy: { maxTokens: 8000 }, + }, } } }); return Promise.resolve({ data: [] }); }); axios.patch.mockResolvedValue({ data: { success: true } }); renderPage(); - await screen.findByRole('button', { name: 'Save identity' }); + const save = await screen.findByRole('button', { name: 'Save identity' }); + expect(save).toBeDisabled(); fireEvent.change(screen.getByRole('textbox', { name: 'Display name' }), { target: { value: 'Maya 2' } }); - fireEvent.change(screen.getByRole('textbox', { name: 'Instructions' }), { target: { value: 'New instructions' } }); - fireEvent.change(screen.getByRole('combobox', { name: 'Tone' }), { target: { value: 'technical' } }); - fireEvent.click(screen.getByRole('button', { name: 'Save identity' })); + expect(screen.queryByRole('textbox', { name: 'Instructions' })).not.toBeInTheDocument(); + expect(screen.queryByRole('group', { name: 'Persona' })).not.toBeInTheDocument(); + expect(screen.queryByRole('combobox', { name: 'Tone' })).not.toBeInTheDocument(); + expect(save).toBeEnabled(); + fireEvent.click(save); await waitFor(() => expect(axios.patch).toHaveBeenCalledWith( '/api/registry/pods/pod-1/agents/maya-claude', - expect.objectContaining({ - instanceId: 'default', displayName: 'Maya 2', instructions: 'New instructions', - persona: expect.objectContaining({ tone: 'technical', specialties: ['writing'] }), - }), + { instanceId: 'default', displayName: 'Maya 2' }, + )); +}); + +test('OpenClaw identity keeps the live Persona editor and saves its changes', async () => { + axios.get.mockImplementation((url) => { + if (url.startsWith('/api/agent-memory/')) return Promise.resolve({ data: { + displayName: 'Scout', pods: [{ id: 'pod-1', name: 'Workspace' }], + } }); + if (url.endsWith('/manage-settings')) return Promise.resolve({ data: { + writablePodId: 'pod-1', installations: [{ podId: 'pod-1', podName: 'Workspace', status: 'active' }], + } }); + if (url.startsWith('/api/registry/pods/')) return Promise.resolve({ data: { agent: { + name: 'openclaw', instanceId: 'scout', displayName: 'Scout', + profile: { persona: { tone: 'friendly', specialties: ['writing'], customInstructions: 'Stay clear' } }, + } } }); + return Promise.resolve({ data: [] }); + }); + axios.patch.mockResolvedValue({ data: { success: true } }); + renderPage('/v2/agents/openclaw/scout'); + expect(await screen.findByRole('group', { name: 'Persona' })).toBeInTheDocument(); + fireEvent.change(screen.getByRole('textbox', { name: 'Persona notes' }), { target: { value: 'Keep detail' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save identity' })); + await waitFor(() => expect(axios.patch).toHaveBeenCalledWith( + '/api/registry/pods/pod-1/agents/openclaw', + { instanceId: 'scout', persona: { + tone: 'friendly', specialties: ['writing'], boundaries: [], customInstructions: 'Keep detail', + } }, + )); +}); + +test('OpenClaw name-only edits leave Persona out of the PATCH', async () => { + axios.get.mockImplementation((url) => { + if (url.startsWith('/api/agent-memory/')) return Promise.resolve({ data: { + displayName: 'Scout', pods: [{ id: 'pod-1', name: 'Workspace' }], + } }); + if (url.endsWith('/manage-settings')) return Promise.resolve({ data: { + writablePodId: 'pod-1', installations: [{ podId: 'pod-1', podName: 'Workspace', status: 'active' }], + } }); + if (url.startsWith('/api/registry/pods/')) return Promise.resolve({ data: { agent: { + name: 'openclaw', instanceId: 'scout', displayName: 'Scout', + profile: { persona: { tone: 'friendly', specialties: ['writing'] } }, + } } }); + return Promise.resolve({ data: [] }); + }); + axios.patch.mockResolvedValue({ data: { success: true } }); + renderPage('/v2/agents/openclaw/scout'); + expect(await screen.findByRole('group', { name: 'Persona' })).toBeInTheDocument(); + fireEvent.change(screen.getByRole('textbox', { name: 'Display name' }), { target: { value: 'Scout 2' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save identity' })); + await waitFor(() => expect(axios.patch).toHaveBeenCalledWith( + '/api/registry/pods/pod-1/agents/openclaw', + { instanceId: 'scout', displayName: 'Scout 2' }, )); }); diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index 4b8ff883a9..e96578e500 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -4735,7 +4735,7 @@ describe('agent runtime and grants fit the Settings column', () => { describe('agent Identity editor', () => { const css = read('../agents/v2-agent-settings.css'); test('form fields retain a visible boundary over the v2 input reset', () => { - const input = selectorRuleBody(css, '.v2-root .v2-agent-settings .v2-agent-settings__identity input,'); + const input = ruleBody(css, '.v2-root .v2-agent-settings .v2-agent-settings__identity input'); expect(input).toContain('border: 1px solid var(--v2-border)'); expect(input).toContain('background: var(--v2-surface)'); }); diff --git a/frontend/src/v2/agents/V2AgentIdentitySection.tsx b/frontend/src/v2/agents/V2AgentIdentitySection.tsx index 8fb3e38fd0..a5359922b8 100644 --- a/frontend/src/v2/agents/V2AgentIdentitySection.tsx +++ b/frontend/src/v2/agents/V2AgentIdentitySection.tsx @@ -31,9 +31,11 @@ const asPersona = (value: unknown): PersonaFields => { const V2AgentIdentitySection: React.FC = ({ installation, update }) => { const { t } = useTranslation(); const profile = installation.profile; + const supportsPersona = installation.name.toLowerCase() === 'openclaw'; const [displayName, setDisplayName] = useState(installation.displayName || ''); - const [instructions, setInstructions] = useState(profile?.instructions || ''); const [persona, setPersona] = useState(() => asPersona(profile?.persona)); + const personaChanged = supportsPersona && Object.entries(asPersona(profile?.persona)) + .some(([key, value]) => persona[key as keyof PersonaFields] !== value); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [saved, setSaved] = useState(false); @@ -68,7 +70,6 @@ const V2AgentIdentitySection: React.FC = ({ installation, update }) => { useEffect(() => { setDisplayName(installation.displayName || ''); - setInstructions(profile?.instructions || ''); setPersona(asPersona(profile?.persona)); setError(null); setSaved(false); @@ -87,13 +88,12 @@ const V2AgentIdentitySection: React.FC = ({ installation, update }) => { try { await update({ ...(name !== installation.displayName ? { displayName: name } : {}), - instructions, - persona: { + ...(personaChanged ? { persona: { tone: persona.tone, specialties: commaList(persona.specialties), boundaries: commaList(persona.boundaries), customInstructions: persona.customInstructions, - }, + } } : {}), }); setSaved(true); } catch (requestError) { @@ -122,10 +122,7 @@ const V2AgentIdentitySection: React.FC = ({ installation, update }) => { -