* 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.
Bun 1.4.2 on Windows reads NODE_EXTRA_CA_CERTS as ANSI and takes it as
UTF-8: C:\Users\<中文>\.cache\magpie\bun\system-ca-*.pem became
"锟斤拷" and "load failed: No such file or directory". An 8.3 short name
doesn't help (Windows gives 测试 none). Bun opens a relative path from
where it runs, which is under the same user folder, so the path is given
relative to Bun's dir when it isn't ASCII.
Tried on the Windows box with Bun 1.4.2 and a local HTTPS server signed
by a test root, from a cwd under 测试: absolute path -> warn + FAIL
UNABLE_TO_VERIFY_LEAF_SIGNATURE; relative path -> 200. The new test's
Windows cases ran there (windows/amd64 test binary): PASS.
TestPluginFailover failed about 1 run in 90 at "a refused isn't marked".
With notePluginLapse logged, a failing run read: a background reading of
a's allowance (loginReading, after the "stream" round's StaleAllowance)
began at 31.985; the upstream answered a's request 401 at 32.001 and
notePluginSaid marked a lapsed at 32.003; the reading ended clean at
32.011 and notePluginLapse(200) took the mark off; the test read the
logins at 32.042. Users meet the same: a reading of the Usage page or of
routing's allowances that is out when a request is refused clears the
mark the refusal left, and routing goes on trying the refused account;
one that ends refused marks an account a request has just gone through
on.
What plugin accounts' sign-ins are told of is now counted under
loginsMu (pluginSignInTold, pluginSignInSaid). A request's answer, a
sign-in and the plugin's own signIn events are told now and apply as
before. pluginLoginQuota takes the count before plugin.AccountUsage, and
what its reading found applies only when nothing was told of that
account since (notePluginLapseSince), as Allowances leaves out an
account renewed while it read. A count rather than a time: Windows'
clock moves in ticks of up to 15.6ms, and a reading begun just after an
answer would read as begun with it.
TestPluginUsageReadOlderThanAnswer holds the fake plugin's usage on an
httptest server ($FAKE_USAGE, whose 401 the fake now reads as an expired
sign-in) while a request's answer is noted, both ways, and checks that
readings begun afterwards still mark and clear. Each direction fails
without the fix. TestPluginFailover now reads the allowances the
"stream" round left stale before the "gone" round: the fake's usage
doesn't ask the upstream that refuses, so a reading that asks after the
401 finds the sign-in good and rightly takes the mark off. A review
reproduced that with a delay injected before the count is taken.
Left as it is: a reading whose own renewal of the token tells "renewed"
before it asks skips the "expired" it then finds; the next 401 marks the
account. Not tried with a real plugin account.
Under a temp HOME with Go's caches pinned and bun 1.4.2: gofmt, go vet
-tags nogui ./..., builds for darwin, GOOS=linux -tags nogui and
GOOS=windows (and windows vet of provider and gateway) pass; go test
-tags nogui ./... passes; the changed provider tests and
TestPluginSaysSignIn/TestPluginFailover pass with -race -count=20; the
report's `go test -tags nogui -race -cpu 1,2,4 -count=30 -run
'^TestPluginFailover$' ./internal/gateway/` passed 3 rounds (270 runs),
where it failed 3 of about 54 runs before the fix on the same loaded
machine.
GitHub's search was asked at most every six hours. An author who had opened
the Plugins page before tagging their repository, as lee04052822 had,
didn't see it until the six hours were up. It is now asked at most every
ten minutes. A search is one request of the ten a minute that GitHub allows
without a token. What each repository's package.json, npm and entry file
said is kept while its last push stays the same, so asking again costs one
request, not three per repository. An answer GitHub or npm didn't give (a
503 for the entry, say) isn't kept, and is asked again next time.
Against real GitHub: the topic has two repositories,
mu-zi-lee/magpie-doubao-translate and SadWood/magpie-gaccode. Both are
listed as github: installs, the first time in 3.8s and in 0.2s once the ten
minutes are up. Both installed and loaded in a sandbox magpie, giving the
Doubao Translation and GACCode providers. TestTaggedRepos checks that
asking again after ten minutes reads only flaky-entry again, and it fails
with 21 reads when the per-repository cache is turned off. go test -race
-count=2 ./internal/plugin and go vet pass.
GitHub's search for topic:magpie-plugin (not archived, not a fork, not
magpie-community's own, which the market lists) is asked at most every six
hours and kept on disk, so a restart or GitHub being down shows what it said
last. Each repository is read from its package.json on the default branch.
When npm has that package published from the same repository, it is
installed from npm, because a repository often builds its dist only to
publish and Bun runs no build for a git install. Otherwise it is installed as
github:owner/repo, and only if the file it loads (exports, main or
magpie.middleware) is in the repository. Its page warns that it is
unofficial, says whether npm or the repository is what gets installed, and
shows the repository's README until it is installed. MAGPIE_PLUGIN_MARKET=off
asks GitHub nothing.
Checked against real GitHub with the topic set to opencode-plugin in a
sandbox: 43 repositories came back, and 32 were kept (30 from npm, 2 from
git). github:ndom91/open-plan-annotator, one of the git ones, really
installed and loaded. alfaoz/opencode-see-image, whose dist isn't in its
repository, failed to load before the entry check and is now left out. The
test fixtures are GitHub's and npm's real answers.
Ran: go test -race -count=3 ./internal/plugin, go test ./internal/gui, go
vet, and plugin-github.test.cjs (Chromium and WebKit, 980/440, en/zh/ja/de,
18/18). The full GUI suite passed 3343 of 3348. The 5 failures were WebKit
number-units, purpose-filter and routing-failed-open, which pass 3/3 on
their own and don't touch the Plugins page.
ready-wait failed twice on 10-07 in full `go test -tags nogui ./...` runs on
main ("signal: killed" at 10.00s) and passed alone. The cause is the test's
size, not the host's cancellation. The case runs 30,000 fetch+abort rounds,
each waiting at least one setImmediate turn. That takes ~4s alone, 4.4-8.5s
beside a full go test ./... plus CPU and fresh-script load loops, and more
than 10s when starved (taskpolicy -c background with every core busy: killed
3 of 3). The cost per round stays flat from 5,000 to 120,000 rounds (no growth,
so no leak), and the counts at the end are exact: retainedWaiters 0, one
reaction on the shared ready promise, one drain listener.
Those counts are what catches a leak, and they catch it at any size. With
1,000 rounds, leaving the waiter in readyWaiters fails with retainedWaiters
1000, and a reaction per request on the shared ready promise fails with
retainedReadyReactions 1000.
Before: starved, 3 of 3 runs killed at 10.04-10.26s; -race -count=20 79.9s.
After: starved, 5 of 5 pass (ready-wait 0.6-5.6s, mostly node startup);
-race -count=20 9.9s.
No production code changed.
macOS checks a newly written executable before its first exec: 0.27s
alone, 2-5s while other new programs start (a go test ./... starting its
test binaries). The same file again takes 0.009s, and the pass is kept
across a rename and an in-place rewrite. 108852b4 worked round this for one
test with /bin/sh -c; the class was every test that writes a `#!/bin/sh`
fake and times its answer.
testenv.Program(t, path, script) replaces os.WriteFile(path, script, 0o755)
for fakes. On macOS it renames a file that has run once from a pool to path
and writes the script into it in place; at the test's cleanup the file goes
back to the pool (only a path the test created; a stand-in it wrote over
stays). Fakes still end with their tests: nothing about how they are
started or waited on changed. Run (and gateway's TestMain) remove the pool
at the end. TestMain's PATH stand-ins are run once in the background
(testenv.StandIns). Other systems write the file as before.
Converted every `#!/bin/sh` fake written by tests under internal/ (agent,
gateway, gui, library, plugin, proc, provider, update). Windows, binary
and node/bun fakes are unchanged.
Under the same load (3 loops each starting 8 freshly written scripts at a
time), 4 rounds each, before (108852b4 / origin/main) vs after:
- provider TestSavedButSignedOutClaudeServed: 4/4 fail (8.01s) -> 4/4 pass
- provider TestClaudeSignInOverStaleCredentials: 4/4 fail (5.00s) -> 4/4 pass
- provider TestDevinSignedInOnlyInMagpie: 3/4 fail ("CLI's own []") -> 4/4 pass
- gateway TestClaudeRunEndsWhenTheClientLeaves: 4/4 fail -> 4/4 pass
(and 3/3 fail -> 3/3 pass at a third of that load)
- gateway TestClientGoneRecordedAsCanceled: 4/4 fail -> 4/4 pass
- gui TestProvidersAnswerWhileListsComeIn ("cursor found false" at 3.03s):
4/4 fail -> 4/4 pass. The same class, not bfe8bcaf's test order.
- proc TestUserPath (login shell's PATH never came, 7.01s): 4/4 fail ->
4/4 pass
Unloaded, every touched package passes; no pool directory or process is
left after a run.
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.
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.
The Grok plugin's sign-in said only "grok login gave no link to open".
The plugin host has magpie's proxy only as MAGPIE_*_PROXY, so the
`grok login` the plugin runs went out with none, where the built-in's had
it (netproxy.Env); where x.ai is reached only through a proxy, it failed.
host.js's spawn wrapper now gives a started program HTTPS_PROXY and
HTTP_PROXY (the account's own proxy, none for direct, else magpie's) when
the plugin set none.
grok 1.0.46, unable to reach x.ai, prints "Error: error sending request
for url (https://auth.x.ai/oauth2/device/code): …", whose URL the
built-in's runCLISignIn took for the page to open; a line saying error
gives no link, and a login with none ends with the CLI's last line. The
plugin does the same from 0.1.8, which the grok mover now asks for.
plugin_cliproxy_test.go and TestCLISignInSaysWhyNoLink fail before this.
Checked with grok 1.0.46 itself through the plugin host and the published
plugin's code, given a proxy that refuses: the CLI goes through it and the
error names what it said.
* gateway: a Chat upstream gets a mended tool exchange — Kimi turns a broken one away 400 ("tool_call_id is not found"): a turn's tool results all go ahead of its own text, in the calls' order; a result whose call is gone gets a synthetic call (unknown_tool) so it survives; an unanswered call gets a synthetic error result; a thinking-only assistant turn, which an aborted stream leaves behind, is dropped
* gateway: a translated reply whose stream the upstream cuts off mid-way goes on instead of failing — the same conversation is asked again with what the client already has of the reply sent back as its last message, for the model to go on from (vk-relay dropping Kimi K3 and DeepSeek streams mid-reply: "API Error: Server error mid-response"). The reply so far goes back with its reasoning for models that read reasoning_content back (Kimi, DeepSeek), a full echo of it is dropped, and the continuation's fresh thinking isn't shown again; the client waits with keepalives between tries, up to two. A reply a tool call of has begun can't be prefilled, and a refusal isn't asked again: both end with the error in the stream, as they used to. A stream's own error event mid-reply ends its read at once rather than waiting on a vendor that keeps the connection open. TestStreamCutMidReplyContinues (reply goes on, one message), TestStreamCutContinuationEchoDropped, TestStreamCutAfterToolCallEndsAsBefore, TestStreamCutContinuesOnlySoLong, TestStreamCutMidThinkingContinues(WithoutReasoning), TestStreamErrorMidReplyContinuesAtOnce and TestContinuationUnecho cover it; TestStreamErrorKeptOpenFailsOverAtOnce and the severed-stream tests pass unchanged. go vet ./internal/gateway, go build (darwin, GOOS=linux, GOOS=windows), go test of gateway, provider, catalog and imagemcp pass.
* gateway: a Chat upstream's mended tool exchange — an orphaned or late result reaches the model as a user message, the Responses path's policy, so no call is made up and no id is used twice; an empty assistant is dropped only inside a pending exchange, where it would break adjacency, and passes through outside one; a second answer to a call is dropped, the first stands; the session replay harness gives way to fixed fixtures
* gateway: a cut reply is asked to go on only where the upstream goes on from a prefill — a trailing assistant message isn't one on most Chat APIs: the model answers it again from the start, reworded, and the client, an exact echo alone dropped, kept its part and the re-answer spliced as one reply with no error to tell (api.deepseek.com did so 5 times in 6). It goes on only where the prefill is real: an Anthropic Messages upstream natively (a model that turns one away answers 400 and the reply ends as it used to; the newest Claude among them), a Chat one in its vendor's own mode — DeepSeek's prefix, asked of its /beta endpoint with "prefix": true on the message, Kimi's "partial": true — a relay on this machine or the LAN serving one of their models taken to front the vendor, as geminiCompat takes one to front Gemini; everywhere else the reply ends with the error, as it used to, for the agent to try the turn again. reasoning_content goes back with the prefill by the same decision (reasoningBack named any kimi model on any host); the prefill's trailing whitespace, which Anthropic turns the request away for, is trimmed, the client having it already; and the tries' usages are summed into the reply's, the client's and the ledger's, where only the last try's was kept while the vendor billed each (a cut try's is only what it said before the cut). New tests: a re-answering upstream is never asked to go on (the client reads the error, not the garble), DeepSeek's /beta prefix mode and Kimi's partial on the wire, an Anthropic upstream's native prefill (text, thinking-only, whitespace trimmed, a refused prefill ending as it used to), the tries' usages summed, chatPrefill and prefillHow themselves. go vet, builds (darwin, nogui) and go test -count=1 -race ./internal/gateway pass (2146; gui's TestSetPortMovesTheGateway fails on main too).
* tests: three environment- and drift-broken suites pass again — port: a moved gateway finishes the connections it has (Relisten's handover), so answers() pooled a keep-alive one from its start-up polls and the old port answered from it forever; it now dials with keep-alive off, asking what the test means to: does the old port take NEW connections. plugin: TestBunIsGivenTheSystemsRoots read the shell's ambient NODE_EXTRA_CA_CERTS as the user's own (it wins by design); the test sets its own. accounts: forgetting the account in use puts the one left first since 30d952cf, which updated the provider and GUI tests but not accounts_plugin_cli_test.go; it now expects that. All three failed on upstream/main on a machine with a proxy CA configured; the full nogui suite passes.
* plugin: a slow reader pauses its stream instead of racing a watchdog
#664 gave a plugin reply a soft watermark (softQueuedBytes 4 MiB, softQueuedEvents 4096) and a 10-second idle watchdog: past the watermark, a reader that made no progress for stallIdle had its call failed with errStalledReader, and hardQueuedBytes/hardQueuedEvents (64 MiB / 65536) cut the reply outright. Both are guesses at how long a reader may take, and both end a reply that reader was still consuming.
The host now hands each fetch a credit window instead. Go grants streamWindow (512 KiB) up front and returns credit as the reader takes bytes, so the child stops producing at the window instead of being timed out; the window is charged per delivered byte plus frameOverhead (2048), so a run of tiny frames cannot fill it with metadata. streamBudget (64 MiB) and maxFrame (64 KiB) bound a whole reply and a single frame, and a child that sends past its window is a protocol violation (errProtocolOverrun) rather than a slow reader.
The plugin side grew the matching gate (makeGate, gate.take/spend/add, waitDrain): a fetch holds at the window until Go credits it, and its pending writes are cancellable. Cancellation and shutdown now have one owner per call - a five-state lifecycle (sending, active, draining, aborting, released) whose single transition helper disposes the queue, detaches the watcher, commits the terminal answer, creates the abort and returns capacity exactly once - so a host failure, a Close, a cancelled context and an already-delivered reply can no longer race each other over who cleans up. A reply the host already delivered keeps its bytes and its vendor outcome when the host dies.
Tests: the JS cancellation cases run the production sections with controlled stdout backpressure (stopped gate, split chunk, terminal suppression, acquired-body cancel, 30,000 cancelled bootstrap waits with no retained waiter or ready reaction); the Go cases drive lifecycle orderings directly (terminal-before-failure, failure-before-terminal, abort acknowledged before capacity returns, blocked enqueue), and the shutdown, body-close and mid-setup-cancel cases block on explicit channels instead of sleeping. TestFlowBigReplyProd reads 100 MiB non-SSE and SSE through the real host and checks lengths and SHA-256, including a 12 s reader pause. CI installs Bun 1.4.2 on Linux and runs that case in its own step, since the plugin sandbox skips when bun is not on PATH.
* plugin: the stream-head credit test waits for the credits before the reply ends (CI, macos-latest: TestHostStreamHeadChunksFinal failed on the macOS runner with "the credits were given back didn't happen", while the same package passed on ubuntu-latest and locally) — the test sent the child's final answer together with its 50 chunks, so the call could be marked stopped and its pending credits dropped before waitFor saw them; a completed fetch needs no credit, so waiting longer could not recover them and the expectation was a race, not a real accounting bug. The final answer is now withheld until io.ReadFull has taken the 140 payload bytes in order and exactly 140 + 49*frameOverhead credits have been observed with frameOverhead still owed, and only then does the child answer and the test assert a zero-byte EOF read. No production code changed.
Tests: go test -run '^TestHostStreamHeadChunksFinal$' -count=500 ./internal/plugin/ and -count=200 -race pass; go test -count=1 and -race -count=1 ./internal/plugin/... pass; gofmt -l and git diff --check clean.
* Add German translation and harden paths against unset env vars
Add a German locale to the GUI, tray menu, usage alerts and website home page, and refuse to start when the home folder is unset or relative so nothing is written under the working folder; the pi plugin no longer adopts the user's PI_CODING_AGENT_DIR, and an inherited APPIMAGE no longer selects portable mode. Scripts refuse to run from the wrong folder or empty a shared out folder, and a new internal/testenv sandboxes every test package from the user's real files, which also fixes the Claude picker and Termux installer tests on Windows.
* Makefile: know magpie's folder by its go.mod, so paths with spaces work
The check compared $(CURDIR) with $(dir $(abspath …)), which make splits on spaces, so a checkout under 'My Projects' or 'John Doe' refused every target. It now reads go.mod relative to the working folder and wants magpie's module there: make -C and a symlinked Makefile work, make -f from another folder still stops before any recipe runs.
* i18n: German for the strings main added since, with a parity test
75 strings main added (group order, patterns, slow-start wait, usage caps, RTK savings …) get their German, and two that main dropped go. gui-de.test.cjs checks, as gui-ja does, that de has every zh string with its placeholders and tags, and that the window reads in German when picked or on a German system.
* appdir: ignore relative folder variables when reading, keep them for children
CheckEnv no longer unsets anything. magpie reads the XDG folders, APPDATA/LOCALAPPDATA and every agent's folder variable through appdir.Getenv/LookupEnv, which pass over a relative value; the environment stays as it is, so the agents magpie starts get the variable as the user set it. That covers what proc.UserPath lends from the login shell too, which is set after main's check and reached the desktop app unchecked. On Windows a path rooted on the current drive (\Users\x, Git Bash's /c/x) counts as usable, and magpie version and help answer without a home, before run makes any folder.
* Sandbox the tests main added since
internal/wslrun gets testenv's TestMain, and four tests main added that set HOME alone set their home with testenv.SetHome, so on Windows they don't read the runner's profile through USERPROFILE.
* agentenv: PI_CONFIG_DIR and OPENCODE_DB take relative names on purpose
omp puts $PI_CONFIG_DIR under the home and OpenCode a relative $OPENCODE_DB in its data folder, so neither depends on the working folder: appdir no longer passes them over.
* i18n: German for the Library's multi-select and System One's decision API
The 19 strings main added since (#791's skill multi-select, the decision API snippets) get their German; the one main replaced (four APIs → five APIs) goes.
* appdir: tidy the import into magpie's group; say what Go makes of /c/x
The appdir import joins the group of magpie's own imports where a file has one. rooted's comment now says that Go takes a Git Bash path like /c/x as \c\x on the current drive, not C:\x.
* plugin,gui: a stalled stream can't wedge the host, stale reads can't draw, and the browser suite runs in CI
internal/plugin: the host has one reader of the child's stdout, and it handed
each message to its call by sending on that call's channel. A caller that
stopped draining a streamed body (a slow HTTP client on a plugin-backed chat
stream) filled the channel; the reader then blocked, and every other call
through the host -- all plugin providers and accounts, moved built-ins
included -- stopped being dispatched until the stalled stream was drained. A
second call's answer arrived only after the first was drained.
Each call now queues its head and chunks in a bounded backlog (4 MiB of
chunks, 4096 of them) drained by its own reader, so the host's reader never
waits on one consumer; a call whose consumer stops reading is given up on
alone, with the limit as why. Aborts go through a goroutine too (abortLoop),
so a child that stopped reading its stdin can't make an abort wait either.
host_test.go covers the stalled consumer, the bounds and the abort path.
internal/gui: loadUsage() and refreshUsage() both read the Overview's
usage?period=, and neither checked after the await that its answer was still
the newest: a 24 h answer landing after a 7 d one drew the old period's
figures under the new period's name, and leaving the tab could still write the
Overview's cost into the shared header. Both now claim one shared read
generation before the request and draw only when it is still the newest and
the page, tab and period are unchanged. providersWhileFetching() also stopped
for good after one failed poll or reload; it now asks again.
usage-stale-answer.test.cjs and providers-fetching-retry.test.cjs lift the
real functions out of app.js and drive them with a faked api/page/timers, so
they need no browser; usage-refresh-flight.test.cjs gains the browser case for
the same race.
Makefile/.github: `make test-ui` runs every internal/gui/tests/*.test.cjs
through Node's test runner -- auto-discovered, UI_TEST_CONCURRENCY files at a
time (default 2) -- and a new `ui` job in Test runs it on ubuntu-latest with
Playwright's Chromium and WebKit. A missing browser fails the job; it never
skips to a pass. internal/gui/tests/README.md points at `make test-ui` instead
of a hand-kept command line.
Verified: node --test on the two new files 21/21; go test -count=1
./internal/plugin/ ok; go test -race -count=1 ./internal/plugin/ ok; go vet
./internal/plugin/ and go build ./... clean; node --check on the changed JS;
actionlint on the workflow.
Not verified: the full browser suite is not green yet. `make test-ui` is 1641
passed / 70 failed of 1711 across 225 files, and 12 of the 19 failing files
fail the same way on a pristine main (a known baseline: a fixture boots the
page in web mode while asserting the desktop window's narrow behaviour). The
`ui` job will be red until those baselines are dealt with -- nothing is
skipped or masked -- and the job has not yet run on a GitHub-hosted runner.
* plugin: distinguish stalled readers from large responses
* plugin: clarify best-effort abort delivery
Moody-Sin: the Routing page showed a WorkBuddy account as "已用 71%" only, which for a plan counted in credits says little. A quota window now carries its own count when the vendor counts in amounts — amount of limit, in unit (QuotaWindow, QuotaSpan for quota --json and /v1/magpie/quotas, and the routing trace's Weighed, from the window its share is of: Allowance.Count). WorkBuddy's built-in fills it from the resource summary, and @magpie-community/opencode-workbuddy-auth 0.1.8 tells it from auth.usage (amount / limit / unit, passed through host.js); the workbuddy mover's min is 0.1.8. The Routing page's stage and its list of what each account did, the Usage page's meter, the menu bar's tooltip and the TUI say "3,550 / 5,000 credits · 71% used", or "1,450 / 5,000 credits · 29% left" with Left on (积分 in Chinese); a window counted in shares alone reads as before.
quota-credits.test.cjs (WebKit, en/zh, used/left, Routing and Usage pages) fails on the old assets and passes; TestQuotaWindowCount, TestAllowanceCount, TestTraceTellsTheCount, TestPluginUsage (the count through the host) and TestWorkBuddyAccounts cover the Go side. Tried with the real WorkBuddy and WorkBuddy AI accounts in a sandbox magpie: moved onto the local plugin, /api/usage/quotas gives 33.29 / 3900 and 13.47 / 2250 credits and the Usage page reads "33.29 / 3,900 积分 · 已用 0.9%", "3,866.71 / 3,900 积分 · 剩余 99.1%" after a click on the count; not moved, the built-in gives the same for WorkBuddy. The Routing page with two real accounts of one provider wasn't tried (one account each). go vet, builds (darwin, GOOS=linux, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass.
* termux: build natively and run plugins on Bun 1.4.2
Select nogui for Android builds, open browser and OAuth links with
termux-open-url, and write an executable Termux:Boot script that starts
the gateway with shell-quoted paths.
Bun 1.3 has no official Android build. Upgrade the default plugin runtime
to 1.4.2, add the official Android arm64 and amd64 download targets, and
update SHA-256 checksums for every supported platform.
Document Termux installation, startup and source updates.
Validation on Termux arm64:
- Native build and go vet pass.
- A fresh config downloads and verifies Android Bun 1.4.2 and loads a
test plugin.
- Boot startup can be enabled and disabled.
- The Web page, authenticated state API, unauthenticated API rejection,
gateway healthcheck and Web quit action work.
- Browser argument preservation, boot path quoting and permissions, and
plugin tests pass.
Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-01a10014-56c1-73d3-b3ee-2ea569713989
* site: install Android builds in Termux
Detect Termux through TERMUX_VERSION or its PREFIX and select the Android
CLI asset instead of a Linux binary. Install into PREFIX/bin by default,
keep MAGPIE_BIN_DIR overrides, and suggest the Web or terminal interface
without Linux desktop setup instructions.
When the release has no Android asset, explain how to build from source.
Add android/arm64 to release-cli and stop the build loop on failure.
Document the installer and its release-asset requirement.
Shell syntax and all 11 site tests pass, covering platform selection,
custom paths, missing assets, checksum rejection and existing Linux
installation paths. The Android arm64 release build succeeds on Termux;
with a simulated release feed, the installer verifies and installs that
binary into PREFIX/bin and the installed command runs successfully.
Amp-Thread-ID: https://ampcode.com/threads/T-01a10014-56c1-73d3-b3ee-2ea569713989
Co-authored-by: Amp <amp@ampcode.com>
* ci: check Android releases and the installer
Build both linux/amd64 and android/arm64 through make release-cli in the
CLI test matrix. Run the site and installer tests in the Linux job with
Node 22, and check the installer's shell syntax.
The release workflow in yetone/magpie-releases already calls the source
Makefile's release-cli target, uploads dist/magpie-cli-*, and generates
checksums for every artifact. The Android target added to TARGETS is
therefore included without changing that workflow.
Replace the full Termux README section with a short platform note beside
the Linux and Windows installation requirements, removing duplicate
commands, the fork URL and temporary release availability details.
Both CLI release builds, all 11 site tests, shell syntax checks and
actionlint validation pass locally.
Amp-Thread-ID: https://ampcode.com/threads/T-01a10014-56c1-73d3-b3ee-2ea569713989
Co-authored-by: Amp <amp@ampcode.com>
---------
Co-authored-by: Amp <amp@ampcode.com>