feat(team): labels on a teammate message, and on the teammate's list (#1637) - #1845
Merged
Conversation
jhgaylor
force-pushed
the
stack/1637-label-filter
branch
from
September 10, 2026 13:45
e854bc5 to
9fcabc5
Compare
jhgaylor
force-pushed
the
stack/1637-team-labels
branch
from
September 10, 2026 13:45
8278957 to
7781635
Compare
lex00
approved these changes
Sep 10, 2026
jhgaylor
force-pushed
the
stack/1637-label-filter
branch
from
September 11, 2026 02:27
9fcabc5 to
6ceca0b
Compare
jhgaylor
force-pushed
the
stack/1637-team-labels
branch
from
September 11, 2026 02:27
7781635 to
fd06276
Compare
jhgaylor
force-pushed
the
stack/1637-label-filter
branch
from
September 11, 2026 02:44
6ceca0b to
ca05537
Compare
jhgaylor
force-pushed
the
stack/1637-team-labels
branch
from
September 11, 2026 02:44
fd06276 to
84f534d
Compare
jhgaylor
force-pushed
the
stack/1637-label-filter
branch
from
September 11, 2026 02:53
ca05537 to
bfaa827
Compare
jhgaylor
force-pushed
the
stack/1637-team-labels
branch
from
September 11, 2026 02:53
84f534d to
260d1ba
Compare
…mbined (#1637) `GET /api/conversations?label=env:prod&label=drift:true` keeps the conversations that carry both. The query is jsonb containment, which the GIN index the column already has serves; a row with more labels than the filter names still matches. Each value splits on its **first** colon only, so `label=path:a:b` filters `path` for `a:b`, and a value with no colon or an empty key is a 400 `invalid_label_filter` rather than a silent match-all. The parameter is declared honestly as an array (`style: form, explode: true`), which needed one piece of plumbing to be possible at all — `FountainWeb.Plugs.RepeatedQueryParam`. Two things sit in the way of a repeated query key here: - `Plug.Conn.Query` collapses a repeated key to its **last** value, so `conn.params["label"]` would be `"drift:true"` and the first filter would be gone; - `OpenApiSpex.CastParameters` reads query parameters straight out of Plug and implements only the `explode: false` comma-joined form itself, so a parameter declared as an array would be handed that string and refuse it as "not an array" — turning an ordinary `?label=env:prod` into a 422. So the plug reads the raw query string, collects every occurrence, and writes the list back onto both `query_params` and `params` before the cast runs. `label[]=` is collected too, for a client whose HTTP layer only builds arrays that way. `FountainWeb.LabelFilter` is the parsing, in one place, because the team route takes the same parameter next and a second copy is exactly how the two would drift. The TypeScript SDK gains `conversations({labels})`, and its query builder now expands an array into a repeated key instead of joining it with commas — joining would have sent one filter the server cannot parse. Fourth of nine on #1637. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018SgxmwtJNGJBvgCxSfGBaf
…1637) `POST /api/team/:agent_id/messages` takes `labels`, merged onto the conversation the message lands on — the teammate's current one, or the fresh one a thread past resuming is replaced by, in which case they ride in the create attrs so the conversation is not labelled twice. **Merged before the prompt is queued.** A label the limits refuse therefore means nothing happened at all, rather than "the message went and the labels did not". Through `Conversations.set_conversation_labels/4` and not the writer beneath it, because this route accepts a sandbox's own `sprite` token and the teammate's conversation is somebody else's conversation as far as that token is concerned. The refusal is the same 403 `PATCH .../labels` gives. `GET /api/team/:agent_id/conversations` takes the same repeatable, AND-combined `label` parameter, over the same `FountainWeb.LabelFilter` and the same containment fragment, so the two lists cannot disagree about what `?label=env:prod` means. Fifth of nine on #1637. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018SgxmwtJNGJBvgCxSfGBaf
jhgaylor
force-pushed
the
stack/1637-label-filter
branch
from
September 11, 2026 03:10
bfaa827 to
e0bedf5
Compare
jhgaylor
force-pushed
the
stack/1637-team-labels
branch
from
September 11, 2026 03:10
260d1ba to
57043c2
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
POST /api/team/:agent_id/messagestakeslabels, merged onto theconversation the message lands on — the teammate's current one, or the fresh
one a thread past resuming is replaced by, in which case they ride in the
create attrs so the conversation is not labelled twice.
Merged before the prompt is queued. A label the limits refuse therefore
means nothing happened at all, rather than "the message went and the labels
did not".
Through
Conversations.set_conversation_labels/4and not the writer beneathit, because this route accepts a sandbox's own
spritetoken and theteammate's conversation is somebody else's conversation as far as that token
is concerned. The refusal is the same 403
PATCH .../labelsgives.GET /api/team/:agent_id/conversationstakes the same repeatable, AND-combinedlabelparameter, over the sameFountainWeb.LabelFilterand the samecontainment fragment, so the two lists cannot disagree about what
?label=env:prodmeans.Fifth of nine on #1637.
The stack for #1637
Nine PRs, each based on the one above it. Merge top down, and do not
--delete-branchwhile a child still points at a branch.stack/1637-labels-columnConversations.Labels's limits,labelson createstack/1637-labels-writerset_conversation_labels/4,_unsafe_merge_labels/3, the sandbox rule, the audit event, the channel resumestack/1637-labels-apiPATCH /api/conversations/:id/labels,FountainWeb.SandboxKey, the 403,labelson the wirestack/1637-label-filter?label=key:value,RepeatedQueryParam,LabelFilter, the SDK filterstack/1637-team-labelsstack/1637-acp-stamp_fountain/labelsover the agent's own ACP sessionstack/1637-webhook-labelsdata.labelson everyconversation.*payloadstack/1637-console-chipsstack/1637-releaseThis is #1676 re-cut under the no-big-PRs rule, rebased onto current
main(re-rebased after #1832-#1839 landed).The combined tip is byte-identical to #1676 over
apps/, apart from threeprose fixes the destink and STE gates asked for and the regenerated contract
and SDK types.
Validation on the tip of the stack:
mix precommitclean, core and ee4,911 tests, 0 failures,
fountain_buzz144,fountain_support33. TheSDK contract
--check, the TypeScript typecheck and its 105 tests pass.docs-style.py,vale(0 errors) anddestinkare clean.🤖 Generated with Claude Code
https://claude.ai/code/session_018SgxmwtJNGJBvgCxSfGBaf