mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 12:55:51 +00:00
feat: 注册所有新命令到命令系统和工具注册表
- commands.ts: 注册所有新命令(memory-stores、vault、schedule 等), 移除 require() 动态加载,统一为 ESM import - tools.ts: 注册 LocalMemoryRecallTool、VaultHttpFetchTool - 补充命令测试(bridge-kick、commit、commit-push-pr、init-verifiers) - 补充工具测试(AgentTool、RemoteTrigger、SkillTool、WebFetch、WebSearch) - 集成测试:autonomy-lifecycle-user-flow 更新 - 探测脚本和功能文档 Co-Authored-By: glm-5-turbo <zai-org@claude-code-best.win>
This commit is contained in:
@@ -38,6 +38,7 @@ import {
|
||||
type BackgroundRemoteSessionPrecondition,
|
||||
} from 'src/tasks/RemoteAgentTask/RemoteAgentTask.js';
|
||||
import { assembleToolPool } from 'src/tools.js';
|
||||
import { filterParentToolsForFork } from 'src/utils/agentToolFilter.js';
|
||||
import { asAgentId } from 'src/types/ids.js';
|
||||
import { runWithAgentContext, type SubagentContext } from 'src/utils/agentContext.js';
|
||||
import { isAgentSwarmsEnabled } from 'src/utils/agentSwarmsEnabled.js';
|
||||
@@ -148,12 +149,6 @@ const baseInputSchema = lazySchema(() =>
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('Set to true to run this agent in the background. You will be notified when it completes.'),
|
||||
fork: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
'Set to true to fork from the parent conversation context. The child inherits full history, system prompt, and model. Requires FORK_SUBAGENT feature flag.',
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -197,23 +192,24 @@ const fullInputSchema = lazySchema(() => {
|
||||
// type, but call() destructures via the explicit AgentToolInput type below
|
||||
// which always includes all optional fields.
|
||||
export const inputSchema = lazySchema(() => {
|
||||
const base = feature('KAIROS') ? fullInputSchema() : fullInputSchema().omit({ cwd: true });
|
||||
return isBackgroundTasksDisabled
|
||||
? !isForkSubagentEnabled()
|
||||
? base.omit({ run_in_background: true, fork: true })
|
||||
: base.omit({ run_in_background: true })
|
||||
: !isForkSubagentEnabled()
|
||||
? base.omit({ fork: true })
|
||||
: base;
|
||||
const schema = feature('KAIROS') ? fullInputSchema() : fullInputSchema().omit({ cwd: true });
|
||||
|
||||
// GrowthBook-in-lazySchema is acceptable here (unlike subagent_type, which
|
||||
// was removed in 906da6c723): the divergence window is one-session-per-
|
||||
// gate-flip via _CACHED_MAY_BE_STALE disk read, and worst case is either
|
||||
// "schema shows a no-op param" (gate flips on mid-session: param ignored
|
||||
// by forceAsync) or "schema hides a param that would've worked" (gate
|
||||
// flips off mid-session: everything still runs async via memoized
|
||||
// forceAsync). No Zod rejection, no crash — unlike required→optional.
|
||||
return isBackgroundTasksDisabled || isForkSubagentEnabled() ? schema.omit({ run_in_background: true }) : schema;
|
||||
});
|
||||
type InputSchema = ReturnType<typeof inputSchema>;
|
||||
|
||||
// Explicit type widens the schema inference to always include all optional
|
||||
// fields even when .omit() strips them for gating (cwd, run_in_background).
|
||||
// subagent_type is optional; call() defaults it to general-purpose.
|
||||
// fork is gated by FORK_SUBAGENT flag; when omitted or flag is off, no fork.
|
||||
// subagent_type is optional; call() defaults it to general-purpose when the
|
||||
// fork gate is off, or routes to the fork path when the gate is on.
|
||||
type AgentToolInput = z.infer<ReturnType<typeof baseInputSchema>> & {
|
||||
fork?: boolean;
|
||||
name?: string;
|
||||
team_name?: string;
|
||||
mode?: z.infer<ReturnType<typeof permissionModeSchema>>;
|
||||
@@ -327,7 +323,6 @@ export const AgentTool = buildTool({
|
||||
{
|
||||
prompt,
|
||||
subagent_type,
|
||||
fork,
|
||||
description,
|
||||
model: modelParam,
|
||||
run_in_background,
|
||||
@@ -412,11 +407,12 @@ export const AgentTool = buildTool({
|
||||
return { data: spawnResult } as unknown as { data: Output };
|
||||
}
|
||||
|
||||
// Fork routing: explicit `fork: true` parameter triggers the fork path
|
||||
// (inherits parent context and model). Requires FORK_SUBAGENT flag.
|
||||
// subagent_type is ignored when fork takes effect.
|
||||
const isForkPath = fork === true && isForkSubagentEnabled();
|
||||
const effectiveType = subagent_type ?? GENERAL_PURPOSE_AGENT.agentType;
|
||||
// Fork subagent experiment routing:
|
||||
// - subagent_type set: use it (explicit wins)
|
||||
// - subagent_type omitted, gate on: fork path (undefined)
|
||||
// - subagent_type omitted, gate off: default general-purpose
|
||||
const effectiveType = subagent_type ?? (isForkSubagentEnabled() ? undefined : GENERAL_PURPOSE_AGENT.agentType);
|
||||
const isForkPath = effectiveType === undefined;
|
||||
|
||||
let selectedAgent: AgentDefinition;
|
||||
if (isForkPath) {
|
||||
@@ -697,6 +693,10 @@ export const AgentTool = buildTool({
|
||||
// dependency issues during test module loading.
|
||||
const isCoordinator = feature('COORDINATOR_MODE') ? isEnvTruthy(process.env.CLAUDE_CODE_COORDINATOR_MODE) : false;
|
||||
|
||||
// Fork subagent experiment: force ALL spawns async for a unified
|
||||
// <task-notification> interaction model (not just fork spawns — all of them).
|
||||
const forceAsync = isForkSubagentEnabled();
|
||||
|
||||
// Assistant mode: force all agents async. Synchronous subagents hold the
|
||||
// main loop's turn open until they complete — the daemon's inputQueue
|
||||
// backs up, and the first overdue cron catch-up on spawn becomes N
|
||||
@@ -710,6 +710,7 @@ export const AgentTool = buildTool({
|
||||
(run_in_background === true ||
|
||||
selectedAgent.background === true ||
|
||||
isCoordinator ||
|
||||
forceAsync ||
|
||||
assistantForceAsync ||
|
||||
(proactiveModule?.isProactiveActive() ?? false)) &&
|
||||
!isBackgroundTasksDisabled;
|
||||
@@ -778,7 +779,7 @@ export const AgentTool = buildTool({
|
||||
: enhancedSystemPrompt && !worktreeInfo && !cwd
|
||||
? { systemPrompt: asSystemPrompt(enhancedSystemPrompt) }
|
||||
: undefined,
|
||||
availableTools: isForkPath ? toolUseContext.options.tools : workerTools,
|
||||
availableTools: isForkPath ? filterParentToolsForFork(toolUseContext.options.tools) : workerTools,
|
||||
// Pass parent conversation when the fork-subagent path needs full
|
||||
// context. useExactTools inherits thinkingConfig (runAgent.ts:624).
|
||||
forkContextMessages: isForkPath ? toolUseContext.messages : undefined,
|
||||
@@ -889,7 +890,7 @@ export const AgentTool = buildTool({
|
||||
toolUseContext,
|
||||
rootSetAppState,
|
||||
agentIdForCleanup: asyncAgentId,
|
||||
enableSummarization: isCoordinator || isForkPath || getSdkAgentProgressSummariesEnabled(),
|
||||
enableSummarization: isCoordinator || isForkSubagentEnabled() || getSdkAgentProgressSummariesEnabled(),
|
||||
getWorktreeResult: cleanupWorktreeIfNeeded,
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, mock, test } from 'bun:test'
|
||||
|
||||
mock.module('bun:bundle', () => ({
|
||||
feature: (_name: string) => true,
|
||||
}))
|
||||
|
||||
describe('resumeAgent', () => {
|
||||
test('module exports resumeAgentBackground', async () => {
|
||||
const mod = await import('../resumeAgent.js')
|
||||
expect(typeof mod.resumeAgentBackground).toBe('function')
|
||||
})
|
||||
|
||||
test('module exports ResumeAgentResult type (compile-time)', async () => {
|
||||
// TypeScript-only: just ensure the module loads cleanly so the type
|
||||
// surface is in the patch coverage trace.
|
||||
const mod = await import('../resumeAgent.js')
|
||||
expect(mod).toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -6,6 +6,7 @@ import type { CanUseToolFn } from 'src/hooks/useCanUseTool.js'
|
||||
import type { ToolUseContext } from 'src/Tool.js'
|
||||
import { registerAsyncAgent } from 'src/tasks/LocalAgentTask/LocalAgentTask.js'
|
||||
import { assembleToolPool } from 'src/tools.js'
|
||||
import { filterParentToolsForFork } from 'src/utils/agentToolFilter.js'
|
||||
import { asAgentId } from 'src/types/ids.js'
|
||||
import { runWithAgentContext } from 'src/utils/agentContext.js'
|
||||
import { runWithCwdOverride } from 'src/utils/cwd.js'
|
||||
@@ -160,7 +161,7 @@ export async function resumeAgentBackground({
|
||||
mode: selectedAgent.permissionMode ?? 'acceptEdits',
|
||||
}
|
||||
const workerTools = isResumedFork
|
||||
? toolUseContext.options.tools
|
||||
? filterParentToolsForFork(toolUseContext.options.tools)
|
||||
: assembleToolPool(workerPermissionContext, appState.mcp.tools)
|
||||
|
||||
const runAgentParams: Parameters<typeof runAgent>[0] = {
|
||||
|
||||
@@ -1,17 +1,31 @@
|
||||
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
mock,
|
||||
test,
|
||||
} from 'bun:test'
|
||||
import { authMock } from '../../../../../../tests/mocks/auth'
|
||||
import { setupAxiosMock } from '../../../../../../tests/mocks/axios'
|
||||
|
||||
let requestStatus = 200
|
||||
const auditRecords: Record<string, unknown>[] = []
|
||||
|
||||
mock.module('axios', () => ({
|
||||
default: {
|
||||
request: async () => ({
|
||||
status: requestStatus,
|
||||
data: { ok: requestStatus >= 200 && requestStatus < 300 },
|
||||
}),
|
||||
},
|
||||
}))
|
||||
const axiosHandle = setupAxiosMock()
|
||||
axiosHandle.stubs.request = async () => ({
|
||||
status: requestStatus,
|
||||
data: { ok: requestStatus >= 200 && requestStatus < 300 },
|
||||
})
|
||||
|
||||
beforeAll(() => {
|
||||
axiosHandle.useStubs = true
|
||||
})
|
||||
afterAll(() => {
|
||||
axiosHandle.useStubs = false
|
||||
})
|
||||
|
||||
mock.module('src/utils/auth.js', authMock)
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
MAX_LISTING_DESC_CHARS,
|
||||
formatCommandsWithinBudget,
|
||||
} from '../prompt.js'
|
||||
import type { Command } from 'src/types/command.js'
|
||||
|
||||
// Helper to build a minimal prompt Command
|
||||
function makeCmd(
|
||||
name: string,
|
||||
description: string,
|
||||
whenToUse?: string,
|
||||
): Command {
|
||||
return {
|
||||
type: 'prompt',
|
||||
name,
|
||||
description,
|
||||
whenToUse,
|
||||
hasUserSpecifiedDescription: false,
|
||||
allowedTools: [],
|
||||
disableModelInvocation: false,
|
||||
userInvocable: true,
|
||||
isHidden: false,
|
||||
progressMessage: 'running',
|
||||
userFacingName: () => name,
|
||||
source: 'userSettings',
|
||||
loadedFrom: 'skills',
|
||||
async getPromptForCommand() {
|
||||
return [{ type: 'text' as const, text: '' }]
|
||||
},
|
||||
} as unknown as Command
|
||||
}
|
||||
|
||||
describe('MAX_LISTING_DESC_CHARS', () => {
|
||||
test('cap is 1536 (not the old 250)', () => {
|
||||
// Regression: v2.1.117 upgraded the per-entry description cap from 250 → 1536
|
||||
expect(MAX_LISTING_DESC_CHARS).toBe(1536)
|
||||
})
|
||||
|
||||
test('description longer than 1536 chars is truncated', () => {
|
||||
const longDesc = 'x'.repeat(2000)
|
||||
const cmd = makeCmd('test-skill', longDesc)
|
||||
const result = formatCommandsWithinBudget([cmd], 200_000)
|
||||
// Should contain truncation ellipsis and must not contain the full 2000-char desc
|
||||
expect(result).toContain('…')
|
||||
// The entry itself should not exceed 1536 chars of description content
|
||||
// (the - name: prefix adds overhead we ignore here)
|
||||
expect(result.length).toBeLessThan(2000)
|
||||
})
|
||||
|
||||
test('description of exactly 1536 chars is NOT truncated', () => {
|
||||
const desc = 'a'.repeat(1536)
|
||||
const cmd = makeCmd('my-skill', desc)
|
||||
const result = formatCommandsWithinBudget([cmd], 200_000)
|
||||
expect(result).not.toContain('…')
|
||||
expect(result).toContain(desc)
|
||||
})
|
||||
|
||||
test('description longer than 250 but shorter than 1536 is NOT truncated by the cap', () => {
|
||||
// Regression: with old cap=250, a 300-char description would be truncated.
|
||||
// With cap=1536 it must pass through intact.
|
||||
const desc = 'b'.repeat(300)
|
||||
const cmd = makeCmd('another-skill', desc)
|
||||
const result = formatCommandsWithinBudget([cmd], 200_000)
|
||||
expect(result).toContain(desc)
|
||||
})
|
||||
})
|
||||
@@ -26,7 +26,8 @@ export const DEFAULT_CHAR_BUDGET = 8_000 // Fallback: 1% of 200k × 4
|
||||
// full content on invoke, so verbose whenToUse strings waste turn-1 cache_creation
|
||||
// tokens without improving match rate. Applies to all entries, including bundled,
|
||||
// since the cap is generous enough to preserve the core use case.
|
||||
export const MAX_LISTING_DESC_CHARS = 250
|
||||
// v2.1.117: raised from 250 → 1536 to allow richer skill descriptions.
|
||||
export const MAX_LISTING_DESC_CHARS = 1536
|
||||
|
||||
export function getCharBudget(contextWindowTokens?: number): number {
|
||||
if (Number(process.env.SLASH_COMMAND_TOOL_CHAR_BUDGET)) {
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { beforeEach, describe, expect, mock, test } from 'bun:test'
|
||||
import {
|
||||
afterAll,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
mock,
|
||||
test,
|
||||
} from 'bun:test'
|
||||
import { logMock } from '../../../../../../tests/mocks/log'
|
||||
import { setupAxiosMock } from '../../../../../../tests/mocks/axios'
|
||||
|
||||
type MockAxiosResponse = {
|
||||
data: ArrayBuffer
|
||||
@@ -18,17 +27,12 @@ type MockAxiosError = Error & {
|
||||
|
||||
let getMock: (url: string) => Promise<MockAxiosResponse>
|
||||
|
||||
mock.module('axios', () => {
|
||||
const axiosMock = {
|
||||
get: (url: string) => getMock(url),
|
||||
isAxiosError: (error: unknown): error is MockAxiosError =>
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
(error as { isAxiosError?: unknown }).isAxiosError === true,
|
||||
}
|
||||
|
||||
return { default: axiosMock }
|
||||
})
|
||||
const axiosHandle = setupAxiosMock()
|
||||
axiosHandle.stubs.get = (url: string) => getMock(url)
|
||||
axiosHandle.stubs.isAxiosError = (error: unknown): boolean =>
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
(error as { isAxiosError?: unknown }).isAxiosError === true
|
||||
|
||||
mock.module('src/services/analytics/index.js', () => ({
|
||||
logEvent: () => {},
|
||||
@@ -67,6 +71,14 @@ beforeEach(() => {
|
||||
})
|
||||
})
|
||||
|
||||
beforeAll(() => {
|
||||
axiosHandle.useStubs = true
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
axiosHandle.useStubs = false
|
||||
})
|
||||
|
||||
describe('WebFetch response headers', () => {
|
||||
test('reads redirect Location from AxiosHeaders-style get()', async () => {
|
||||
getMock = async () => {
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { describe, expect, mock, test } from 'bun:test'
|
||||
import { afterAll, describe, expect, mock, test } from 'bun:test'
|
||||
import { setupAxiosMock } from '../../../../../../tests/mocks/axios'
|
||||
|
||||
// Each test below calls `mock.module('axios', ...)` per-test. Re-register a
|
||||
// spread-real axios mock at end-of-file so the per-test stubs do not leak
|
||||
// into subsequent test files (mock.module is process-global, last-write-wins).
|
||||
afterAll(() => {
|
||||
setupAxiosMock()
|
||||
})
|
||||
|
||||
const _abortMock = () => ({
|
||||
AbortError: class AbortError extends Error {
|
||||
|
||||
@@ -1,4 +1,22 @@
|
||||
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
mock,
|
||||
test,
|
||||
} from 'bun:test'
|
||||
import { setupAxiosMock } from '../../../../../../tests/mocks/axios'
|
||||
|
||||
// Each test below calls `mock.module('axios', ...)` per-test. Without an
|
||||
// afterAll cleanup, the LAST per-test stub leaks into every test file that
|
||||
// runs after this one (mock.module is process-global, last-write-wins). The
|
||||
// spread-real mock registered here at the end re-routes axios to the real
|
||||
// module, undoing the stub leakage so later suites see real axios.
|
||||
afterAll(() => {
|
||||
setupAxiosMock()
|
||||
})
|
||||
|
||||
// Defensive mock: agent.test.ts mocks config.js which can corrupt Bun's
|
||||
// src/* path alias resolution. Provide AbortError directly so the dynamic
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { afterEach, describe, expect, mock, test } from 'bun:test'
|
||||
import { afterAll, afterEach, describe, expect, mock, test } from 'bun:test'
|
||||
import { setupAxiosMock } from '../../../../../../tests/mocks/axios'
|
||||
|
||||
// Each test below calls `mock.module('axios', ...)` per-test. Re-register a
|
||||
// spread-real axios mock at end-of-file so the per-test stubs do not leak
|
||||
// into subsequent test files (mock.module is process-global, last-write-wins).
|
||||
afterAll(() => {
|
||||
setupAxiosMock()
|
||||
})
|
||||
|
||||
const _abortMock = () => ({
|
||||
AbortError: class AbortError extends Error {
|
||||
|
||||
Reference in New Issue
Block a user