@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 +19 -0
- package/LICENSE +21 -0
- package/README.md +47 -2
- package/extensions/context-info/context_test.go +427 -0
- package/extensions/context-info/extension.go +1919 -0
- package/extensions/context-info/extension_test.go +293 -0
- package/extensions/context-info/fakehost_test.go +548 -0
- package/extensions/context-info/fit_test.go +140 -0
- package/extensions/context-info/git_branch_test.go +60 -0
- package/extensions/context-info/go.mod +6 -0
- package/extensions/context-info/go.sum +2 -0
- package/extensions/context-info/hostreads.go +70 -0
- package/extensions/context-info/procattr_other.go +7 -0
- package/extensions/context-info/procattr_windows.go +13 -0
- package/extensions/context-info/truncate_test.go +51 -0
- package/package.json +37 -4
- package/provenance.json +7 -0
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
|
-
#
|
|
1
|
+
# context-info
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
}
|