@pi-in-go/pigpen-context-info 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CREDITS.md ADDED
@@ -0,0 +1,19 @@
1
+ # Credits
2
+
3
+ `extensions/context-info` is original work by **Michael Kinsy**, moved into
4
+ Pigpen from the author's personal PiG configuration and released under Pigpen's
5
+ MIT license (see [LICENSE](LICENSE)). It is not derived from another project.
6
+
7
+ Changes made when it was moved:
8
+
9
+ - Ported to PiG's public Go SDK, including its error-returning host reads and
10
+ Pi's nullable context usage (tokens and percent are unknown right after
11
+ compaction).
12
+ - The status footer is **off by default** (it replaces PiG's footer). Turn it on
13
+ with the `--context-footer` flag or `/context-footer on`.
14
+ - Removed `/prompt-edit`, the session prompt overlay, the deletion of the overlay
15
+ file at session start, and the subagent cost section of `/cost`. They belonged
16
+ to the author's own subagent extension, which is not part of this Package.
17
+ - Windows: git is started without a console window.
18
+ - `/prompts` no longer fails on an agent file shorter than four bytes, and reads
19
+ frontmatter only when the file starts with it.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Michael Kinsy
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,48 @@
1
- # Temporary Holding Version
1
+ # context-info
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A Go extension for PiG that shows what fills the context window and what the
4
+ session costs. It shows nothing until you ask. In the background it counts tool
5
+ calls and reads the session at start and after compaction to calibrate its token
6
+ estimate.
7
+
8
+ | Command | Shows |
9
+ |---|---|
10
+ | `/context` (or `Ctrl+Shift+I`) | The context window: system prompt (with skills), tool definitions, conversation, cache, model and thinking level, available room. Uses the provider's live token count when there is one and an estimate (self-calibrating characters per token) when there is not, for example right after compaction. |
11
+ | `/tools` | Every tool, grouped by source, active or not, with call counts. |
12
+ | `/cost` | Tokens and cost by kind and by model, and the active model's per-million-token rates. |
13
+ | `/prompts` | The current system prompt. `/prompts agents` lists agent definitions from `<config home>/agents/*.md`; `/prompts <name>` shows one. |
14
+ | `/context-footer [on\|off]` | A two-line status footer: directory and git branch, context percentage, model, thinking level, cost, tokens, tools and calls. |
15
+
16
+ The footer **replaces PiG's footer**, so it is off by default. Start PiG with
17
+ `--context-footer` to have it from the start, or use `/context-footer on`. Turning it
18
+ off gives PiG's footer back. While it is off, the extension makes no footer or status
19
+ updates.
20
+
21
+ ## Cost figures are estimates
22
+
23
+ Cost is tokens times per-million-token rates. The rates come from the active model.
24
+ Models billed by subscription carry no price, so for GitHub Copilot models the
25
+ extension falls back to a list-price table in `extension.go` (dated 2025-06-01,
26
+ verify before relying on it). A model that is in neither shows tokens and `$0.00`.
27
+
28
+ ## Install
29
+
30
+ ```sh
31
+ pig install ./components/context-info
32
+ ```
33
+
34
+ The extension builds from source on first use (Go toolchain required) or fuses into a
35
+ Piglet Binary. No network, no Node runtime. It reads the session through the host and
36
+ files under the PiG config home (`agents/`); it writes nothing. The footer starts
37
+ `git` to show the branch, at most once every two seconds.
38
+ `pig package validate ./components/context-info` validates the Package.
39
+
40
+ ## Test
41
+
42
+ The tests use PiG's public SDK through a fake host. From the Pigpen root:
43
+
44
+ ```sh
45
+ PIG_BIN=/path/to/pig npm run test:go-ports -- -race
46
+ ```
47
+
48
+ MIT. © Michael Kinsy. See [CREDITS.md](CREDITS.md) for what changed when it moved here.
@@ -0,0 +1,427 @@
1
+ package contextinfo
2
+
3
+ import (
4
+ "sync"
5
+ "testing"
6
+
7
+ sdk "github.com/MichaelKinsy/PiG/extensions/sdk"
8
+ )
9
+
10
+ func TestFmtNum(t *testing.T) {
11
+ cases := []struct {
12
+ in int
13
+ want string
14
+ }{
15
+ {0, "0"},
16
+ {999, "999"},
17
+ {1_000, "1.0k"},
18
+ {1_500, "1.5k"},
19
+ {10_000, "10.0k"},
20
+ {999_999, "1000.0k"},
21
+ {1_000_000, "1.0M"},
22
+ {2_500_000, "2.5M"},
23
+ }
24
+ for _, tc := range cases {
25
+ got := fmtNum(tc.in)
26
+ if got != tc.want {
27
+ t.Errorf("fmtNum(%d) = %q, want %q", tc.in, got, tc.want)
28
+ }
29
+ }
30
+ }
31
+
32
+ func TestFmtCost(t *testing.T) {
33
+ cases := []struct {
34
+ in float64
35
+ want string
36
+ }{
37
+ {0, "$0.00"},
38
+ {0.005, "$0.0050"},
39
+ {0.001, "$0.0010"},
40
+ {0.01, "$0.01"},
41
+ {0.10, "$0.10"},
42
+ {1.0, "$1.00"},
43
+ {12.345, "$12.35"},
44
+ }
45
+ for _, tc := range cases {
46
+ got := fmtCost(tc.in)
47
+ if got != tc.want {
48
+ t.Errorf("fmtCost(%f) = %q, want %q", tc.in, got, tc.want)
49
+ }
50
+ }
51
+ }
52
+
53
+ func TestCommaFmt(t *testing.T) {
54
+ cases := []struct {
55
+ in int
56
+ want string
57
+ }{
58
+ {0, "0"},
59
+ {999, "999"},
60
+ {1_000, "1,000"},
61
+ {10_000, "10,000"},
62
+ {100_000, "100,000"},
63
+ {1_000_000, "1,000,000"},
64
+ {1_234_567, "1,234,567"},
65
+ }
66
+ for _, tc := range cases {
67
+ got := commaFmt(tc.in)
68
+ if got != tc.want {
69
+ t.Errorf("commaFmt(%d) = %q, want %q", tc.in, got, tc.want)
70
+ }
71
+ }
72
+ }
73
+
74
+ func TestCharsToTokens(t *testing.T) {
75
+ // Default charsPerToken = 3.7
76
+ charsPerToken = 3.7
77
+ cases := []struct {
78
+ chars int
79
+ want int
80
+ }{
81
+ {0, 0},
82
+ {1, 1}, // ceil(1/3.7) = 1
83
+ {4, 2}, // ceil(4/3.7) = ceil(1.08) = 2
84
+ {37, 10}, // ceil(37/3.7) = 10
85
+ {370, 100}, // exact
86
+ }
87
+ for _, tc := range cases {
88
+ got := charsToTokens(tc.chars)
89
+ if got != tc.want {
90
+ t.Errorf("charsToTokens(%d) = %d, want %d", tc.chars, got, tc.want)
91
+ }
92
+ }
93
+ }
94
+
95
+ func TestAnalyzeSystemPrompt(t *testing.T) {
96
+ prompt := "You are helpful.\n<skill name=\"test\">some skill content</skill>\nDo things."
97
+ a := analyzeSystemPrompt(prompt)
98
+
99
+ if a.skillCount != 1 {
100
+ t.Errorf("skillCount = %d, want 1", a.skillCount)
101
+ }
102
+ if a.totalChars != len(prompt) {
103
+ t.Errorf("totalChars = %d, want %d", a.totalChars, len(prompt))
104
+ }
105
+ // skillChars = len(`<skill name="test">some skill content</skill>`) = 45
106
+ if a.skillChars != 45 {
107
+ t.Errorf("skillChars = %d, want 45", a.skillChars)
108
+ }
109
+ if a.baseChars != len(prompt)-45 {
110
+ t.Errorf("baseChars = %d, want %d", a.baseChars, len(prompt)-45)
111
+ }
112
+ }
113
+
114
+ func TestAnalyzeSystemPrompt_NoSkills(t *testing.T) {
115
+ prompt := "Simple prompt with no skills."
116
+ a := analyzeSystemPrompt(prompt)
117
+
118
+ if a.skillCount != 0 {
119
+ t.Errorf("skillCount = %d, want 0", a.skillCount)
120
+ }
121
+ if a.baseChars != len(prompt) {
122
+ t.Errorf("baseChars = %d, want %d", a.baseChars, len(prompt))
123
+ }
124
+ }
125
+
126
+ func TestAnalyzeConversation(t *testing.T) {
127
+ branch := []sdk.BranchEntry{
128
+ {Type: "message", Role: "user", Content: "hello"},
129
+ {Type: "message", Role: "assistant", Content: "hi there", Thinking: "let me think",
130
+ ToolCalls: []sdk.ToolCallInfo{{Name: "bash", Args: `{"command":"ls"}`}}},
131
+ {Type: "message", Role: "toolResult", ToolName: "bash", ToolCallID: "tc1", Content: "file.go"},
132
+ {Type: "other"},
133
+ }
134
+
135
+ a := analyzeConversation(branch)
136
+
137
+ if a.messageCount != 3 {
138
+ t.Errorf("messageCount = %d, want 3", a.messageCount)
139
+ }
140
+ if a.toolCallCount != 1 {
141
+ t.Errorf("toolCallCount = %d, want 1", a.toolCallCount)
142
+ }
143
+ if a.toolResultCount != 1 {
144
+ t.Errorf("toolResultCount = %d, want 1", a.toolResultCount)
145
+ }
146
+ if a.thinkingChars != len("let me think") {
147
+ t.Errorf("thinkingChars = %d, want %d", a.thinkingChars, len("let me think"))
148
+ }
149
+ }
150
+
151
+ func TestAnalyzeConversation_CompactionUsesFirstKeptEntryID(t *testing.T) {
152
+ charsPerToken = 1
153
+ branch := []sdk.BranchEntry{
154
+ {ID: "old", Type: "message", Role: "user", Content: "oldoldold"},
155
+ {ID: "kept", Type: "message", Role: "user", Content: "kept"},
156
+ {ID: "compact", Type: "compaction", Content: "sum", FirstKeptEntryID: "kept"},
157
+ {ID: "new", Type: "message", Role: "assistant", Content: "new"},
158
+ }
159
+
160
+ a := analyzeConversation(branch)
161
+ if a.textChars != len("sum")+len("kept")+len("new") {
162
+ t.Fatalf("textChars = %d, want summary + kept + new", a.textChars)
163
+ }
164
+ if a.messageCount != 3 {
165
+ t.Fatalf("messageCount = %d, want summary + kept + new", a.messageCount)
166
+ }
167
+ }
168
+
169
+ func TestGetSessionCostData(t *testing.T) {
170
+ invalidateCostCache()
171
+ branch := []sdk.BranchEntry{
172
+ {Role: "assistant", ModelID: "test-model", Usage: &sdk.UsageInfo{Input: 1000, Output: 500, CacheRead: 200, CacheWrite: 100}},
173
+ {Role: "assistant", ModelID: "test-model", Usage: &sdk.UsageInfo{Input: 2000, Output: 1000}},
174
+ {Role: "user"}, // no usage — skipped
175
+ }
176
+ model := &sdk.ModelInfo{
177
+ ID: "test-model",
178
+ InputCostPer1M: 3.0,
179
+ OutputCostPer1M: 15.0,
180
+ CacheReadCostPer1M: 0.3,
181
+ CacheWriteCostPer1M: 3.75,
182
+ }
183
+
184
+ d := getSessionCostData(branch, model)
185
+
186
+ if d.input != 3000 {
187
+ t.Errorf("input = %d, want 3000", d.input)
188
+ }
189
+ if d.output != 1500 {
190
+ t.Errorf("output = %d, want 1500", d.output)
191
+ }
192
+ if d.cacheRead != 200 {
193
+ t.Errorf("cacheRead = %d, want 200", d.cacheRead)
194
+ }
195
+ if d.cacheWrite != 100 {
196
+ t.Errorf("cacheWrite = %d, want 100", d.cacheWrite)
197
+ }
198
+ // Cost: 3000*3/1M = 0.009, 1500*15/1M = 0.0225, 200*0.3/1M = 0.00006, 100*3.75/1M = 0.000375
199
+ wantInput := 0.009
200
+ if d.cost.input != wantInput {
201
+ t.Errorf("cost.input = %f, want %f", d.cost.input, wantInput)
202
+ }
203
+ if d.cost.total == 0 {
204
+ t.Error("cost.total should be > 0")
205
+ }
206
+ }
207
+
208
+ func TestGetSessionCostData_FallsBackToCurrentModelForOldSessions(t *testing.T) {
209
+ invalidateCostCache()
210
+ branch := []sdk.BranchEntry{
211
+ {Role: "assistant", Usage: &sdk.UsageInfo{Input: 1000, Output: 500, TotalTokens: 1500}},
212
+ }
213
+ model := &sdk.ModelInfo{ID: "claude-sonnet-4.5", Provider: "github-copilot"}
214
+
215
+ d := getSessionCostData(branch, model)
216
+ if d.cost.total == 0 {
217
+ t.Fatal("cost.total = 0, want fallback Copilot pricing for current model")
218
+ }
219
+ if len(d.byModel) != 1 || d.byModel[0].modelID != "claude-sonnet-4.5" {
220
+ t.Fatalf("byModel = %+v", d.byModel)
221
+ }
222
+ if d.totalTokens != 1500 {
223
+ t.Fatalf("totalTokens = %d, want totalTokens from usage", d.totalTokens)
224
+ }
225
+ }
226
+
227
+ func TestGetSessionCostData_ProviderQualifiedModelID(t *testing.T) {
228
+ invalidateCostCache()
229
+ branch := []sdk.BranchEntry{
230
+ {Role: "assistant", ModelID: "github-copilot/claude-sonnet-4.5", Usage: &sdk.UsageInfo{Input: 1000, Output: 500}},
231
+ }
232
+
233
+ d := getSessionCostData(branch, nil)
234
+ if d.cost.total == 0 {
235
+ t.Fatal("cost.total = 0, want pricing for provider-qualified model id")
236
+ }
237
+ if len(d.byModel) != 1 || d.byModel[0].modelID != "claude-sonnet-4.5" {
238
+ t.Fatalf("byModel = %+v", d.byModel)
239
+ }
240
+ }
241
+
242
+ func TestGetSessionCostData_CacheKeyIncludesLeafID(t *testing.T) {
243
+ invalidateCostCache()
244
+ model := &sdk.ModelInfo{ID: "claude-sonnet-4.5", Provider: "github-copilot"}
245
+ first := []sdk.BranchEntry{{ID: "leaf-a", Role: "assistant", ModelID: "claude-sonnet-4.5", Usage: &sdk.UsageInfo{Input: 1000, Output: 100}}}
246
+ second := []sdk.BranchEntry{{ID: "leaf-b", Role: "assistant", ModelID: "claude-sonnet-4.5", Usage: &sdk.UsageInfo{Input: 2000, Output: 200}}}
247
+
248
+ _ = getSessionCostData(first, model)
249
+ got := getSessionCostData(second, model)
250
+ if got.input != 2000 || got.output != 200 {
251
+ t.Fatalf("cached stale cost data: input/output = %d/%d", got.input, got.output)
252
+ }
253
+ }
254
+
255
+ func TestGetSessionCostData_NoModel(t *testing.T) {
256
+ invalidateCostCache()
257
+ branch := []sdk.BranchEntry{
258
+ {Role: "assistant", Usage: &sdk.UsageInfo{Input: 1000, Output: 500}},
259
+ }
260
+ d := getSessionCostData(branch, nil)
261
+
262
+ if d.input != 1000 {
263
+ t.Errorf("input = %d, want 1000", d.input)
264
+ }
265
+ if d.cost.total != 0 {
266
+ t.Errorf("cost.total = %f, want 0 (no model pricing)", d.cost.total)
267
+ }
268
+ }
269
+
270
+ func TestAddAssistantUsageToCostCacheConcurrent(t *testing.T) {
271
+ invalidateCostCache()
272
+ model := &sdk.ModelInfo{ID: "claude-sonnet-4.5", Provider: "github-copilot", InputCostPer1M: 3, OutputCostPer1M: 15}
273
+
274
+ const workers = 8
275
+ const perWorker = 200
276
+ var wg sync.WaitGroup
277
+ wg.Add(workers * 2)
278
+ for w := 0; w < workers; w++ {
279
+ go func() {
280
+ defer wg.Done()
281
+ for i := 0; i < perWorker; i++ {
282
+ addAssistantUsageToCostCache("claude-sonnet-4.5", sdk.UsageInfo{Input: 10, Output: 5, TotalTokens: 15}, model)
283
+ }
284
+ }()
285
+ go func() {
286
+ defer wg.Done()
287
+ for i := 0; i < perWorker; i++ {
288
+ _, _ = cachedSessionCostData()
289
+ }
290
+ }()
291
+ }
292
+ wg.Wait()
293
+
294
+ got, ok := cachedSessionCostData()
295
+ if !ok {
296
+ t.Fatal("cost cache missing after concurrent updates")
297
+ }
298
+ wantInput := workers * perWorker * 10
299
+ wantOutput := workers * perWorker * 5
300
+ if got.input != wantInput || got.output != wantOutput {
301
+ t.Fatalf("lost updates under concurrency: input=%d (want %d) output=%d (want %d)", got.input, wantInput, got.output, wantOutput)
302
+ }
303
+ if len(got.byModel) != 1 || got.byModel[0].msgs != workers*perWorker {
304
+ t.Fatalf("byModel = %+v, want one bucket with %d messages", got.byModel, workers*perWorker)
305
+ }
306
+ }
307
+
308
+ func TestAddAssistantUsageToCostCacheUpdatesWithoutBranchRebuild(t *testing.T) {
309
+ invalidateCostCache()
310
+ model := &sdk.ModelInfo{ID: "claude-sonnet-4.5", Provider: "github-copilot"}
311
+ _ = getSessionCostData([]sdk.BranchEntry{
312
+ {ID: "leaf-a", Role: "assistant", ModelID: "claude-sonnet-4.5", Usage: &sdk.UsageInfo{Input: 1000, Output: 500, TotalTokens: 1500}},
313
+ }, model)
314
+
315
+ addAssistantUsageToCostCache("claude-sonnet-4.5", sdk.UsageInfo{Input: 2000, Output: 1000, TotalTokens: 3000}, model)
316
+ got, ok := cachedSessionCostData()
317
+ if !ok {
318
+ t.Fatal("cost cache missing after incremental update")
319
+ }
320
+ if got.input != 3000 || got.output != 1500 || got.totalTokens != 4500 {
321
+ t.Fatalf("incremental totals = input:%d output:%d total:%d", got.input, got.output, got.totalTokens)
322
+ }
323
+ if len(got.byModel) != 1 || got.byModel[0].msgs != 2 {
324
+ t.Fatalf("byModel = %+v, want one bucket with two messages", got.byModel)
325
+ }
326
+ }
327
+
328
+ func TestAssistantMapFromEventAcceptsNestedAndFlatShapes(t *testing.T) {
329
+ cases := []map[string]any{
330
+ {"message": map[string]any{"Assistant": map[string]any{"role": "assistant", "model": "m"}}},
331
+ {"message": map[string]any{"assistant": map[string]any{"role": "assistant", "model": "m"}}},
332
+ {"message": map[string]any{"role": "assistant", "model": "m"}},
333
+ }
334
+ for _, tc := range cases {
335
+ got, ok := assistantMapFromEvent(tc)
336
+ if !ok || got["model"] != "m" {
337
+ t.Fatalf("assistantMapFromEvent(%#v) = %#v, %v", tc, got, ok)
338
+ }
339
+ }
340
+ if _, ok := assistantMapFromEvent(map[string]any{"message": map[string]any{"User": map[string]any{"role": "user"}}}); ok {
341
+ t.Fatal("user message parsed as assistant")
342
+ }
343
+ }
344
+
345
+ func TestUsageInfoFromMapAcceptsProviderAndSDKNames(t *testing.T) {
346
+ got := usageInfoFromMap(map[string]any{
347
+ "input_tokens": float64(10),
348
+ "output_tokens": float64(20),
349
+ "cache_read": float64(3),
350
+ "cache_write": float64(4),
351
+ "totalTokens": float64(37),
352
+ })
353
+ if got.Input != 10 || got.Output != 20 || got.CacheRead != 3 || got.CacheWrite != 4 || got.TotalTokens != 37 {
354
+ t.Fatalf("usageInfoFromMap = %+v", got)
355
+ }
356
+ }
357
+
358
+ func TestGetSessionCostData_CacheKeyIncludesCurrentModel(t *testing.T) {
359
+ invalidateCostCache()
360
+ branch := []sdk.BranchEntry{
361
+ {ID: "leaf", Role: "assistant", Usage: &sdk.UsageInfo{Input: 1000, Output: 500, TotalTokens: 1500}},
362
+ }
363
+ claude := &sdk.ModelInfo{ID: "claude-sonnet-4.5", Provider: "github-copilot"}
364
+ gptMini := &sdk.ModelInfo{ID: "gpt-5-mini", Provider: "github-copilot"}
365
+
366
+ first := getSessionCostData(branch, claude)
367
+ second := getSessionCostData(branch, gptMini)
368
+
369
+ if len(first.byModel) != 1 || first.byModel[0].modelID != "claude-sonnet-4.5" {
370
+ t.Fatalf("first byModel = %+v", first.byModel)
371
+ }
372
+ if len(second.byModel) != 1 || second.byModel[0].modelID != "gpt-5-mini" {
373
+ t.Fatalf("cache ignored current model; second byModel = %+v", second.byModel)
374
+ }
375
+ if first.cost.total == second.cost.total {
376
+ t.Fatalf("cost cache reused stale model pricing: first=%f second=%f", first.cost.total, second.cost.total)
377
+ }
378
+ }
379
+
380
+ func TestReconstructInitialToolCountsKeepsEmptyBranchFallbackArmed(t *testing.T) {
381
+ reconstructInitialToolCounts(nil)
382
+ if !needsBranchCountReconstruction() {
383
+ t.Fatal("empty initial branch must keep before_agent_start reconstruction fallback armed")
384
+ }
385
+
386
+ reconstructInitialToolCounts([]sdk.BranchEntry{{ID: "u1", Role: "user"}})
387
+ if needsBranchCountReconstruction() {
388
+ t.Fatal("populated initial branch should satisfy reconstruction")
389
+ }
390
+ }
391
+
392
+ func TestReconstructToolCountsFromBranch(t *testing.T) {
393
+ branch := []sdk.BranchEntry{
394
+ {Role: "assistant", ToolCalls: []sdk.ToolCallInfo{{Name: "bash"}, {Name: "read"}}},
395
+ {Role: "user", ToolCalls: []sdk.ToolCallInfo{{Name: "ignored"}}},
396
+ {Role: "assistant", ToolCalls: []sdk.ToolCallInfo{{Name: "bash"}, {}}},
397
+ }
398
+
399
+ reconstructToolCountsFromBranch(branch)
400
+
401
+ mu.Lock()
402
+ defer mu.Unlock()
403
+ if totalToolCalls != 4 {
404
+ t.Fatalf("totalToolCalls = %d, want 4", totalToolCalls)
405
+ }
406
+ if toolCallCounts["bash"] != 2 || toolCallCounts["read"] != 1 || toolCallCounts["unknown"] != 1 {
407
+ t.Fatalf("toolCallCounts = %#v", toolCallCounts)
408
+ }
409
+ }
410
+
411
+ func TestEstimateToolDefTokens(t *testing.T) {
412
+ tools := []sdk.ToolInfo{
413
+ {Name: "bash", Description: "Execute a command"},
414
+ {Name: "read", Description: "Read a file"},
415
+ }
416
+ tokens, chars, _, total := estimateToolDefTokens(tools)
417
+ if total != 2 {
418
+ t.Errorf("totalCount = %d, want 2", total)
419
+ }
420
+ wantChars := len("bash") + len("Execute a command") + len("read") + len("Read a file")
421
+ if chars != wantChars {
422
+ t.Errorf("chars = %d, want %d", chars, wantChars)
423
+ }
424
+ if tokens == 0 {
425
+ t.Error("tokens should be > 0")
426
+ }
427
+ }