Files
magpie/internal/plugin/testdata
forever 8412a5d1a0 gateway tests: the keepalive tests count only the idle time a client waits through — the keepalive after the vendor's first words, and nothing after the reply's last event (#1163)
* gateway tests: the quiet mid-reply tests wait for, and count, the keepalive after the vendor's first words, as a held stream's before them is the agent's too (TestQuietStreamMidReplyKeptAlive failed once on main at GOMAXPROCS=2, load ~30)

The failing body began ": keepalive" before the role chunk and "hel".
That ordering is allowed, not a bug: quietFast lets watch keep the agent
alive 40ms into the try, and a vendor slow to answer (status 0, nothing
held) or one that flushed response.created and paused before "hel" (only
the role chunk, a lead, held) gets the held stream's keepalive first, as
TestSilentHeldStreamKeptAlive requires. Content that reaches the
holdWriter is scanned and flowed under h.mu, which keepQuiet and
keepAlive take, so no keepalive goes out after content the writer has
and before it is relayed. A vendor delayed 150ms before its first bytes,
or between response.created and "hel", gives the failing body every
time, with the keepalive sent at status 0 or with only the role chunk
held.

The tests released the vendor at the first keepalive and required "hel"
before it, so a slow first write made them fail, or test the held path
instead of the mid-reply one. readQuiet now releases the vendor at the
first keepalive after the given text, and aliveAfter asserts a keepalive
between "hel" and "lo": TestQuietStreamMidReplyKeptAlive and
TestQuietStreamsKeptAliveEveryProtocol's mid-reply cases.
TestQuietKeptAliveOnlySoLong counts only the keepalives after "hel", as
the held ones before them have their own 200ms. The every-protocol check
matches `"lo"` quoted: bare, it matched the anthropic cases'
content_block.

With a 150ms vendor delay injected, the old assertions fail all 5
mid-reply cases and the new ones pass. With keepQuiet's passing-branch
keepalive removed, all 6 mid-reply tests fail, delay or not. go vet;
go test -tags nogui -race -count=20 -run
'TestQuietStream|TestSilentHeld|TestQuietKeptAliveOnlySoLong'
./internal/gateway; the whole package with go test, and as a prebuilt
binary at GOMAXPROCS=2 three times (load 18-28), all pass.

* gateway tests: TestTranslatedStreamKeepsClientAlive stops counting the client's idle timeout once it has the reply's last event (red on ubuntu CI at ef5fba2c, run 37471004500: responses_to_chat "idle_timeout=true completed=true") — the gateway writes and flushes finish_reason, usage and data: [DONE] back to back in chatEncoder.finish, then serve's bookkeeping (NoteServed, saveSticks, usage.Append) runs before the handler returns and net/http sends the stream's close. On a loaded runner that, or the test's own goroutine being held, passed the 150 ms the test still counted after [DONE]. No client's idle timeout counts that wait: Codex hangs up on response.completed, and the clients that read on to the close give minutes. Chat's marker is now data: [DONE], the stream's last event, instead of finish_reason two events before it. With a 200 ms sleep after enc.finish() on the Chat path, the old test fails 20 of 20 exactly as CI did and the new one passes 20 of 20. The new test still fails 20 of 20 with the sleep before enc.finish() (completed=false), with readSSEAlive's keepalives taken out (#436), and with the Chat keepalive followed by a blank line again ("events with no data"). go test -race -count=20 -run '^TestTranslatedStreamKeepsClientAlive$' ./internal/gateway, go vet and go test -tags nogui ./... pass.

* plugin tests: the fake plugin's windows renew at times set when the host loads it, so the gateway's TestPluginFailover passes when an hour begins between two readings of an account's usage (waitingsnow)

The fake plugin told each account's five hours to renew an hour after
its usage was read, and its week a day after. TestPluginFailover weighs
the accounts by what the plugin tells (provider.Allowances), and Smart
routing puts first the account whose week renews sooner, to the hour;
accounts alike keep their order. A request that fails on an account's
quota, or in its stream, drops that account's reading
(staleAllowance), and the next request reads it again. A 401, as in
the gone round, is failOther and drops nothing (rest,
gateway/routing.go). When an hour began between a@fake's read after
the quota round and its read after the stream round, a@fake's week
renewed in the hour after b@fake's. b@fake then went first in the gone
round, failed with a 401 and kept its first reading, so the request
with both out was tried as b, a, full, which fails the test at
plugin_failover_test.go:210. An hour that began after the first
readings but before a@fake's read after the quota round put b@fake
first in the stream round instead, which failed b@fake and read it
again too, and the test passed.

The test fails every time an hour begins between those two reads of
a@fake, and passes otherwise: the 8 such runs below failed and the
other 13 passed. The reads are 12 to 76ms apart in the runs below
without the hold, and a reviewer's -race build measured 33 to 106ms in
6 runs, so a run at a random time fails about once in 34,000 to
300,000. CI's Linux job installs bun 1.4.2 and runs the test in its
Test and Race steps. Found by a review of the tests that compare
renewals to the hour; no CI run was checked for it.

The windows now renew an hour and a day after born, when the host
loaded the plugin, however often an account is read, as a vendor's
windows renew at times of their own. Run once in a process, as CI's
Test and Race steps run it, TestPluginFailover reads its accounts from
the one host started after its plugin.Add, so they renew together. Run
again in one process (-count), a run can still weigh an account by the
previous run's host, born perhaps in the hour before, until that
account fails and is read again: provider's allowances and usage
readings outlive fresh(t) and plugin.Settle. That account then goes
first, and the test's rounds fail it and read it again before the
checks that depend on order, so it still passes; clearing those caches
is left for its own commit.

TestPluginUsage now reads a@fake's card a second time from its refresh
button, 2ms later, and wants each window to renew when it did the first
time, as a vendor's do. That is the check that fails without this
change. Its other bounds (the five hours in 50 minutes or more, the
week in 23 to 25 hours, full@fake's five hours used up) hold while the
host is under 10 minutes old; no other test reads these times.

Verified under a temporary HOME with Go's caches pinned, every test run
with -tags nogui and bun 1.4.2. With 01aaa8e9's index.js put back,
TestPluginUsage fails every run:
  plugin_usage_test.go:109: a@fake's 5 hours renews at 2026-10-07
  12:16:35.567 +0000 UTC read again, at 2026-10-07 12:16:35.539 +0000
  UTC first
and with this one it passes, also with -count=5.

With 01aaa8e9's index.js, TestPluginFailover passes unless an hour
begins between those two reads. failgap.sh, a scratch script, builds
the gateway's test binary once and runs TestPluginFailover with the
old plugin and with this one. It makes the plugin's usage hook tell
its windows from Date.now() plus a shift, set at the first reading so
that an hour begins a set time later (Go's clock is left alone), and
logs each reading. Its copy of the test can hold 2s before the stream
round, which puts its lines 5 further down. With the hour 30ms after
the first reading, the old plugin failed 3 runs of 3, its two reads of
a@fake either side of the hour (at 23 and 42ms, 20 and 34, 18 and 37):
  plugin_failover_test.go:215: fake-claude with the others out: 200
  {...} (tried [b a full])
At 45ms it failed 1 of 3 (44 and 69ms). At 60, 80 and 120ms it passed
9 of 9, a@fake read twice before the hour, or once after it and b@fake
read again. Held 2s before the stream round, with the hour 50 or
1100ms after the first reading, it failed 4 of 4 (a@fake read at 24 to
34ms and 2044 to 2121ms). This plugin passed all 19 of those runs.
That shows only that the test passes with it: its windows are fixed
from born when the host loads it, before failgap's first reading moves
the clock, so the moved hour never reaches them. Held 2s with the hour
30 minutes away, both plugins passed 2 of 2. born.sh, run as a process
per run, logged the host and the born behind each of the plugin's
reads in 20 more runs of this one: 9 a run, each run's from one host
and one born. It logs the plugin's reads, not what routing weighed.

gofmt -l internal/provider/plugin_usage_test.go prints nothing. go vet
./..., go build ./..., GOOS=linux go build -tags nogui ./...,
GOOS=windows go build ./... and GOOS=windows go vet -tags nogui
./internal/provider/ pass. go test -tags nogui ./... passes, 43
packages ok, with bun 1.4.2 on PATH, and so do TestPluginFailover and
TestPluginUsage* with -race -count=20.

* gateway tests: TestTranslatedStreamKeepsClientAlive fails when the stream doesn't close within 5s of its last event, rather than waiting on it with no limit

After the reply's last event the read had no time limit, so a gateway
that never closed the stream would hang the test until the package
timeout (yetone on #1163). The idle timeout still stops at the last
event; a 5s limit on the close follows it.
2026-10-08 10:28:06 +08:00
..
providers, sign-in, tray, plugins: four of StringKe's reports on Discord — (1) Removing a provider left its editor open and listed, and a second Remove said there was no such provider: the removal had been made, but moving the agents off its models failed on one agent whose config couldn't be written, and Reseat returned that as the change's error; now such an agent is a move with its error ("still on X, not moved: …", a warning in the panel, a line in the CLI and TUI), the others are moved, and the panel, told a delete failed, re-reads the list and closes the editor when the provider is gone. (2) The sign-in waiting sheet shows the sign-in link itself, selected whole on a click, with a copy button, for a browser other than the one that opens. (3) A subscription with several accounts offers "Account in use" among the menu bar's usages, following the account the gateway goes to first. (4) The Antigravity plugin's sign-in never started: magpie called every plugin's authorize with an inputs object, the way OpenCode's CLI does, and opencode-antigravity-auth then asked its Project ID on the terminal — magpie's stdin — until the minute ran out; inputs now go only to a method with prompts, as OpenCode's TUI passes them (checked against the real package under Bun in a sandbox: the Google sign-in URL comes back at once). TestReseatUnmovable, TestTrayInUse, TestInUseOf, TestAuthorizeNoPrompts, provider-remove.test.cjs, signin-link.test.cjs and tray-inuse.test.cjs fail without the change. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass; click-scroll, old-webkit, tray-usages, plugin-signin, signin-callback, factory-sub pass.
2026-09-30 20:45:23 +08:00
providers: Max concurrent requests — over it, an agent's requests wait in line and go out in the order they came (Lemon on Discord: 希望 provider 或者插件能够支持一个参数类似 max_concurrency,如果超过上限,放到无界队列里面依序请求; Memo: ChatGPT/Codex subscriptions' risk control trips at about 5-6 requests at once) — the gateway sent every request to its provider the moment it came, so a few agents (or one agent's subagents) on one subscription could have any number out at the vendor at once, and nothing in magpie could hold them to what an account takes. A provider now has maxConcurrency (providers.json; nil or 0 is no limit, a negative one is saved as 0), and a signed-in agent's accounts keep theirs with their picks. The limit is per key or account, as the gateway already tells them apart (candidate.who(): provider#keyid, provider@user, else the provider), so a subscription pooling several accounts lets each one have that many out. Before a try is forwarded it takes a slot in that lane (internal/gateway/concurrency.go); with none free it waits in a FIFO queue with no bound, and is let in, first come first served, as one before it ends. The slot is held for the whole try (attempt reads the vendor's reply to its end or until the agent hangs up, and its body is closed when it returns) and given back once whatever happens (sync.Once), and a lane with nothing out and no one waiting is dropped. An agent that hangs up while waiting leaves the queue at once, is never sent and takes no slot (a slot granted in the same instant is handed on), and goes the way of any canceled request (499, nothing recorded). Waiting is not a failure: a request in line stays for its provider and is not moved on to a fallback or another group member, the account isn't rested, and the time it waited is told in its route's try (Try.Queued, ms). A plugin can say how many each of its accounts takes, as its own field: the auth hook's maxConcurrency, else package.json's magpie.maxConcurrency (a whole number over 0, at most 1000); the user's setting on the provider goes over it. GET /v1/magpie/concurrency (local or shared callers, as quotas) gives each lane's out, waiting and limit. The provider editor (keys, custom, signed-in accounts) has "Max concurrent requests" / 最大并发请求数 under Proxy, "超出时排队,按顺序发出;0 或留空为不限制" below it (with a plugin's own number as its placeholder), refuses anything but a whole number from 0 to 1000 before anything is posted, and an empty field saves none (the plugin's, if it has one); a save that doesn't send the field keeps what the provider had. No coloured left border, and a click in the field leaves the page where it is. TestMaxConcurrency (limit 2, five streams: never more than 2 at the vendor, 3 waiting, let in a..e in order; one canceled while waiting leaves the queue, takes no slot and never reaches the vendor; slots given back when each stream ends and when an agent hangs up mid-stream; the next one served at once) fails without the change (all five reach the vendor at once, no lane), as do TestLanesLimitLifted, TestProviderConcurrency and the updated TestPluginOwnIconAndKeyHint (the auth hook's 3, package.json's 5 when the hook's is 2.5); provider-concurrency.test.cjs (chromium and webkit, en and zh: typed and saved, 2.5 refused with nothing posted, empty saved as null, an account opening on its 5 and saving 0, a plugin's 4 as the placeholder, every string translated, no stripe, no scroll on click) passes. No vendor was asked. go vet ./internal/..., go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui -count=1 ./... (under a temp HOME) and go test -race on the new tests pass. provider-proxy.test.cjs's account test fails the same on HEAD without the change (its editor redrawn under it).
2026-10-02 13:38:23 +08:00
plugin: a pi package's sign-in screen on pi's terminal UI is asked in magpie, and an empty reply a package makes of a vendor's error says the error (all pi plugins: Kiro's login hung, pi-zcode's quota ran out as an empty answer) — pi-provider-kiro asks how to sign in through ctx.ui.custom, a component of pi-tui (a SelectList, then Input fields for a start URL and region), and magpie gave pi's runner no UI, so custom came back undefined and the sign-in waited forever. pi's runner now has a UI for the sign-in's questions: select, confirm, input and editor become magpie's sign-in prompts, and custom draws the package's component and asks its list or field the same way, answering through the component's own onSelect/onSubmit, until the package calls done. A component may keep its parts to itself (Kiro's gives only render and handleInput) and each package loads its own copy of pi-tui, so the parts are found by what drawing walks: every container goes through its children, so the arrays iterated while it renders are what it shows, in order; the text above the list is its question. Outside a sign-in the UI answers as pi's no-UI context does. session_start now reaches every package at startup, which Kiro needs to keep the context it asks through. Separately, pi-zcode turns ZCode's 1005 quota error into a reply with no content; magpie keeps the small JSON or text bodies a package's fetch gets while it streams, and a stream that ends empty answers that body's message and code (429 for quota or rate limits, else 502) instead of an empty message. TestPiPackage drives a fake package that asks on real pi-tui classes (a list, then a start URL field) and fakes ZCode's empty quota reply; it fails on the old code (no first prompt) and passes. With real accounts in a sandbox HOME: Kiro's "Kiro Login" (API Key, Web Login, Device Code) came as a select, Web Login gave the app.kiro.dev sign-in, and after signing in kiro-plugin/claude-sonnet-4-5 called get_weather and answered with its result. pi-cursor (1.4.38, latest) answers plain turns but its models say no get_weather tool exists, though the package sends it (toolCount 1, its MCP schema) and Cursor's stream carries fields the package doesn't know; that is between pi-cursor and Cursor. The ZCode quota path wasn't tried with a real account. go vet, builds (darwin, GOOS=linux, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass.
2026-10-03 23:47:15 +08:00
plugin: a host stopped mid-renewal saves the new token first, and two hosts never write each other's sign-ins away — a stopped host (quit, plugin update, restart) was killed 2 s after stdin closed even while it renewed a sign-in through auth.refresh, after the vendor had spent the old refresh token, so the account was signed out; and while a host restarts the old one and the new one both rewrote all of plugin-auth.json from what each had read, so one host's save could drop the other's accounts or newer token. host.js now tells magpie how many renewals are under way (event "renewing"), and stop gives a host still renewing after the 2 s up to 15 s in all, until the renewal is saved; with none it stops as fast as before. Every change to plugin-auth.json (setAuth, removeAuth, settle, take, auth.set, a renewal's save, and magpie's SignOut when no host runs) is made under plugin-auth.json.lock (O_EXCL, broken when older than 10 s), reading the file afresh and changing only its own accounts; a renewal's save checks under the lock that the stored sign-in is still the one it renewed, so an older host never writes a stale token over a newer one. TestStopLetsARenewalEnd (a 4 s refresh, the host restarted in the middle: old code kept the spent token) and TestTwoHostsKeepEachOthersSignIns (two hosts saving 40 accounts each at once: old code lost 38 of 80) fail on the old code and pass; TestStopWithoutRenewalIsQuick keeps the quick stop. go vet, builds (darwin, GOOS=linux, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass but TestProvidersAnswerWhileListsComeIn and TestClaudeSignedOut, timing tests that pass alone and in their packages rerun. Not tried with real plugin accounts.
2026-10-03 15:47:10 +08:00