Skip to content

feat: add labels to chats#23594

Merged
kylecarbs merged 1 commit intomainfrom
chat-labels
Mar 25, 2026
Merged

feat: add labels to chats#23594
kylecarbs merged 1 commit intomainfrom
chat-labels

Conversation

@kylecarbs
Copy link
Member

Summary

Adds a general-purpose map[string]string label system to chats, stored as jsonb with a GIN index for efficient containment queries.

This is a standalone foundational feature that will be used by the upcoming Automations feature for session identity (matching webhook events to existing chats), replacing the need for bespoke session-key tables.

Changes

Database

  • Migration 000451: Adds labels jsonb NOT NULL DEFAULT '{}' column to chats table with a GIN index (idx_chats_labels)
  • InsertChat: Accepts labels on creation via COALESCE(@labels, '{}')
  • UpdateChatByID: Supports partial update — COALESCE(sqlc.narg('labels'), labels) preserves existing labels when NULL is passed
  • GetChats: New has_labels filter using PostgreSQL @> containment operator
  • GetAuthorizedChats: Synced with generated GetChats (new column scan + query param)

API

  • Create chat (POST /chats): Accepts optional labels field, validated before creation
  • Update chat (PATCH /chats/{chat}): Supports labels field for atomic label replacement
  • List chats (GET /chats): Supports ?label=key:value query parameters (multiple are AND-ed)

SDK

  • Chat, CreateChatRequest, UpdateChatRequest, ListChatsOptions all gain Labels fields
  • UpdateChatRequest.Labels is a pointer (*map[string]string) so nil means "don't change" vs empty map means "clear all"

Validation (coderd/httpapi/labels.go)

  • Max 50 labels per chat
  • Key: 1–64 chars, must match [a-zA-Z0-9][a-zA-Z0-9._/-]* (supports namespaced keys like github.repo, automation/pr-number)
  • Value: 1–256 chars
  • 13 test cases covering all edge cases

Chat runtime

  • chatd.CreateOptions gains Labels field, threaded through to InsertChat
  • Existing UpdateChatByID callers (e.g., quickgen title updates) are unaffected — NULL labels preserve existing values via COALESCE

@coder-tasks
Copy link
Contributor

coder-tasks bot commented Mar 25, 2026

Documentation Check

Updates Needed

  • docs/ai-coder/agents/chats-api.md — Add labels field to the Create a chat endpoint parameters table and update the example response JSON to include "labels": {}
  • docs/ai-coder/agents/chats-api.md — Document the ?label=key:value query parameter on the List chats endpoint (multiple parameters are AND-ed; format is key:value)
  • docs/ai-coder/agents/chats-api.md — Document label update support on the Update chat (PATCH /api/experimental/chats/{chat}) endpoint, including the labels field (pointer semantics: null = no change, empty map = clear all)

Automated review via Coder Tasks

@kylecarbs kylecarbs force-pushed the chat-labels branch 3 times, most recently from 7d40c26 to 7080442 Compare March 25, 2026 15:10
@kylecarbs kylecarbs requested a review from mafredri March 25, 2026 15:46
Copy link
Member

@mafredri mafredri left a comment

Choose a reason for hiding this comment

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

🤖 Agent on behalf of mafredri

Clean approach to adding labels as a first-class chat property. The COALESCE pattern for optional label updates, GIN index with containment queries, and the validation suite with boundary tests are well executed.

1 P1 (title-clobber race), 2 P2s (missing integration tests, non-atomic patch), 1 P3, 1 observation, and 3 nits across 8 inline comments.

Fifteen reviewers agreed on one thing: that COALESCE pattern for labels is exactly right. They just want the title to get the same treatment.

SET
title = @title::text,
labels = COALESCE(sqlc.narg('labels')::jsonb, labels),
updated_at = NOW()
Copy link
Member

Choose a reason for hiding this comment

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

P1 patchChat label update can silently revert a concurrently generated title. (Meruem P1, Hisoka P1, Ryosuke P1, Knuckle P2, Mafuuu P2)

UpdateChatByID unconditionally sets title = @title::text while using COALESCE(sqlc.narg('labels')::jsonb, labels) for labels. The patchChat handler passes Title: chat.Title from the middleware-loaded snapshot. If quickgen generates a new title between the middleware read and the label-update write, the generated title is overwritten with the stale value.

Meruem traces the interleaving:

  1. User creates chat (title = "New Chat"), quickgen triggers in background.
  2. Client sends PATCH with labels. Middleware loads chat.Title = "New Chat".
  3. quickgen writes Title: "Fix CI pipeline", labels preserved via COALESCE.
  4. patchChat writes Title: "New Chat", labels updated. Title silently reverts.

The PR description says this feature backs the upcoming Automations system, which will PATCH labels via webhook events shortly after chat creation, exactly when quickgen is most likely running.

Two clean fixes:

  1. Apply COALESCE to title too: title = COALESCE(sqlc.narg('title')::text, title). Each caller updates only what it owns.
  2. Add a dedicated UpdateChatLabels query that only touches labels and updated_at. This follows the existing convention (UpdateChatWorkspace, UpdateChatMCPServerIDs, UpdateChatStatus are all single-concern queries).

Option 2 is structurally cleaner; option 1 is a smaller diff.

Copy link
Member Author

Choose a reason for hiding this comment

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

Went with option 2 — added a dedicated UpdateChatLabelsByID query that only touches labels and updated_at. Reverted UpdateChatByID back to title-only. Follows the existing single-concern convention (UpdateChatWorkspace, UpdateChatMCPServerIDs, UpdateChatStatus).

Copy link
Member Author

Choose a reason for hiding this comment

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

Went with option 2: added a dedicated UpdateChatLabelsByID query that only touches labels and updated_at. Reverted UpdateChatByID back to title-only. Follows the existing single-concern convention (UpdateChatWorkspace, UpdateChatMCPServerIDs, UpdateChatStatus).

@@ -23,6 +23,7 @@ import (
"github.com/go-chi/chi/v5"
Copy link
Member

Choose a reason for hiding this comment

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

P2 No integration tests for any label behavior. (Bisky P2, Ryosuke P2, Mafu-san P1, Knuckle P3)

The only label tests are the validation unit tests in coderd/httpapi/chatlabels_test.go, which verify ValidateChatLabels in isolation. No test proves that: labels survive a create/read round-trip, ?label=key:value filtering works with the @> containment query, PATCH replaces or clears labels, or COALESCE preserves labels when omitted from an update.

The validation function being correct doesn't prove it's wired in. If someone removes the ValidateChatLabels call from postChats or patchChat, no test breaks. For a feature described as "foundational" that automations will depend on, at least one test per operation (create with labels, list with filter, patch labels) would catch regressions.

Copy link
Member Author

Choose a reason for hiding this comment

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

Added TestChatLabels with 5 subtests covering: create with labels, create without (default empty), update labels (set + clear), title update preserves labels, and label containment filtering (single-key, multi-key AND, no-filter returns all).

}

if req.Labels != nil {
if errs := httpapi.ValidateChatLabels(*req.Labels); len(errs) > 0 {
Copy link
Member

Choose a reason for hiding this comment

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

P2 Labels and archived updates are separate non-transactional writes. (Hisoka P2, Kurapika P2, Nami P2, Luffy P2, Mafu-san P2)

When a client sends {"labels": {"env": "prod"}, "archived": true}, the handler first commits labels via UpdateChatByID, then attempts the archive toggle. If the archive call fails, labels are already committed. The client receives a 500 error with no indication that labels succeeded.

On retry, labels are idempotent, so no data corruption occurs. But the error response misrepresents what happened. Could wrap both in db.InTx (the codebase uses this for CreateChat), or document that fields are applied independently and partial success is expected.

Copy link
Member Author

Choose a reason for hiding this comment

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

Accepted as-is. With the P1 fix, labels now go through their own UpdateChatLabelsByID and archive through chatDaemon.ArchiveChat -- fully independent single-concern writes. Both are idempotent so partial success on retry is safe.

return
}
updatedChat, err := api.Database.UpdateChatByID(ctx, database.UpdateChatByIDParams{
ID: chat.ID,
Copy link
Member

Choose a reason for hiding this comment

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

P3 Label update goes directly to api.Database, bypassing chatDaemon pubsub. (Ryosuke P2, Bisky P3)

The archive path routes through api.chatDaemon.ArchiveChat(), which publishes a pubsub event. The label path calls api.Database.UpdateChatByID() directly, so no pubsub event fires.

This may be intentional for the initial implementation since the automation use case queries by labels rather than subscribing to label changes. Worth noting for consistency: if label changes should eventually be visible to live subscribers (other browser tabs, chat list UIs), they'll need to go through the daemon.

Copy link
Member Author

Choose a reason for hiding this comment

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

Intentional for v1. The automation use case queries by labels rather than subscribing to changes. If label changes need to be visible to live subscribers later, we can route through the daemon then.

for _, lp := range labelParams {
parts := strings.SplitN(lp, ":", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Copy link
Member

Choose a reason for hiding this comment

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

Obs Label filter keys skip the validation that the create/update path enforces. (Hisoka, Meruem, Mafuuu, Knuckle, Kurapika, Nami, Mafu-san)

listChats checks for non-empty key and value but does not run ValidateChatLabels on the filter map. A query like ?label=bad key:value passes the parser and reaches the @> query, which harmlessly returns no results. Not exploitable (parameterized query), but the API gives silent empty results instead of a 400 for malformed filters.

Whether to add validation here is a judgment call. Reusing labelKeyRegex on query keys would give callers actionable feedback.

Copy link
Member Author

Choose a reason for hiding this comment

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

Added empty key/value rejection in the filter parser. Decided against full labelKeyRegex validation on filter keys -- a filter for a nonexistent key just returns zero results harmlessly, and being strict here would break forward compatibility if key rules ever relax.

// characters.
maxLabelValueLength = 256
)

Copy link
Member

Choose a reason for hiding this comment

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

Nit len(v) measures bytes, not characters; the constant and error message say "characters." (Mafuuu P3, Ging, Leorio)

For keys this is moot since the regex restricts to ASCII. For values, a 100-character string of 4-byte emoji would be 400 bytes and fail the 256 check. The documented contract says "characters" but the enforcement is bytes.

Either say "bytes" in the comment and error message, or use utf8.RuneCountInString(v) if character semantics are intended. Probably easiest to just say "bytes" since label values will almost always be ASCII identifiers.

Copy link
Member Author

Choose a reason for hiding this comment

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

Fixed — comments and error messages now say "bytes" instead of "characters". Label values are expected to be ASCII identifiers in practice, so byte semantics are fine.

Copy link
Member Author

Choose a reason for hiding this comment

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

Fixed -- comments and error messages now say "bytes" instead of "characters".

labelMap := make(map[string]string, len(labelParams))
for _, lp := range labelParams {
parts := strings.SplitN(lp, ":", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
Copy link
Member

Choose a reason for hiding this comment

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

Nit strings.SplitN + length check could be strings.Cut. (Ging)

strings.Cut (Go 1.18+) returns (key, value, found) directly, removing the slice indexing:

key, value, ok := strings.Cut(lp, ":")
if !ok || key == "" || value == "" {

PS. The error message "expected non-empty key:value" is slightly ambiguous about what "non-empty" modifies. "Expected format key:value (both key and value must be non-empty)" would be clearer. (Leorio)

Copy link
Member Author

Choose a reason for hiding this comment

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

Done -- switched to strings.Cut. Also improved the error message clarity.

END
AND CASE
WHEN sqlc.narg('has_labels')::jsonb IS NOT NULL THEN chats.labels @> sqlc.narg('has_labels')::jsonb
ELSE true
Copy link
Member

Choose a reason for hiding this comment

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

Nit has_labels reads as a boolean flag but carries a JSONB document for containment matching. (Gon)

A caller seeing HasLabels pqtype.NullRawMessage has to read the SQL to understand it's a containment filter, not a presence flag. contains_labels or label_filter would communicate the actual semantics.

Copy link
Member Author

Choose a reason for hiding this comment

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

Renamed to label_filter throughout (SQL param, Go struct field, handler variable).

Copy link
Member Author

@kylecarbs kylecarbs left a comment

Choose a reason for hiding this comment

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

Replies posted inline.

Copy link
Member

@mafredri mafredri left a comment

Choose a reason for hiding this comment

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

🤖 Agent on behalf of mafredri

Checked the latest push, here's where things stand.

P1 chats.sql title-clobber race — ✅ Fixed. Dedicated UpdateChatLabelsByID query touches only labels + updated_at. UpdateChatByID reverted to title-only. quickgen and patchChat now operate on separate queries with no shared mutable fields. Clean split.

P2 No integration tests — ✅ Fixed. TestChatLabels added with 5 subtests: create with labels, create without (verifies empty default), update + clear labels, title update preserves labels, and label containment filtering (single-key, multi-key AND). Covers the round-trip, COALESCE, and @> semantics.

P2 Non-atomic dual writes — ✅ Accepted. With labels going through UpdateChatLabelsByID and archive through chatDaemon.ArchiveChat, these are independent single-concern writes. Both are idempotent on retry. No transaction needed.

P3 chatDaemon bypass — ✅ Acknowledged as intentional for v1. Automation use case queries by labels, doesn't subscribe to label changes.

Obs Filter key validation — ✅ Acknowledged. Added empty key/value rejection. Author's reasoning on not adding full regex validation is sound: strict filter keys would break forward compatibility if key rules relax.

Nit bytes vs chars — ✅ Fixed. Comments and error messages now say "bytes."

Nit strings.Cut — ✅ Fixed. Also improved the error message clarity.

Nit has_labels naming — ✅ Fixed. Renamed to label_filter in SQL, Go struct, and handler.

All findings addressed.

A good day: eight findings in, eight findings resolved, and the title finally has a query all to itself. Hyvää työtä!

Copy link
Member

@mafredri mafredri left a comment

Choose a reason for hiding this comment

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

One auth gotcha but otherwise LGTM! 🏂🏻

Adds a general-purpose key-value label system to chats, stored as jsonb
with a GIN index for efficient containment queries.

Labels can be set on chat creation, updated via PATCH, and used to filter
chats in list queries via `?label=key:value` query parameters. Multiple
label params are AND-ed using PostgreSQL's @> containment operator.

This lays the groundwork for the Automations feature, where labels will
be used for session identity (matching webhooks to existing chats) instead
of bespoke session key tables.

Validation enforces:
- Max 50 labels per chat
- Key max length: 64 chars, must match [a-zA-Z0-9][a-zA-Z0-9._/-]*
- Value max length: 256 chars, must be non-empty

Changes:
- Migration 000451: adds `labels` jsonb column + GIN index to chats
- SQL queries: InsertChat, UpdateChatByID, GetChats updated for labels
- GetAuthorizedChats: synced with generated GetChats (new column + param)
- SDK: Chat, CreateChatRequest, UpdateChatRequest gain Labels fields
- HTTP: postChats validates/passes labels, patchChat supports label
  updates, listChats supports ?label=key:value filtering
- chatd: CreateOptions gains Labels field, threaded to InsertChat
- Validation helper: httpapi.ValidateLabels with full test coverage
@kylecarbs kylecarbs enabled auto-merge (squash) March 25, 2026 17:21
@kylecarbs kylecarbs merged commit d4660d8 into main Mar 25, 2026
48 of 50 checks passed
@kylecarbs kylecarbs deleted the chat-labels branch March 25, 2026 17:26
@github-actions github-actions bot locked and limited conversation to collaborators Mar 25, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants