Skip to content

Commit d208855

Browse files
unraidclaude
andcommitted
feat: 添加 builtin-tools 增强与测试覆盖
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 7881cc6 commit d208855

12 files changed

Lines changed: 1438 additions & 974 deletions

File tree

packages/builtin-tools/src/tools/CtxInspectTool/CtxInspectTool.ts

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@ import { z } from 'zod/v4'
22
import type { ToolResultBlockParam } from 'src/Tool.js'
33
import { buildTool } from 'src/Tool.js'
44
import { lazySchema } from 'src/utils/lazySchema.js'
5+
import { tokenCountWithEstimation } from 'src/utils/tokens.js'
6+
import {
7+
getStats,
8+
isContextCollapseEnabled,
9+
} from 'src/services/contextCollapse/index.js'
10+
import { isSessionMemoryInitialized } from 'src/services/SessionMemory/sessionMemoryUtils.js'
511

612
const CTX_INSPECT_TOOL_NAME = 'CtxInspect'
713

@@ -19,6 +25,10 @@ type CtxInput = z.infer<InputSchema>
1925
type CtxOutput = {
2026
total_tokens: number
2127
message_count: number
28+
context_window_model: string
29+
prompt_caching_enabled: boolean
30+
session_memory_enabled: boolean
31+
context_collapse_enabled: boolean
2232
summary: string
2333
}
2434

@@ -67,13 +77,45 @@ Use this to understand your context budget before deciding whether to snip old m
6777
}
6878
},
6979

70-
async call() {
71-
// Context inspection is wired into the context collapse system.
80+
async call(input: CtxInput, context) {
81+
const messages = context.messages ?? []
82+
const model = context.options?.mainLoopModel ?? 'unknown'
83+
const totalTokens = tokenCountWithEstimation(messages)
84+
const collapseEnabled = isContextCollapseEnabled()
85+
const collapseStats = getStats()
86+
const focused = input.query?.trim()
87+
88+
const sessionMemoryEnabled = isSessionMemoryInitialized()
89+
// Prompt caching is an API-level feature controlled by the provider, not
90+
// a user-facing toggle. Report as enabled only for providers known to
91+
// support Anthropic-style prompt caching (first-party, Bedrock, Vertex).
92+
const promptCachingEnabled = !model.startsWith('openai/') &&
93+
!model.startsWith('grok/') &&
94+
!model.startsWith('gemini/')
95+
96+
const summaryParts = [
97+
focused ? `Focus: ${focused}` : 'Overall context summary',
98+
`Model context: ${model}`,
99+
`Prompt caching: ${promptCachingEnabled ? 'enabled' : 'disabled'}`,
100+
`Session memory: ${sessionMemoryEnabled ? 'enabled' : 'disabled'}`,
101+
`Context collapse: ${collapseEnabled ? 'enabled' : 'disabled'}`,
102+
]
103+
104+
if (collapseEnabled) {
105+
summaryParts.push(
106+
`Collapse spans: ${collapseStats.collapsedSpans} committed, ${collapseStats.stagedSpans} staged, ${collapseStats.collapsedMessages} messages summarized`,
107+
)
108+
}
109+
72110
return {
73111
data: {
74-
total_tokens: 0,
75-
message_count: 0,
76-
summary: 'Context inspection requires the CONTEXT_COLLAPSE runtime.',
112+
total_tokens: totalTokens,
113+
message_count: messages.length,
114+
context_window_model: model,
115+
prompt_caching_enabled: promptCachingEnabled,
116+
session_memory_enabled: sessionMemoryEnabled,
117+
context_collapse_enabled: collapseEnabled,
118+
summary: summaryParts.join('\n'),
77119
},
78120
}
79121
},
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
2+
3+
mock.module('src/utils/log.ts', () => ({
4+
logError: () => {},
5+
logToFile: () => {},
6+
getLogDisplayTitle: () => '',
7+
logEvent: () => {},
8+
logMCPError: () => {},
9+
logMCPDebug: () => {},
10+
dateToFilename: (d: Date) => d.toISOString().replace(/[:.]/g, '-'),
11+
getLogFilePath: () => '/tmp/mock-log',
12+
attachErrorLogSink: () => {},
13+
getInMemoryErrors: () => [],
14+
loadErrorLogs: async () => [],
15+
getErrorLogByIndex: async () => null,
16+
captureAPIRequest: () => {},
17+
_resetErrorLogForTesting: () => {},
18+
}))
19+
20+
mock.module('src/services/tokenEstimation.ts', () => ({
21+
roughTokenCountEstimation: (text: string) => Math.ceil(text.length / 4),
22+
roughTokenCountEstimationForMessages: (msgs: unknown[]) => msgs.length * 64,
23+
roughTokenCountEstimationForMessage: () => 64,
24+
roughTokenCountEstimationForFileType: () => 64,
25+
bytesPerTokenForFileType: () => 4,
26+
countTokensWithAPI: async () => 0,
27+
countMessagesTokensWithAPI: async () => 0,
28+
countTokensViaHaikuFallback: async () => 0,
29+
}))
30+
31+
let sessionMemoryInitialized = false
32+
mock.module('src/services/SessionMemory/sessionMemoryUtils.ts', () => ({
33+
isSessionMemoryInitialized: () => sessionMemoryInitialized,
34+
waitForSessionMemoryExtraction: async () => {},
35+
getLastSummarizedMessageId: () => undefined,
36+
getSessionMemoryContent: async () => null,
37+
setLastSummarizedMessageId: () => {},
38+
markExtractionStarted: () => {},
39+
markExtractionCompleted: () => {},
40+
setSessionMemoryConfig: () => {},
41+
getSessionMemoryConfig: () => ({}),
42+
recordExtractionTokenCount: () => {},
43+
markSessionMemoryInitialized: () => {},
44+
hasMetInitializationThreshold: () => false,
45+
hasMetUpdateThreshold: () => false,
46+
getToolCallsBetweenUpdates: () => 0,
47+
resetSessionMemoryState: () => {},
48+
DEFAULT_SESSION_MEMORY_CONFIG: {},
49+
}))
50+
51+
mock.module('src/utils/slowOperations.ts', () => ({
52+
jsonStringify: JSON.stringify,
53+
jsonParse: JSON.parse,
54+
slowLogging: { enabled: false },
55+
clone: (value: unknown) => structuredClone(value),
56+
cloneDeep: (value: unknown) => structuredClone(value),
57+
callerFrame: () => '',
58+
SLOW_OPERATION_THRESHOLD_MS: 100,
59+
writeFileSync_DEPRECATED: () => {},
60+
}))
61+
62+
const { initContextCollapse, resetContextCollapse } = await import(
63+
'src/services/contextCollapse/index.js'
64+
)
65+
const { tokenCountWithEstimation } = await import('src/utils/tokens.js')
66+
const { CtxInspectTool } = await import('../CtxInspectTool.js')
67+
68+
function makeUserMessage(text: string) {
69+
return {
70+
type: 'user' as const,
71+
uuid: `user-${text}`,
72+
message: { role: 'user' as const, content: text },
73+
}
74+
}
75+
76+
function makeAssistantMessage(text: string) {
77+
return {
78+
type: 'assistant' as const,
79+
uuid: `assistant-${text}`,
80+
message: {
81+
role: 'assistant' as const,
82+
content: [{ type: 'text' as const, text }],
83+
},
84+
}
85+
}
86+
87+
function makeContext(messages: unknown[], mainLoopModel = 'claude-sonnet-4-6') {
88+
return {
89+
messages,
90+
options: {
91+
mainLoopModel,
92+
},
93+
getAppState: () => ({}),
94+
} as any
95+
}
96+
97+
const allowTool = async (input: Record<string, unknown>) => ({
98+
behavior: 'allow' as const,
99+
updatedInput: input,
100+
})
101+
102+
const parentMessage = makeAssistantMessage('Parent tool call')
103+
104+
beforeEach(() => {
105+
resetContextCollapse()
106+
sessionMemoryInitialized = false
107+
})
108+
109+
afterEach(() => {
110+
resetContextCollapse()
111+
sessionMemoryInitialized = false
112+
})
113+
114+
describe('CtxInspectTool', () => {
115+
test('tool exports and metadata remain stable', async () => {
116+
expect(CtxInspectTool).toBeDefined()
117+
expect(CtxInspectTool.name).toBe('CtxInspect')
118+
expect(typeof CtxInspectTool.call).toBe('function')
119+
expect(await CtxInspectTool.description()).toContain('context')
120+
expect(CtxInspectTool.userFacingName()).toBe('CtxInspect')
121+
expect(CtxInspectTool.isReadOnly()).toBe(true)
122+
expect(CtxInspectTool.isConcurrencySafe()).toBe(true)
123+
})
124+
125+
test('formats tool results for transcript rendering', () => {
126+
const block = CtxInspectTool.mapToolResultToToolResultBlockParam(
127+
{
128+
total_tokens: 192,
129+
message_count: 3,
130+
context_window_model: 'claude-sonnet-4-6',
131+
prompt_caching_enabled: true,
132+
session_memory_enabled: true,
133+
context_collapse_enabled: false,
134+
summary: 'Context collapse: disabled',
135+
},
136+
'tool-use-id',
137+
)
138+
139+
expect(block.tool_use_id).toBe('tool-use-id')
140+
expect(block.content).toContain('192 tokens')
141+
expect(block.content).toContain('3 messages')
142+
expect(block.content).toContain('Context collapse: disabled')
143+
})
144+
145+
test('returns live context counts and mechanism state', async () => {
146+
const messages = [
147+
makeUserMessage('Inspect the current context budget.'),
148+
makeAssistantMessage('Looking at the current conversation state.'),
149+
]
150+
const context = makeContext(messages, 'claude-sonnet-4-6')
151+
152+
const result = await (CtxInspectTool as any).call(
153+
{},
154+
context,
155+
allowTool,
156+
parentMessage,
157+
)
158+
159+
expect(Object.keys(result.data).sort()).toEqual([
160+
'context_collapse_enabled',
161+
'context_window_model',
162+
'message_count',
163+
'prompt_caching_enabled',
164+
'session_memory_enabled',
165+
'summary',
166+
'total_tokens',
167+
])
168+
expect(result.data.message_count).toBe(messages.length)
169+
expect(result.data.total_tokens).toBe(tokenCountWithEstimation(messages as any))
170+
expect(result.data.context_window_model).toBe('claude-sonnet-4-6')
171+
expect(result.data.prompt_caching_enabled).toBe(true)
172+
expect(result.data.session_memory_enabled).toBe(false)
173+
expect(result.data.context_collapse_enabled).toBe(false)
174+
expect(result.data.summary).toContain('Overall context summary')
175+
expect(result.data.summary).toContain('Session memory: disabled')
176+
expect(result.data.summary).toContain('Context collapse: disabled')
177+
})
178+
179+
test('query input focuses summary and collapse runtime changes the reported state', async () => {
180+
const messages = [
181+
makeUserMessage('Show me tool usage pressure in this thread.'),
182+
makeAssistantMessage('Summarizing tool-heavy context now.'),
183+
]
184+
const context = makeContext(messages, 'claude-sonnet-4-6')
185+
186+
const disabledResult = await (CtxInspectTool as any).call(
187+
{ query: 'tool usage' },
188+
context,
189+
allowTool,
190+
parentMessage,
191+
)
192+
193+
initContextCollapse()
194+
195+
const enabledResult = await (CtxInspectTool as any).call(
196+
{ query: 'tool usage' },
197+
context,
198+
allowTool,
199+
parentMessage,
200+
)
201+
202+
expect(disabledResult.data.message_count).toBe(messages.length)
203+
expect(enabledResult.data.message_count).toBe(messages.length)
204+
expect(disabledResult.data.total_tokens).toBe(
205+
tokenCountWithEstimation(messages as any),
206+
)
207+
expect(enabledResult.data.total_tokens).toBe(
208+
tokenCountWithEstimation(messages as any),
209+
)
210+
expect(disabledResult.data.summary).toContain('Focus: tool usage')
211+
expect(disabledResult.data.context_collapse_enabled).toBe(false)
212+
expect(enabledResult.data.context_collapse_enabled).toBe(true)
213+
expect(enabledResult.data.summary).toContain('Context collapse: enabled')
214+
expect(enabledResult.data.summary).toContain('Collapse spans:')
215+
})
216+
})

0 commit comments

Comments
 (0)