-
Notifications
You must be signed in to change notification settings - Fork 3k
feat(core): declarative agent frontmatter v1 — permissionMode bridge + maxTurns wiring + color allowlist (CC 2.1.168 parity) #4842
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
eca730a
7b83764
70889cb
9b903ee
6894984
745c9b2
550775a
1d29569
35185c5
2bfd20e
637b8b7
70a876d
167bb63
fabd030
98036e0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,134 @@ | ||
| /** | ||
| * @license | ||
| * Copyright 2025 Qwen | ||
| * SPDX-License-Identifier: Apache-2.0 | ||
| */ | ||
|
|
||
| import { describe, it, expect } from 'vitest'; | ||
| import { | ||
| PERMISSION_MODE_VALUES, | ||
| COLOR_VALUES, | ||
| claudePermissionModeToApprovalMode, | ||
| parseMaxTurns, | ||
| isPermissionMode, | ||
| isColor, | ||
| } from './agent-frontmatter-schema.js'; | ||
|
|
||
| describe('agent-frontmatter-schema', () => { | ||
| describe('enum constants — Claude Code 2.1.168 parity', () => { | ||
| it('PERMISSION_MODE_VALUES matches DL7 $E / kc constant exactly', () => { | ||
| expect([...PERMISSION_MODE_VALUES]).toEqual([ | ||
| 'acceptEdits', | ||
| 'auto', | ||
| 'bypassPermissions', | ||
| 'default', | ||
| 'dontAsk', | ||
| 'plan', | ||
| ]); | ||
| }); | ||
|
|
||
| it('COLOR_VALUES matches CC _Y allowlist exactly', () => { | ||
| expect([...COLOR_VALUES]).toEqual([ | ||
| 'red', | ||
| 'blue', | ||
| 'green', | ||
| 'yellow', | ||
| 'purple', | ||
| 'orange', | ||
| 'pink', | ||
| 'cyan', | ||
| ]); | ||
| }); | ||
| }); | ||
|
|
||
| describe('claudePermissionModeToApprovalMode bridge', () => { | ||
| it('maps all 6 CC permissionMode values', () => { | ||
| expect(claudePermissionModeToApprovalMode('default')).toBe('default'); | ||
| expect(claudePermissionModeToApprovalMode('plan')).toBe('plan'); | ||
| expect(claudePermissionModeToApprovalMode('acceptEdits')).toBe( | ||
| 'auto-edit', | ||
| ); | ||
| expect(claudePermissionModeToApprovalMode('auto')).toBe('auto-edit'); | ||
| expect(claudePermissionModeToApprovalMode('bypassPermissions')).toBe( | ||
| 'yolo', | ||
| ); | ||
| expect(claudePermissionModeToApprovalMode('dontAsk')).toBe('default'); | ||
| }); | ||
|
|
||
| it('returns undefined for unknown permissionMode', () => { | ||
| expect(claudePermissionModeToApprovalMode('not-a-mode')).toBeUndefined(); | ||
| expect(claudePermissionModeToApprovalMode('')).toBeUndefined(); | ||
| expect(claudePermissionModeToApprovalMode(undefined)).toBeUndefined(); | ||
| }); | ||
|
|
||
| it('does not walk the prototype chain for `__proto__` / `constructor`', () => { | ||
| // Implemented with `Map.get`, not a plain object lookup, so prototype | ||
| // keys cannot return Object.prototype / Function constructor. | ||
| expect(claudePermissionModeToApprovalMode('__proto__')).toBeUndefined(); | ||
| expect(claudePermissionModeToApprovalMode('constructor')).toBeUndefined(); | ||
| expect( | ||
| claudePermissionModeToApprovalMode('hasOwnProperty'), | ||
| ).toBeUndefined(); | ||
| expect(claudePermissionModeToApprovalMode('toString')).toBeUndefined(); | ||
| }); | ||
|
|
||
| it('preserves restrictive intent of dontAsk by mapping to default', () => { | ||
| // dontAsk in CC denies any tool call that would prompt the user. | ||
| // We map to `default` (which also requires approval) rather than | ||
| // `auto-edit` (which auto-approves). This preserves the restrictive | ||
| // intent. | ||
| expect(claudePermissionModeToApprovalMode('dontAsk')).toBe('default'); | ||
| }); | ||
| }); | ||
|
|
||
| describe('parseMaxTurns — DL7 number-or-numeric-string lenience', () => { | ||
| it('accepts positive integer number', () => { | ||
| expect(parseMaxTurns(50)).toBe(50); | ||
| }); | ||
|
|
||
| it('accepts positive integer string', () => { | ||
| expect(parseMaxTurns('50')).toBe(50); | ||
| }); | ||
|
|
||
| it('returns undefined for zero or negative numbers', () => { | ||
| expect(parseMaxTurns(0)).toBeUndefined(); | ||
| expect(parseMaxTurns(-1)).toBeUndefined(); | ||
| }); | ||
|
|
||
| it('returns undefined for non-integer numbers', () => { | ||
| expect(parseMaxTurns(5.5)).toBeUndefined(); | ||
| }); | ||
|
|
||
| it('returns undefined for non-numeric strings', () => { | ||
| expect(parseMaxTurns('many')).toBeUndefined(); | ||
| expect(parseMaxTurns('')).toBeUndefined(); | ||
| }); | ||
|
|
||
| it('returns undefined for null / undefined / non-numeric types', () => { | ||
| expect(parseMaxTurns(undefined)).toBeUndefined(); | ||
| expect(parseMaxTurns(null)).toBeUndefined(); | ||
| expect(parseMaxTurns(true)).toBeUndefined(); | ||
| expect(parseMaxTurns({})).toBeUndefined(); | ||
| }); | ||
| }); | ||
|
|
||
| describe('type guards', () => { | ||
| it('isPermissionMode — accepts every PERMISSION_MODE_VALUES, rejects others', () => { | ||
| for (const v of PERMISSION_MODE_VALUES) { | ||
| expect(isPermissionMode(v)).toBe(true); | ||
| } | ||
| expect(isPermissionMode('not-a-mode')).toBe(false); | ||
| expect(isPermissionMode('')).toBe(false); | ||
| expect(isPermissionMode(undefined)).toBe(false); | ||
| }); | ||
|
|
||
| it('isColor — accepts every COLOR_VALUES, rejects others (CC silently drops)', () => { | ||
| for (const v of COLOR_VALUES) { | ||
| expect(isColor(v)).toBe(true); | ||
| } | ||
| expect(isColor('magenta')).toBe(false); | ||
| expect(isColor('white')).toBe(false); | ||
| expect(isColor(undefined)).toBe(false); | ||
| }); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,119 @@ | ||
| /** | ||
| * @license | ||
| * Copyright 2025 Qwen | ||
| * SPDX-License-Identifier: Apache-2.0 | ||
| */ | ||
|
|
||
| /** | ||
| * @fileoverview Declarative-agent frontmatter schema constants and parsers. | ||
| * | ||
| * Mirrors Claude Code 2.1.168's `.claude/agents/<name>.md` schema verbatim so | ||
| * a user can drop a Claude Code agent file into `.qwen/agents/` and have it | ||
| * parse identically. The internal verification source (DL7 / Ig5 / GN / kc / | ||
| * P37 / _Y) is documented in `docs/declarative-agents-port.md`. | ||
| * | ||
| * Parsing follows DL7's "lenient" posture: invalid optional fields are dropped | ||
| * to undefined rather than thrown — the caller layer is responsible for | ||
| * deciding whether a dropped field surfaces a warning. This intentionally | ||
| * differs from the strict throw-on-invalid posture used for `approvalMode` | ||
| * elsewhere in the loader, because that field predates this port and changing | ||
| * its semantics would break existing `.qwen/agents/*.md` files. | ||
| */ | ||
|
|
||
| /** Permission mode enum (DL7 `$E` / `kc` constant). */ | ||
| export const PERMISSION_MODE_VALUES = [ | ||
| 'acceptEdits', | ||
| 'auto', | ||
| 'bypassPermissions', | ||
| 'default', | ||
| 'dontAsk', | ||
| 'plan', | ||
| ] as const; | ||
| export type PermissionModeValue = (typeof PERMISSION_MODE_VALUES)[number]; | ||
|
|
||
| /** Color allowlist (DL7 `_Y` constant). Values outside this list are silently dropped. */ | ||
| export const COLOR_VALUES = [ | ||
| 'red', | ||
| 'blue', | ||
| 'green', | ||
| 'yellow', | ||
| 'purple', | ||
| 'orange', | ||
| 'pink', | ||
| 'cyan', | ||
| ] as const; | ||
| export type ColorValue = (typeof COLOR_VALUES)[number]; | ||
|
|
||
| /** | ||
| * Mapping from Claude Code permissionMode → qwen-code approvalMode. | ||
| * | ||
| * Note: Claude's `dontAsk` denies any tool call that would prompt the user, | ||
| * making it restrictive. We map it to `default` (which also requires approval) | ||
| * rather than `auto-edit` (which auto-approves), preserving the restrictive | ||
| * intent. `bypassPermissions` is the Claude mode that auto-approves everything. | ||
| * | ||
| * Use `Map` instead of a plain `Record` so a caller passing `'__proto__'` or | ||
| * `'constructor'` cannot walk the prototype chain and get back a non-string | ||
| * value (e.g. `Object.prototype`). | ||
| */ | ||
| const PERMISSION_MODE_TO_APPROVAL_MODE = new Map<string, string>([ | ||
| ['default', 'default'], | ||
| ['plan', 'plan'], | ||
| ['acceptEdits', 'auto-edit'], | ||
| ['auto', 'auto-edit'], | ||
| ['bypassPermissions', 'yolo'], | ||
| ['dontAsk', 'default'], | ||
| ]); | ||
|
|
||
| /** | ||
| * Map a Claude Code `permissionMode` frontmatter value to a qwen-code | ||
| * `approvalMode` value. Returns `undefined` for unknown / falsy input. | ||
| * | ||
| * Disambiguated from `packages/core/src/tools/agent/agent.ts`'s internal | ||
| * `permissionModeToApprovalMode`, which maps the qwen `PermissionMode` enum | ||
| * to the qwen `ApprovalMode` enum (different domain entirely). Importing the | ||
| * wrong symbol via IDE auto-complete would silently return `undefined` for | ||
| * every qwen enum value, hence the longer name. | ||
| */ | ||
| export function claudePermissionModeToApprovalMode( | ||
| permissionMode: string | undefined, | ||
| ): string | undefined { | ||
| if (!permissionMode) return undefined; | ||
| return PERMISSION_MODE_TO_APPROVAL_MODE.get(permissionMode); | ||
| } | ||
|
|
||
| /** | ||
| * Parse a maxTurns value. Accepts a positive integer number or numeric string. | ||
| * Returns `undefined` for anything else (matches DL7 `W46`). | ||
| */ | ||
| export function parseMaxTurns(value: unknown): number | undefined { | ||
| let candidate: number; | ||
| if (typeof value === 'number') { | ||
| candidate = value; | ||
| } else if (typeof value === 'string' && value.length > 0) { | ||
| candidate = Number(value); | ||
| if (Number.isNaN(candidate)) return undefined; | ||
| } else { | ||
| return undefined; | ||
| } | ||
| if (!Number.isFinite(candidate)) return undefined; | ||
| if (!Number.isInteger(candidate)) return undefined; | ||
| if (candidate <= 0) return undefined; | ||
| return candidate; | ||
| } | ||
|
|
||
| /** Type guard: value is a valid PERMISSION_MODE_VALUES literal. */ | ||
| export function isPermissionMode(value: unknown): value is PermissionModeValue { | ||
| return ( | ||
| typeof value === 'string' && | ||
| (PERMISSION_MODE_VALUES as readonly string[]).includes(value) | ||
| ); | ||
| } | ||
|
|
||
| /** Type guard: value is a valid COLOR_VALUES literal. */ | ||
| export function isColor(value: unknown): value is ColorValue { | ||
| return ( | ||
| typeof value === 'string' && | ||
| (COLOR_VALUES as readonly string[]).includes(value) | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -37,5 +37,14 @@ export { | |||||||||||||||||||
| // Validation system | ||||||||||||||||||||
| export { SubagentValidator } from './validation.js'; | ||||||||||||||||||||
|
|
||||||||||||||||||||
| // NOTE: declarative-agent schema helpers (e.g. | ||||||||||||||||||||
| // claudePermissionModeToApprovalMode, parseMaxTurns, isPermissionMode) | ||||||||||||||||||||
| // live in `agent-frontmatter-schema.ts` and are intentionally NOT | ||||||||||||||||||||
| // re-exported here — they are internal to the `SubagentManager` / | ||||||||||||||||||||
| // `claude-converter` parse paths and locking their names in the | ||||||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] The comment says these helpers are "internal to the
Suggested change
— qwen3.7-max via Qwen Code /review |
||||||||||||||||||||
| // package's public API would constrain follow-up PRs (e.g. when | ||||||||||||||||||||
| // `js-yaml` lands and the schema shape changes). Re-introduce specific | ||||||||||||||||||||
| // exports here when a cross-package caller actually needs them. | ||||||||||||||||||||
|
|
||||||||||||||||||||
| // Main management class | ||||||||||||||||||||
| export { SubagentManager } from './subagent-manager.js'; | ||||||||||||||||||||
Uh oh!
There was an error while loading. Please reload this page.