CoolFace
Datasetpublic

basant307/AI_Governance_Project

sourceHugging Faceapache-2.0updated 2mo agoView on Hugging Face
0likes48downloads
workspace-agents.test.js826 linesDownload Raw Back to serve
1/**2 * @license3 * Copyright 2025 Qwen Team4 * SPDX-License-Identifier: Apache-2.05 */6import { promises as fs } from 'node:fs';7import * as os from 'node:os';8import * as path from 'node:path';9import express from 'express';10import request from 'supertest';11import { afterEach, beforeEach, describe, expect, it, vi, } from 'vitest';12import { QWEN_DIR, Storage } from '@qwen-code/qwen-code-core';13import { createMutationGate } from './auth.js';14import { mountWorkspaceAgentsRoutes } from './workspace-agents.js';15function buildBridgeStub(opts = {}) {16    const events = [];17    const known = new Set(opts.knownIds ?? []);18    return {19        events,20        publishWorkspaceEvent(event) {21            events.push(event);22        },23        knownClientIds() {24            return new Set(known);25        },26        spawnOrAttach: () => {27            throw new Error('not implemented');28        },29        loadSession: () => {30            throw new Error('not implemented');31        },32        resumeSession: () => {33            throw new Error('not implemented');34        },35        sendPrompt: () => {36            throw new Error('not implemented');37        },38        cancelSession: () => {39            throw new Error('not implemented');40        },41        subscribeEvents: () => {42            throw new Error('not implemented');43        },44        closeSession: () => {45            throw new Error('not implemented');46        },47        updateSessionMetadata: () => {48            throw new Error('not implemented');49        },50        respondToPermission: () => {51            throw new Error('not implemented');52        },53        respondToSessionPermission: () => {54            throw new Error('not implemented');55        },56        listWorkspaceSessions: () => {57            throw new Error('not implemented');58        },59        recordHeartbeat: () => {60            throw new Error('not implemented');61        },62        getHeartbeatState: () => undefined,63        getWorkspaceMcpStatus: async () => {64            throw new Error('not implemented');65        },66        getWorkspaceSkillsStatus: async () => {67            throw new Error('not implemented');68        },69        getWorkspaceProvidersStatus: async () => {70            throw new Error('not implemented');71        },72        getSessionContextStatus: async () => {73            throw new Error('not implemented');74        },75        getSessionSupportedCommandsStatus: async () => {76            throw new Error('not implemented');77        },78        setSessionModel: async () => {79            throw new Error('not implemented');80        },81        killSession: async () => { },82        detachClient: async () => { },83        sessionCount: 0,84        pendingPermissionCount: 0,85        killAllSync: () => { },86        shutdown: async () => { },87        preheat: async () => { },88    };89}90function buildApp(opts) {91    const app = express();92    app.use(express.json({ limit: '10mb' }));93    const mutate = createMutationGate({94        tokenConfigured: opts.strictNoToken !== true,95        requireAuth: false,96    });97    mountWorkspaceAgentsRoutes(app, {98        bridge: opts.bridge,99        boundWorkspace: opts.boundWorkspace,100        mutate,101        parseClientId: (req, res) => {102            const raw = req.get('x-qwen-client-id');103            if (raw === undefined || raw === '')104                return undefined;105            if (raw.length > 128 || !/^[A-Za-z0-9._:-]+$/.test(raw)) {106                res.status(400).json({107                    error: '`X-Qwen-Client-Id` must be a non-empty token',108                    code: 'invalid_client_id',109                });110                return null;111            }112            return raw;113        },114        safeBody: (req) => {115            const raw = req.body;116            if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {117                return Object.create(null);118            }119            const out = Object.create(null);120            for (const [k, v] of Object.entries(raw)) {121                if (k === '__proto__' || k === 'constructor' || k === 'prototype') {122                    continue;123                }124                out[k] = v;125            }126            return out;127        },128    });129    return app;130}131describe('workspace agents routes', () => {132    let tmp;133    let workspace;134    let globalDir;135    let getGlobalQwenDirSpy;136    beforeEach(async () => {137        tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'qwen-serve-agents-'));138        workspace = path.join(tmp, 'workspace');139        globalDir = path.join(tmp, 'global');140        await fs.mkdir(workspace, { recursive: true });141        await fs.mkdir(globalDir, { recursive: true });142        getGlobalQwenDirSpy = vi143            .spyOn(Storage, 'getGlobalQwenDir')144            .mockReturnValue(globalDir);145    });146    afterEach(async () => {147        getGlobalQwenDirSpy.mockRestore();148        await fs.rm(tmp, { recursive: true, force: true });149    });150    function missingAgentName(prefix = 'missing-agent') {151        return `${prefix}-${path.basename(tmp)}`;152    }153    it('lists built-in agents alongside on-disk project agents', async () => {154        const projectAgentsDir = path.join(workspace, QWEN_DIR, 'agents');155        await fs.mkdir(projectAgentsDir, { recursive: true });156        await fs.writeFile(path.join(projectAgentsDir, 'reviewer.md'), `---\nname: reviewer\ndescription: reviews PRs\n---\nyou are a reviewer agent\n`, 'utf8');157        const bridge = buildBridgeStub();158        const app = buildApp({ bridge, boundWorkspace: workspace });159        const res = await request(app).get('/workspace/agents');160        expect(res.status).toBe(200);161        const names = res.body.agents.map((a) => a.name);162        expect(names).toContain('reviewer');163        expect(names).toContain('general-purpose');164        const reviewerEntry = res.body.agents.find((a) => a.name === 'reviewer');165        expect(reviewerEntry?.level).toBe('project');166        // Listings exclude the systemPrompt for bounded payload.167        expect(reviewerEntry?.systemPrompt).toBeUndefined();168    });169    it('GET /workspace/agents reflects out-of-band agent file changes', async () => {170        const bridge = buildBridgeStub();171        const app = buildApp({ bridge, boundWorkspace: workspace });172        // First call populates SubagentManager's cache.173        let res = await request(app).get('/workspace/agents');174        expect(res.status).toBe(200);175        const before = res.body.agents.map((a) => a.name);176        expect(before).not.toContain('fresh-out-of-band');177        // Out-of-band: a developer / IDE adapter writes a new agent file178        // directly to disk, bypassing the daemon's POST route. Without179        // `force: true` on the LIST handler, `listSubagents()` would180        // serve the stale cache from the first call and silently miss181        // the new entry — diverging from the detail route, which always182        // re-reads disk.183        const projectAgentsDir = path.join(workspace, QWEN_DIR, 'agents');184        await fs.mkdir(projectAgentsDir, { recursive: true });185        await fs.writeFile(path.join(projectAgentsDir, 'fresh-out-of-band.md'), `---\nname: fresh-out-of-band\ndescription: out-of-band agent description\n---\nyou are the fresh out-of-band agent\n`, 'utf8');186        res = await request(app).get('/workspace/agents');187        expect(res.status).toBe(200);188        const after = res.body.agents.map((a) => a.name);189        expect(after).toContain('fresh-out-of-band');190    });191    it('returns the full detail (with systemPrompt) on GET /workspace/agents/:agentType', async () => {192        const bridge = buildBridgeStub();193        const app = buildApp({ bridge, boundWorkspace: workspace });194        const create = await request(app).post('/workspace/agents').send({195            name: 'detail-agent',196            description: 'detail agent description',197            systemPrompt: 'you are the detail agent',198            scope: 'workspace',199        });200        expect(create.status).toBe(201);201        const res = await request(app).get('/workspace/agents/detail-agent');202        expect(res.status).toBe(200);203        expect(res.body.name).toBe('detail-agent');204        expect(res.body.systemPrompt).toBe('you are the detail agent');205        expect(res.body.isBuiltin).toBe(false);206        expect(res.body.level).toBe('project');207    });208    it('returns 404 agent_not_found for unknown agent', async () => {209        const bridge = buildBridgeStub();210        const app = buildApp({ bridge, boundWorkspace: workspace });211        const name = missingAgentName();212        const res = await request(app).get(`/workspace/agents/${name}`);213        expect(res.status).toBe(404);214        expect(res.body.code).toBe('agent_not_found');215    });216    it('matches frontmatter name case-insensitively', async () => {217        const projectAgentsDir = path.join(workspace, QWEN_DIR, 'agents');218        await fs.mkdir(projectAgentsDir, { recursive: true });219        await fs.writeFile(path.join(projectAgentsDir, 'casey.md'), `---\nname: CaseInsensitive-Agent\ndescription: case insensitive lookup test\n---\nyou are a test agent\n`, 'utf8');220        const bridge = buildBridgeStub();221        const app = buildApp({ bridge, boundWorkspace: workspace });222        const res = await request(app).get('/workspace/agents/caseinsensitive-agent');223        expect(res.status).toBe(200);224        expect(res.body.name).toBe('CaseInsensitive-Agent');225    });226    it('creates a project-level agent and emits agent_changed', async () => {227        const bridge = buildBridgeStub();228        const app = buildApp({ bridge, boundWorkspace: workspace });229        const res = await request(app).post('/workspace/agents').send({230            name: 'tester',231            description: 'runs tests in the project',232            systemPrompt: 'you are a tester agent',233            scope: 'workspace',234        });235        expect(res.status).toBe(201);236        expect(res.body.ok).toBe(true);237        expect(res.body.agent.name).toBe('tester');238        expect(res.body.agent.level).toBe('project');239        const events = bridge.events;240        expect(events).toHaveLength(1);241        expect(events[0]?.type).toBe('agent_changed');242        expect(events[0]?.data).toMatchObject({243            change: 'created',244            name: 'tester',245            level: 'project',246        });247        // File was actually written.248        const onDisk = await fs.readFile(path.join(workspace, QWEN_DIR, 'agents', 'tester.md'), 'utf8');249        expect(onDisk).toContain('name: tester');250    });251    it('creates a user-level agent when scope=global', async () => {252        const bridge = buildBridgeStub();253        const app = buildApp({ bridge, boundWorkspace: workspace });254        const res = await request(app).post('/workspace/agents').send({255            name: 'global-helper',256            description: 'cross-workspace helper',257            systemPrompt: 'you are a helper agent',258            scope: 'global',259        });260        expect(res.status).toBe(201);261        expect(res.body.agent.level).toBe('user');262        const onDisk = await fs.readFile(path.join(globalDir, 'agents', 'global-helper.md'), 'utf8');263        expect(onDisk).toContain('name: global-helper');264    });265    it('returns 409 agent_already_exists when name collides at the same level', async () => {266        const bridge = buildBridgeStub();267        const app = buildApp({ bridge, boundWorkspace: workspace });268        const body = {269            name: 'duplicate',270            description: 'first description',271            systemPrompt: 'you are the duplicate agent',272            scope: 'workspace',273        };274        const first = await request(app).post('/workspace/agents').send(body);275        expect(first.status).toBe(201);276        const second = await request(app).post('/workspace/agents').send(body);277        expect(second.status).toBe(409);278        expect(second.body.code).toBe('agent_already_exists');279    });280    it.each(['general-purpose', 'explore'])('rejects 422 invalid_config when create uses builtin agent name %s', async (name) => {281        const bridge = buildBridgeStub();282        const app = buildApp({ bridge, boundWorkspace: workspace });283        const res = await request(app).post('/workspace/agents').send({284            name,285            description: 'a description longer than ten chars',286            systemPrompt: 'this is a system prompt',287            scope: 'workspace',288        });289        expect(res.status).toBe(422);290        expect(res.body.code).toBe('invalid_config');291        expect(res.body.error).toMatch(/built-in/i);292    });293    it('returns 422 invalid_config for missing required fields', async () => {294        const bridge = buildBridgeStub();295        const app = buildApp({ bridge, boundWorkspace: workspace });296        const res = await request(app)297            .post('/workspace/agents')298            .send({ scope: 'workspace' });299        expect(res.status).toBe(422);300        expect(res.body.code).toBe('invalid_config');301    });302    it('returns 400 invalid_scope for bad scope value', async () => {303        const bridge = buildBridgeStub();304        const app = buildApp({ bridge, boundWorkspace: workspace });305        const res = await request(app).post('/workspace/agents').send({306            name: 'a-name',307            description: 'a description longer than ten chars',308            systemPrompt: 'this is the system prompt',309            scope: 'project',310        });311        expect(res.status).toBe(400);312        expect(res.body.code).toBe('invalid_scope');313    });314    it('updates an existing project-level agent and emits agent_changed', async () => {315        const bridge = buildBridgeStub();316        const app = buildApp({ bridge, boundWorkspace: workspace });317        await request(app).post('/workspace/agents').send({318            name: 'updatable',319            description: 'old description',320            systemPrompt: 'you are an updatable agent',321            scope: 'workspace',322        });323        const res = await request(app)324            .post('/workspace/agents/updatable')325            .send({ description: 'new description' });326        expect(res.status).toBe(200);327        expect(res.body.agent.description).toBe('new description');328        const events = bridge.events;329        const changeEvents = events.filter((e) => e.type === 'agent_changed');330        expect(changeEvents).toHaveLength(2);331        expect(changeEvents[1]?.data).toMatchObject({332            change: 'updated',333            name: 'updatable',334            level: 'project',335        });336    });337    it('returns 404 agent_not_found when updating an unknown agent', async () => {338        const bridge = buildBridgeStub();339        const app = buildApp({ bridge, boundWorkspace: workspace });340        const name = missingAgentName();341        const res = await request(app)342            .post(`/workspace/agents/${name}`)343            .send({ description: 'x' });344        expect(res.status).toBe(404);345        expect(res.body.code).toBe('agent_not_found');346    });347    it('returns 403 agent_readonly when updating a built-in agent', async () => {348        const bridge = buildBridgeStub();349        const app = buildApp({ bridge, boundWorkspace: workspace });350        const res = await request(app)351            .post('/workspace/agents/general-purpose')352            .send({ description: 'rewritten' });353        expect(res.status).toBe(403);354        expect(res.body.code).toBe('agent_readonly');355    });356    it('deletes a project-level agent and emits agent_changed', async () => {357        const bridge = buildBridgeStub();358        const app = buildApp({ bridge, boundWorkspace: workspace });359        await request(app).post('/workspace/agents').send({360            name: 'temporary',361            description: 'temp description',362            systemPrompt: 'you are a temp agent',363            scope: 'workspace',364        });365        const res = await request(app).delete('/workspace/agents/temporary');366        expect(res.status).toBe(204);367        const events = bridge.events;368        const changeEvents = events.filter((e) => e.type === 'agent_changed');369        expect(changeEvents.at(-1)?.data).toMatchObject({370            change: 'deleted',371            name: 'temporary',372            level: 'project',373        });374    });375    it('returns 403 agent_readonly when deleting a built-in agent', async () => {376        const bridge = buildBridgeStub();377        const app = buildApp({ bridge, boundWorkspace: workspace });378        const res = await request(app).delete('/workspace/agents/general-purpose');379        expect(res.status).toBe(403);380        expect(res.body.code).toBe('agent_readonly');381    });382    it('returns 404 when deleting a missing agent', async () => {383        const bridge = buildBridgeStub();384        const app = buildApp({ bridge, boundWorkspace: workspace });385        const name = missingAgentName();386        const res = await request(app).delete(`/workspace/agents/${name}`);387        expect(res.status).toBe(404);388        expect(res.body.code).toBe('agent_not_found');389    });390    it('refuses POST with 401 token_required on no-token loopback strict mode', async () => {391        const bridge = buildBridgeStub();392        const app = buildApp({393            bridge,394            boundWorkspace: workspace,395            strictNoToken: true,396        });397        const res = await request(app).post('/workspace/agents').send({398            name: 'a-name',399            description: 'a description longer than ten chars',400            systemPrompt: 'this is the system prompt',401            scope: 'workspace',402        });403        expect(res.status).toBe(401);404        expect(res.body.code).toBe('token_required');405    });406    it('rejects 400 invalid_client_id for unknown X-Qwen-Client-Id', async () => {407        const bridge = buildBridgeStub({ knownIds: ['client_known'] });408        const app = buildApp({ bridge, boundWorkspace: workspace });409        const res = await request(app)410            .post('/workspace/agents')411            .set('X-Qwen-Client-Id', 'client_stranger')412            .send({413            name: 'a-name',414            description: 'a description longer than ten chars',415            systemPrompt: 'this is the system prompt',416            scope: 'workspace',417        });418        expect(res.status).toBe(400);419        expect(res.body.code).toBe('invalid_client_id');420    });421    it('trims leading/trailing whitespace on the agent name', async () => {422        const bridge = buildBridgeStub();423        const app = buildApp({ bridge, boundWorkspace: workspace });424        const res = await request(app).post('/workspace/agents').send({425            name: '  trimmed-name  ',426            description: 'a description longer than ten chars',427            systemPrompt: 'you are a trimmed name agent',428            scope: 'workspace',429        });430        expect(res.status).toBe(201);431        expect(res.body.agent.name).toBe('trimmed-name');432        // File on disk uses the trimmed name; the original-with-spaces433        // version must NOT exist (would otherwise be unfindable via434        // case-insensitive lookup).435        const onDisk = await fs.readFile(path.join(workspace, QWEN_DIR, 'agents', 'trimmed-name.md'), 'utf8');436        expect(onDisk).toContain('name: trimmed-name');437    });438    it('returns 422 invalid_config when scalar field has wrong type on create', async () => {439        const bridge = buildBridgeStub();440        const app = buildApp({ bridge, boundWorkspace: workspace });441        const res = await request(app).post('/workspace/agents').send({442            name: 'wrong-type',443            description: 'a description longer than ten chars',444            systemPrompt: 'you are a wrong-type test agent',445            scope: 'workspace',446            model: 123,447        });448        expect(res.status).toBe(422);449        expect(res.body.code).toBe('invalid_config');450        expect(res.body.error).toMatch(/model.*string/);451    });452    it('returns 422 invalid_config for unknown approvalMode', async () => {453        const bridge = buildBridgeStub();454        const app = buildApp({ bridge, boundWorkspace: workspace });455        const res = await request(app).post('/workspace/agents').send({456            name: 'bad-mode',457            description: 'a description longer than ten chars',458            systemPrompt: 'you are a bad-mode test agent',459            scope: 'workspace',460            approvalMode: 'rampage',461        });462        expect(res.status).toBe(422);463        expect(res.body.code).toBe('invalid_config');464        expect(res.body.error).toMatch(/approvalMode/);465    });466    it('strips unknown runConfig keys and rejects malformed values', async () => {467        const bridge = buildBridgeStub();468        const app = buildApp({ bridge, boundWorkspace: workspace });469        // Unknown keys are silently dropped, valid known keys preserved.470        const res = await request(app)471            .post('/workspace/agents')472            .send({473            name: 'run-config',474            description: 'a description longer than ten chars',475            systemPrompt: 'you are a run-config test agent',476            scope: 'workspace',477            runConfig: { max_turns: 5, mystery_field: 'oops' },478        });479        expect(res.status).toBe(201);480        expect(res.body.agent.runConfig).toEqual({ max_turns: 5 });481        // Malformed known field fails closed.482        const res2 = await request(app)483            .post('/workspace/agents')484            .send({485            name: 'run-config-bad',486            description: 'a description longer than ten chars',487            systemPrompt: 'you are a run-config bad agent',488            scope: 'workspace',489            runConfig: { max_turns: -1 },490        });491        expect(res2.status).toBe(422);492        expect(res2.body.code).toBe('invalid_config');493    });494    it('rejects 400 invalid_scope on repeated ?scope= query', async () => {495        const bridge = buildBridgeStub();496        const app = buildApp({ bridge, boundWorkspace: workspace });497        // Express parses repeated query params as an array; we should498        // fail-closed rather than treating it as absent.499        const res = await request(app).delete('/workspace/agents/some-name?scope=workspace&scope=global');500        expect(res.status).toBe(400);501        expect(res.body.code).toBe('invalid_scope');502    });503    it('rejects 400 invalid_config for empty update body', async () => {504        const bridge = buildBridgeStub();505        const app = buildApp({ bridge, boundWorkspace: workspace });506        await request(app).post('/workspace/agents').send({507            name: 'has-fields',508            description: 'a description longer than ten chars',509            systemPrompt: 'you are a has-fields test agent',510            scope: 'workspace',511        });512        const res = await request(app)513            .post('/workspace/agents/has-fields')514            .send({});515        expect(res.status).toBe(400);516        expect(res.body.code).toBe('invalid_config');517    });518    it('stamps originatorClientId on agent_changed for known clients (create / update / delete)', async () => {519        const bridge = buildBridgeStub({ knownIds: ['client_audit'] });520        const app = buildApp({ bridge, boundWorkspace: workspace });521        // Create with a stamped client id.522        const createRes = await request(app)523            .post('/workspace/agents')524            .set('X-Qwen-Client-Id', 'client_audit')525            .send({526            name: 'audited',527            description: 'a description longer than ten chars',528            systemPrompt: 'you are an audited agent',529            scope: 'workspace',530        });531        expect(createRes.status).toBe(201);532        // Update with the same client id.533        const updateRes = await request(app)534            .post('/workspace/agents/audited')535            .set('X-Qwen-Client-Id', 'client_audit')536            .send({ description: 'a NEW description longer than ten chars' });537        expect(updateRes.status).toBe(200);538        expect(updateRes.body.changed).toBe(true);539        // Delete with the same client id.540        const deleteRes = await request(app)541            .delete('/workspace/agents/audited')542            .set('X-Qwen-Client-Id', 'client_audit');543        expect(deleteRes.status).toBe(204);544        const events = bridge.events;545        const agentEvents = events.filter((e) => e.type === 'agent_changed');546        expect(agentEvents).toHaveLength(3);547        // All three must be stamped with the originator id so audit /548        // echo-suppression on the SDK side can attribute them.549        for (const evt of agentEvents) {550            expect(evt.originatorClientId).toBe('client_audit');551        }552        // Sequence: created → updated → deleted.553        expect(agentEvents.map((e) => e.data.change)).toEqual(['created', 'updated', 'deleted']);554    });555    it('returns 400 invalid_agent_type for path-traversal-shaped agentType', async () => {556        const bridge = buildBridgeStub();557        const app = buildApp({ bridge, boundWorkspace: workspace });558        // The readdir-based scan in `findSubagentByNameAtLevel` already559        // protects against path traversal (filenames are matched, not560        // joined-and-resolved), but the route-level regex check fails561        // fast at the boundary so unsafe-shaped names never reach562        // SubagentManager.563        const res = await request(app).get('/workspace/agents/..%2Fetc%2Fpasswd');564        expect(res.status).toBe(400);565        expect(res.body.code).toBe('invalid_agent_type');566    });567    it('rejects 400 invalid_agent_type for over-long agentType', async () => {568        const bridge = buildBridgeStub();569        const app = buildApp({ bridge, boundWorkspace: workspace });570        const longName = 'a'.repeat(65);571        const res = await request(app).delete(`/workspace/agents/${longName}`);572        expect(res.status).toBe(400);573        expect(res.body.code).toBe('invalid_agent_type');574    });575    it('returns 500 agent_delete_partial when one level unlink silently fails', async () => {576        // Windows ignores Unix-style permission bits passed to577        // `fs.chmod` — the user-agents directory stays writable, the578        // unlink succeeds, and the partial-delete path this test579        // exercises is unreachable. SubagentManager's `unlink` import580        // (`import * as fs from 'fs/promises'`) creates a sealed581        // namespace object that vitest can't `spyOn`, so a per-platform582        // mock is also off-limits. The route logic itself is583        // platform-agnostic; the Ubuntu + macOS runs cover it. Mirrors584        // the `process.platform === 'win32'` early-return idiom used in585        // `customBanner.test.ts:232`.586        if (process.platform === 'win32')587            return;588        const bridge = buildBridgeStub();589        const app = buildApp({ bridge, boundWorkspace: workspace });590        // Set up a project-level agent.591        await request(app).post('/workspace/agents').send({592            name: 'partial-target',593            description: 'a description longer than ten chars',594            systemPrompt: 'you are a partial-target agent',595            scope: 'workspace',596        });597        // Set up a user-level shadow with the same name.598        await request(app).post('/workspace/agents').send({599            name: 'partial-target',600            description: 'a description longer than ten chars',601            systemPrompt: 'you are a partial-target user agent',602            scope: 'global',603        });604        // Lock the user-level agent's containing directory so the unlink605        // raises EACCES — `SubagentManager.deleteSubagent` swallows the606        // error and returns "success" because the project-level unlink607        // worked. Without this PR's per-level `fs.access` verification,608        // the route would 204 and publish a misleading `agent_changed`609        // event for the user-level file that's still on disk.610        const userAgentsDir = path.join(globalDir, 'agents');611        const userPath = path.join(userAgentsDir, 'partial-target.md');612        const originalMode = (await fs.stat(userAgentsDir)).mode;613        await fs.chmod(userAgentsDir, 0o555); // r-x: blocks unlink614        try {615            const res = await request(app).delete('/workspace/agents/partial-target');616            expect(res.status).toBe(500);617            expect(res.body.code).toBe('agent_delete_partial');618            expect(res.body.removedLevels).toEqual(['project']);619            expect(res.body.remainingLevels).toEqual(['user']);620            // Event fan-out: only one event for the level that actually621            // disappeared. The remaining level (still on disk) must NOT622            // emit a misleading deleted event.623            const events = bridge.events;624            const deletedEvents = events.filter((e) => e.type === 'agent_changed' &&625                e.data.change === 'deleted');626            expect(deletedEvents).toHaveLength(1);627            expect((deletedEvents[0]?.data).level).toBe('project');628            // Verify the user-level file is still on disk.629            await expect(fs.access(userPath)).resolves.toBeUndefined();630        }631        finally {632            // Restore permissions so afterEach's rmdir succeeds.633            await fs.chmod(userAgentsDir, originalMode);634        }635    });636    it('DELETE /workspace/agents/:agentType?scope=workspace removes only the project shadow', async () => {637        const bridge = buildBridgeStub();638        const app = buildApp({ bridge, boundWorkspace: workspace });639        await request(app).post('/workspace/agents').send({640            name: 'scoped-delete',641            description: 'a description longer than ten chars',642            systemPrompt: 'you are a scoped-delete project agent',643            scope: 'workspace',644        });645        await request(app).post('/workspace/agents').send({646            name: 'scoped-delete',647            description: 'a description longer than ten chars',648            systemPrompt: 'you are a scoped-delete user agent',649            scope: 'global',650        });651        const res = await request(app).delete('/workspace/agents/scoped-delete?scope=workspace');652        expect(res.status).toBe(204);653        // Project file gone; user file still exists.654        await expect(fs.access(path.join(workspace, QWEN_DIR, 'agents', 'scoped-delete.md'))).rejects.toMatchObject({ code: 'ENOENT' });655        await expect(fs.access(path.join(globalDir, 'agents', 'scoped-delete.md'))).resolves.toBeUndefined();656        // Exactly one agent_changed event, at project level.657        const events = bridge.events;658        const deleteEvents = events.filter((e) => e.type === 'agent_changed' &&659            e.data.change === 'deleted');660        expect(deleteEvents).toHaveLength(1);661        expect((deleteEvents[0]?.data).level).toBe('project');662    });663    it('POST /workspace/agents/:agentType?scope=global updates the user shadow', async () => {664        const bridge = buildBridgeStub();665        const app = buildApp({ bridge, boundWorkspace: workspace });666        await request(app).post('/workspace/agents').send({667            name: 'scoped-update',668            description: 'a description longer than ten chars',669            systemPrompt: 'you are a scoped-update project agent',670            scope: 'workspace',671        });672        await request(app).post('/workspace/agents').send({673            name: 'scoped-update',674            description: 'a description longer than ten chars',675            systemPrompt: 'you are a scoped-update user agent',676            scope: 'global',677        });678        const res = await request(app)679            .post('/workspace/agents/scoped-update?scope=global')680            .send({ description: 'NEW user-level description (longer than ten)' });681        expect(res.status).toBe(200);682        expect(res.body.changed).toBe(true);683        expect(res.body.agent.level).toBe('user');684        expect(res.body.agent.description).toBe('NEW user-level description (longer than ten)');685        // Project-level definition is untouched.686        const projectFile = await fs.readFile(path.join(workspace, QWEN_DIR, 'agents', 'scoped-update.md'), 'utf8');687        expect(projectFile).toContain('a description longer than ten chars');688    });689    it('rejects 422 when create has whitespace-only systemPrompt', async () => {690        const bridge = buildBridgeStub();691        const app = buildApp({ bridge, boundWorkspace: workspace });692        const res = await request(app).post('/workspace/agents').send({693            name: 'whitespace-prompt',694            description: 'a description longer than ten chars',695            systemPrompt: '   \n  \t  ',696            scope: 'workspace',697        });698        expect(res.status).toBe(422);699        expect(res.body.code).toBe('invalid_config');700        expect(res.body.error).toMatch(/systemPrompt.*non-empty/);701    });702    it('rejects 422 when update has whitespace-only systemPrompt', async () => {703        const bridge = buildBridgeStub();704        const app = buildApp({ bridge, boundWorkspace: workspace });705        await request(app).post('/workspace/agents').send({706            name: 'prompt-target',707            description: 'a description longer than ten chars',708            systemPrompt: 'you are a prompt-target agent',709            scope: 'workspace',710        });711        const res = await request(app)712            .post('/workspace/agents/prompt-target')713            .send({ systemPrompt: '\n\n  \t' });714        expect(res.status).toBe(422);715        expect(res.body.code).toBe('invalid_config');716    });717    it('toDetail.runConfig only emits the documented fields', async () => {718        const bridge = buildBridgeStub();719        const app = buildApp({ bridge, boundWorkspace: workspace });720        await request(app)721            .post('/workspace/agents')722            .send({723            name: 'detail-pick',724            description: 'a description longer than ten chars',725            systemPrompt: 'you are a detail-pick agent',726            scope: 'workspace',727            runConfig: { max_time_minutes: 5, max_turns: 7 },728        });729        const res = await request(app).get('/workspace/agents/detail-pick');730        expect(res.status).toBe(200);731        // Detail must contain ONLY the whitelisted runConfig keys; if732        // `SubagentConfig.runConfig` ever gains a new field in core, this733        // assertion fails until the route schema is updated explicitly.734        expect(Object.keys(res.body.runConfig).sort()).toEqual([735            'max_time_minutes',736            'max_turns',737        ]);738    });739    it('rejects 422 when update body has whitespace-only description', async () => {740        const bridge = buildBridgeStub();741        const app = buildApp({ bridge, boundWorkspace: workspace });742        await request(app).post('/workspace/agents').send({743            name: 'whitespace-target',744            description: 'a description longer than ten chars',745            systemPrompt: 'you are a whitespace target agent',746            scope: 'workspace',747        });748        // Update path used to silently accept "   " and overwrite the749        // description with blank — divergent from create which 422s.750        const res = await request(app)751            .post('/workspace/agents/whitespace-target')752            .send({ description: '   ' });753        expect(res.status).toBe(422);754        expect(res.body.code).toBe('invalid_config');755        expect(res.body.error).toMatch(/non-empty/);756    });757    it('detects no-op partial runConfig update (preserves omitted keys)', async () => {758        const bridge = buildBridgeStub();759        const app = buildApp({ bridge, boundWorkspace: workspace });760        await request(app)761            .post('/workspace/agents')762            .send({763            name: 'runconfig-noop',764            description: 'a description longer than ten chars',765            systemPrompt: 'you are a runconfig-noop agent',766            scope: 'workspace',767            runConfig: { max_time_minutes: 30, max_turns: 10 },768        });769        const eventsBefore = bridge770            .events.length;771        // Partial update with the SAME max_time_minutes value. Without the772        // fix, isNoOpUpdate compared `undefined !== existing.max_turns` →773        // true and re-wrote the file + emitted agent_changed.774        const res = await request(app)775            .post('/workspace/agents/runconfig-noop')776            .send({ runConfig: { max_time_minutes: 30 } });777        expect(res.status).toBe(200);778        expect(res.body.changed).toBe(false);779        const events = bridge.events;780        expect(events.length).toBe(eventsBefore);781    });782    it('detects real partial runConfig change and writes', async () => {783        const bridge = buildBridgeStub();784        const app = buildApp({ bridge, boundWorkspace: workspace });785        await request(app)786            .post('/workspace/agents')787            .send({788            name: 'runconfig-real',789            description: 'a description longer than ten chars',790            systemPrompt: 'you are a runconfig-real agent',791            scope: 'workspace',792            runConfig: { max_time_minutes: 30, max_turns: 10 },793        });794        const res = await request(app)795            .post('/workspace/agents/runconfig-real')796            .send({ runConfig: { max_time_minutes: 45 } });797        expect(res.status).toBe(200);798        expect(res.body.changed).toBe(true);799        // Merged result preserves max_turns from existing.800        expect(res.body.agent.runConfig).toEqual({801            max_time_minutes: 45,802            max_turns: 10,803        });804    });805    it('short-circuits no-op updates with changed: false and no event', async () => {806        const bridge = buildBridgeStub();807        const app = buildApp({ bridge, boundWorkspace: workspace });808        await request(app).post('/workspace/agents').send({809            name: 'noop-target',810            description: 'a description longer than ten chars',811            systemPrompt: 'you are a noop-target agent',812            scope: 'workspace',813        });814        const eventsBefore = bridge815            .events.length;816        const res = await request(app)817            .post('/workspace/agents/noop-target')818            .send({ description: 'a description longer than ten chars' });819        expect(res.status).toBe(200);820        expect(res.body.changed).toBe(false);821        // No new agent_changed event for the no-op update.822        const events = bridge.events;823        expect(events.length).toBe(eventsBefore);824    });825});826//# sourceMappingURL=workspace-agents.test.js.map
basant307/AI_Governance_Project · CoolFace