Commit Graph
2072 Commits
Author SHA1 Message Date
yetone c06a550b73 provider tests: the hold tests pass on any -count, each starting from a config folder of its own instead of the files the last round left
TestHoldReadsSettingsOnce and TestHoldReadsMigrationsOnce failed on every
-count>=2, without any load ("a visibility before any was set", "moved
before any move"): they wrote settings.json and migrations.json into the
package's one test HOME, so the second round found held-agent's visibility
and held-sub's move already there. The leftover was only those files, not a
package global production relies on: Hold's cache is dropped at the last
release, and filememo re-reads a file that changed. Each of the four hold
tests that writes a magpie file now sets XDG_CONFIG_HOME to a TempDir, as
the rest of the package's tests do.

Before: go test -run TestHoldReads -count=2 fails both. After: -run TestHold
-count=20 -race -shuffle=on passes.
2026-10-07 02:21:39 +08:00
yetone 108852b4e1 provider tests: the CLI sign-in tests run their fake login as /bin/sh -c, not a script written for the test, so macOS's first-run check of a new executable no longer eats linkWait (TestCursorSignInJoinsAWrappedLink failed once in a full go test ./... with "login gave no link to open") — macOS assesses a newly written executable before its first exec: ~0.3s alone (a fresh #!/bin/sh script runs in 0.25–0.35s, the same file again in 0.00s), and seconds while other new executables start, as a go test ./... starts its test binaries. runCLISignIn's linkWait counts from Start, so the "never whole, and nothing more" case, which sets linkWait to 3s, got no line at all before the wait was over. Before: with a loop starting 8 fresh scripts at a time beside it, the test failed 2 of 3 at cursor_signin_test.go:70 with "login gave no link to open", 39s a run. After, under the same load: 5 of 5 pass, 3.9s a run; -race -count=50 -cpu 1,2,4 of TestCursorSignIn|TestCLISignIn pass; -shuffle=on -count=5 of the package beside go test ./internal/gateway and that loop never fails them. TestCLISignInSaysWhyNoLink starts its fake the same way. No fake outlives its test: stop() kills the shell, which has exec'd its sleep. 2026-10-07 02:17:15 +08:00
forever e961836fb0 usage, gateway, gui, provider, cli: the tests pass when a run crosses midnight, and a usage answer reads the clock once (#1046)
* usage: the usage tests pass at any time of day, midnight included

The usage tests stamped their calls with the machine's time and then
asked for today, 7 or 30 days, or a cached answer, which the code
worked out from its own time.Now a moment later. A minute after
midnight a call made "a minute ago" was yesterday's, and when midnight
fell between the test's read and the code's, the call and the period
were of two days. magpie usage, the TUI's Usage page, the two Codex
cache-write tests and the computers' shared days failed in the first
minutes after midnight (the TUI's after midnight UTC, the zone its
package's tests run in since 1caea638), and 28 more failed when
midnight fell inside them.

usage.Clock is now the time every period is read by: the start of
today, 7 days and 30 days (LedgerOf, Summarize, Direct, QueryPage), the
hours or days of the chart (LedgerSeries, QueryPage), the day a cached
summary or page is for, the time Append gives a call that has none,
the days davsync shares and the date in an exported CSV's name. It is
time.Now, as before, and a variable for the tests, like LogCalls. Each
test that built its calls from the time now holds Clock at noon on
2026-09-30 (holdClock; holdUsageClock in gui and the CLI; three lines
in tui and davsync) and stamps its calls from that instant, among them
TestReasoningReplyTimedByItsAnswer, whose timeline rows 117b6c7f made
at time.Now. TestPeriodsReadTheClock checks that every one of these
reads goes through Clock. TestQueryPageSourceAndIdentityInvalidation is
held too; that it fails when run alone is fixed apart from this, in
#1045.

Seven held tests could not fail at midnight before, and are held so
that the tests read one clock: TestUsageSameSizeAndMtimeRewrite (All's
totals), TestDirectCountsOnlySessionFileCalls (30 days around its own
noon), TestQueryPageIOOutsideCacheLock (a file two days old), gui's
TestUsageLedgerDayRoutes (7 days hold its calls from either day),
TestLedgerSingleSnapshotAndLocalSession and
TestLedgerNamesModelAtProvider (their calls are stamped after the code
reads the time), and TestUsageRawAndPricedChunksShareBudget, which
fails only when the midnight in it starts a new week of All's chart, on
a Sunday night; at.sh sets the time of day and not the weekday, so that
one was not shown.

Verified with at.sh, a scratch script that runs a test binary under a
zone made with zic, so that the local time of day is the one asked for
as the run starts (HOME in a sandbox, Go's caches pinned). The runs were
first made on c5a54bc7, and those marked (698da36f) again on 698da36f,
with the same results. The tui package runs in UTC since 1caea638, a
zone at.sh doesn't move, so its runs on 698da36f used copies of both
trees whose TestMain keeps the zone TZ sets.

Before, at 00:00:30, 00:01:30 and 12:00:00 (698da36f):
  TestUsageShowsCallsNotThroughMagpie, 00:00:30:
    usage_cli_test.go:95: no calls today · route an agent through
    magpie and its usage shows up here
  00:01:30: "5 tokens today · 1 call · no price", without the calls
  not through magpie; TestUsagePageShowsCallsNotThroughMagpie (tui)
  fails the same two ways at usage_test.go:138
  --- FAIL: TestCodexCacheWriteNotInInput (00:00:30)
    codex_cache_write_test.go:104: rows []
  --- FAIL: TestCodexCacheWriteMatchesGatewayCall (00:01:30)
    codex_cache_write_test.go:73: filter {...}: 0 rows, 0 calls, want
    the gateway's 2: []
  --- FAIL: TestUsageSharedBetweenComputers (00:01:30)
    usage_share_test.go:134: a's days on the server:
    [...-2026-10-05 ...-2026-10-06 ...-2026-10-07]
  12:00:00: PASS

Midnight inside a test, in scratch copies of the head. Started at
23:59:59.3 with the code's first period read held 1.5s, so that it
reads after midnight what the test stamped before it, 16 usage tests,
3 gui tests and TestUsageCSV FAIL, among them
  ledger_test.go:257: today: 0
  review_test.go:69: chart counted 0
  incremental_log_test.go:202: concurrent append lost
  TestCachedSummaryRemainsCallerOwned: panic: runtime error: index out
  of range [0] with length 0
  usage_test.go:121: second page: {... Period:today Rows:[] ...
  Total:0 ...}
  usage_test.go:311: "": magpie-requests-7d-2026-10-06
Started at 23:59:55 with the test waiting past midnight between two of
its reads, 7 more FAIL:
  request_day_test.go:45: selecting a day changed the chart or its
  legend
  cache_bounds_test.go:84: uncached today summary stale
  growing_rewrite_test.go:71: today summary stale
  cache_bounds_test.go:153: page key changed instead of exercising the
  same cache
  priced_blocks_test.go:55, caller_provider_test.go:129 and :172: page
  differs
TestReasoningReplyTimedByItsAnswer, with a scratch 1.5s sleep before
its LedgerSeries, started at 23:59:59.3 (698da36f):
  usage_test.go:255: part: {Calls:0 Tokens:0 Cost:0 Timed:0 TTFT:0
  DecodeMs:0 DecodeOut:0}

After: the same at.sh runs PASS at 00:00:30, 00:01:30 and 12:00:00
(698da36f); the same scratch copies made of this change PASS, each
test taking 1.5s or 5.2s, so midnight did fall inside it (698da36f for
TestReasoningReplyTimedByItsAnswer's); and every changed test PASS at
23:59:59.9, 00:00:30 and 12:00:00 (698da36f).

Without the change (698da36f): with the reads put back on time.Now, 21
usage tests, 6 gui, 2 CLI, the tui and the davsync test fail, among
them
  --- FAIL: TestPeriodsReadTheClock
    clock_test.go:55: Direct(Today): 0 calls since 2026-10-06 00:00:00
    -0700 PDT, want 1 since 2026-09-30 00:00:00 -0700 PDT
    clock_test.go:63: Summarize(Today) the next day: 1 calls, want 0
  --- FAIL: TestReasoningReplyTimedByItsAnswer
    usage_test.go:255: part: {Calls:0 Tokens:0 ...}
Each of the eleven reads put back alone fails a test:
TestPeriodsReadTheClock for the nine in usage (LedgerSeries:
"clock_test.go:52: LedgerSeries(Today): 0 calls, want 2"; the page's
cache key: "clock_test.go:66: QueryPage(Today) the next day: 1 rows,
want 0"), TestCSVStamp for the CSV's name (usage_test.go:325: "":
magpie-requests-7d-2026-10-06) and TestUsageSharedBetweenComputers for
davsync (usage_share_test.go:224: all computers: 4 rows, 707 in; want
5, 737).

On this commit: go test -tags nogui -count=1 . ./internal/usage
./internal/tui ./internal/davsync ./internal/gui ok; -race -count=20
over the changed tests of each of these packages ok; gofmt -l prints
nothing, and go vet -tags nogui on the five packages finds nothing.

* usage: pages, CSVs and magpie usage asked at midnight show one day

An answer about usage read the time more than once. The Requests page
read it for the day it is kept for, for the period its log and session
files are read over, for the period of its rows and for its chart's
hours; the page from the ledger (QueryPage's path when LogCalls is
set, as in the tests) for its rows and for its chart; Direct for its
period and for its rows; a CSV for its rows and for its name;
magpie usage and the TUI's Usage page for Summarize and then for
Direct. Most of these reads have the log and the session files read
between them, seconds on a large log, and when midnight fell between
two of them the answer was of two days: yesterday's calls listed over
today's empty chart, Direct starting yesterday and counting none of
yesterday's calls, the calls not through magpie left out of magpie
usage and the Usage page (they were the new day's, none yet), and
yesterday's calls saved as magpie-requests-today-<the new day>.csv.

Each answer now reads Clock once and passes that moment down. queryPage
reads it for its key, its sources, its rows and its chart
(buildRequestBlocks takes it); the page from the ledger reads its rows
and its chart at it (LedgerOfAt, ledgerSeriesAt); Direct reads the
ledger at the moment of its period. usage.Summaries(p) is Summarize(p)
and Direct(p) at one moment, and magpie usage and the TUI's Usage page,
when a period is picked and when it is read again, ask for it: one
exported name, the clock left inside usage and one line changed at
each caller, where SummarizeAt and DirectAt would be two names and
each caller would read the clock itself. The CSV download and export
read their rows with the new usage.LedgerOfAt at the moment their file
is named for, which csvStamp now takes; the rows and the name are read
in two packages, so LedgerOfAt is exported. LedgerOf, LedgerSeries,
Summarize, Direct and QueryPage keep their signatures, each reading
Clock once.

The new tests set Clock to read a tenth of a second before midnight
the first time and a tenth of a second after it from then on, set
again for each answer. The midnight is the one that starts 2026-10-01,
local time: a past instant, at midnight because that is what is
tested, and the day before it is the 2026-09-30 the held tests use.
TestAnswerReadsTheClockOnce asks QueryPage(Today) from the indexed log
and from the ledger: its 3 rows, one from a session file written that
day at 23:00, are under that day's chart, and asked again after
midnight the page is the next day's, not the one kept for the day
before; Direct(Today) counts the call and starts that day.
TestUsageAskedAtMidnight (magpie usage today), TestUsagePageAtMidnight
(the TUI's Usage page, today picked and read again) and
TestUsageCSVAtMidnight (download and export) check that the calls
through magpie and those not through it, or the rows and the file's
name, are of the day asked on. TestCSVStamp passes its time.

Verified on the previous commit, at.sh as there. The runs were first
made on c5a54bc7, when the tui package's tests still ran in the local
zone, and those marked (698da36f) again on 698da36f, with the same
results.

Before: the new tests on the previous commit, at any time of day
(at.sh at 12:00:00 too) (698da36f):
  --- FAIL: TestAnswerReadsTheClockOnce
    clock_test.go:131: QueryPage(Today): 0 rows, 0 in a chart from
    2026-10-01 00:00:00 -0700 PDT; want 3 under one from 2026-09-30
    00:00:00 -0700 PDT
    clock_test.go:141: QueryPage(Today): 3 rows, 0 in a chart from
    2026-10-01 ...; want 3 under one from 2026-09-30 ...
    clock_test.go:145: Direct(Today): 0 calls since 2026-09-30
    00:00:00 -0700 PDT, want 1 since 2026-09-30 00:00:00 -0700 PDT
  --- FAIL: TestUsageAskedAtMidnight
    usage_cli_test.go:154: 5 tokens today · 1 call · no price ...,
    with no part for the calls not through magpie
  --- FAIL: TestUsagePageAtMidnight
    usage_test.go:184: today picked: ... (the same)
    usage_test.go:187: read again: ...
  --- FAIL: TestUsageCSVAtMidnight
    usage_test.go:367: download: 200, attachment;
    filename="magpie-requests-today-2026-10-01.csv" with 2 rows
    (<nil>), want magpie-requests-today-2026-09-30.csv with 2
    usage_test.go:378: export: 200
    {"path":"~/Downloads/magpie-requests-today-2026-10-01.csv",
    "rows":1}, want magpie-requests-today-2026-09-30.csv with 1 row

With the real clock, in scratch copies of both trees: scratch tests
held one of Clock's reads 1.5s after it was made, as reading a large
log does (the first; for one of them the page's third, its chart's),
and at.sh started each at 23:59:59.3. Before, all 9 FAIL, among them
  asked 23:59:59.564, answered 00:00:01.068
  Direct(Today): 0 calls since 2026-10-05 00:00:00 -0940 MID, want 1
  since 2026-10-05 00:00:00 -0940 MID
  QueryPage(Today): 2 rows, 0 in a chart from 2026-10-06 ...; want 2
  under one from 2026-10-05 ...
  download: attachment; filename="magpie-requests-today-2026-10-06.csv"
  with a call of 2026-10-05 23:59
After, all 9 PASS, answered at 00:00:00.9 to 00:00:01.0, so midnight
fell inside each, except the chart's: its held read is gone with the
change. A scratch sleep before the chart shows that one: before "2
rows, 0 in a chart from 2026-10-06", after PASS, asked 23:59:59.344
and answered 00:00:00.941. At 12:00:00 all of them PASS in both trees.

After: every changed test PASS with at.sh at 23:59:59.9, 00:00:30 and
12:00:00 (698da36f).

Without the change (698da36f): with the production put back to the
previous commit's (gui/usage.go only so far that TestCSVStamp compiles:
csvStamp takes the time, read apart from the rows as before), go test
-count=1 . ./internal/usage ./internal/tui ./internal/gui fails the four
new tests and no other, with the lines above. Each of the 16 places put
back alone fails one of them: the page's key (clock_test.go:134: "3
rows, 3 in a chart from 2026-09-30 ...; want 0 under one from 2026-10-01
..."), its sources (:131: "2 rows"), its rows (:131: "0 rows"), its
chart (:131), the ledger page's rows and chart (:141), LedgerOfAt (:141,
:145), ledgerSeriesAt (:141), Direct (:145); Summaries, indexedSummary
and magpie usage (usage_cli_test.go:154); Summaries and the TUI's period
key and reload (usage_test.go:184, :187); the CSV's download and export
(usage_test.go:367, :378).

On this commit: go test -tags nogui -count=1 . ./internal/usage
./internal/gui ./internal/tui ./internal/davsync ./internal/gateway ok;
-race -count=20 over the changed tests of each package ok; gofmt -l
prints nothing, and go vet -tags nogui finds nothing.

* gateway: the key-limit tests pass when a run crosses local midnight

A go test run that crossed local midnight could fail the gateway's
key-limit tests. keyLimited and the /v1/magpie/limit endpoint each read
time.Now() when they check a key, and access.Window turns that into a
calendar window (a day from midnight, a week from Monday, a month from
the 1st). The tests seed "yesterday" from their own time.Now() and then
make several calls, so a run that crossed midnight saw its calls split
over two windows: the second call of a 350-token day was let in, or a
restart counted nothing.

The gateway's key-limit path now reads limitClock, a variable for the
tests like ruleClock and idClock, and production behaviour is unchanged
(it is time.Now). TestKeyLimitRefusesBeforeUpstream,
TestKeyLimitHoldsParallelRequests and TestKeyLimitWindowResets pin it
at 2026-09-30 12:00 local and seed "yesterday" from that instant. Calls
are still recorded at the real time, after the pinned one, and a window
counts every call from its start, so they all count in the pinned
window; the "tomorrow's window" check in TestKeyLimitWindowResets stays
on the real now, the first window with none of those calls in it. No
other test sets a key limit with records in its window (the gateway-key
CLI test and the GUI caller-key tests count 0 calls or have no limit).

TestKeyLimitRefusesBeforeUpstream now also checks that the 429's
Retry-After and X-Magpie-Limit-Reset name the next midnight after the
check, and that the limit endpoint's week starts on the pinned
Monday.

Verified with at.sh, a scratch script that runs the test binary under
a zone made with zic, so that the local time of day is the one asked
for as the run starts:
- before, at.sh at 23:59:59 with COUNT=500/300 and FRAC 0.3/0.6/0.8:
  3 of 6 runs of each test failed with
  key_limit_test.go:217: spent key let in
  key_limit_test.go:117: a restart forgot what the key used 200
  key_limit_test.go:121: status &{Period:day Start:2026-10-06 ...
  Calls:0 Tokens:0 ...}
- after, the same 6 runs (COUNT=500, both tests) print PASS, and so do
  ^TestKeyLimit at 12:00:00 and 00:00:00 with COUNT=20.
- go test -tags nogui -count=1 ./internal/gateway: ok (79.5s);
  -race -count=20 -run '^TestKeyLimit' ./internal/gateway: ok.
- gofmt -l prints nothing; go vet -tags nogui ./internal/gateway is
  clean.

The month boundary (TestKeyLimitHoldsParallelRequests) and Monday can't
be reproduced by changing the time of day; the pinned clock covers them
the same way.

Fails without the change (tests pinned, keyLimited back on time.Now):
--- FAIL: TestKeyLimitRefusesBeforeUpstream
    key_limit_test.go:107: headers map[... Retry-After:[80228]
    X-Magpie-Limit-Reset:[2026-10-07T00:00:00-07:00] ...]
and with only the limit endpoint back on time.Now:
--- FAIL: TestKeyLimitRefusesBeforeUpstream
    key_limit_test.go:159: limit endpoint {... "period":"week",
    "start":"2026-10-05T00:00:00-07:00" ...}

* gateway: the routing history tests pass when a run crosses midnight

TestHistoryKeepsDaysAndDropsOld and TestRemoteMagpieGroupHistory took
now := time.Now() and saved routes at now and now+1s. saveRoute files a
route by its own day (r.Time.Local()), so a test started at 23:59:59 put
the +1s route in tomorrow's file and History(today) found one route
less. TestHistoryKeepsSessionAndTokenTiers raced the other clock:
saveRoute prunes by time.Now(), so when the day turned between the
test's time.Now() and the first saveRoute, the prune gzipped "today"
after the first route, the second went to a new .jsonl, and the test's
own pruneHistory gzipped that over the first .gz, losing route 1.

saveRoute now prunes by historyClock (time.Now outside the tests). The
three tests take historyNoon(t), which holds that clock at
2026-09-30 12:00 local and returns it as their now, restored with
t.Cleanup. Every route, day file and prune is then on that one past day.
The fixture can't be a fixed past day without the clock: pruning by
the real today gzips that day's file at once (and, a month on, drops
it as too old).

The other history tests keep the real clock:
TestHistoryRouteOutsideDayLimit writes its day files itself from one
now and the code under test reads no clock, and
TestHistoryRouteDayAndNoLock already uses a fixed day.

Verified with at.sh, a scratch script that sets the local time of day
through a zone made with zic as the run starts:
- before, at.sh ./internal/gateway at 23:59:59:
  --- FAIL: TestHistoryKeepsDaysAndDropsOld
      history_test.go:50: days [{2026-10-06 1} {2026-10-05 1} {2026-10-04 1}]
  --- FAIL: TestRemoteMagpieGroupHistory
      remote_group_served_test.go:144: routes [... only ID 1 ...]
  and, with a 2s sleep after now := time.Now() standing in for the turn
  of the day, --- FAIL: TestHistoryKeepsSessionAndTokenTiers
      history_test.go:22: compressed history lost accounting: [only ID 2]
  12:00:00 control: PASS.
- after: the three tests PASS at 23:59:59, 23:59:59.9 (FRAC=0.9),
  12:00:00, and 23:59:59 with the same 2s sleep; all TestHistory* and
  TestRemoteMagpieGroupHistory PASS at 23:59:59 with -test.count=3.
- with saveRoute pruning by time.Now() again (the fixtures still pinned),
  at any time of day:
  --- FAIL: TestHistoryKeepsSessionAndTokenTiers
      history_test.go:32: compressed history lost accounting: [only ID 2]
- go test -tags nogui -count=1 ./internal/gateway: ok;
  -race -count=20 on the three tests: ok; gofmt -l and
  go vet -tags nogui ./internal/gateway print nothing.

* gateway, davsync: the request archive tests pass when a run crosses UTC midnight

The archive names a call "<date>/<id>" from the call's own start, in
UTC (archiving in archive.go). TestArchiveOn read time.Now().UTC()
before posting and TestRequestArchiveToS3 read it before post(), and
each expected that read's date. A run where UTC midnight (17:00 PDT)
fell between the test's read and the request's start expected the day
before the one the archive used, and failed.

Both tests now take the date from the Time the call carries
(s.Recent()[0].Time, the same start the archive's name is made from),
in UTC, so nothing is read from the clock a second time. No production
code changes. The other archive tests already go by the call's Archive
name, and internal/gui's archive test uses a fixed name; no other test
in internal/gateway, internal/davsync or internal/gui predicts an
archive key or a UTC-dated name from its own clock.

Verified: at.sh only moves the local time of day, and this is a UTC
crossing (UTC midnight was ~16h away), so it was shown in a scratch
copy whose serve starts each request 0.2s after the next UTC midnight,
as a request does when the test read the clock just before it. There
the old tests fail:
  archive_test.go:115: call archive "2026-10-07/000000-f4690de23ee8a859",
  want "2026-10-06/000000-f4690de23ee8a859"
  archive_test.go:93: nothing at
  "team x+y/magpie/archive/2026-10-06/000000-0e74a87816ef6a56.json"
and the new ones pass. Unshifted, under at.sh at local 23:59:59,
00:00:00 and 12:00:00, both pass, as the old ones do at 23:59:59 and
00:00:00 (local time doesn't enter the name). With archiving naming by
start.Format (local) instead of start.UTC().Format, both new tests fail
under at.sh at local 23:00:00, where the local date is a day behind
UTC's:
  --- FAIL: TestArchiveOn ... archive_test.go:116: call archive
  "2026-10-05/082500-7322712bd89f71da", want
  "2026-10-06/082500-7322712bd89f71da"
  --- FAIL: TestRequestArchiveToS3 ... archive_test.go:95: nothing at
  "team x+y/magpie/archive/2026-10-06/082500-3587ab4192da2cd5.json"
and pass at 12:00:00. go test -tags nogui -count=1 ./internal/gateway
./internal/davsync passes in a sandbox HOME; -race -count=20 -run
'^(TestArchiveOn|TestRequestArchiveToS3)$' passes; gofmt -l and go vet
-tags nogui on both packages are clean.

* gui: TestWhatsNewTodayRoute passes when the run crosses local midnight

TestWhatsNewTodayRoute posts {"on":true} to /api/whatsnew/today, which
writes today() to whatsnew-quiet-day, then checks quietToday(), which
compares the file with a second today(). A run that crossed local
midnight between the two read different days and failed with
"on: 204 false". The neighbouring TestWhatsNewQuietAfterRestartToUpdate
already pins today; this test didn't.

The test now pins today to 2026-09-30 (restored with t.Cleanup), so the
post and the check read the same day. The other What's new tests start
from an empty folder, so quietToday finds no file and never reads
today().

Verified: with a temporary 1s sleep between the post and the check, the
unpinned test under at.sh (local time of day set by a zone) started at
local 23:59:59.5 failed with "whatsnew_test.go:237: on: 204 false" and
passed at 12:00:00; pinned, it passes at 23:59:59.5 (same sleep), and,
without the sleep, at 23:59:59, 00:00:00 and 12:00:00. With
setQuietToday writing time.Now().Format("2006-01-02") instead of
today(), the test fails: "--- FAIL: TestWhatsNewTodayRoute ...
whatsnew_test.go:239: on: 204 false". go test -tags nogui -count=1
./internal/gui passes in a sandbox HOME; -race -count=20 -run
'^TestWhatsNewTodayRoute$' passes; gofmt -l and go vet -tags nogui
./internal/gui are clean.

* provider: TestZCodeStartSentAsTheApp passes when the run crosses midnight

A ZCode Start Plan request opens with a context prefix that says
"Today's date is <day>" (zcodeDateReminder), the day zcodeStartRequest
took from its own time.Now(). TestZCodeStartSentAsTheApp sent the
request, then built the expected prefix from a second time.Now(). A run
that crossed midnight between the two failed on "the context prefix",
the request holding the day before.

zcodeStartRequest now takes the time from zcodeClock (a package
variable, time.Now outside tests, as gateway's ruleClock), and the test
pins it to 2026-09-30 12:00 local, restored with t.Cleanup, and expects
"Today's date is 2026-09-30." The test sets TZ=Asia/Shanghai, which
moves time.Local only when nothing has read it yet; a mid-day instant in
time.Local formats to the same day either way. The other calls to
zcodeStartBody in the test check shaping only, not the day.

No other provider test builds a day string from a second clock: the
WorkBuddy credits, daily warm-up and check-in tests pass fixed times to
code that takes now as an argument, and TestQoderCheckinOnTheCard's day
is only carried, not compared.

Verified with at.sh (local time of day set by a zone, run as
-run '^(TestDailyCredits|TestZCodeStartSentAsTheApp)$' so that
time.Local is read before the test's t.Setenv("TZ"), as in a full run;
alone, Local becomes Asia/Shanghai): with a temporary 1s sleep after the
request, the unpinned test started at local 23:59:59.5 failed with
"zcode_client_test.go:195: the context prefix: ... Today's date is
2026-10-05." and passed at 12:00:00; pinned, it passes at 23:59:59.5
(same sleep) and, without the sleep, at 23:59:59, 00:00:00 and 12:00:00.
With zcodeStartRequest back on time.Now(), the pinned test fails:
"--- FAIL: TestZCodeStartSentAsTheApp ... zcode_client_test.go:196: the
context prefix: ... Today's date is 2026-10-06." go test -tags nogui
-count=1 ./internal/provider passes in a sandbox HOME; -race -count=20
-run '^TestZCodeStartSentAsTheApp$' passes; gofmt -l and go vet -tags
nogui ./internal/provider are clean.

* accounts, quota: a window's reset reads against one time per listing; TestQuotaCell passes across midnight

quotaCell, a window in `magpie accounts` and `magpie quota`, read the
clock three times: whether the reset had passed, how long until it, and
ResetClock's label, which names the calendar day ("tomorrow 02:13" or
"02:13"). TestQuotaCell set the reset 2h13m30s from time.Now() and
built the expected label from yet another time.Now(); a run that crossed
local midnight between quotaCell's read and the test's got "tomorrow
02:13" from one and "02:13" from the other and failed.

quotaCell now takes now. accountsCmd and quotaCmd take one time.Now()
just before printing and pass it to every window's cell, so a listing's
windows agree on what "today" is. The test passes a fixed 2026-09-30
12:00 local and expects "↻2h13m 14:13", and for a reset the next day
"↻14h13m tomorrow 02:13"; no zone in tzdata 2026c changes its clock
between those instants, so both read the same in any TZ. resetsCell's
label, quotaReadingCell's expired check and quotaResetCmd's prompt
still read time.Now(); no test compares them against a second clock.
internal/tui's quotaCell and quotaLines already take now, and their
tests pass a fixed one.

Verified with at.sh (local time of day set by a zone): with a temporary
1s sleep between quotaCell and the check, the old test started at local
23:59:59.5 failed with "accounts_cli_test.go:128: \"5h 42% ↻2h13m
tomorrow 02:13\"" and passed at 12:00:00; the new one passes at
23:59:59.5 (same sleep) and, without the sleep, at 23:59:59, 00:00:00
and 12:00:00. Putting any of quotaCell's three reads back on the wall
clock fails the test ("--- FAIL: TestQuotaCell"). ResetClock's now
alone fails the next-day case: "accounts_cli_test.go:134: tomorrow:
\"7d 42% ↻14h13m 02:13\"". The passed check alone, or all three:
"accounts_cli_test.go:129: \"5h 42% · reset time passed Sep 30
14:13\"". untilShort alone: "accounts_cli_test.go:129: \"5h 42% ↻now
14:13\"". go test -tags nogui -count=1 . passes in a sandbox HOME;
-race -count=20 -run '^TestQuotaCell$' passes; gofmt -l and go vet
-tags nogui . are clean.
2026-10-07 02:14:29 +08:00
forever e277f5c5ef usage: four tests pass when run alone and in any order — one saves no account copy mid-test, three price with the catalogue they write (#1045)
* usage: TestQueryPageSourceAndIdentityInvalidation passes when run alone

Run alone, the test failed at any time of day:

  request_page_test.go:224: unchanged source rebuilt

In a package run it passed only because an earlier test had looked at
the accounts less than 30 seconds before it. QueryPage calls
provider.All, whose rememberLogins saves a copy of the account Codex is
signed in to into logins.json, at most once every 30s (loginsSeenAt, a
package variable in provider). Alone, the test's first QueryPage made
that save. SessionIdentities then listed "one" twice (auth.json and the
saved copy), the request cache's key changed and every chunk was
rebuilt, so the pointer check failed. The test also expects only
auth.json's identity later on: with a saved "one" sharing a/u with
"two", the "two" step resolves to "" (two members of one workspace).
With the first check let through, it failed at request_page_test.go:237
"identity {... SessionAccount: ...}". The same failure hits a package
run whenever more than 30s pass between the last earlier look and this
test.

The test now calls provider.ForgetAccounts and provider.Accounts before
it signs Codex in, as an earlier test in a package run does, so no copy
is saved while it runs and the result no longer depends on test order.
This holds while the test runs within rememberLogins' 30s of that look;
it takes well under a second, -race and at.sh included. No other usage
test fails run alone: each test in the package was run alone, each
under a fresh HOME, and only this one failed.

Verified (go test -tags nogui under a temp HOME, Go caches pinned):
- before: -count=1 -run '^TestQueryPageSourceAndIdentityInvalidation$'
  ./internal/usage: --- FAIL ... request_page_test.go:224: unchanged
  source rebuilt; -race -count=20: 1 of 20 failed (the first; the
  later ones ran inside the 30s window); the same at local 12:00:00.
  With its neighbours (-run '^(TestQueryPage|TestRequestPage)') it
  passed, and failed again at :224 with a scratch 31s sleep at the
  test's start.
- after: alone -count=1 ok, -count=20 ok, -race -count=20 ok; at local
  12:00:00 and 00:00:30 PASS; with neighbours plus the scratch 31s
  sleep ok; go test ./internal/usage ok; go vet ok; gofmt clean.

Without the change the run-alone command fails with the FAIL line above.

* usage: tests that write their own catalogue price with it, in any order

TestDirectCountsOnlySessionFileCalls and TestLedgerWithSessionLogCalls
write a catalogue to catalog.CachePath() and expect calls priced from
it, but never called catalog.Reset. The catalog package reads the cache
once and keeps what it read until Reset, so each test priced with
whatever catalogue an earlier test had left loaded. In file order they
passed only because no earlier test had. In another order the calls
came out unpriced. Run as

  go test -run '^(TestDirectCountsOnlySessionFileCalls|TestLedgerUsesTheStatedPrice|TestLedgerWithSessionLogCalls)$' ./internal/usage

TestDirectCountsOnlySessionFileCalls ran first and left its gpt-6
catalogue loaded, and TestLedgerWithSessionLogCalls failed:

  ledger_test.go:228: logged call {... Provider:session-unknown ...
  Cost:0 Priced:false ... Source:log}

With -shuffle=1, TestLedgerWithSessionLogCalls ran first and left its
claude-sonnet-5 catalogue loaded, and the other one failed:

  direct_test.go:55: not priced: {Calls:2 ... Cost:0 Unpriced:2 ...}

Each passes alone, so running every test alone doesn't find this. It
was found in review with go test -shuffle=on ./internal/usage, which
failed 5 runs of 6.

Both tests now call catalog.Reset after writing the cache, and again
on cleanup, as TestLedger and TestSummaryByAccount do. So does
TestLedgerUsesTheStatedPrice, the only other test that writes the
cache with no Reset there. It passes in any order, since its stated
prices win, but it checks that no tier is counted at the catalogue's
price, which needs its own catalogue. Every other test in the
repository that writes catalog.CachePath() already resets the
catalogue there.

Verified (go test -tags nogui under a temp HOME, Go caches pinned):
- before, on main: the run above fails as shown. All six orders of the
  three tests (-shuffle=1, 2, 5, 7, 13 and 16) fail: at
  ledger_test.go:228 when TestDirectCountsOnlySessionFileCalls runs
  before TestLedgerWithSessionLogCalls, at direct_test.go:55 when it
  runs after. go test -shuffle=N ./internal/usage fails for the
  review's seeds 1791276105858112000, 1791276117280881000,
  1791276129005822000, 1791276140549291000 and 1791276151835143000,
  and for 101, 202, 505, 808 and 1010 of the seeds 101, 202, ... 1010;
  each failure is in one or both of these two tests.
- after: the run above ok; all six orders ok; -race -count=20 on the
  three tests ok; at local 12:00:00 PASS; go test ./internal/usage ok,
  and ok with each of those 15 seeds; each test in the package
  passes run alone; go vet ./internal/usage ok; gofmt clean.

Without the change the run above fails with the ledger_test.go:228
line, and with -shuffle=1 it fails with the direct_test.go:55 line.
2026-10-07 02:09:24 +08:00
forever 8a7f600cb0 provider: the Requests page keeps its cache when magpie first saves a signed-in account (#1049)
SessionIdentities lists the identity in Codex's auth.json (and Claude's
profile), then one per saved login. Once rememberLogins had saved a copy
of the account signed in now, that account was listed twice. QueryPage
keys the request cache on this list, so the save that added the copy
changed the key, and the next Requests page query dropped every cached
chunk and read and priced every session log again, though no identity
had changed. Users could see that as one more slow Requests page after
magpie saved its first copy of an account: one an agent was signed in
to outside magpie (codex login, Claude Code's /login), or the one
already signed in when magpie first ran. Switching between saved
accounts and signing in through magpie never caused it: rememberLogins
replaces a saved account in place, and magpie's own sign-in (addLogin)
writes logins.json together with auth.json. The resolver gets nothing
from the duplicate: an identical entry never changes which email or
login a session resolves to.

SessionIdentities now leaves out a saved identity equal to one already
in the list. One that differs in any field is still listed, such as
another member of the same workspace (the same account id, another
email): sessionResolver.codexUser names no member when two identities
share a workspace and user id but not the email.

TestSessionIdentitiesListTheSavedCurrentAccountOnce saves the account
Codex is signed in to with rememberLogins(true), checks that it was
saved and that the identities are unchanged, then saves a second
member of the same workspace and checks that it is listed.

A scratch test on main (c5a54bc7) showed when the list changed:
switching between two saved Codex accounts changed it once and the save
after that didn't; writing a new account into auth.json changed it, and
the save after that changed it again (3 to 4 identities). With this
change the second change is gone. Found while characterising
TestQueryPageSourceAndIdentityInvalidation: run alone on main with only
this change applied, it gets past request_page_test.go:224 "unchanged
source rebuilt" (the cache is kept) and stops at
request_page_test.go:237, an assumption of the test's own, fixed in
#1045.

Verified on 698da36f (go test -tags nogui under a temp HOME, Go caches
pinned), as first on c5a54bc7:
- -run '^TestSessionIdentities' ./internal/provider: both PASS, also at
  local 23:59:59.9, 00:00:30 and 12:00:00.
- go test ./internal/provider ok, and ok with -shuffle=11. With
  -shuffle=22 and 33, TestCopilotSignAndModels fails
  (account_test.go:403: available after fetch: ...), and at 33
  TestPlanQuotas too (planquota_test.go:290: cards: map[]). They fail
  the same with this commit's test skipped and session_identity.go as on
  main: -shuffle orders every test before -skip leaves one out, so the
  others keep their order. Main fails TestCopilotSignAndModels at
  -shuffle=33 too. It is an order dependence among the provider tests,
  apart from this change.
- Broken on purpose, the new test fails each time. A dedupe on Agent and
  AccountID alone: "session_identity_test.go:63: another member of the
  workspace was left out: [{Agent:codex AccountID:acct-1 ...
  User:me@example.com ...}]". rememberLogins writing nothing:
  "session_identity_test.go:49: the signed-in account wasn't saved: 0
  saved logins".

Without the change in session_identity.go:
  --- FAIL: TestSessionIdentitiesListTheSavedCurrentAccountOnce
  session_identity_test.go:53: saving the signed-in account changed its
  identities: before [{Agent:codex AccountID:acct-1 ...}], after
  [{Agent:codex AccountID:acct-1 ...}
  {Agent:codex AccountID:acct-1 ...}]

On this commit: go test -tags nogui ./... ok, go vet ./... ok, gofmt
clean, and -race -count=20 -run '^TestSessionIdentities'
./internal/provider ok.
2026-10-07 02:09:17 +08:00
yetone 9db5753ba3 providers: a provider's model list reads the settings once, not three times per model (lml on Discord, Windows: 每次至少都得等五六秒…供应商或者额度列表还在加载) — each model's row asked for its image answer twice (ApplyImage, ImageOverride) and its reply limit once (ReplyLimit), and each ask parsed settings.json again, even inside a GET's hold, since filememo keeps its bytes, not the parse. providerInfo now reads the settings once and uses provider.ImageOverrideIn and Provider.ReplyLimitIn. This removes redundant work found while measuring lml's report; it does not explain the whole 5-6s, which needs their setup (see below).
Measured with a copy of the owner's config (23 providers, ~1500 models) under a sandbox HOME and the real server (magpie web, dead proxy): GET /api/providers on the Windows box 497-558ms → 148-214ms, on the Mac 442-525ms → 174-191ms; the JSON answer is byte-for-byte the same apart from the gateway port. Everything else the window waits on at open answered locally in under 0.6s on Windows, so the rest of a 5-6s wait is network (the usage cards wait for every vendor on a cold start, and on Usage's own open), not this.

TestProviderListReadsSettingsOnce (300 models, a big settings file, held as a GET is) takes 5.3s without the change and 41ms with it, and checks an image answer and a reply limit the user set are still said. go vet, GOOS=windows go build, GOOS=linux go build -tags nogui and go test -tags nogui ./... under a temp HOME pass.
2026-10-07 01:58:10 +08:00
yetone 54c3cb06fc agent: the default thinking effort set on one of magpie's models in WorkBuddy stays through a sync, as it does in CodeBuddy Code, ZCode, Kimi Code and MiniMax Code (悠悠哥 on Discord: 供应商能设置模型默认的思考强度吗,我的workbuddy每次手动设置了思考强度,还是会被更新覆盖掉)
WorkBuddy's Settings > Models saves an edited custom model whole into
~/.workbuddy/models.json: reasoning.defaultEffort (absent for its Auto), and
the vendor of the provider it matches, which for magpie's entries is the
"Custom" provider's (WorkBuddy 5.x app.asar: inferProviderIdFromLocalCustomModel,
the form's save in ui-docs-viewer). buddyWrite knew its entries by vendor
"magpie" only, so a sync kept the edited entry as the user's and added
magpie's own with the same id and magpie's default effort; one set by hand
under vendor magpie was simply rewritten.

buddyWrite (WorkBuddy and CodeBuddy Code) now also knows magpie's entries by
their key (magpie-<agent>, or a NAS magpie's key already on its entries),
and keeps each model's defaultEffort (or the older reasoning.effort) while
the model still offers that level; WorkBuddy's Auto stays Auto. A new model
gets magpie's default; a model magpie no longer has is taken out, the edited
one included. The same rule (keptEffort) keeps a hand-set default in ZCode's
config.json (defaultVariant), Kimi Code's config.toml (default_effort) and
MiniMax Code's config.yaml (thinking.defaultEffort), which a sync rewrote too.
The composer's own effort pick is WorkBuddy's localStorage
(cb-effort:by-model), which magpie never touched.

Verified: kept_effort_test.go (TestWorkBuddy/CodeBuddy/ZCode/Kimi/MiniMax
KeepsTheUsersDefault*) fail on the old writers, WorkBuddy's with
"think/wide twice" and the rest with magpie's default back; pass with this,
-race -count=3. go test -tags nogui ./... green under a temp HOME; go vet,
GOOS=linux and GOOS=windows builds. Not tried in the real WorkBuddy app: the
fixture follows its save code, not a file it wrote.
2026-10-07 01:44:03 +08:00
yetone 05683637d5 plugins: the Bun the plugin host runs on is kept as magpie-bun, so a proxy app's PROCESS-NAME rule can route the requests plugins (Cursor and the other moved subscriptions) make (#1048)
magpie downloads its own Bun into ~/.cache/magpie/bun/<version>/. It is now
written there as magpie-bun (magpie-bun.exe on Windows), and a Bun downloaded
before is renamed in place the first time it is looked for, so nothing is
downloaded again and no second copy takes disk. The host, bun add and bun
update all run it, so all of their traffic carries the one name. A
$MAGPIE_BUN the user set is run as it is.

Verified: TestBunDownloadedAsMagpieBun, TestBunDownloadedBeforeTakesTheName
and TestHostRunsAsMagpieBun fail without the change (the Bun is .../bun, the
host runs .../bun). TestHostRunsAsMagpieBun starts the real host on a copy of
Bun 1.4.2 and `ps -o comm=` names it magpie-bun on macOS. On the Windows box
the three tests pass with the Windows build of Bun 1.4.2, a running bun.exe
renames to magpie-bun.exe, and Get-Process/Win32_Process name it magpie-bun.
Linux not run here (CI's ubuntu job runs the test with Bun on PATH).
go test -tags nogui ./... green; -race -count=10 on the new tests green.
2026-10-07 01:37:31 +08:00
yetone de2c84ace7 gateway: a Responses relay that turns away reasoning with nothing sealed in it, naming only the input, is asked once more without it, and isn't sent it again for that model (#1044: Codex's /compact through Anyrouter failed with 400 invalid_request_error param=input on old conversations)
Codex hands back reasoning items with no encrypted_content (an rs_ id and
a summary, as magpie's translated replies give it). #1008 left them out
only for OpenAI's API and Azure OpenAI. A relay in front of OpenAI turns
them away the same way but with its own message ("bad response status
code 400", param "input"), so the compaction, which is relayed as it is,
failed. They are not left out for every other upstream: a DeepSeek model
behind a Responses relay wants its reasoning back (#388), and magpie's own
translator reads the summary as thinking.

passthrough now, on a 400 to a Responses request whose error names the
input (refusesInput: param input / input[…], or an item named by id),
asks once more without the bare reasoning (withoutBareReasoning), sealed
reasoning and every other item kept; once that works the provider and
model are remembered (bareReasoningRefused) and later requests go without
it at once. Any other 400 goes back as it came.

TestBareReasoningLeftOutWhereRefused (the reporter's item kinds: message,
function_call(_output), custom_tool_call(_output), two bare reasoning items
and a sealed one; the relay's 400 word for word) fails without the
gateway.go change for both the compaction and a relayed turn (400 back to
Codex) and passes. TestBareReasoningKeptWhereTaken and
TestBareReasoningKeptOverAnotherRefusal guard the upstreams that take it
and other 400s; TestAzureCompactionWithoutMagpiesReasoning (OpenAI/Azure
unchanged) still passes. -race -count=5 on these, TestCodexCompact* and
TestImageTool*; go test -tags nogui ./internal/gateway ./internal/provider,
go vet ./internal/..., gofmt, linux and windows (nogui) builds pass. Not
tried against Anyrouter itself (no account); the 400 is from the issue's
capture.
2026-10-07 01:34:57 +08:00
yetone 615eba382b providers: MiMo's mover asks for opencode-mimo-auth 0.1.8, whose card shows a Token Plan bought at the open platform rather than Free (yetone: 我明明已经订阅了啊为啥说我是 free 账号啊) 2026-10-07 00:57:27 +08:00
yetone 71f0541342 gateway: Antigravity's Claude gets a tool turn's images after every tool result, each labelled with the call it came from (@sinswing on X: an image between two functionResponses came back from Google as "tool_use ids were found without tool_result blocks immediately after")
Checked with the owner's real Antigravity account: two Reads that each
return a PNG fail on claude-sonnet-4-6 and claude-opus-4-6-thinking
before, and both answer with each file's own colour after. Gemini on
Antigravity keeps each image beside its response.
2026-10-07 00:44:32 +08:00
yetone 698da36f31 gateway, providers, gui: a model on this computer or the local network can be set to get requests unmasked by Settings' redaction (lc on Discord: 本地模型可以不脱敏)
The provider editor has a Redaction row, "Send requests unmasked", while
every address of the provider is on this computer or the local network
(localhost, *.local, a loopback or private IP): Ollama, LM Studio, a vLLM
box. Ticked (provider.Unredacted; CLI `magpie provider set <id>
unmasked=yes`), a request whose provider and fallbacks, or whose routing
group's models, all skip redaction (provider.SkipsRedaction) goes to them
as the agent wrote it; anything that may reach another provider stays
masked for all of them. An address edited to a vendor's masks it again; a
signed-in account, a plugin's provider and another magpie never skip. The
log and recent calls keep the masked body. count_tokens and embeddings
follow the same rule.

It is a per-provider choice, not automatic for local addresses, because
a local address isn't a local model: a relay run locally (new-api,
CLIProxyAPI) or Ollama's cloud models pass the request on to a vendor.

Verified: TestLocalUnredactedProviderGetsTheRequestWhole (set, unset,
fallback to a vendor, fallback to another local one, a group of it alone,
a group with a vendor; chat and count_tokens) and TestLocalUnredactedEmbeddings
fail without the gateway change ("sent whole false, want true: … my key is
{{API_KEY_…}}"; count_tokens alone: "counted whole false"); TestSkipsRedaction;
TestProviderSaveKeepsUnredacted fails without the preset save line.
provider-unmasked.test.cjs (Chromium+WebKit, en/zh, 900 and 440 wide) 32/32,
3 of 4 fail on the old app.js; gui-ja, gui-de, i18n-keys, provider-search
and the other provider editor suites pass. go test -race on gateway,
provider, redact, gui; -race -count=10 on the new tests; go vet,
GOOS=linux/windows builds. Real: a sandbox gateway in front of the local
Ollama (llama3 8B) through a logging proxy: unset, Ollama was sent
{{API_KEY_…}}; with unmasked=yes it was sent the key as written, and
answered both times.
2026-10-07 00:33:15 +08:00
yetone 1caea63804 tui tests: the package runs in UTC from TestMain, so no test swaps time.Local under the gateway's loops
TestSessionsPage raced about 1 in 20-40 runs under -race: sessionsHome set
time.Local = time.UTC (and back in its cleanup) while goroutines an earlier
test left running read it. gateway.ListenAndServe starts its Keep* loops
(check-ins, credits, history, quotas) on its ctx and returns once the
server is shut down, without waiting for them, and those loops call
.Local() and time.Date(..., time.Local). TestMain now sets time.Local to
UTC once, before any test starts a goroutine, and sessionsHome no longer
touches it; the sessions fixtures' days are counted in UTC as before.

A probe test with a goroutine calling time.Now().Local() while
sessionsHome ran reported DATA RACE at sessions_test.go:30 on the old code
and passes -race -count=3 on the new one (probe not committed). The
reported race itself didn't reproduce here before the change (-race
-count=40 of the package, -count=150 of TestTUI*|TestSessionsPage).
go test -race -count=40 -run TestSessionsPage ./internal/tui and
go test -race -count=3 ./internal/tui pass; go vet and gofmt are clean.
2026-10-07 00:32:49 +08:00
yetone 5d8717239d routing: accounts sit beside the requests only where each request still keeps to one line (#860, huoranxuanyuan: 宽窗口下变成两栏,请求行折成三行)
The two columns (0281df4d, an agent's call; the owner's 614d5179 asked only that they line up) began at an 860px Routing area, where the requests' column is under 760px and each request breaks into three lines. They now begin at 1150px, where the requests' column stays over 760px; narrower, the accounts go below and each request reads on one line. routing-columns.test.cjs: at 940 and 1100 one column with one-line requests (fails on the old CSS: '940px: one column'), at 1440 and 1920 side by side, aligned, with one-line requests. routing-account-identity, routing-sessions and routing-served pass, Chromium and WebKit, en and zh.
2026-10-07 00:29:47 +08:00
yetone fdcdb5af21 usage: the requests table fits a narrow window with the reporter's rows, Speed and Status in sight (#860, huoranxuanyuan: 窗口较窄的提示出现了,速度和状态列仍被挤出去)
Hiding the optional columns (tight) wasn't enough for rows whose provider · account cell takes two lines and whose cost carries a price-reference line: on macOS fonts that table needed 858 (zh) / 923 (en) CSS px, and the reporter's 1569px window at 175% is about 897. When the table still overflows once tight, ledFit adds tighter: the price-reference line goes (it stays in the cost's tooltip and the row's details), model and provider names are cut at 110px with their tooltips whole, cells have 4px sides and headers may wrap. The minimum is now 734 (zh) / 760 (en). The ResizeObserver refits until tighter. usage-ledger-fit.test.cjs is built from the reporter's rows (Codex, openai · chatgpt.com, codex-auto-review priced as gpt-5.6-luna): at 820px the table fits with Status in sight, the hint shows, the reference line is hidden and is in the cost's title and the details; at 1100px the line shows. Chromium and WebKit, en and zh. Not run on Windows.
2026-10-07 00:29:47 +08:00
yetone 2fb538c4d7 panel: the tray panel's usage has All beside Today, 7 days and 30 days, as the window's Usage does (#860, huoranxuanyuan: 托盘里用量只有今天/7天/30天,没有全部)
PANEL_USE_PERIODS gains ["all","All"] (already a t() string, in ja/de/zh), and a saved period is checked against the list so All survives a restart. panel-usage.test.cjs picks All in en and zh, Chromium and WebKit, at the panel's width: the request reads period=all, the segment is on, and the four segments stay on one line inside the bar without scrolling the panel.
2026-10-07 00:29:47 +08:00
yetone 8ef53f2ec8 gui: an Antigravity card keeps its order when a refresh leaves out a pool's 5 hours (#860, huoranxuanyuan: 刷新额度有时会让5h和七天的顺序颠倒)
retrieveUserQuotaSummary sometimes sends a pool without its 5-hour bucket
(the reporter's Gemini, its week used up). Since #745 the pool's models then
keep their own 5-hour windows, shown as a family row; poolWindows put that
row after every pool, so the card read Gemini · 7 days, Claude & GPT · 5
hours, Claude & GPT · 7 days, Gemini, and on a refresh that sent the 5 hours,
Gemini · 5 hours, Gemini · 7 days, … — in the card's two columns the 5 hours
and the weeks swapped sides. poolWindows now puts a 5-hour-less pool's
family rows in its 5-hour place, before its week; a pool read whole is as
before.

quota-pool-order.test.cjs is the reporter's card (Gemini's week only,
Claude & GPT both): on the old code the Usage page lists Gemini last
(fails in Chromium and WebKit, en and zh); it passes now, with the panel's
rings Gemini, Gemini, Claude, Claude. quota-pools, quota-families,
usage-quota-head and routing-quota-left pass in both engines. Not tried
with a real Antigravity account answering a partial summary.
2026-10-07 00:29:47 +08:00
yetone 92469ee9f9 library, gui: a check for updates lists the skills a repository added beside the library's skills from it, to Add or Ignore (Raven on Discord)
Raven: "check for updates" only updated skills already in the library; when
dontbesilent2025/dbskill or mattpocock/skills adds a skill, magpie never said.
CheckSkills now asks GitHub's git trees API once per repo@ref (ETag-cached, a
304 is free) and only when a skill folder under the installed skills' common
folder (newBase) is neither in the library nor already offered does findNew
fetch the tarball for names and descriptions. library.json's seenSkills keeps,
per repo@ref, the paths the install picker showed and the ones set aside, so
an offered skill is offered once. The Skills page shows them under "Also in
their repositories" with Add (to the agents that have the repo's other skills)
and Ignore, plus Add all / Ignore all (/api/library/skills/{add-new,ignore-new}).

Verified: TestCheckSkillsFindsNewOnes (fake repo: nothing offered after
install, no tarball on a second check; two added skills offered, a nested one
and a .wip one not; Add installs with the repo's agents; Ignore keeps it out
on the next check via 304; remove clears) fails without the change (no
NewSkills / findNew) and passes -race -count=10; TestNewBase. A real run in a
sandbox HOME against GitHub: tdd from mattpocock/skills/skills/engineering
offered its 19 siblings; dbs from dbskill offered nothing (the picker showed
all), 33 with seenSkills wiped, and Add installed dbs-action. GUI:
skill-new-in-repo.test.cjs (Chromium+WebKit, 860/440px, en+zh) fails on
main's library.js and passes; gui-ja, gui-de, click-scroll, library-items,
every-skill, pick-skills, import/remove-all-skills, skill-groups,
skill-repo-groups, github-token, library-all pass. -race on library, gui,
tui; gofmt, vet, darwin/linux/windows builds.
2026-10-07 00:26:54 +08:00
yetone da1fd6cb0c gateway: a Gemini functionResponse's own parts reach the upstream — the image agy's view_file read (#1038, handong66: agy via magpie can't see images, the model makes up what they show)
agy sends the image a tool read inside the function response, as Gemini 3's
multimodal function responses carry it: functionResponse.parts[].inlineData
(under role "model", as agy sends tool results). parseGemini read only id,
name and response, so the parts were dropped before any translation: the
reporter's prompt was 96 tokens instead of 1115 and the model invented the
watermark and subtitle, with a 200 / STOP the client couldn't tell apart.

parseGemini now reads functionResponse.parts (camelCase, and snake_case
through geminiCamel): an image (inlineData or fileData) becomes the tool
result's Images, which every builder already carries — Antigravity/Code
Assist and Factory's Gemini as inlineData right after the functionResponse
in the same turn (the shape top-level images already work in), Anthropic as
tool_result image blocks, Responses as function_call_output input_image,
Chat as the image message after the tool's. A non-image file (a PDF) can't
sit in a tool result, so it follows the result in the same turn instead of
being lost. walkImages now looks inside a Gemini functionResponse's parts
too, so a model that can't see is sent the request with the tool's image
replaced by the omitted note, as for every other surface's tool images.

Tests from the reporter's literal request shape (view_file, call_462432,
response.output + parts[].inlineData; role model and user; camelCase and
snake_case): TestGeminiFunctionResponseImage (parse + Code Assist, Anthropic,
Chat, Responses bodies), TestGeminiFunctionResponseImageReachesUpstream
(/v1beta to a Chat upstream through the gateway), TestGeminiFunctionResponseFile
and TestGeminiFunctionResponseImageIsATools. All four fail without the change
("Images:[]", "the tool's image was not seen"). Not run against a real agy
or Antigravity account.
2026-10-07 00:23:34 +08:00
yetone 18d973ba7b docs: LESSONS.md gets the rest of 10-06's review — merging before writing what was run (9 of 10 merges), the reviewed head (#974), re-deriving what a change builds on (e0bf3e61, 2a990175) and another app's semantics from its code (#966/#996) are seen a second day; two new: no blocking send under a lock the reader needs (4fb8e887, c8690ca0), and a test's fake processes end with it (38a0f470) 2026-10-07 00:23:19 +08:00
yetone 1dceda18a1 agent: taking magpie out of a running OpenHanako 1.0 puts its agent on one of OpenHanako's own models, as provider/id (#1040: "model: OpenHanako: 400 Bad Request /api/agents/xiaojing/config: models.chat requires provider/id")
OpenHanako 1.0.0-beta's PUT /api/agents/:id/config takes models.chat only as a model it has, with both provider and id (its Uh + requireModelAvailable): no model or an id alone is a 400. Turning magpie off sent back what magpie had stashed, and with nothing stashed (the agent was put on magpie in OpenHanako, or under another agent than the stash key's, as the reporter's xiaojing) that was null, so the reset failed and magpie's provider stayed. A stashed id alone, or a model whose provider has gone, failed the same way.

Now, while OpenHanako runs, the reset reads its models (GET /api/models) and puts the agent back on the stashed model when it has it (an id alone found by its id), else on the first model of OpenHanako's own providers; with none but magpie's it says to add a provider in OpenHanako first and changes nothing. The stash is dropped only once the model is back. With OpenHanako closed the files are written as before.

TestHanakoLiveBackNeedsProviderID (a fake that answers as 1.0's server does: nothing stashed, an id alone, a provider/id whose id has a slash, a provider gone, only magpie's models) fails on the old code with the reported "models.chat requires provider/id" and passes. Against the real OpenHanako 1.0.0-beta server (from HanaAgent.app's seed, in a sandbox HANA_HOME on port 3519, with a deepseek provider): magpie connects and disconnects; with the stash lost the old code's disconnect gives the reporter's 400 and the new one puts the agent on deepseek/deepseek-chat and removes magpie. Not tried with the desktop app's sealed catalog or on the reporter's machine. go test -race ./internal/agent, vet (darwin, linux, windows) and gofmt pass.
2026-10-07 00:17:38 +08:00
yetone 117b6c7fb4 usage tests: TestReasoningReplyTimedByItsAnswer's timeline rows are made now, as LedgerSeries buckets today by the clock
The test gave LedgerSeries records dated 2026-10-06 14:30 UTC while
LedgerSeries reads today from time.Now(), so it passed only on 2026-10-06
and has failed on every machine since ("part: {Calls:0 ...}" just after
midnight CST on 10-07). The summary and row checks keep the fixed date;
the rows given to LedgerSeries take the clock's. go test -race
./internal/usage passes; gofmt and vet are clean.
2026-10-07 00:16:20 +08:00
wizzy-yangandwizzy-yang 764bbfdad5 sessions, gui, tui, cli: the Sessions list is as long as the count beside it, and drawn in pages (#1019)
The Sessions page's list and its session count came from two different
lengths: the count is over every session (statsFor's Overview), while
GET /api/sessions fell back to sessions.Limit (200) when the page asked for
no number — and the page never asks for one. So the page said "238 sessions"
over a list of 200, and each agent whose sessions were oldest lost them
whole: Codex had 121 sessions here and the list showed 7, OpenCode 33 showed
2, ZCode 20 showed 3.

  * sessions.All (5000) is what a listing that means every session asks for,
    and the endpoint reads it when no number is asked of it; a caller that
    asks for one still gets it. It lives in internal/sessions so the page, the
    TUI and the CLI ask for the same number.
  * sessions.Limit stays 200 and is not raised: traceRecentFiles sizes the
    window gateway attribution is judged over by it (TraceSessionVisible
    decides whether a session has a readable local transcript), so raising it
    would change which calls count as magpie's.
  * The page draws that list in pages: SESS_PAGE (200) rows, and "Show N
    more" appends the next. A filter click or a keystroke in Search draws the
    first page and no more, so the cost does not grow with the history. The
    data stays complete, so the count and the list still agree. A list served
    4000 sessions long rebuilt every row on every keystroke — 275–320 ms a
    keystroke on a fast Mac with very small rows, worse on Windows — and now
    draws 200.
  * The note under the list counts the list the filters kept, not every
    session on the computer: it says "showing {drawn} of {n}" while pages are
    left and "{n} session"/"{n} sessions" once the whole list is drawn. Before
    it counted the whole history beside the drawn rows, so a search matching
    one session read "the list holds all 2600, 1 drawn" — as if 2599 were
    behind a "Show more" that isn't there. It also no longer says "drawn",
    which is how the page is built, not what a reader sees.
  * The CLI read sessions.List(0) — 200 — and then cut to its own --limit, so
    --limit 500 could never show more than 200 and the "%d more with --limit"
    foot was counted off the truncated list. It reads the whole set now; only
    o.limit rows are printed, so that costs a parse, not output.
  * The TUI read List(0) beside a head that says how many sessions there are,
    and then List(sessions.Limit+1) — 201, still not the whole set. It reads
    sessions.All now.

What the wider list costs is unchanged: every session file is read either way
to sort by last activity. Measured here with 384 sessions and 84.5k calls:
cold List(200) 10.3 s, List(5000) 10.5 s. What it adds is per-row work while
List holds the lock — a Codex session's titles stat, a pi session's omp
look-up, 25× the rows of JSON — hundreds of milliseconds, not seconds.

Tests, each failing without its change:
  * TestSessionsListIsAsLongAsTheCount (internal/gui): 205 Claude Code
    sessions as Claude Code keeps them; all 205 with no number asked, 5 with
    ?limit=5, 201 with ?limit=201. Fails with the list holding 200.
  * TestSessionsListIsNotCutAtTheLimit (internal/tui): 210 sessions, of which
    the ten past sessions.Limit are all in the list. Fails at 201.
  * TestSessionsMoreCountsTheWholeSet (root): the default --limit 20 over 212
    sessions says "192 more with --limit", and --limit 205 prints 205 rows.
    Fails saying 180 (counted off the truncated list).
  * sessions-paging.test.cjs (Chromium and WebKit, en and zh): 2600 sessions;
    the first page is 200 rows with "Show 200 more", the button appends to
    600 with the rows above left in place, a keystroke in Search draws a page
    in under 250 ms and starts the pages again, a search matching one session
    reads "1 session" and never the 2600 on the computer, and the note follows
    the list the filters kept (200 of 1111 after that search). Fails on
    origin/main (200 rows are not a page there) and with the pages taken out;
    counting every session again, or writing "1 sessions", fails it too.

Verified: go vet; go build (windows); go test ./internal/sessions
./internal/tui ./internal/gui . — only what origin/main fails (TestCursor,
TestGatewayFixQuitsOnlyAMagpie, TestPurposeAssetMatchesUsage,
TestUpdateThatWontStart, TestPluginListSaysMiddleware,
TestPluginOnOffRmShortName); node --check app.js and i18n.js; the browser
suites above plus sessions-overview, sessions-today, sessions-usage-open,
sessions-toolbar, sessions-calendar-fill, usage-sessions-agents-fit,
i18n-keys, usage-ledger and click-scroll.

Co-authored-by: wizzy-yang <wizzy-yang@users.noreply.github.com>
2026-10-07 00:16:13 +08:00
yetone d7b1d9df41 gui: with Settings' Bar icon on, Omarchy's bar shows one magpie, not the widget and the tray icon side by side (Alex on Discord)
On Omarchy the tray's StatusNotifierItem is always registered, so a user
who had magpie in the bar's tray and turned on Bar icon got the widget
beside it: two magpies. Wails has no Hide on Linux (a no-op), so the item
now says Status "Passive" while the widget is in (set through Wails' own
properties, as the tooltip is, with NewStatus sent), and "Active" again once
it is taken out. Omarchy's Tray.qml leaves Passive items out of both its
pinned row and drawer (bucket()), as waybar's tray does; Omarchy itself
hides Dropbox's tray item when its Dropbox widget is in the bar. The status
is set when the app starts (from whether the widget is in) and whenever
Settings' Bar icon adds or removes it (onBarIcon), from what is in the bar
afterwards even if the omarchy command failed.

TestBarIconTellsTheTray (POST /api/omarchy/widget on, then off, with
stand-in omarchy commands) fails with the call taken out: "Bar icon on: the
tray was told [], want [true]"; passes with it, -race -count=10.
On the omarchy VM (Omarchy 4.0.4 shell): a test SNI item pinned in the bar
showed when Active, was gone within a second of Passive + NewStatus and
came back on Active. This build (go build -tags gtk3 on the VM, vet clean),
run in a sandbox HOME with MAGPIE_OMARCHY=1: with the widget's files in its
config the tray item's Status read "Passive" over busctl, without them
"Active". Not run on the VM: turning the switch at runtime (the route is
covered by the test). go test -race ./internal/gui ./internal/omarchy,
gofmt, vet, darwin/linux nogui/windows builds pass.
2026-10-07 00:16:02 +08:00
yetone a7e7fe2b0f library: switching a group of skills on or off no longer reads every copy every agent has (#1031, mintonight: 开关一整个技能组时很卡,要10多秒才能完成)
The page already sends one request for a group (agents-some), but its one
change ran a sync that, for every skill given as a copy (every WSL agent,
Windows without the right to link, agents set to copies), hashed the copy
and the library's skill twice, in every agent, on every change, and the
page's read hashed the copies once more. Over a WSL distro's share that is
every file of every skill read through 9P each click.

A copy's mark now keeps the hash of what was copied (markCopy). A copy
whose mark says the library skill's current hash, with nothing in it newer
than the mark, is current without its files being read (copyIsFresh);
takeEdits' walk of a copy is not done a second time in the same sync, and
each library skill is hashed once per change (libHash). Copies with an old
mark, or edited since, are hashed as before. Claude Desktop's copies and
project copies use the same check.

Measured in a sandbox (55 skills x 31 files, copies for 10 agents, a
5-skill group switched on for an 11th): the change went from 1.54s to
0.31s and the page's read from 0.66s to 0.25s on a local SSD; the copies
not touched are no longer read at all, which is the cost over WSL.

TestSyncDoesNotReadUnchangedCopies fails without the change ("codex's
copy of pdf was read and made again"): an unreadable file in an unchanged
copy is left alone now. TestCopyFreshByMark checks a changed library skill
is still copied again, an old mark without a hash still works, and an edit
in a copy is still taken. go test -race ./internal/library ./internal/gui
pass, the new tests pass -race -count=10; vet, gofmt, darwin/linux/windows
builds pass. Not run on Windows or WSL.
2026-10-07 00:14:00 +08:00
yetone 14af0f100b provider, plugins: a plugin's browser sign-in finishes from the address the browser ended on, for magpie on a server or in Docker (Chicring)
The built-in sign-ins that come back to a port on this machine (Codex,
ChatGPT API, Devin, Kiro, Zed, Gemini, Antigravity) already took the
pasted address. Their plugins didn't: a subscription moved onto its plugin
(Devin, Kiro, Zed) or a plugin's own browser sign-in (Trae) showed only
"the account shows up here as soon as you're done", and a browser on
another computer than magpie's ended on a page that won't load with no way
to finish.

Now a plugin's "auto" sign-in whose page names a port on this machine to
come back to (redirect_uri, auth_callback_url, callback on localhost, or
Zed's native_app_port: loopbackPorts) sets pasteCallback, so the GUI shows
the same Callback URL field as the built-ins, and `magpie plugin login` /
`magpie accounts add` ask for it too. SubmitSignInCallback opens the pasted
address on 127.0.0.1 at that port, never through a proxy, so the plugin's
own listener finishes the sign-in as it would for the browser; an address
on another port or host is refused, a page that sends the browser on to a
page that comes back to the same port (Kiro's AWS sign-in) becomes the next
page to open. Command Code's page posts its key from script, which leaves
no address to paste, so it keeps offering the plugin's API key way.

Verified: TestPluginSignInTakesAPastedCallback (fake plugin with a loopback
sign-in, $FAKE_LOOPBACK) fails without the wiring in pluginBegin ("a
sign-in that comes back to a port here takes no pasted address:
{... PasteCallback:false ...}") and passes with it, -race -count=10;
TestLoopbackPorts covers the community plugins' page addresses.
go test -race ./internal/provider ./internal/plugin and the root package's
Plugin|Login|SignIn tests pass; vet, gofmt, darwin/linux nogui/windows
builds pass. With the real community Devin, Zed and Kiro plugins in a
sandbox HOME, each sign-in offered the field and a pasted address reached
the plugin's listener and the vendor (bogus code refused by Devin 401, Zed
"couldn't be decrypted", Kiro 400). signin-paste-plugin.test.cjs (new),
signin-callback and signin-paste-codex pass in Chromium and WebKit, en and
zh, at 360px; no new strings. Not tried: a real successful sign-in from a
browser on another computer.
2026-10-07 00:13:03 +08:00
wizzy-yang 9d84443086 test: a profile with no catalog to write back from is still said (#1037)
yetone's review of #1029 asked for this branch: `canWriteBack` is false when
magpie has no catalog, and the loop over the other profiles must then say a
profile whose start names one of magpie's models, not pass it over in
silence. Setting `canWriteBack` to true and dropping the guard both keep
every other test green, so nothing covered it.

TestDshCheckSaysWhenNoCatalogAndAProfilesStartIsMagpies is the case: no
provider saved (no catalog), web wired with magpie's route and start, and a
cli profile stripped of the route with its start still on one of magpie's.
It passes here and fails with the guard mutated either way.
2026-10-07 00:02:41 +08:00
yetone b1cba65477 davsync tests: the slow transfer runs on a line with small buffers, and the PUT's tail is the server's to hold (TestSlowTransferIsNotCutShort red on ubuntu in PR #1030's run 37466034290: "the server sent and took nothing for 300ms (PUT)")
The test scales the watchdog from 60 s down to 300 ms and sends 20 MB at 6.4 MB/s, taking each of magpie's reads of the body as the line moving. On Linux the line's buffers aren't scaled with it: the kernel hands a writer room in bursts, waking it once a third of its send buffer has drained, and it tunes that buffer up to 4 MB (the receiver's window opens in steps the same way). A probe on magpie's body reads in a golang:1.26.3 container showed gaps of 130–290 ms between two of them while the line never stopped, every run; one over 300 ms failed the test. That is the test's ratio, not the watchdog's: at the real 60 s, a burst that long needs a line under ~25 KB/s. Not a race (the same in -race and without).

narrowLine serves the fake WebDAV server and gives the client with both ends' buffers held to 128 KB (SetWriteBuffer on the client's connection, SetReadBuffer on the server's), so a burst is a chunk or two. The MBs that used to sit in the buffers after magpie had handed the body over, which is what allSent's longer wait is for, are now the server's own tail: it answers the PUT twice the watchdog after the body is in. The server also grows its buffer to the body's length up front.

On Linux (golang:1.26.3, 14 CPUs): before, 5 of 30 runs failed (-race -count=10 -cpu 1,2,4); after, 30 of 30 pass, and -race -count=20 -cpu 1,2,8 of TestSlowTransferIsNotCutShort, TestStalledTransferFailsPromptly and TestMetadataRequestHasALimit with four busy loops beside them pass. With allSent changed to put the watchdog back by stallAfter alone, the test fails 3 of 3 ("took nothing for 300ms (PUT)"), so the tail is still tested. go test -race ./internal/davsync (darwin), go vet and gofmt -l pass. No change outside the test.
2026-10-06 23:25:27 +08:00
yetone 60f87223c3 gui tests: skill-how clicks a By agent choice once its sheet has finished growing in (skill-how.test.cjs red on WebKit about 2 runs in 5, Codex's way posted "" where "link" was asked)
The test's press() reads a button's box and clicks the middle of it with the mouse. The By agent sheet opens with the dialog's entrance, which grows it from about a third of its size, and on WebKit the box was read some 25 ms in (transform matrix(0.34…), Links 16 px wide at x 346); by the time the click landed the sheet had grown, and that point was over Library's way, left of Links. So Codex was given the library's way, and posts[2] said how "" in both en and zh. Chromium was mostly through the entrance by then. library.js has no race here: a click lands where the sheet is drawn when it lands.

press() now waits until #modal has no running animations before reading the box, as other GUI tests do with getAnimations. Before: 3 of 6 WebKit runs failed with how '' at line 168 (a probe logged the box mid-entrance each time). After: 15 of 15 WebKit and 15 of 15 Chromium runs of skill-how.test.cjs pass. No change outside the test.
2026-10-06 23:25:27 +08:00
yetone e2f1c6bacf agent: OpenHanako 1.0's encrypted provider-catalog.json is never written over; magpie's address and models are kept in its plugin file (owner: "~/.hanako/provider-catalog.json: top level is not a JSON object")
OpenHanako 1.0.0-beta seals provider-catalog.json under a key its desktop app keeps in the keychain ("HANA-SECRET-1.AES-256-GCM.<nonce>.<body>", shared/credential-envelope.ts); its own compatibility note says a reader without the key must refuse a sealed file. With OpenHanako closed, magpie's Sync couldn't read the key out of it, took magpie's entry for changed, and edit.SetJSON failed on the file. Now, when the catalog is sealed:
- Sync and a model pick keep magpie's definition (address, api, models) current in provider-plugins/magpie/providers/magpie.json, where OpenHanako has moved it (everything but the key and headers, which stay in the catalog: wun's overlay), and the key, unreadable, counts as unknown, not changed, in Sync and Check; Check names magpie.json for the address.
- Adding magpie with no plugin file, or taking it out, needs the key in the catalog: with OpenHanako closed it says "open OpenHanako and try again" and changes nothing (taking out refuses before the agent's model is put back). While it runs, its API does it all, as before.

TestHanakoSealed (a catalog sealed the way OpenHanako seals it, a plugin on an old port with an old model) fails on the old code with the reported "provider-catalog.json: top level is not a JSON object" and passes; a copy of the owner's ~/.hanako (sealed by 1.0.0-beta) syncs with no error, its catalog's bytes unchanged and the plugin's address and models current, Check then empty. go test -race ./internal/agent, vet (darwin, linux, windows) and gofmt pass.
2026-10-06 23:06:47 +08:00
yetone 7bdb0b34e8 ui-preview: the sandbox gets made-up data before it is recorded, so a change that only shows with data can be seen (owner: pr-preview 的时候有时候没有数据压根儿测不了,能否帮忙造一些假数据之后再测啊)
seed.mjs write fills the fresh home before magpie starts:
- a Sub2API relay (three keys, rotate, 5h/1d/7d windows, a month of balance history), Acme Cloud and a routing group with a rule;
- 30 days of requests from Claude Code, Codex, OpenCode and Gemini CLI over models priced on models.dev, at different speeds, with TTFT, cache reads, reasoning, sessions, accounts and a few 429/502s;
- 308 Claude Code and 94 Codex session files, more than a page, many never sent through magpie;
- two ChatGPT accounts signed in to Codex (Pro with credits, Plus near its limit) with five weeks of quota history;
- library skills from GitHub (one author with two repositories) and a folder, and some only in the agents' folders.

seed.mjs serve stays up as the mock: the fake providers in the Chat, Responses and Anthropic APIs, the relay's /v1/usage, and, as magpie's HTTPS_PROXY, chatgpt.com's usage and Codex models under a certificate from a CA that run.sh makes. Every other host is tunnelled as it is, so DeepSeek still answers for real. The warm-up also goes through the relay, Acme and the group, and record.mjs's guide says what the sandbox now has.

Checked by running run.sh in a Linux container:
- the Codex cards are read live, not stale, and their history draws;
- the relay's windows and balance trend show;
- every model is priced;
- sessions list 200 of 402;
- the warm-ups all pass.
Then record.mjs ran for real (DeepSeek planning, Playwright) on 6e53ee1f's diff (#1031). It planned and shot the skills page's author groups, with GitHub skills above and local below.
2026-10-06 22:49:36 +08:00
Zhizhen He dfc66030e8 provider: keep saved credentials when reading or backing them up fails (#1034)
Follow up #965: keepUnreadLogins still ignored read and backup failures,
so a new sign-in could replace credentials that had not been recovered.
Return those errors and stop writeLogins before replacing the original.
A missing logins.json can still be created, and successful backups keep
the existing behavior. Document the save failure contract.

TestUnreadLoginsWriteErrorsKeepCredentials fails on the latest base for
both faults with "original credentials overwritten" and passes with the
fix. The unread-logins tests also pass under -race -count=20.

On 588e51ea, the full nogui suite, go vet, and macOS/Linux(nogui)/Windows
builds pass under a temporary HOME with pinned Go caches. The full suite
uses a temporary, checksum-verified Bun for the CLI's plugin test.
2026-10-06 22:48:34 +08:00
yetone 5b9b9c9b05 gui: a call's JSON body in the Gateway page's recent calls folds, is coloured and copies a key or a value (owner: 这里应该支持 json 对象折叠以及代码高亮以及方便复制某个key/value)
Request and response bodies that are JSON, a stream's events and the reply put together are drawn as a tree: keys, strings, numbers and keywords in the code colours, an arrow before each object or array folds it to {…} with its count (lazily drawn on first unfold, folds kept across the list being redrawn), a copy button at the end of a line (on hover; faded rather than hidden so the pointer crossing to it keeps the line hovered) copies a string's text or an object's JSON, and a click on a key copies its name. The body's innerText stays the pretty-printed JSON (buttons are SVG only, 0 wide, nothing out of flow, which Chromium would break lines around). The {…} summary sits on the head's line, so a click on it holds that line, not the next one the unfolding pushes down. A cut or non-JSON body is as before.

json-tree.test.cjs passes in Chromium and WebKit, en/zh/ja/de, 1000px and 440px; sse-body, recent-calls-more, request-archive, request-archive-usage, gateway-caller-keys, routing-side-calls, gui-ja, gui-de, i18n-keys and click-scroll pass in both engines.
2026-10-06 22:41:31 +08:00
yetone dcbae2ef98 library, gui: the MCP servers page has the skills page's Select, Turn all on, Turn all off and Remove all, under one "In the library" heading (#1027, emo172)
The servers page had only By agent and the order, each with its own "In the library" heading, so with both the heading showed twice. Turning every server on or off, or removing them all, took one chip or one row at a time. The page now has one heading (lib-serverhead, which wraps when narrow) holding the order, Select, Turn all on, Turn all off, By agent and Remove all, as the skills page does. The skills page's helpers now serve both pages rather than being copied. picking names the page ("skills" or "servers"). pickingFor, pickRow, pickBar and groupChips(kind) are shared, and so are everyButtons, confirmEveryOff and everyOf, which replace everySkillButtons and everySkill. removeEveryButton, confirmRemoveEvery and removeEvery replace removeEverySkillButton. The skills page says and does what it did before.

- Turn all on and Turn all off post every agent shown to servers/agents-all (EveryServerAgents, now serversAgents(nil, …)). A hidden agent is left alone. Turn all on is greyed once every server is on for every agent that can reach it. Turn all off asks first, in the page.
- Select puts a box on each row. A row's click ticks it rather than opening the server. The bar's chips post the servers picked to the new servers/agents-some (SomeServersAgents), which gives an agent only the ones it can reach (mcpFile.supports) and leaves unnamed servers and agents as they are. An agent that can reach none of the picks has its chip greyed, with the reason.
- Remove all asks first. It says which agents lose the servers, the projects they are taken from, the sign-ins forgotten, and that the agents' own servers stay. It then posts every name to the new servers/remove-all (RemoveServers). This is one change that takes each server out as RemoveServer does: out of the library, its projects and its icon (removeServer, now shared by both), then out of every agent through syncMCP, which takes only what Applied says magpie wrote, and then forgets its sign-in. A name the library hasn't is listed in Unremoved, and the rest go all the same.
- The pick bar wraps, so its chips and Done fit at 440px in ja and de.

TestRemoveServers checks a server the user put in Claude Code's file and Codex's own "docs" of a library server's name: both stay, while fs, docs and neon leave the library, both agents and the project's .mcp.json, and the project's own "team" stays. neon's sign-in is gone, "nope" is reported, and an empty list or one with only "nope" errors. TestSomeServersAgents checks that Codex gets fs but not the SSE web, that git stays untouched, that a call naming no servers or no agents errors, and that a call naming an unknown server writes nothing. Both pass with -race -count=10, alongside TestEveryServerAgents and TestRemoveServer*.

library-servers-bulk.test.cjs passes on Chromium and WebKit in en, zh, ja and de at 440px. It checks one heading with every button, Turn all on's post without the hidden Copilot CLI, Turn all off's Cancel posting nothing and Confirm keeping Copilot CLI's, the pick bar's codex chip posting agents-some for the two picked and its greyed state for web alone, the All box, Done, and Remove all's lines, Cancel and post. It also checks for no sideways scroll and that each control clicked stays where it was on the screen. The fake check answers every server, since an unchecked one is checked again at each drawing. It fails without the change and passes 6 runs beside pick-skills, every-skill and gui-ja at --test-concurrency=4. by-agent, pick-skills, every-skill, remove-all-skills, group-*, library-*, mcp-signin, project-servers, skill-*, i18n-keys, gui-ja and gui-de pass. skill-how's WebKit case and click-scroll's "Live and a day" fail the same on the unchanged origin/main when run beside each other, and pass alone. go test -tags nogui ./internal/..., go vet, gofmt -l, and the linux (nogui) and windows builds pass. Not tried in the real app. docs/subsystems/library.md says it.
2026-10-06 22:24:04 +08:00
yetone 51d2cf88ea plugin host: a plugin's node:http2 connection goes through magpie's proxy like its fetch does (#1035, cataglyphis: Cursor opus-5-5 high ends after ~148s with "Cursor: 502 Bad Gateway" cursor_error) — the Cursor plugin runs agent.v1.AgentService/Run over node:http2, which Bun connects directly and ignores the proxy that host.js gives every fetch; the built-in sent the same request through s.client, so a user who needs magpie's proxy (setting, env or the system's) to reach Cursor had their sign-in, models and usage proxied but every Run went out directly. host.js now wraps http2.connect: for a URL proxyFor gives a proxy to (provider/account via, global, SOCKS bridged), it opens an HTTP CONNECT tunnel (Proxy-Authorization from the URL) and runs TLS with ALPN h2 over it; "direct", a caller's own createConnection and unproxied URLs keep Bun's connect; a tunnel that fails ends the session with "proxyconnect tcp: …". TestPluginHTTP2TakesTheProxy (an HTTP/2 vendor at an unresolvable host reached only through a CONNECT proxy; direct and a dead proxy fail with the reason) fails without the change ("h2: getaddrinfo ENOTFOUND h2vendor.invalid"; it carried 0) and passes -race -count=10. With the owner's Cursor account in a sandbox HOME and a logging proxy set, Claude Code's captured request on cursor/claude-opus-5-5 high: before, the proxy saw api2.cursor.sh only; after, it also sees agentn.global.api5.cursor.sh and the reply streams. The reporter's 502 wasn't reproduced here (direct runs answer in 4–20s). go test -race ./internal/plugin, the gateway/provider Plugin|Proxy|Cursor tests, gofmt and vet pass. 2026-10-06 22:16:24 +08:00
wizzy-yangandwizzy-yang a0cc4562e7 agent: a dsh patch list that lost its route to dsh's own save is written back quietly (#1029)
dsh reads a patch list live and keeps the model its sessions start on
across its own saves of the file (its desktop app writes the list anew
from a snapshot as it saves), but such a save has been seen to take
magpie's route out while keeping the start: the providers beside
magpie's stayed, the start still named magpie's, and the row went red
"no longer goes through magpie" until Apply again — which the next save
reddened again.

The start naming a model of magpie's is what says magpie was not taken
off: taking it off is the switch's doing and it takes the start with it,
and a list whose start is dsh's own or the user's stays as it is. So
dshRouteAgain writes the route back beside the user's own where the
start survives it, and dshCheck says nothing for the loss where a round
can repair it — said with no catalog to write from, it stands as before.

yetone's review of #1029: the home layer is only written into where it
already holds an llm-pi-ai entry of its own — its config replaces every
profile's whole entry (dshOver), so one magpie added there would take the
user's own providers out of dsh's list (#804) — and the loop over the
other profiles no longer skips itself with no catalog: only a profile the
round can write back is passed over, and one taken off magpie for real is
still said. A list still on the old wiring is dshMove's, not the round's
to write beside.

Co-authored-by: wizzy-yang <wizzy-yang@users.noreply.github.com>
2026-10-06 22:09:44 +08:00
yetone 33e4f165aa docs: how magpie picks an account — the steps a request's accounts go through, what Make first counts under each routing, Smart's and Weekly pace's real rules, rests after running out, gateway vs. on its own, and how often an allowance is read (#1032, Cyb3rN8)
docs/reference.md gets a section "How magpie picks an account" after Routing
groups; docs/subsystems/gateway-routing.md links it and notes the two
points users most often misread. Docs only; no behavior changes.

Every claim in the issue was checked against origin/main (38a0f470):
internal/gateway/routing.go (weighRouted, restAfterMarked, rateRest),
affinity.go (affine), fallback.go (perKeyBarred, plan, restLast),
gateway.go (serve), internal/provider/routing.go (Allowance.For, Renewal,
Pace, Allowances), codex_switch.go (NextLogin, KeepOnAnAccountWithRoom),
subscription_usage.go (claudeWindows), claude_limits.go, login_usage.go,
and app.js's Make first.

Claims in the issue that were wrong or incomplete:
- The order has more than four steps: Sink (when on) after the routing,
  the provider's fallback models after its accounts, then resting ones
  last over all of them, then affinity.
- A Claude account that has to be signed in again is not filtered out: it
  stays a candidate and is passed over at its turn, without resting.
- "Used to about 90% and it switches" holds only for new conversations:
  affinity keeps a conversation on an account until 98% (100% In order).
- Smart: Make first is not "useless once resets differ by an hour". Resets
  are compared window by window, biggest first, each truncated to the clock
  hour, so 13:55 and 14:05 differ while 14:05 and 14:55 tie; the order
  counts only when every window ties (or, at 90%+, shares are equal).
- Weekly pace: Make first counts only when the recent tokens are equal,
  not "similar" (a decayed float: in practice both zero).
- Saved Claude accounts' usage heard from rate_limit_event has no 1-hour
  validity: it is kept until replaced (a window past its reset reads as
  empty). claudeHeard's hour only lets such a reading stand over a failed
  /usage of the signed-in account.
- Claude's /usage also runs once after start and on `magpie accounts`, the
  TUI's Usage page and `magpie quota wait`, not only the GUI refresh.
- The direct-use switch also moves on at a usage cap, uses 100% under In
  order, goes back once the account it moved off has every window under
  90%, and never moves to a saved Claude account whose allowance isn't
  known yet.
- "Response header" in the rest order is magpie's own carrier of the
  refusal's reset time; Retry-After is held to an hour.

No UI hint: the natural spot (Make first's tooltip) would reword existing
strings in four languages; left for a separate change.
2026-10-06 21:52:41 +08:00
yetone 38a0f4709c provider tests: the fake claude auth login ends once its test's folder is gone, rather than polling for ever — a TestClaudeSignInByPaste run that ended without signing in left its fake looping every 50ms for 2 days (pid seen 10-06, ppid 1). Its loop waits for the callback's file or the pasted code, both in a TempDir the test removed, so neither ever came. Checked: the loop body exits 3 after its folder is removed; TestClaudeSignIn*, TestClaudeSavedAccountDir pass -race -count=5. 2026-10-06 21:46:29 +08:00
yetone 6e53ee1f1d library, gui: skills installed from GitHub are above and this computer's below, and one author's repositories are one group (#1031, mintonight: 同一个作者的skill没有自动分组,原本在local的一些技能自动跑到上面来了 … local和online上下分开,给local中的skill分组后也只显示在下方的local里面,恢复上方online的自动分组功能)
Two changes put local skills among the online ones. 6d198f7f (White Immortal on Discord) gave a skill not installed from GitHub the repository magpie traces for it (the skills CLI's lock, the git clone it is in, From) and made it a repository group, sorted by name among the groups of skills installed from GitHub; so op7418/Humanizer-zh and typesafe-ai/skills, local skills, sat between online groups. And 6ac01d28 (#791) put a group of the user's own above every group by source, so a group made of local skills was at the very top. The groups by source were always by repository (owner/repo, since 23847a8a), never by author: skills.sh, which the Library's search reads, lists many authors' skills a repository each (oil-oil/oil-ui, oil-oil/oil-ppt …), so one author's skills installed from it were a group each.

skillGroups now orders the page in two: above, the groups with a skill installed from GitHub (kind github, or CC Switch's from there) and the user's groups of such skills alone; below, the user's groups with a skill of this computer's in it, the repositories traced for local skills alone (still a group each, as 6d198f7f made them), and On this computer last. Above, an author with more than one repository is one group named by the author, opened at github.com/<author>, its parts each repository once it has more than 8 skills; a repository on another git host isn't merged. What the agents get is unchanged.

skill-online-local.test.cjs (two oil-oil repositories, mattpocock/skills, a traced local skill, a user group of local skills, one of a GitHub skill, a local skill, and an author of 10 skills in two repositories; Chromium and WebKit, en, zh, ja and de, 1100px and 440px, no sideways scroll) fails against the old library.js and passes. skill-repo-groups and skill-groups say the new order (the traced gitlab group and a group with a local skill are below GitHub's). every-skill, group-skill-chips, import-all-skills, library-all, library-own-folder, own-skill, pick-skills, remove-all-skills, shared-skills, skill-name-many-agents, skill-parts, i18n-keys, gui-ja and gui-de pass on Chromium and WebKit; skill-how fails now and then on WebKit (zh: the codex agent's how posted as "" for "link") the same at origin/main without this change. go test -tags nogui ./internal/... under a temp HOME, go vet, gofmt -l, linux (nogui) and windows builds pass. No Go change.
2026-10-06 21:30:13 +08:00
yetone ee2588c268 provider: a key that says it is out of its windows while they are being read has them read again once that reading is back (TestRestingKeyReadAgainInAnOrderedGroup red on macOS CI, runs 37467016755 and on main since 555f5706) — since 555f5706 the first planning of a lone key reads its windows behind the request. When that reading was still out as the key refused for quota, StaleKeyAllowance only zeroed the time it was read, and the reading, asked before the refusal, came back, set it to now and was trusted for a minute: the rest out of the key's windows wasn't lifted by the reading planned after it, though its limit had been raised. StaleKeyAllowance now marks a reading that is out (stale), and once it is back the key's windows are read again at once; forgetting every key's windows clears the mark. TestKeyAllowanceStaleMidRead (the first reading held until after StaleKeyAllowance, then answered; the second answered with the week half used) fails without the change (asked once) and passes -race -count=20 with the other TestKeyAllowance tests. The gateway test fails every time with its first reading delayed 300 ms ("still rests by window …, its windows read 2 times", as CI said) and passes with the change. go test -race ./internal/provider (8 of 9 runs pass; the first run's failure wasn't kept and didn't come back), go test -race -count=10 -run 'TestResting|Key|Lone|Allowance' ./internal/gateway, go vet, gofmt -l pass. 2026-10-06 21:29:55 +08:00
tsuixl 29f4909262 fonts: list upright styles before slanted faces at the same weight (#1030)
Follow up on the Menlo style ordering noted in PR #1007. Compare normal, italic and oblique explicitly after weight, retaining the existing width and name tie-breakers.

The Menlo regression fails before the change with Italic, Regular, Bold Italic, Bold, and passes with Regular, Italic, Bold, Bold Italic. Group/tie-break tests, fresh Windows native discovery, focused nogui GUI/font tests, go vet and Windows/Linux nogui builds pass.
2026-10-06 21:15:19 +08:00
yetone c8690ca0d2 gateway: a Claude subscription run knows the tools ToolSearch loaded before its agent is handed the results (TestToolSearchLoadKeepsTheRun failed on CI now and then; wizzy-yang's #1026 retried the assertion) — on the continue path run.continueWith answered the agent's MCP call before run.tools recorded the request's tools, so a turn that finished quickly could be shelved (shelve copies run.tools) or asked offers() without the tool ToolSearch had just loaded, and the next request started a new run; on a new run, run.tools was written without run.mu while the agent was already running. The tools are now recorded under run.mu before continueWith, and a new run's tools and search are set under one lock. A 50ms sleep after continueWith fails TestToolSearchLoadKeepsTheRun 5/5 on the old code with CI's message and passes with this; go test -race -count=100 -cpu 1,2,8 -run TestToolSearch and the gateway's subscription tests pass; gofmt, vet pass. 2026-10-06 21:11:25 +08:00
yetone a8d798e2cb gateway, provider tests: the tests stay off the network (testenv.Offline in their TestMains) — after 555f5706 (#1016) routing reads a plan key's quota windows, and a test whose provider has a vendor's real base URL and a made-up key would ask the vendor in the background. Routing's own read (keyAllowance, allowances) was already stubbed in routing_test.go's init, and a survey with a dial guard found no plan-key dial, but it found others: every plugin host's first start fetched models.dev's catalog (65 dials in the gateway's plugin tests), and provider tests asked api.github.com (TestSubscriptionUsageEachAccount, TestSubscriptionUsageServesStale), chatgpt.com (TestCopilotQuotaWithoutEditorsSignIn), server.codeium.com (TestDevinSignedInOnlyInMagpie) and decide.test (TestModelTest) with made-up accounts; each passed whether the vendor answered or not. internal/testenv's Offline wraps http.DefaultTransport's DialContext (so http.DefaultClient and every transport cloned from it later): localhost, *.localhost, loopback and unspecified addresses are dialled, anything else is refused before it is looked up, said once per address on stderr ("testenv: a test asked models.dev:443, refused: tests stay off the network"). internal/gateway's TestMain and internal/provider's (kiro_main_test.go, in RunIn's setup) call it before the tests run. TestLocal (the address table) and TestOffline (a loopback httptest server answers through DefaultClient; models.dev is refused through it and through a Clone made after the call) pass -race -count=3. go test -race -tags nogui -timeout 30m ./internal/testenv ./internal/provider ./internal/gateway passes under a temp HOME (gateway 516 s). The gateway and provider test binaries were then run under sandbox-exec with every non-loopback network-outbound denied, with the kernel's Sandbox reports streamed: both PASS, Offline refused only the six addresses above, and the kernel reported no denial, so no transport outside DefaultTransport reaches the network either. go vet, gofmt -l, linux and windows (nogui) builds pass. Tests only. 2026-10-06 21:08:57 +08:00
yetone f311a61859 gui tests: usage-speed.test.cjs's comment gives the numbers the fixture makes (75.0 tok/s for glm-fast against the hour's 69.7, not 80.8 against 75.2) — comment only 2026-10-06 21:08:57 +08:00
yetone 70e7e36675 gui tests: a press is made only on a target wholly in sight, so Playwright's click doesn't scroll it the last fraction of a pixel itself (request-archive-usage.test.cjs: "a click moved the page: 860 to 861" in Chromium once the Usage page's archive switch shows, which #1017 makes it do) — reader.inView took a target cut off by up to a pixel at the view's foot as in sight; Fetch from archive spanned 539.80–562.59 in a 562px view (the switch makes .led-head 4px taller, so the 20px wheel steps land the rows elsewhere), and Playwright's click scrollIntoViewIfNeeded'd it 1px before dispatching, before the app's capture-phase click listener took the positions hold() keeps, so the app saw nothing to put back. A raw page.mouse.click at the same point moves nothing; no page code scrolls or focuses there, and overflow-anchor: none changes nothing. inView now wants the target wholly in the view (or, taller than it, its top in sight), and the press test's web ±1 allowance ("settles a row's first opening up to a pixel off") is gone: a click moves the page by nothing. With #1017's diff applied, request-archive-usage.test.cjs failed in Chromium (both app modes) and passed in WebKit; with this it passes 5/5 in both, and 3/3 with the allowance removed. The 32 suites using reader.cjs, request-archive*, usage-*, click-scroll and ledger-* pass in Chromium and WebKit (275/275). No app change. 2026-10-06 21:08:57 +08:00
yetone aad799eae6 library: an RTK release winget or Homebrew doesn't have yet says "v0.51.0 is out · waiting for winget" with no Upgrade, until it does (#1025, Fermin214)
On Windows, RTK installed by winget was 0.50.0 and GitHub had 0.51.0, which winget didn't yet. Library → RTK showed "v0.51.0 可升级" with Upgrade; Upgrade ran winget upgrade, which rightly said "winget's RTK is 0.50.0 so far…", and the card went back to "可升级" with Upgrade. The card compared rtk's version only with GitHub's latest release, never with what the package manager that installed it has, and the upgrade's answer was forgotten as soon as the card was read again.

RTKView.CheckLatest now also asks, when the latest release is newer than this rtk and it was installed by winget or Homebrew, what that package manager has: winget show --id rtk-ai.rtk --exact (the version is the first one after the line naming the package, as winget's labels are in the system's language), and brew info --json=v2 rtk without auto-update, as the Agents page's CLI updates read Homebrew. An upgrade through either records the version it left as the newest that package manager has. The answer is kept six hours (a failure fifteen minutes) and is asked again after that. When it has nothing newer than this rtk, the view says waiting (winget or Homebrew) and waitingHas. The card then shows "v{v} is out · waiting for {via}" (zh "v{v} 已发布 · 等待 {via}", ja, de), with a tooltip saying what it has, and no Upgrade button. Once it has the release, Upgrade is back. A package manager that can't be asked, or doesn't say, leaves Upgrade offered as before. GET /api/library/rtk runs the whole check within its 3s budget. magpie library rtk prints the same. Cargo (built from rtk's repository) and rtk's own script (its GitHub releases) can't lag, so they are unchanged. The Agents page's CLI updates already compare with the installing channel's own latest (Homebrew's or npm's), so they don't have this bug. The Japanese "v{v} is out" is shortened to "v{v} 公開済み", because with Upgrade beside it the card head overflowed at 440px.

TestRTKWaitsForWinget (a fake winget whose show prints Chinese labels: unaskable leaves Upgrade; asked, it waits; the reporter's upgrade waits with the "so far" note, and so does the card read again; winget given 0.51.0 and asked again, Upgrade is back and works) and the extended TestRTKUpgradeBrew (Homebrew's 0.49.0 after brew upgrade waits, on the card read again too) fail without the change (waiting <nil>) and pass. TestRTKChannelVersion covers winget show in English, German and Chinese with spinner frames, no package, and brew info's JSON. -race -count=10 on them. library-rtk-upgrade.test.cjs's new test (Upgrade finds winget behind: the waiting tag, no button, the note; a reload still waits; winget has it: Upgrade back; card head and page fit at 440px) fails without the library.js change and passes on Chromium and WebKit in en, zh, ja and de. i18n-keys, gui-ja, gui-de and the library-rtk-* tests pass on both engines. go test -tags nogui ./internal/..., go vet, gofmt -l, darwin, linux (nogui) and windows builds pass. Not tried with a real winget or on Windows: winget show's output is taken from its documented format, and the version line is assumed to follow the package line in every language.
2026-10-06 20:56:52 +08:00
yetone dd027bc433 sessions: a Claude Code workflow's agents are read as their session's calls (ksinverse on X: 发现Claude的用量和会话不太准,codex就非常好。是不是sub-agents没有被统计进去?默认开ultracode) — Claude Code writes a workflow's (ultracode) agents a folder deeper than other subagents, <session>/subagents/workflows/<run>/agent-*.jsonl beside the run's journal.jsonl, and ccFiles read only <session>/subagents/*.jsonl, so with ultracode on every workflow agent's calls and tokens were missing from Usage, the Requests page and the session's totals. ccFiles now reads each run's agent-*.jsonl too (the journal isn't a transcript and stays out), and sessionOfPath gives them their session, so they count as the session's like any subagent's; Claude Desktop's and Qoder's readers, which share ccFiles, follow. TestCallsWorkflowAgents (a session, a workflow agent's file and a journal with a line that reads as a call) fails without the change (1 call, the agent's missing) and passes. On this machine's real ~/.claude (read only), 34 workflow-agent calls of one session were unread and are now that session's. go test ./internal/sessions, -race -count=10 on TestCalls*/Subagent*/Trace*, go vet (darwin, windows), gofmt -l, linux and windows (nogui) builds pass. Not tried against the reporter's own transcripts. docs/reference.md says it. 2026-10-06 20:54:36 +08:00
yetone a334fe091c gateway: a request a proxy or tunnel on this computer forwards to loopback is answered as one from another machine, and needs the gateway key (#1022, znjhahaha: asked for a Cloudflare Tunnel in magpie, with the gateway key checked for requests through it) — local() was the request's address alone, so cloudflared, ngrok, Tailscale serve or funnel, frp's HTTP proxy, or a Caddy or nginx here, all reaching 127.0.0.1:3425 for clients anywhere, were taken for this computer's agents: whoever had the tunnel's address used the user's subscriptions with any token, read /v1/magpie/quotas (the accounts and their balances) and the remote cards, and their usage was counted as local, all without sharing on. reference.md said a proxy here had to sign its clients in itself; a quick tunnel signs in nobody. A request on loopback carrying a forwarding header (Forwarded, X-Forwarded-For, X-Real-IP, Cf-Connecting-IP, True-Client-IP, Tailscale-User-Login), which no agent sends, is now proxied, and local() is false for it, so every check built on local() takes it as from the network: lanGuard refuses it while magpie isn't shared, saying it came through a proxy or tunnel and that Settings → Share on local network and a gateway key let it in; shared, it needs an enabled gateway key and is then sharedWith, as a LAN client is, for the model APIs, quotas, remote cards, concurrency, session route and MCP sign-ins; the Omarchy theme and the Codex title link stay this machine's only, and its usage is no longer marked local. A gateway MAGPIE_ADDR opens to the network stays open to a tunnel as to anyone; one MAGPIE_ADDR keeps on loopback is closed to it. MAGPIE_TRUST_PROXY=1 says a proxy here signs its clients in itself and keeps what it forwards local, as before (listed in agentenv_test's notAnAgent). A tunnel forwarding bare TCP (frp tcp, ssh -R, socat) adds no header and can't be told from an agent: reference.md says to point one at the LAN address with sharing on. Running cloudflared from magpie with a pasted Tunnel Token is not done here: magpie would have to download or run a program it doesn't ship; reference.md says how to serve magpie through a Cloudflare Tunnel today. TestProxiedLoopbackNeedsTheKey (from 127.0.0.1 with cloudflared's, ngrok's, nginx's, RFC 7239's, Akamai's and Tailscale serve's headers: refused unshared, 401 shared without a key, with magpie's token or a wrong key, let in with the key under its identity, a disabled key refused; an agent on loopback let in with any token; MAGPIE_TRUST_PROXY; MAGPIE_ADDR on loopback and open) and TestProxiedLoopbackRoutes (quotas, quotas history, models, Omarchy and /mcp through a tunnel, unshared and shared, with and without the key) fail on main (200 for every tunnel unshared) and pass -race -count=10 with TestLANGuard, TestQuotasOverLAN, TestManagedLANGuard and TestExplicitOpenGatewayAcceptsAllRemoteTokens; go test -race -tags nogui ./internal/gateway (579 s, under -timeout 30m) and go test -tags nogui ./internal/... under a temp HOME pass; go vet (darwin, windows), gofmt -l, linux and windows (nogui) builds pass. No GUI change. Not tried with a real cloudflared (not installed here); its Cf-Connecting-IP and X-Forwarded-For are what Cloudflare documents it sends. 2026-10-06 20:35:18 +08:00
yetone 555f570660 gateway, provider: the Routing page says what a lone key or account has left, and a key bought as a plan (Kimi Code, GLM Coding Plan, MiniMax, OpenCode Go, Command Code) is weighed by its plan's windows (#1016, gphysign: Kimi Code (China) with one key on, Smart routing; the card said only "Kimi Code (China) 只启用了一个 Key,无需选择。" while /v1/magpie/quotas had its 5 hours 49 used, 51 left and its 7 days 45 used) — two gaps. weighRouted returned before reading anyone's allowance when there was one candidate, so a lone key or a lone subscription account was never known in the trace, and the Routing page, which shows a bar and "{n} used · renews in {d}" for a known row, had nothing to show; every single-account subscription said "what's left not known yet" the same way. And KeyAllowance read only a sub2api key's limits, so a key whose plan the Usage page reads (planQuotaSourceOf: Kimi Code, GLM Coding Plan and Z.ai, MiniMax Coding Plan, OpenCode Go, Command Code) was never known to routing, with one key or several. weighRouted now reads what each candidate has left before returning a lone one (one alone is still weighed against nobody). readsKeyWindows takes a plan key too; keyWindows reads it through planKeyWindows (planWindows, with Zhipu's team quota behind a key that has none of its own, as PlanQuotas does), and PlanQuotas' own read of a card is noted for routing at once (noteKeyAllowance), so the Usage page and routing never ask twice; the card kept on disk seeds it after a restart (keyCardTag keys a plan card as plan:, a sub2api one as balance:). A key at such a vendor that turns out to be pay-as-you-go, with no windows, is asked again every 30 minutes (keyNoWindowsAge), not every minute it is used. So, beyond the issue: several plan keys on one provider are now weighed by their windows under Smart, usage and pace, and a plan key the vendor refuses as out of quota rests until its window renews and comes back once read short of full, as a sub2api key does. affine no longer says a lone key or account all but used up "moved" its conversation: there is nobody to move to (at > 0). The GUI needed no change. TestLoneOneTellsWhatItHasLeft (a lone Kimi key known at 49% with both renews, the week first; a lone Codex account known; a plain DeepSeek key not), TestLoneSpentKeyKeepsItsConversation (fails with the at > 0 guard taken out), TestPlanKeyAllowance (the windows read behind the request, asked once; DeepSeek and Moonshot keys never asked), TestPlanKeyAllowanceFromItsCard (PlanQuotas' read known at once without a second ask; after a restart with the vendor down, the card on disk stands in and a failed read keeps it) and TestPayAsYouGoKeyAskedSeldom (a GLM pay-as-you-go key asked once, not two minutes on, again 31 minutes on) all fail without the change and pass -race -count=20 (provider) / -count=10 (gateway). routing-key-windows.test.cjs gains a lone Kimi Code key at 1100px and 440px (the one-key story, the row's bar at 49%, its provider row "49% used · renews in …" fitting, no sideways scroll) and passes in full on Chromium and WebKit in en, zh, ja and de; routing-quota-left and panel-routing pass. go test -tags nogui ./internal/..., go test -race on ./internal/provider and ./internal/gateway in full, go vet, gofmt -l, darwin, linux and windows (nogui) builds pass. Not tried with a real Kimi Code key. StepFun's Step Plan is read through a sign-in, not the key, so it isn't one of these. docs/subsystems/gateway-routing.md, providers-accounts.md and docs/reference.md say it. 2026-10-06 20:25:04 +08:00
wizzy-yangandwizzy-yang 3bcf71b003 gui: the speed chart's marks that leave their column get a leader line (#1018)
The Speed trend's column for a day is that day's own speed — every reply's
tokens over every reply's decode time — while each model's mark is drawn at
its own speed. A model faster than the day it is in leaves its mark above
the column, in nothing, reading as a stray dash (huoranxuanyuan, #860): with
five models the fastest is nearly always above the day's own, so this is the
chart's usual look, not an edge case.

Each mark that leaves its column now gets a dotted leader line down to the
column's top, so it reads as that column's; the line stops at the top when
the mark is within a pixel of it, or it would be drawn back down into the
column. The column itself is drawn in the track's colour rather than the
grey the stacked charts use for "Other", so it reads as the track the marks
sit on. The colour goes through style, not the presentation attribute:
var() substitutes in a style and not in an attribute.

box.emphasize() strokes a line and fills a rect: the marks' leader lines are
lines, so dimming a day or emphasizing a model sets their stroke where it
sets the marks' fill, and the two read the same.

usage-speed.test.cjs's hour(0) fixture now has the day's own speed under
glm-fast's, so the case asserts the leader line — that it is drawn, that it
runs down the mark's middle from the column's top to the mark's foot, that
it is dotted, that the column is the track's colour, and that the colour
resolves (it goes through style). It fails on origin/main, where the mark is
drawn with no line at all.

Verified: node --check; go vet; go build (windows); the new assertions
failing on origin/main's app.js and passing on this one; usage-speed 6/6 and
usage-ledger-chart 18/18 in Chromium and WebKit, en and zh; 560px with
nothing past the view and no sideways scroll.

Co-authored-by: wizzy-yang <wizzy-yang@users.noreply.github.com>
2026-10-06 20:23:11 +08:00