Files
magpie/internal/sessions/opencode_pi_test.go
T
yetone 71d2da1f42 catalog, usage, gateway, gui: a price can have a long-context tier and a 1-hour cache write, and the price boxes take decimals (PAMI on Discord)
OpenAI bills gpt-6-astra, gpt-6.1-sol and gpt-6-luna over 272K input at 2× input and cache and 1.5× output, for the whole request. A price now has tiers: `magpie model price <m> 10,50,1,12.5 --tier 272k 20,75,2,25` (--tier can repeat). A tier applies when input + cache reads + cache writes is over its size, so 272,000 is not over and 272,001 is. The built-in prices take tiers only where models.dev lists one (cost.tiers of type "context"), and it does list one for those three. The ledger, each request and OTLP are costed at the tier the call reached. Session and day totals sum many calls, so they stay at the base price.

Anthropic bills a cache write kept for an hour at 2× input and one kept for 5 minutes at 1.25×. Its usage reports which is which in cache_creation.ephemeral_5m/1h_input_tokens. The gateway (API and Claude subscription) and Claude Code's transcripts now keep that split. A price's fifth part is the 1-hour write. When it isn't given, it is 2× input: that is Anthropic's rule, and only Anthropic reports 1-hour writes. Claude list prices carry the 2× too. cache_write_tokens is still the total, and the usage CSV adds cache_write_5m_tokens and cache_write_1h_tokens at the end. The app shows the split on a request's Cache and on the usage tooltips. Old prices and old records, which have no 1-hour part, read as before. The sessions cache (18) and the call cache (calls-v8) are re-read once.

In the GUI, the price boxes were number inputs and dropped what they didn't parse ("0,25"). They are now decimal text boxes that take both "0.25" and "0,25". A model's price row has a 1-hour box (empty means 2× input) and a quiet Long-context price row. That row shows the list's tier greyed in, or sits behind a link when the list has none. A bad size is refused by name. en/zh/ja/de.

Tests:
- New: TestPriceTiers, TestCostSplit, TestPriceJSON, TestModelPriceTiers, TestModelPriceTierCmd, TestAnthropicCacheWriteSplit and TestClaudeCacheWriteSplit. The ledger CSV test expects the new columns.
- model-price-tiers.test.cjs is new; model-price-edit and provider-price-rate are updated. All of them fail against the old assets and pass in WebKit. Chromium couldn't be launched here.
- go vet, go build (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui ./... under a temp HOME pass.
2026-10-05 21:56:59 +08:00

389 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package sessions
import (
"database/sql"
"encoding/json"
"os"
"path/filepath"
"runtime"
"testing"
"time"
_ "modernc.org/sqlite"
)
const (
ocMain = "ses_1a2b3c4d5e6fAAAAAAAAAAAAAA"
ocChild = "ses_1a2b3c4d5e6fBBBBBBBBBBBBBB"
piFirst = "0199aaaa-1111-7222-8333-444455556666"
piFork = "0199bbbb-1111-7222-8333-444455556666"
)
// setupMore is setup with OpenCode's JSON files and Pi's sessions put
// where the two keep them.
func setupMore(t *testing.T) (data, pi string) {
claude, _ := setup(t)
dir := filepath.Dir(claude)
data, pi = filepath.Join(dir, "data"), filepath.Join(dir, "pi")
copyTree(t, "testdata/opencode", filepath.Join(data, "opencode"))
copyTree(t, "testdata/pi", pi)
return
}
func find(t *testing.T, ss []Session, agent, id string) Session {
t.Helper()
for _, s := range ss {
if s.Agent == agent && s.ID == id {
return s
}
}
t.Fatalf("no %s session %s in %+v", agent, id, ss)
return Session{}
}
func at(s string) time.Time {
t, _ := time.Parse(time.RFC3339, s)
return t
}
// checkOpenCode checks the OpenCode fixtures' one session with anything in
// it: its subagent's usage in, the reasoning in the output, the typed words
// of its first prompt its title.
func checkOpenCode(t *testing.T, ss []Session) {
t.Helper()
n := 0
for _, s := range ss {
if s.Agent == "opencode" {
n++
}
}
if n != 1 {
t.Fatalf("want 1 OpenCode session (the child in its parent, the empty one out), got %d: %+v", n, ss)
}
oc := find(t, ss, "opencode", ocMain)
if oc.Cwd != "/work/oc" || oc.Title != "Why does the login test flake?" {
t.Fatalf("opencode: %+v", oc)
}
if a := model(oc, "gpt-6-astra"); a.Tokens != (Tokens{1000, 120, 5000, 0, 0}) || !a.Priced {
t.Fatalf("astra: %+v", a)
}
if l := model(oc, "codex/gpt-6-luna"); l.Tokens != (Tokens{300, 60, 2000, 0, 0}) || l.Priced {
t.Fatalf("luna: %+v", l)
}
if o := model(oc, "claude-opus-5-5"); o.Tokens != (Tokens{40, 60, 700, 300, 0}) {
t.Fatalf("the subagent's opus: %+v", o)
}
if !oc.Start.Equal(at("2026-09-26T10:00:00Z")) || !oc.Last.Equal(at("2026-09-26T10:03:00Z")) {
t.Fatalf("opencode times %s %s", oc.Start, oc.Last)
}
if runtime.GOOS != "windows" {
if want := "cd '/work/oc' && opencode --session " + ocMain; oc.Resume != want {
t.Fatalf("resume %q", oc.Resume)
}
}
}
func TestOpenCodeFiles(t *testing.T) {
inZone(t, 0)
setupMore(t)
checkOpenCode(t, List(0))
// the work of the main session only, 10:00:00–10:03:00, every pause short
s := statsAt(0, statsNow)
if got, sec := usageOn(s, "2026-09-26", "/work/oc"); got != (Tokens{1340, 240, 7700, 300, 0}) || sec != 180 {
t.Fatalf("opencode on the 26th: %+v, %ds active", got, sec)
}
}
// ocDB builds an OpenCode database from the JSON fixtures, as OpenCode's
// own migration does.
func ocMakeDB(t *testing.T, path string) *sql.DB {
t.Helper()
db := ocMakeEmpty(t, path, "")
root := "testdata/opencode/storage"
infos, _ := filepath.Glob(filepath.Join(root, "session", "*", "*.json"))
for _, p := range infos {
var i ocInfo
b, _ := os.ReadFile(p)
if err := json.Unmarshal(b, &i); err != nil {
t.Fatal(err)
}
var parent any
if i.ParentID != "" {
parent = i.ParentID
}
// the times OpenCode's migration gave them: not the latest message's
if _, err := db.Exec("INSERT INTO session (id, project_id, parent_id, slug, directory, title, version, time_created, time_updated) VALUES (?, '0f3a', ?, 's', ?, ?, '1.1.53', ?, ?)",
i.ID, parent, i.Directory, i.Title, i.Time.Created, i.Time.Created); err != nil {
t.Fatal(err)
}
msgs, _ := filepath.Glob(filepath.Join(root, "message", i.ID, "*.json"))
for _, mp := range msgs {
b, _ := os.ReadFile(mp)
var m ocMessage
json.Unmarshal(b, &m)
ocInsert(t, db, i.ID, m.ID, m.Time.Created, max(m.Time.Created, m.Time.Completed), string(b))
parts, _ := filepath.Glob(filepath.Join(root, "part", m.ID, "*.json"))
for _, pp := range parts {
b, _ := os.ReadFile(pp)
if _, err := db.Exec("INSERT INTO part VALUES (?, ?, ?, 0, 0, ?)", filepath.Base(pp), m.ID, i.ID, string(b)); err != nil {
t.Fatal(err)
}
}
}
}
return db
}
// ocMakeEmpty makes OpenCode's tables, the session's with more columns.
func ocMakeEmpty(t *testing.T, path, more string) *sql.DB {
t.Helper()
db, err := sql.Open("sqlite", path)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
if more != "" {
more = ", " + more
}
for _, q := range []string{
"PRAGMA journal_mode=WAL",
"CREATE TABLE session (id text PRIMARY KEY, project_id text NOT NULL, parent_id text, slug text NOT NULL, directory text NOT NULL, title text NOT NULL, version text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, time_archived integer, tokens_input integer DEFAULT 0 NOT NULL" + more + ")",
"CREATE TABLE message (id text PRIMARY KEY, session_id text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL)",
"CREATE TABLE part (id text PRIMARY KEY, message_id text NOT NULL, session_id text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL)",
} {
if _, err := db.Exec(q); err != nil {
t.Fatal(err)
}
}
return db
}
func ocInsert(t *testing.T, db *sql.DB, sid, mid string, created, updated int64, data string) {
t.Helper()
if _, err := db.Exec("INSERT INTO message VALUES (?, ?, ?, ?, ?)", mid, sid, created, updated, data); err != nil {
t.Fatal(err)
}
}
func TestOpenCodeDB(t *testing.T) {
inZone(t, 0)
data, _ := setupMore(t)
// the JSON files stay behind beside the database made from them: only
// the database is read
db := ocMakeDB(t, filepath.Join(data, "opencode", "opencode.db"))
ss := List(0)
checkOpenCode(t, ss)
oc := find(t, ss, "opencode", ocMain)
if filepath.Base(oc.Path) != "opencode.db#"+ocMain {
t.Fatalf("path %s", oc.Path)
}
s := statsAt(0, statsNow)
if got, sec := usageOn(s, "2026-09-26", "/work/oc"); got != (Tokens{1340, 240, 7700, 300, 0}) || sec != 180 {
t.Fatalf("opencode on the 26th: %+v, %ds active", got, sec)
}
// a reply later in the subagent: its session is read again
ocInsert(t, db, ocChild, "msg_0005", 1790416990000, 1790417000000,
`{"id":"msg_0005","role":"assistant","modelID":"claude-opus-5-5","tokens":{"input":1,"output":2,"reasoning":0,"cache":{"read":3,"write":4}},"time":{"created":1790416990000,"completed":1790417000000}}`)
oc = find(t, List(0), "opencode", ocMain)
if o := model(oc, "claude-opus-5-5"); o.Tokens != (Tokens{41, 62, 703, 304, 0}) {
t.Fatalf("after a new reply: %+v", o)
}
if !oc.Last.Equal(at("2026-09-26T10:03:20Z")) {
t.Fatalf("last %s", oc.Last)
}
// $OPENCODE_DB names another database, in the data folder; with none
// made there yet, the JSON files are what there is
t.Setenv("OPENCODE_DB", "other.db")
if oc := find(t, List(0), "opencode", ocMain); filepath.Ext(oc.Path) != ".json" {
t.Fatalf("read a database $OPENCODE_DB doesn't name: %s", oc.Path)
}
if len(dbs) != 0 {
t.Fatalf("databases left open: %v", dbs)
}
}
func TestPi(t *testing.T) {
inZone(t, 0)
setupMore(t)
ss := List(0)
p := find(t, ss, "pi", piFirst)
if p.Cwd != "/work/pi" || p.Title != "Refactor the parser" {
t.Fatalf("pi: %+v", p)
}
// a tool's model work and a compaction on the model in use, a usage
// entry on its own
if o := model(p, "claude-opus-5-5"); o.Tokens != (Tokens{110, 55, 1300, 200, 0}) || !o.Priced {
t.Fatalf("opus: %+v", o)
}
if o := model(p, "claude/claude-opus-5-5"); o.Tokens != (Tokens{60, 40, 500, 0, 0}) {
t.Fatalf("magpie's opus: %+v", o)
}
if !p.Start.Equal(at("2026-09-24T08:00:00Z")) || !p.Last.Equal(at("2026-09-24T08:04:00Z")) {
t.Fatalf("pi times %s %s", p.Start, p.Last)
}
if runtime.GOOS != "windows" {
if want := "cd '/work/pi' && pi --session " + piFirst; p.Resume != want {
t.Fatalf("resume %q", p.Resume)
}
}
// the fork: what it copied counted in the session it came from
f := find(t, ss, "pi", piFork)
if f.Tokens != (Tokens{7, 3, 0, 0, 0}) || f.Title != "Refactor the parser" {
t.Fatalf("fork: %+v", f)
}
if !f.Start.Equal(at("2026-09-25T09:00:00Z")) || !f.Last.Equal(at("2026-09-25T09:00:20Z")) {
t.Fatalf("fork times %s %s", f.Start, f.Last)
}
s := statsAt(0, statsNow)
if got, sec := usageOn(s, "2026-09-24", "/work/pi"); got != (Tokens{170, 95, 1800, 200, 0}) || sec != 240 {
t.Fatalf("pi on the 24th: %+v, %ds active", got, sec)
}
if got, sec := usageOn(s, "2026-09-25", "/work/pi"); got != (Tokens{7, 3, 0, 0, 0}) || sec != 20 {
t.Fatalf("pi on the 25th: %+v, %ds active", got, sec)
}
}
// Pi's sessions in a folder of the user's choosing, all in one, as the
// sessionDir setting or $PI_CODING_AGENT_SESSION_DIR puts them.
func TestPiSessionDir(t *testing.T) {
_, pi := setupMore(t)
flat := filepath.Join(filepath.Dir(pi), "flat")
os.MkdirAll(flat, 0o755)
from := filepath.Join(pi, "sessions", "--work-pi--")
name := "2026-09-24T08-00-00-000Z_" + piFirst + ".jsonl"
if err := os.Rename(filepath.Join(from, name), filepath.Join(flat, name)); err != nil {
t.Fatal(err)
}
os.WriteFile(filepath.Join(pi, "settings.json"), []byte("{\n // where the sessions go\n \"sessionDir\": "+jsonString(flat)+",\n}\n"), 0o644)
find(t, List(0), "pi", piFirst)
t.Setenv("PI_CODING_AGENT_SESSION_DIR", filepath.Join(filepath.Dir(pi), "none"))
Reset()
for _, s := range List(0) {
if s.ID == piFirst {
t.Fatal("$PI_CODING_AGENT_SESSION_DIR comes before the setting")
}
}
}
func jsonString(s string) string {
b, _ := json.Marshal(s)
return string(b)
}
func TestDirs(t *testing.T) {
data, pi := setupMore(t)
d := Dirs()
if len(d) != 4 || d[2] != filepath.Join(data, "opencode") || d[3] != pi {
t.Fatalf("dirs %v", d)
}
os.RemoveAll(pi)
if d := Dirs(); len(d) != 3 {
t.Fatalf("a Pi folder that isn't there listed: %v", d)
}
}
// ocMakeV2 makes OpenCode 2's tables (packages/core/src/session/sql.ts,
// the columns read and those beside them) beside the old ones, with the
// fixtures' sessions as its migration copies them over: a prompt's words
// in the prompt, what OpenCode put in beside them a synthetic message of
// its own, a reply's model {id, providerID, variant}.
func ocMakeV2(t *testing.T, path string) *sql.DB {
t.Helper()
db := ocMakeEmpty(t, path, "")
for _, q := range []string{
"CREATE TABLE session_v2 (id text PRIMARY KEY, project_id text NOT NULL, workspace_id text, parent_id text, slug text NOT NULL, directory text NOT NULL, path text, title text, version text NOT NULL, cost real DEFAULT 0 NOT NULL, tokens_input integer DEFAULT 0 NOT NULL, tokens_output integer DEFAULT 0 NOT NULL, tokens_reasoning integer DEFAULT 0 NOT NULL, tokens_cache_read integer DEFAULT 0 NOT NULL, tokens_cache_write integer DEFAULT 0 NOT NULL, agent text, model text, time_created integer NOT NULL, time_updated integer NOT NULL, time_archived integer)",
"CREATE TABLE session_message (id text PRIMARY KEY, session_id text NOT NULL, type text NOT NULL, seq integer NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL)",
"CREATE TABLE kv (key text PRIMARY KEY, value text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL)",
`INSERT INTO kv VALUES ('migration.v1-v2', '{"phase":"completed"}', 0, 0)`,
`INSERT INTO session_v2 (id, project_id, slug, directory, title, version, model, time_created, time_updated) VALUES
('` + ocMain + `', 'global', 'brave-otter', '/work/oc', 'Flaky test hunt', '2.0.18', '{"id":"codex/gpt-6-luna","providerID":"magpie","variant":"default"}', 1790416800000, 1790416800000),
('ses_1a2b3c4d5e6fCCCCCCCCCCCCCC', 'global', 'idle-crow', '/work/oc', NULL, '2.0.18', NULL, 1790413200000, 1790413200000)`,
`INSERT INTO session_v2 (id, project_id, parent_id, slug, directory, title, version, time_created, time_updated) VALUES
('` + ocChild + `', 'global', '` + ocMain + `', 'calm-heron', '/work/oc', NULL, '2.0.18', 1790416860000, 1790416860000)`,
} {
if _, err := db.Exec(q); err != nil {
t.Fatal(err)
}
}
for i, m := range []struct{ sid, id, typ, data string }{
{ocMain, "msg_0001", "user", `{"text":"Why does the login test\nflake?","time":{"created":1790416805000}}`},
{ocMain, "msg_0001s", "synthetic", `{"text":"Called the Read tool with the following input","time":{"created":1790416805000}}`},
{ocMain, "msg_0002", "assistant", `{"agent":"build","model":{"id":"gpt-6-astra","providerID":"openai","variant":"high"},"content":[{"type":"reasoning","text":"…"},{"type":"text","text":"Let me look."}],"cost":0.01,"tokens":{"input":1000,"output":100,"reasoning":20,"cache":{"read":5000,"write":0}},"time":{"created":1790416806000,"completed":1790416830000}}`},
{ocMain, "msg_0003", "assistant", `{"agent":"build","model":{"id":"codex/gpt-6-luna","providerID":"magpie","variant":"default"},"content":[{"type":"text","text":"It races the clock."}],"cost":0,"tokens":{"input":300,"output":50,"reasoning":10,"cache":{"read":2000,"write":0}},"time":{"created":1790416890000,"completed":1790416980000}}`},
{ocMain, "msg_0003i", "idle", `{"outcome":"succeeded","time":{"created":1790416980000}}`},
{ocChild, "msg_0004", "assistant", `{"agent":"general","model":{"id":"claude-opus-5-5","providerID":"anthropic"},"content":[],"cost":0,"tokens":{"input":40,"output":60,"reasoning":0,"cache":{"read":700,"write":300}},"time":{"created":1790416860000,"completed":1790416890000}}`},
} {
var d struct {
Time struct{ Created, Completed int64 }
}
json.Unmarshal([]byte(m.data), &d)
if _, err := db.Exec("INSERT INTO session_message VALUES (?, ?, ?, ?, ?, ?, ?)", m.id, m.sid, m.typ, i, d.Time.Created, max(d.Time.Created, d.Time.Completed), m.data); err != nil {
t.Fatal(err)
}
}
return db
}
func TestOpenCodeV2(t *testing.T) {
inZone(t, 0)
data, _ := setupMore(t)
db := ocMakeV2(t, filepath.Join(data, "opencode", "opencode.db"))
// an old session left behind in the old tables that OpenCode 2 no
// longer has (deleted since it was copied over)
const gone = "ses_1a2b3c4d5e6fDDDDDDDDDDDDDD"
if _, err := db.Exec("INSERT INTO session (id, project_id, slug, directory, title, version, time_created, time_updated) VALUES (?, '0f3a', 's', '/work/gone', 'Gone', '1.1.53', 1790416800000, 1790416800000)", gone); err != nil {
t.Fatal(err)
}
ocInsert(t, db, gone, "msg_0009", 1790416800000, 1790416810000,
`{"id":"msg_0009","role":"assistant","modelID":"claude-opus-5-5","tokens":{"input":1,"output":1,"reasoning":0,"cache":{"read":0,"write":0}},"time":{"created":1790416800000,"completed":1790416810000}}`)
ss := List(0)
checkOpenCode(t, ss)
oc := find(t, ss, "opencode", ocMain)
if filepath.Base(oc.Path) != "opencode.db#"+ocMain {
t.Fatalf("v2 session: %+v", oc)
}
s := statsAt(0, statsNow)
if got, sec := usageOn(s, "2026-09-26", "/work/oc"); got != (Tokens{1340, 240, 7700, 300, 0}) || sec != 180 {
t.Fatalf("opencode 2 on the 26th: %+v, %ds active", got, sec)
}
// a compaction names no model: it counts on the one last replied with
if _, err := db.Exec(`INSERT INTO session_message VALUES ('msg_0005', ?, 'compaction', 9, 1790417000000, 1790417010000, ?)`, ocMain,
`{"status":"failed","reason":"auto","error":{"type":"unknown","message":"x"},"cost":0,"tokens":{"input":5,"output":1,"reasoning":0,"cache":{"read":0,"write":0}},"time":{"created":1790417000000}}`); err != nil {
t.Fatal(err)
}
oc = find(t, List(0), "opencode", ocMain)
if l := model(oc, "codex/gpt-6-luna"); l.Tokens != (Tokens{305, 61, 2000, 0, 0}) {
t.Fatalf("luna after a compaction: %+v", l)
}
// while the old sessions are still being copied over, one not copied
// yet is read from the old tables, one copied only from the new
if _, err := db.Exec(`UPDATE kv SET value = '{"phase":"sessions","cursor":"x"}'`); err != nil {
t.Fatal(err)
}
ss = List(0)
if g := find(t, ss, "opencode", gone); g.Cwd != "/work/gone" {
t.Fatalf("an old session not copied yet: %+v", g)
}
n := 0
for _, s := range ss {
if s.ID == ocMain {
n++
}
}
if n != 1 {
t.Fatalf("a copied session read %d times", n)
}
if len(dbs) != 0 {
t.Fatalf("databases left open: %v", dbs)
}
}