Skip to content

workbuddy 0.1.32: serve Claude models on Messages, where reasoning effort (incl. max) is honored - #346

Open
waynesguan wants to merge 3 commits into
magpie-community:mainfrom
waynesguan:workbuddy-messages-effort
Open

waynesguan wants to merge 3 commits into
magpie-community:mainfrom
waynesguan:workbuddy-messages-effort

Conversation

@waynesguan

@waynesguan waynesguan commented Oct 23, 2026 •

Copy link
Copy Markdown

What

WorkBuddy's China build serves its Claude models on Anthropic's Messages API (@ai-sdk/anthropic at <endpoint>/v1/messages), beside the /v2/chat/completions the plugin uses today. The plugin now sends them there. Every other model stays on chat, unchanged. WorkBuddy AI keeps all of its models on chat: it has no Messages route.

This is the general route for any Anthropic-shaped client — Claude Code, the Claude Agent SDK, anything that sends /v1/messages — against WorkBuddy's Claude models, with no translation layer in between.

Why

Reasoning effort has no effect on a Claude model over chat. A chat carries reasoning_effort alone, and the chat endpoint answers at about the same length whatever it says. Same prompt, claude-opus-5.5, 4 runs per level, median output tokens:

endpoint low max a value that isn't a level
/v2/chat/completions (reasoning_effort) 3884 5144 5715
/v1/messages (output_config.effort) 5748 30850 —

The plugin also drops a Claude model's max today, because /v3/config lists only up to xhigh, so magpie sends max as xhigh (the routing log shows effort: max → try effort: xhigh).

Two more things follow from the route:

  • No channel check. Claude Code's billing line, its "official CLI for Claude" line, and the Codex and DeepSeek Harness openings all get 200 on Messages; on chat they get 11128. None of the FLAGGED / systemAsUser workarounds are needed.
  • Thinking blocks carry a signature, which the chat route can't produce, and which the model gets back on the following turns.

Changes (packages/workbuddy, 0.1.31 → 0.1.32)

  • nativeOf(site, model): a Claude model on a site with messages: true (the China build alone) goes to Messages; everything else stays on chat. Before choosing it, each /v3/config cli model was asked on /v1/messages: the four Claude models answered in Anthropic's stream; glm-5.3 and deepseek-v4.1-flash returned 500, gpt-5.5 and kimi-k3-1 400.
  • A Claude model's api is { npm: "@ai-sdk/anthropic", url: "<endpoint>/v1" }, and configModels writes provider: { npm, api }, where OpenCode and magpie's host read it. magpie core needs no change: it routes plugin models by npm already.
  • The loader no longer sets a provider-wide baseURL; each model's api.url decides. A provider-wide /v2 base had put /messages under /v2, a 404.
  • A Messages request is forwarded as the agent sent it: none of the chat-only rewrites (default system message, max_completion_tokens/developer, tool reshaping, tool renaming, moving a refused system prompt, the empty reply after tool results). Sign-in, 401 re-issue, enterprise headers and the conversation/turn ids are shared by both routes.
  • count_tokens is a Messages request too, so it no longer takes the chat rewrites.
  • A Claude model gets max added to its levels where /v3/config stops at xhigh.
  • Two refusals of a history that came from elsewhere, both measured and both fixed:
    • Unsigned thinking blocks (a session moved here from chat or another provider) are left out. With a signed block beside one, every request of the session is 11159 "bound to a different conversation"; drop_block doesn't help. A message emptied that way keeps [{type:"text",text:""}], which WorkBuddy accepts (Anthropic's own API would not).
    • A signature WorkBuddy didn't make — another provider's, or an altered one — is 11159 as well. Such a request goes once more without any thinking block, and only when the refusal says so and there was one to drop.
  • One Claude Code send keeps one X-Conversation-Request-ID on this route too (workbuddy: one user send, one X-Conversation-Request-ID #48): a user message of tool_result blocks answers the tool_use before it, and Claude Code's {role:"system"} messages between steps are passed over.

Verified

  • HOME=$(mktemp -d) bun test: 149 pass, 0 fail. bun scripts/check.mjs: ✓. Every new guard is pinned by mutation (revert the fix, a test goes red).
  • End to end through magpie, real account, Claude Code 2.1.307 --effort max:
    • magpie lists the Claude models as @ai-sdk/anthropic, low … xhigh, max.
    • Every request in magpie's routing log: effort: max → try effort: max, 200. Before, the same session showed try effort: xhigh.
    • A multi-turn tool loop (Read → Edit → Bash) fixed a real file; a 5×5 Lights Out solver verified by its own checker; thinking blocks came back signed and were sent back on later turns.
    • A session started on 0.1.31 (chat, 3 unsigned thinking blocks) resumed twice on this branch: every request 200 at max, history left with signed and unsigned blocks mixed, task still verified.
  • Also accepted on Messages: anthropic-version, anthropic-beta, cache_control with ttl: 1h, context_management, metadata, image input, max_tokens: 64000. Prompt cache is read back (cache_read grows turn over turn).
  • What binds a signature, measured: not the conversation id, the account, the model, or the position in the history. Another turn's signature, or another model's, answers 200; a truncated or altered one is 11159; an assistant message that is thinking alone is 11159 whatever its thinking.

Not covered

  • /v1/messages/count_tokens returns 404. magpie treats a 404 there as "no counter" (unsupportedCount) and answers with its own estimate; a count through magpie returns 200 with one.
  • An account can still be refused a Claude model it isn't entitled to (11158). That is the account's, not the endpoint's, and surfaces as before.
  • How much more max thinks than xhigh varies by model and run; the table is one prompt, 4 runs per cell. What the change guarantees is that max reaches the server as max.
  • Tested on the China build. workbuddy-ai is excluded by construction, not by a request.

@yetone yetone left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this, @waynesguan. The measurements are useful, and the routing through magpie is right: pluginFetch takes the model's own api.url once the loader's baseURL is empty, so /v1/messages lands on <endpoint>/v1/messages and chat models still reach /v2. I reviewed 9792707 line by line and ran it. Before it can merge, three things need fixing and one test is missing.

What I ran

  • HOME=$(mktemp -d) bun test in packages/workbuddy: 130 pass, 0 fail. bun scripts/check.mjs workbuddy: ✓.
  • With real accounts on both builds, the PR's plugin called directly (its loader's fetch, sandbox HOME):
    • China build (copilot.tencent.com): /v1/messages exists. glm-5.3 and deepseek-v4.1-flash answer 500 there, as you found. Our account is personal_free_user, so every Claude model answers 11158 and I couldn't get a Claude reply. One thing does support your point that Messages has no channel check. On chat, a Claude model with Claude Code's "official CLI" line, its billing line or DeepSeek Harness's opening gets 11128, so the channel check runs before entitlement there. On /v1/messages the same bodies get past it to 11158.
    • International build (www.workbuddy.ai): /v1/messages answers 404 {"error_msg":"404 Route Not Found"}, for Claude and glm-5.3 alike, and so do /v2/messages and /v1/messages?beta=true.
  • Sandbox magpie (main a93190ae1) with this plugin installed: a Claude-Code-shaped Anthropic request (billing line, "official CLI" line, "Main branch (you will usually use this for PRs)", DeepSeek Harness's opening, output_config.effort: max) to workbuddy/glm-5.3 and workbuddy-ai/glm-5.3 gets 200 "pong" on both builds. So the 0.1.29–0.1.31 rewording still works on the chat route.

1. The international build doesn't serve Messages (index.mjs:125, native.test.mjs:25)
nativeOf(site, model) ignores site, and the test says the international build "serves them the same way". It doesn't: its /v1/messages is a 404 route. Today its cli list has no Claude model (claude-opus-5.5 on its chat answers "service info not found"), so nothing breaks yet. The day it lists one, though, every request for it goes to a 404, where on 0.1.31 it would have gone to chat. Please scope the rule to the site that was measured, e.g. a messages: true on SITES.workbuddy only, have nativeOf check it, and turn the test around (workbuddy-ai → false). effortsOf (index.mjs:1716) repeats the /^claude-/ test on its own. It should ask nativeOf(site, id), so max is added only where Messages actually carries it.

2. On Messages, each step of one send is counted as a new request again (#48) (index.mjs:1888, with turnKey/answeredBefore at :396/:409)
The PR says the conversation/turn ids are shared by both routes. The headers are set, but turnKey only knows chat shapes: a tool result is a role: "tool" message, and an assistant's calls are tool_calls. In an Anthropic body the tool result is a user message of tool_result blocks after an assistant message of tool_use blocks. So answeredBefore is false, and turnKey either returns "" (a fresh random id) or keys on Claude Code's <system-reminder> text. I ran attend on a three-step Claude Code tool loop (prompt → Read → Edit) under one session, and got three different X-Conversation-Request-IDs. The chat version of the same loop keeps one. That is the bug 5d1beb3 fixed: WorkBuddy counts a user send as one request by this id. answeredBefore should also treat a user message whose content is tool_result blocks (after an assistant message with tool_use) as answering, and its text parts should be left out of the turn's signature, as a tool's images are. Please add a test with a Messages-shaped tool loop.

3. configModels' api field is never read (index.mjs:1598, native.test.mjs:105-107)
OpenCode reads a config model's package and URL from provider: { npm, api }, not api: { url, npm }. magpie's host does the same (internal/plugin/host.js info(): npm: m.provider?.npm ?? cfg.npm …, url: m.provider?.api ?? cfg.api …). So a Claude model in the fallback list, used when /v3/config can't be read, would still go as chat to /v2. The test only checks the object the plugin builds, not what a host makes of it. The fix is provider: { npm: NPM_MESSAGES, api: apiMessages(site) }. Today's static list has no Claude model, so this is latent.

4. Nothing tests the loader's fetch on a Messages URL
native.test.mjs checks isMessages, nativeOf, modelOf and withSystem. No test sends a request through the loader to …/v1/messages. Please add one (a stubbed fetch, as unauthorized.test.mjs does) that checks:

  • the body arrives byte-for-byte as sent (no default system message, no max_tokens respelling, no tool renaming);
  • a 401 is renewed once and sent again on this route too;
  • the conversation and turn headers are set (with the fix in 2).

Smaller points, not blockers:

  • README.md:103 says magpie estimates the count "as it does for the other plugins in its estimatedMoved list". workbuddy isn't in that list. The estimate comes from unsupportedCount treating the 404 as "no counter", so please say that.
  • A Messages error comes back as {"code":11102,"error":"11158:…"}, so explained() reads code as 11102. 11140 (restricted) and 14018 (credits used up) are then recognised only if their words appear in the text. Did you see either one on /v1/messages? If so, a fixture of the real body in a test would settle it.

Once 1–4 are in, I'll run it again and merge. Thanks again: getting max through to the server is worth having.

wenwenguan added 2 commits October 24, 2026 08:16
…is honored

WorkBuddy serves the Claude models on Anthropic's Messages API at
<endpoint>/v1/messages, beside the chat completions the rest use. Asked on
2026-10-24, each model of /v3/config's cli agent: claude-opus-5.5,
claude-sonnet-5.5, claude-opus-5 and claude-opus-4.8-1m answered in
Anthropic's stream; glm-5.3 and deepseek-v4.1-flash answered 500, gpt-5.5
and kimi-k3-1 400.

The route is what makes reasoning effort mean anything. A chat carries
reasoning_effort alone, and WorkBuddy's chat endpoint answers at about the
same length whatever it says: the same question four times each gave a
median of 3884 output tokens at low, 5144 at max, and 5715 at a value that
is not a level at all. Messages carries output_config.effort, and max is
honored: a median of 30850 against 5748 at low, same model and question.
So a Claude model lists max in its levels even where /v3/config stops at
xhigh, and a client that picks max reaches the server as max.

A Messages request also goes as the agent sent it: no default system
message, no max_completion_tokens/developer rewrites, no tool reshaping,
and nothing moved for a refused system prompt. Claude Code's own prompt is
taken as it is — billing line, "official CLI for Claude" line and all —
where a chat answers 11128 to it. Its tool loop, thinking blocks with a
signature to send back, and prompt cache all work there: a Claude Code
2.1.307 session fixed a file and solved a puzzle through the route, with
every level sent as max and thinking blocks signed.

The loader gives no baseURL any more: a model's own api.url places it, on
/v2 for a chat and /v1 for Messages. A provider-wide base put /messages
under /v2.

/v1/messages/count_tokens is not served (404); magpie estimates a count
for these models, as it does for the other plugins it estimates for.
…n id per send, unsigned thinking left out

- nativeOf asks site.messages, set on WorkBuddy (China) alone: WorkBuddy AI's
  /v1/messages is a 404 route, so a Claude model it lists goes to chat as on
  0.1.31. effortsOf adds max only where nativeOf says Messages carries it.
- answeredBefore knows Anthropic's shape: a user message of tool_result
  blocks answers the tool_use before it (whatever text sits beside the
  results), and so does a message right after it; Claude Code's
  {role: "system"} messages between steps are passed over. One Claude Code
  send keeps one X-Conversation-Request-ID again (magpie-community#48).
- A Messages body goes without thinking blocks that have no signature.
  A session that began on chat carries them; once this route's signed
  blocks sit beside them WorkBuddy refuses every request, 11159 "bound to a
  different conversation" (drop_block doesn't help). Seen in two live
  Claude Code sessions moved mid-way; their captured bodies answer 200
  with the unsigned blocks left out.
- configModels places a Claude model with provider: { npm, api }, as
  OpenCode and magpie's host read a config model.
- explained reads the code inside the Messages envelopes
  ({"error": {"internal_code": …}}, {"code": 11102, "error": "11158:…"}),
  so 11140 and 14018 are told there too.
- Tests for each, the loader's fetch on /v1/messages included (byte for
  byte, 401 renewed once, turn ids through the loader). README: WorkBuddy AI
  stays on chat, the unsigned-thinking rule, count_tokens' estimate comes
  from unsupportedCount.
@waynesguan
waynesguan force-pushed the workbuddy-messages-effort branch from 9792707 to 9bab215 Compare October 24, 2026 01:17
@waynesguan

Copy link
Copy Markdown
Author

Thanks for the careful review. All four points are fixed in 9bab215 (rebased on main), along with both smaller ones. Your item 2 led me to a related bug: a session that moves onto this route mid-way can fail on every request. That's fixed here too. Details below.

1. WorkBuddy AI stays on chat

SITES.workbuddy gets messages: true, and nativeOf(site, model) checks it. effortsOf(d, site, id) now calls nativeOf, so max is only added where Messages carries it. The tests are flipped. On workbuddy-ai, a Claude model resolves to @ai-sdk/openai-compatible at /v2 and keeps the config's levels without max. I got the same result as you: unauthenticated www.workbuddy.ai/v1/messages returns 404 Route Not Found, while copilot.tencent.com/v1/messages returns 401, so the route exists only on the China build.

2. One send gets one turn id on Messages (#48)

answeredBefore now handles Anthropic's message shape:

  • A user message containing tool_result blocks counts as answering, whatever text is next to the results. Claude Code puts a <system-reminder> there.
  • A user message right after such a message also counts.
  • An assistant message with tool_use counts the same way tool_calls does.
  • Claude Code's {role: "system"} messages between steps (environment, <total_tokens>, per-turn effort) are skipped when looking back.

I checked this against real traffic, not only fixtures. I captured the bodies magpie passed to the plugin's fetch during a two-send Claude Code 2.1.307 session (fix, then a follow-up), and ran attend over all 8 requests:

  • 0.1.31 turnKey: 8 different X-Conversation-Request-IDs
  • fixed: 2 ids, one per user send (6 × A, then 2 × B), one conversation id

native.test.mjs has a Messages-shaped loop built from those bodies: reminder next to the user's words, system messages between steps, and a tool result with a reminder beside it. It's tested both through attend directly and through the loader.

3. Config models use provider: { npm, api }

configModels now writes provider: { npm: NPM_MESSAGES, api: apiMessages(site) }. The test resolves the model the way host.js info() does (m.provider?.npm ?? cfg.npm, m.provider?.api ?? cfg.api) and checks the result, so it no longer just inspects the object the plugin builds.

4. Loader fetch on /v1/messages

Tests with a stubbed fetch check that:

  • a Claude-Code-shaped body (billing line, "official CLI" line, cache_control ttl 1h, a tool name chat would rename, max_tokens: 64000, output_config.effort: max) arrives byte for byte, with WorkBuddy's headers and enterprise id, and without x-api-key;
  • a 401 is renewed once, saved, sent again with the same body, and marked renewed; a second 401 isn't sent a third time;
  • the steps of one send carry one turn id through the loader, and the session header doesn't go further than the plugin;
  • a chat request still gets the chat rewrites.

I reverted each fix in turn and the suite catches every one: old answeredBefore → 4 fail, nativeOf ignoring site → 3, api instead of provider → 1, unsigned thinking kept → 1. The full suite gives 144 pass, 0 fail, and check.mjs workbuddy ✓.

New: unsigned thinking blocks

After the first push I moved two long-running Claude Code sessions onto this route mid-way. Both then failed on every request with 11159, Invalid \signature` in `thinking` block. The block is bound to a different conversation`. Cause: their history held thinking blocks without a signature, which is what the chat route returns. Once a signed block from Messages sits next to them, WorkBuddy rejects the whole body. A history with only unsigned blocks, or only signed ones, is accepted.

What I measured, replaying the captured failing bodies (one with 759 messages):

  • as captured: 11159
  • with thinking.block_binding.prefix_mismatch_behavior: "drop_block", as the error suggests: still 11159
  • with unsigned thinking blocks removed: 200

So a Messages body now drops thinking blocks that have no signature (withoutUnsigned). Signed blocks go back unchanged. An assistant message left empty gets [{type:"text",text:""}], which the endpoint accepts. A body with no unsigned blocks is passed through untouched. All 19 distinct captured bodies return 200 after this.

I also ran it end to end through magpie: a Claude Code session began on 0.1.31 (chat, 3 unsigned thinking blocks), then the same session was resumed twice on this branch. Every request returned 200 at effort: max, the history ended up with signed and unsigned blocks mixed, and the task still verified (OK 15). All 16 Claude requests across three live sessions since the fix: 200, try effort max.

Smaller points

  • README / count_tokens: it now says the estimate comes from magpie's unsupportedCount treating the 404 as "no counter". It also documents the WorkBuddy AI exclusion and the unsigned-thinking rule.
  • Error envelopes: on /v1/messages I saw two shapes, {"code":11102,"error":"11158:…"} and {"error":{"code":"400001","internal_code":"11159",…}}. I didn't see 11140 or 14018 there. explained() now also reads the inner code (internal_code, or the NNNNN: prefix of a string error), so those two would be recognised in either shape. The tests use the real envelope shapes with 11140/14018 substituted, and check that 11158/11159 pass through unchanged. Since I haven't seen a real 11140/14018 body on this route, treat those cases as covering the envelope, not as a captured response.

@yetone

yetone commented Oct 24, 2026

Copy link
Copy Markdown
Contributor

Thanks @waynesguan, this covers all four points and both smaller ones. I reviewed 9bab215 line by line and ran it.

What I ran

  • HOME=<tmp> bun test in packages/workbuddy: 144 pass, 0 fail. bun scripts/check.mjs workbuddy: ✓.
  • Broken by hand to see the new tests guard what they name:
    • With answeredBefore back to chat shapes only, 4 go red, the loader-level "one turn id per Claude Code send" included.
    • With withoutUnsigned taken out of the loader, "a Messages body with unsigned thinking goes without it" goes red.
  • Read: nativeOf now asks site.messages, set on the China build alone; effortsOf asks nativeOf; configModels uses provider: { npm, api }; explained reads internal_code and the "11158:" string code. All as asked.

One thing to settle before merge: the empty text block
withoutUnsigned leaves an assistant message that held only unsigned thinking as [{ type: "text", text: "" }]. Anthropic's own API refuses that ("text content blocks must be non-empty"). If WorkBuddy's /v1/messages passes it on, one such message in the history would get every later request of that session refused, which is the failure this change is meant to end. It's rare: a turn that ended inside its thinking, such as one cut off at max_tokens. My account can't reach a Claude model here (11158), so I can't try it. Could you replay one of your captured bodies with an assistant message of only unsigned thinking and tell us what comes back?

  • If it's 200, please say so in the comment above withoutUnsigned, and I'll merge.
  • If it's refused, dropping that assistant message would keep the history valid only if the user messages on either side are then merged. A non-empty text such as "…" is simpler. Please add a test for it either way.

@yetone

yetone commented Oct 24, 2026

Copy link
Copy Markdown
Contributor

Thanks again, @waynesguan. I reviewed 9bab215 a second time, line by line, and ran it. Everything from the first round holds. One point is still open, and it blocks the merge. Two smaller ones can wait.

What I ran at 9bab215

  • HOME=$(mktemp -d) bun test in packages/workbuddy: 144 pass, 0 fail. bun scripts/check.mjs (every package): all ✓.
  • Broke two of the fixes by hand. With nativeOf ignoring site.messages, 3 tests go red. With the hasBlock(msgs[i], "tool_result") line (index.mjs:414) removed, nothing goes red, because the tool_use check on the message before it already covers that case. That's fine; it just has no test of its own.
  • Ran the loader's fetch against a real local HTTP upstream (127.0.0.1, no stubbed fetch):
    • A Claude Code-shaped tool loop (prompt → Read → Edit → answer, then a new send) under one session got turn ids A A A B and one conversation id.
    • The body went byte for byte, billing line and all. x-api-key and the session header were dropped, anthropic-beta was kept, and User-Agent was WorkBuddy/5.5.6.
    • A /v2/chat/completions request still got the chat rewrites (default system message, max_completion_tokens → max_tokens).
    • workbuddy-ai keeps its /v2 config api, and with baseURL: "" magpie's Provider.Origin (used for /v3/config and search) falls back to that same host.

1. Blocker: the empty text block is still unconfirmed (index.mjs:513, native.test.mjs:224)
This is the question from my last comment, and it's still open. When an assistant message holds only unsigned thinking, withoutUnsigned turns it into [{ type: "text", text: "" }], and the test pins that output. I gave the local upstream Anthropic's own rule ("text content blocks must be non-empty"). A history with such a message (user → assistant[thinking only, unsigned] → user → assistant[signed] → user) then came back 400 on every request, which is the session-wide failure this change is meant to end. This was an emulation, not WorkBuddy's own answer: our sandbox WorkBuddy sign-in is now 401, and before that it got 11158 for every Claude model. So it still needs your account. Could you replay one captured body with such a message?

  • If WorkBuddy answers 200, please say so in the comment above withoutUnsigned and I'll merge.
  • If it refuses, one option is to drop the emptied assistant message and join the user messages on either side into one (concatenating their content). That keeps the turns alternating and adds no text of ours to the prompt. Please add a test for whichever you choose.

2. Smaller: count_tokens goes through the chat rewrites (index.mjs:478)
/\/messages$/ doesn't match /v1/messages/count_tokens, so that body is treated as a chat. I saw it sent with {"role":"system","content":"You are a helpful assistant."} added to messages, next to Anthropic's system array. It's harmless while WorkBuddy answers 404 there. But the day it serves a counter, the request would be malformed. /\/messages(\/count_tokens)?$/ would fix it.

3. Question, not a blocker: signed thinking across accounts and restarts
11159 says a block is "bound to a different conversation". Do you know what binds it: the message prefix, the account, or X-Conversation-ID? The conversation id is derived with a per-process SALT, so it changes when magpie restarts. magpie can also send one session to a second WorkBuddy account. If you have checked a session resumed after a magpie restart (new SALT), or one served by a second account, a line on it in the README would settle this.

Once 1 is answered, and 2 fixed if it's easy, I'll run it again and merge.

…on the Messages route, a signature we didn't make retried without thinking

- The empty text block withoutUnsigned leaves is confirmed against
  WorkBuddy, not only reasoned about: with a real account, the unsigned
  block plus a signed turn after it is 11159 (3 runs), the same with
  [{type:"text",text:""}] in its place is 200 (3 runs, twice over).
  WorkBuddy takes what Anthropic's own API refuses.
- What binds a signature is now measured and written down: not the
  conversation id, not the account, not the model, not where in the
  history. A block keeping an unrelated turn's signature, another model's
  (sonnet-5.5's on opus-5.5), or another account's answers 200; a truncated
  or altered one is 11159; a message that is thinking alone is refused
  whether its thinking is signed or not.
- A signature WorkBuddy didn't make (a session moved here from another
  provider, or an altered one) is 11159 too, and drop_block doesn't help.
  Such a request now goes once more without any thinking block, only when
  the refusal says so and only when there was one to drop.
- isMessages counts /v1/messages/count_tokens as a Messages request, so it
  no longer takes the chat route's rewrites.
@waynesguan

Copy link
Copy Markdown
Author

All three settled in 39f71a7.

1. Blocker: the empty text block — WorkBuddy answers 200

Replayed against WorkBuddy's /v1/messages with a real account, 3 runs per case, three times over:

history answer
user → assistant[thinking only, unsigned] → user → assistant[signed] → user 11159, every run
the same with [{type:"text",text:""}] in that message 200, every run
the same with [{type:"text","text":"…"}] 200
dropping the assistant message, so two user messages meet 200 (but the model then complains it lost a turn)

So WorkBuddy takes the empty text block, where Anthropic's own API refuses it. The comment above withoutUnsigned says so, with the numbers; native.test.mjs pins the shape in "a history whose assistant turn was emptied of unsigned thinking answers". A variant with two emptied turns and two signed ones after them is also 200.

2. count_tokens

isMessages now matches /\/messages(\/count_tokens)?$/, so the counting request goes as a Messages body — no default system message, no chat rewrites. Test added.

3. What binds a signature — measured, not guessed

It is not the conversation, the account, X-Conversation-ID, the model, or the position in the history. What matters is that the history around it is well formed, and that the signature is one WorkBuddy can read at all:

change to a turn that answers 200 answer
new X-Conversation-ID, no id, new request ids 200
first user message reworded, an extra user turn before it 200
system prompt changed, effort changed 200
the assistant's own text before the block changed 200
an assistant turn inserted before it 200
a block keeping another turn's signature 200
a block keeping sonnet-5.5's signature, sent to claude-opus-5.5 200
signature truncated / one char changed / random base64 11159
an assistant message that is thinking alone 11159

So a magpie restart (new SALT, new conversation id) is fine, and so is a second account. The README now says this. The one thing that does break it: a message that is thinking alone, which is why withoutUnsigned empties it rather than leaving it.

One more failure I found, and fixed

That table exposed a gap of mine. A session moved onto this route from another provider keeps that provider's signed thinking blocks, and they are 11159 too — a real signature, just not one WorkBuddy made. block_binding.prefix_mismatch_behavior: "drop_block" doesn't help. That would have refused every request of such a session, which is the same failure this change exists to end.

So a Messages request now goes once more without any thinking block when the refusal names a bad signature, and only when there was one to drop. A session on this route never sends twice (no such refusal comes back), and a refusal that isn't about a signature isn't retried. Three tests: the retry drops the thinking and only that; a body with no thinking isn't sent twice; an entitlement refusal passes straight through.

Tests: 149 pass, 0 fail, check.mjs workbuddy ✓. Each new guard is pinned by mutation: no retry → 1 red, count_tokens back to chat → 1 red, no empty-text fallback → 1 red.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants