basant307/AI_Governance_Project
048
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