mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-18 06:15:51 +00:00
feat: 增强 auto mode 的易用性 (#312)
* feat: poor 模式降级 yolo 审阅模型 * feat: 为多模块添加 Langfuse tracing 支持 在 web search、agent creation、away summary、token estimation、 skill improvement 等模块中集成 Langfuse trace,并透传至 compact/apiQueryHook/execPromptHook 等调用链。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: 让 auto mode 记录回主 trace * fix: reopen auto mode prompt when classifier is unavailable * fix: 修复 auto mode 情况下, llm 报错导致弹窗也不打开的问题 --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,153 +1,136 @@
|
||||
import { mock, describe, expect, test } from "bun:test";
|
||||
import { mock, describe, expect, test } from 'bun:test'
|
||||
import { createFileStateCacheWithSizeLimit } from '../../../utils/fileStateCache.js'
|
||||
import { createSubagentContext } from '../../../utils/forkedAgent.js'
|
||||
import { getEmptyToolPermissionContext } from '../../../Tool.js'
|
||||
|
||||
// Mock log.ts to cut the heavy dependency chain
|
||||
mock.module("src/utils/log.ts", () => ({
|
||||
mock.module('src/utils/log.ts', () => ({
|
||||
logError: () => {},
|
||||
logToFile: () => {},
|
||||
getLogDisplayTitle: () => "",
|
||||
getLogDisplayTitle: () => '',
|
||||
logEvent: () => {},
|
||||
logMCPError: () => {},
|
||||
logMCPDebug: () => {},
|
||||
dateToFilename: (d: Date) => d.toISOString().replace(/[:.]/g, "-"),
|
||||
getLogFilePath: () => "/tmp/mock-log",
|
||||
dateToFilename: (d: Date) => d.toISOString().replace(/[:.]/g, '-'),
|
||||
getLogFilePath: () => '/tmp/mock-log',
|
||||
attachErrorLogSink: () => {},
|
||||
getInMemoryErrors: () => [],
|
||||
loadErrorLogs: async () => [],
|
||||
getErrorLogByIndex: async () => null,
|
||||
captureAPIRequest: () => {},
|
||||
_resetErrorLogForTesting: () => {},
|
||||
}));
|
||||
}))
|
||||
|
||||
const {
|
||||
getDenyRuleForTool,
|
||||
getAskRuleForTool,
|
||||
getDenyRuleForAgent,
|
||||
filterDeniedAgents,
|
||||
} = await import("../permissions");
|
||||
} = await import('../permissions')
|
||||
|
||||
import { getEmptyToolPermissionContext } from "../../../Tool";
|
||||
|
||||
// ─── Helper ─────────────────────────────────────────────────────────────
|
||||
|
||||
function makeContext(opts: {
|
||||
denyRules?: string[];
|
||||
askRules?: string[];
|
||||
}) {
|
||||
const ctx = getEmptyToolPermissionContext();
|
||||
const deny: Record<string, string[]> = {};
|
||||
const ask: Record<string, string[]> = {};
|
||||
|
||||
// alwaysDenyRules stores raw rule strings — getDenyRules() calls
|
||||
// permissionRuleValueFromString internally
|
||||
if (opts.denyRules?.length) {
|
||||
deny["localSettings"] = opts.denyRules;
|
||||
}
|
||||
if (opts.askRules?.length) {
|
||||
ask["localSettings"] = opts.askRules;
|
||||
}
|
||||
|
||||
return {
|
||||
...ctx,
|
||||
alwaysDenyRules: deny,
|
||||
alwaysAskRules: ask,
|
||||
} as any;
|
||||
function makeContext(opts: { denyRules?: string[]; askRules?: string[] }) {
|
||||
const ctx = getEmptyToolPermissionContext()
|
||||
const deny: Record<string, string[]> = {}
|
||||
const ask: Record<string, string[]> = {}
|
||||
if (opts.denyRules?.length) deny.localSettings = opts.denyRules
|
||||
if (opts.askRules?.length) ask.localSettings = opts.askRules
|
||||
return { ...ctx, alwaysDenyRules: deny, alwaysAskRules: ask } as any
|
||||
}
|
||||
|
||||
function makeTool(name: string, mcpInfo?: { serverName: string; toolName: string }) {
|
||||
return { name, mcpInfo };
|
||||
return { name, mcpInfo }
|
||||
}
|
||||
|
||||
// ─── getDenyRuleForTool ─────────────────────────────────────────────────
|
||||
describe('getDenyRuleForTool', () => {
|
||||
test('returns null when no deny rules', () => {
|
||||
const ctx = makeContext({})
|
||||
expect(getDenyRuleForTool(ctx, makeTool('Bash'))).toBeNull()
|
||||
})
|
||||
test('returns matching deny rule for tool', () => {
|
||||
const ctx = makeContext({ denyRules: ['Bash'] })
|
||||
const result = getDenyRuleForTool(ctx, makeTool('Bash'))
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.ruleValue.toolName).toBe('Bash')
|
||||
})
|
||||
test('returns null for non-matching tool', () => {
|
||||
const ctx = makeContext({ denyRules: ['Bash'] })
|
||||
expect(getDenyRuleForTool(ctx, makeTool('Read'))).toBeNull()
|
||||
})
|
||||
test('rule with content does not match whole-tool deny', () => {
|
||||
const ctx = makeContext({ denyRules: ['Bash(rm -rf)'] })
|
||||
const result = getDenyRuleForTool(ctx, makeTool('Bash'))
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("getDenyRuleForTool", () => {
|
||||
test("returns null when no deny rules", () => {
|
||||
const ctx = makeContext({});
|
||||
expect(getDenyRuleForTool(ctx, makeTool("Bash"))).toBeNull();
|
||||
});
|
||||
describe('getAskRuleForTool', () => {
|
||||
test('returns null when no ask rules', () => {
|
||||
const ctx = makeContext({})
|
||||
expect(getAskRuleForTool(ctx, makeTool('Bash'))).toBeNull()
|
||||
})
|
||||
test('returns matching ask rule', () => {
|
||||
const ctx = makeContext({ askRules: ['Write'] })
|
||||
const result = getAskRuleForTool(ctx, makeTool('Write'))
|
||||
expect(result).not.toBeNull()
|
||||
})
|
||||
test('returns null for non-matching tool', () => {
|
||||
const ctx = makeContext({ askRules: ['Write'] })
|
||||
expect(getAskRuleForTool(ctx, makeTool('Bash'))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
test("returns matching deny rule for tool", () => {
|
||||
const ctx = makeContext({ denyRules: ["Bash"] });
|
||||
const result = getDenyRuleForTool(ctx, makeTool("Bash"));
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.ruleValue.toolName).toBe("Bash");
|
||||
});
|
||||
describe('getDenyRuleForAgent', () => {
|
||||
test('returns null when no deny rules', () => {
|
||||
const ctx = makeContext({})
|
||||
expect(getDenyRuleForAgent(ctx, 'Agent', 'Explore')).toBeNull()
|
||||
})
|
||||
test('returns matching deny rule for agent type', () => {
|
||||
const ctx = makeContext({ denyRules: ['Agent(Explore)'] })
|
||||
const result = getDenyRuleForAgent(ctx, 'Agent', 'Explore')
|
||||
expect(result).not.toBeNull()
|
||||
})
|
||||
test('returns null for non-matching agent type', () => {
|
||||
const ctx = makeContext({ denyRules: ['Agent(Explore)'] })
|
||||
expect(getDenyRuleForAgent(ctx, 'Agent', 'Research')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
test("returns null for non-matching tool", () => {
|
||||
const ctx = makeContext({ denyRules: ["Bash"] });
|
||||
expect(getDenyRuleForTool(ctx, makeTool("Read"))).toBeNull();
|
||||
});
|
||||
describe('Langfuse trace propagation', () => {
|
||||
test('subagent context preserves parent trace for nested side queries', () => {
|
||||
const parentTrace = { id: 'parent-trace' } as never
|
||||
const parentContext = {
|
||||
...getEmptyToolPermissionContext(),
|
||||
messages: [],
|
||||
abortController: new AbortController(),
|
||||
readFileState: createFileStateCacheWithSizeLimit(1),
|
||||
getAppState: () => ({ toolPermissionContext: getEmptyToolPermissionContext() }),
|
||||
setAppState: () => {},
|
||||
updateFileHistoryState: () => {},
|
||||
updateAttributionState: () => {},
|
||||
setInProgressToolUseIDs: () => {},
|
||||
setResponseLength: () => {},
|
||||
langfuseTrace: parentTrace,
|
||||
} as never
|
||||
const subagentContext = createSubagentContext(parentContext)
|
||||
expect(subagentContext.langfuseRootTrace).toBe(parentTrace)
|
||||
})
|
||||
})
|
||||
|
||||
test("rule with content does not match whole-tool deny", () => {
|
||||
// getDenyRuleForTool uses toolMatchesRule which requires ruleContent === undefined
|
||||
// Rules like "Bash(rm -rf)" only match specific invocations, not the entire tool
|
||||
const ctx = makeContext({ denyRules: ["Bash(rm -rf)"] });
|
||||
const result = getDenyRuleForTool(ctx, makeTool("Bash"));
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getAskRuleForTool ──────────────────────────────────────────────────
|
||||
|
||||
describe("getAskRuleForTool", () => {
|
||||
test("returns null when no ask rules", () => {
|
||||
const ctx = makeContext({});
|
||||
expect(getAskRuleForTool(ctx, makeTool("Bash"))).toBeNull();
|
||||
});
|
||||
|
||||
test("returns matching ask rule", () => {
|
||||
const ctx = makeContext({ askRules: ["Write"] });
|
||||
const result = getAskRuleForTool(ctx, makeTool("Write"));
|
||||
expect(result).not.toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for non-matching tool", () => {
|
||||
const ctx = makeContext({ askRules: ["Write"] });
|
||||
expect(getAskRuleForTool(ctx, makeTool("Bash"))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getDenyRuleForAgent ────────────────────────────────────────────────
|
||||
|
||||
describe("getDenyRuleForAgent", () => {
|
||||
test("returns null when no deny rules", () => {
|
||||
const ctx = makeContext({});
|
||||
expect(getDenyRuleForAgent(ctx, "Agent", "Explore")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns matching deny rule for agent type", () => {
|
||||
const ctx = makeContext({ denyRules: ["Agent(Explore)"] });
|
||||
const result = getDenyRuleForAgent(ctx, "Agent", "Explore");
|
||||
expect(result).not.toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for non-matching agent type", () => {
|
||||
const ctx = makeContext({ denyRules: ["Agent(Explore)"] });
|
||||
expect(getDenyRuleForAgent(ctx, "Agent", "Research")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── filterDeniedAgents ─────────────────────────────────────────────────
|
||||
|
||||
describe("filterDeniedAgents", () => {
|
||||
test("returns all agents when no deny rules", () => {
|
||||
const ctx = makeContext({});
|
||||
const agents = [{ agentType: "Explore" }, { agentType: "Research" }];
|
||||
expect(filterDeniedAgents(agents, ctx, "Agent")).toEqual(agents);
|
||||
});
|
||||
|
||||
test("filters out denied agent type", () => {
|
||||
const ctx = makeContext({ denyRules: ["Agent(Explore)"] });
|
||||
const agents = [{ agentType: "Explore" }, { agentType: "Research" }];
|
||||
const result = filterDeniedAgents(agents, ctx, "Agent");
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]!.agentType).toBe("Research");
|
||||
});
|
||||
|
||||
test("returns empty array when all agents denied", () => {
|
||||
const ctx = makeContext({
|
||||
denyRules: ["Agent(Explore)", "Agent(Research)"],
|
||||
});
|
||||
const agents = [{ agentType: "Explore" }, { agentType: "Research" }];
|
||||
expect(filterDeniedAgents(agents, ctx, "Agent")).toEqual([]);
|
||||
});
|
||||
});
|
||||
describe('filterDeniedAgents', () => {
|
||||
test('returns all agents when no deny rules', () => {
|
||||
const ctx = makeContext({})
|
||||
const agents = [{ agentType: 'Explore' }, { agentType: 'Research' }]
|
||||
expect(filterDeniedAgents(agents, ctx, 'Agent')).toEqual(agents)
|
||||
})
|
||||
test('filters out denied agent type', () => {
|
||||
const ctx = makeContext({ denyRules: ['Agent(Explore)'] })
|
||||
const agents = [{ agentType: 'Explore' }, { agentType: 'Research' }]
|
||||
const result = filterDeniedAgents(agents, ctx, 'Agent')
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]!.agentType).toBe('Research')
|
||||
})
|
||||
test('returns empty array when all agents denied', () => {
|
||||
const ctx = makeContext({ denyRules: ['Agent(Explore)', 'Agent(Research)'] })
|
||||
const agents = [{ agentType: 'Explore' }, { agentType: 'Research' }]
|
||||
expect(filterDeniedAgents(agents, ctx, 'Agent')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,7 +7,8 @@ import { logForDebugging } from '../debug.js'
|
||||
import { errorMessage } from '../errors.js'
|
||||
import { lazySchema } from '../lazySchema.js'
|
||||
import { logError } from '../log.js'
|
||||
import { getMainLoopModel } from '../model/model.js'
|
||||
import { getMainLoopModel, getSmallFastModel } from '../model/model.js'
|
||||
import { isPoorModeActive } from '../../commands/poor/poorMode.js'
|
||||
import { sideQuery } from '../sideQuery.js'
|
||||
import { jsonStringify } from '../slowOperations.js'
|
||||
|
||||
@@ -172,7 +173,7 @@ ${conversationContext ? `\nRecent conversation context:\n${conversationContext}`
|
||||
|
||||
Explain this command in context.`
|
||||
|
||||
const model = getMainLoopModel()
|
||||
const model = isPoorModeActive() ? getSmallFastModel() : getMainLoopModel()
|
||||
|
||||
// Use sideQuery with forced tool choice for guaranteed structured output
|
||||
const response = await sideQuery({
|
||||
|
||||
@@ -690,13 +690,16 @@ export const hasPermissionsToUseTool: CanUseToolFn = async (
|
||||
setClassifierChecking(toolUseID)
|
||||
let classifierResult
|
||||
try {
|
||||
logForDebugging(
|
||||
`[auto-mode] classifyYoloAction called with langfuseTrace=${context.langfuseTrace ? `id=${(context.langfuseTrace as unknown as Record<string, unknown>).id ?? 'present'}` : 'null/undefined'}`,
|
||||
)
|
||||
classifierResult = await classifyYoloAction(
|
||||
context.messages,
|
||||
action,
|
||||
context.options.tools,
|
||||
appState.toolPermissionContext,
|
||||
context.abortController.signal,
|
||||
context.langfuseTrace,
|
||||
context.langfuseRootTrace ?? context.langfuseTrace,
|
||||
)
|
||||
} finally {
|
||||
clearClassifierChecking(toolUseID)
|
||||
@@ -851,12 +854,30 @@ export const hasPermissionsToUseTool: CanUseToolFn = async (
|
||||
CLASSIFIER_FAIL_CLOSED_REFRESH_MS,
|
||||
)
|
||||
) {
|
||||
if (appState.toolPermissionContext.shouldAvoidPermissionPrompts) {
|
||||
logForDebugging(
|
||||
'Auto mode classifier unavailable, denying with retry guidance (fail closed)',
|
||||
{ level: 'warn' },
|
||||
)
|
||||
return {
|
||||
behavior: 'deny',
|
||||
decisionReason: {
|
||||
type: 'classifier',
|
||||
classifier: 'auto-mode',
|
||||
reason: 'Classifier unavailable',
|
||||
},
|
||||
message: buildClassifierUnavailableMessage(
|
||||
tool.name,
|
||||
classifierResult.model,
|
||||
),
|
||||
}
|
||||
}
|
||||
logForDebugging(
|
||||
'Auto mode classifier unavailable, denying with retry guidance (fail closed)',
|
||||
'Auto mode classifier unavailable, falling back to prompting with retry guidance (fail closed)',
|
||||
{ level: 'warn' },
|
||||
)
|
||||
return {
|
||||
behavior: 'deny',
|
||||
behavior: 'ask',
|
||||
decisionReason: {
|
||||
type: 'classifier',
|
||||
classifier: 'auto-mode',
|
||||
|
||||
@@ -28,7 +28,8 @@ import { errorMessage } from '../errors.js'
|
||||
import { lazySchema } from '../lazySchema.js'
|
||||
import { extractTextContent } from '../messages.js'
|
||||
import { resolveAntModel } from '../model/antModels.js'
|
||||
import { getMainLoopModel } from '../model/model.js'
|
||||
import { getDefaultSonnetModel, getMainLoopModel } from '../model/model.js'
|
||||
import { isPoorModeActive } from '../../commands/poor/poorMode.js'
|
||||
import { getAutoModeConfig } from '../settings/settings.js'
|
||||
import { sideQuery } from '../sideQuery.js'
|
||||
import type { LangfuseSpan } from '../../services/langfuse/index.js'
|
||||
@@ -1350,6 +1351,10 @@ function getClassifierModel(): string {
|
||||
if (config?.model) {
|
||||
return config.model
|
||||
}
|
||||
// Poor mode: downgrade classifier to Sonnet to reduce cost
|
||||
if (isPoorModeActive()) {
|
||||
return getDefaultSonnetModel()
|
||||
}
|
||||
return getMainLoopModel()
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user