From 4a4c0e86f89449c366498319ed3c64d13a28244b Mon Sep 17 00:00:00 2001 From: jiashuoz Date: Sun, 26 Jul 2026 11:48:23 -0700 Subject: [PATCH 01/12] feat(identity): messages field registry for the q filter language Co-Authored-By: Kimi --- .../plans/2026-07-25-filter-query-language.md | 29 ++- internal/identity/filter_registry.go | 151 ++++++++++++++ internal/identity/filter_registry_test.go | 191 ++++++++++++++++++ 3 files changed, 365 insertions(+), 6 deletions(-) create mode 100644 internal/identity/filter_registry.go create mode 100644 internal/identity/filter_registry_test.go diff --git a/docs/superpowers/plans/2026-07-25-filter-query-language.md b/docs/superpowers/plans/2026-07-25-filter-query-language.md index af5b44c8..b61d0ee7 100644 --- a/docs/superpowers/plans/2026-07-25-filter-query-language.md +++ b/docs/superpowers/plans/2026-07-25-filter-query-language.md @@ -2056,7 +2056,24 @@ Co-Authored-By: Kimi " **Interfaces:** - Consumes: `filterquery` public API (Tasks 1–4); existing `escapeLikePattern` (identity/store.go). -- Produces: `MessagesQRegistry() *filterquery.Registry` (lazy singleton); unexported `messagesFieldRegistry()`; value type `createdValue{at time.Time, dayRange bool}`. Task 10's differential tests and Task 11's handler consume these. +- Produces: `MessagesQRegistry() *filterquery.Registry` (lazy singleton); value type `createdValue{at time.Time, dayRange bool}`. Task 10's differential tests and Task 11's handler consume these. + +**Binding corrections and coverage requirements:** + +- Imports use the repository module path + `github.com/tokencanopy/e2a/internal/filterquery`. +- Date-only values denote the entire UTC calendar day. Unit tests must cover + every operator and its exact arguments: `=` is `[start,end)`, `!=` is + outside that range, `<` ends at `start`, `<=` ends at `end`, `>` begins at + `end`, and `>=` begins at `start`. In particular, + `created<=YYYY-MM-DD` includes that whole day and + `created>YYYY-MM-DD` starts at the following midnight. +- `from:` and `subject:` must preserve the flat-filter behavior exactly: + byte-counted 200-byte bound, case-insensitive substring, literal `%`, `_`, + and `\` escaping, and `*` translated to the ILIKE wildcard. Add boundary + tests for 200/201 bytes and exact tests showing `=`/`!=` do not translate + wildcards. +- Registry unit tests must be parallel-safe and must not mutate global state. - [ ] **Step 1: Write the failing tests** @@ -2068,7 +2085,7 @@ import ( "testing" "time" - "e2a/internal/filterquery" + "github.com/tokencanopy/e2a/internal/filterquery" ) func compileQ(t *testing.T, q string, start int) (string, []any) { @@ -2187,7 +2204,7 @@ import ( "sync" "time" - "e2a/internal/filterquery" + "github.com/tokencanopy/e2a/internal/filterquery" ) // q-language field registry for the messages table. Semantics MUST match the @@ -2404,8 +2421,8 @@ import ( "testing" "time" - "e2a/internal/filterquery" - "e2a/internal/testutil" + "github.com/tokencanopy/e2a/internal/filterquery" + "github.com/tokencanopy/e2a/internal/testutil" ) // Differential testing: for each q expression, the rows returned by the @@ -2795,7 +2812,7 @@ Pass `QEmit: qEmit` in the `identity.MessageListFilter{…}` literal (~737), inc (find the existing `if cur.AgentID != … || … !stringSlicesEqual(cur.Labels, labelsFilter)` block and add the one line; also set `Q: in.Q` on the cursor struct literal that encodes the next cursor.) -Import `"e2a/internal/filterquery"` in messages.go. +Import `"github.com/tokencanopy/e2a/internal/filterquery"` in messages.go. - [ ] **Step 4: Run tests to verify they pass** diff --git a/internal/identity/filter_registry.go b/internal/identity/filter_registry.go new file mode 100644 index 00000000..b942d807 --- /dev/null +++ b/internal/identity/filter_registry.go @@ -0,0 +1,151 @@ +package identity + +import ( + "fmt" + "regexp" + "strings" + "sync" + "time" + + "github.com/tokencanopy/e2a/internal/filterquery" +) + +// Everything messages-specific lives in this file — internal/filterquery +// stays schema-agnostic. +var ( + qRegistryOnce sync.Once + qRegistry *filterquery.Registry +) + +// MessagesQRegistry returns the shared field registry for list_messages q. +func MessagesQRegistry() *filterquery.Registry { + qRegistryOnce.Do(func() { + reg, err := filterquery.NewRegistry( + labelQField(), + fromQField(), + subjectQField(), + hasQField(), + createdQField(), + ) + if err != nil { + panic("filterquery: static messages registry is invalid: " + err.Error()) + } + qRegistry = reg + }) + return qRegistry +} + +var qLabelRe = regexp.MustCompile(`^[a-z0-9:_-]{1,64}$`) + +func labelQField() filterquery.FieldSpec { + return filterquery.FieldSpec{ + Name: "label", + Ops: []string{":"}, + Coerce: func(raw string, quoted bool) (any, error) { + if !qLabelRe.MatchString(raw) { + return nil, fmt.Errorf("labels must match [a-z0-9:_-]+ (max 64 chars), got %q", raw) + } + return raw, nil + }, + Emit: func(c *filterquery.Comparison, e *filterquery.EmitCtx) (string, error) { + return "m.labels @> " + e.PH([]string{c.Value.(string)}), nil + }, + } +} + +// likeSubstring builds the ILIKE pattern shared by from:/subject:. '*' maps +// to '%'; literal %, _, and \\ are escaped with the flat-filter helper first. +func likeSubstring(v string) string { + return "%" + strings.ReplaceAll(escapeLikePattern(v), "*", "%") + "%" +} + +func textQField(name, column string, maxLen int) filterquery.FieldSpec { + return filterquery.FieldSpec{ + Name: name, + Ops: []string{":", "=", "!="}, + Coerce: func(raw string, quoted bool) (any, error) { + if raw == "" { + return nil, fmt.Errorf("empty %s value", name) + } + if len(raw) > maxLen { + return nil, fmt.Errorf("%s filter too long (max %d bytes)", name, maxLen) + } + return raw, nil + }, + Emit: func(c *filterquery.Comparison, e *filterquery.EmitCtx) (string, error) { + v := c.Value.(string) + switch c.Op { + case ":": + return column + ` ILIKE ` + e.PH(likeSubstring(v)) + ` ESCAPE '\'`, nil + case "=": + return "LOWER(" + column + ") = LOWER(" + e.PH(v) + ")", nil + default: // "!=" + return "LOWER(" + column + ") != LOWER(" + e.PH(v) + ")", nil + } + }, + } +} + +func fromQField() filterquery.FieldSpec { return textQField("from", "m.sender", 200) } +func subjectQField() filterquery.FieldSpec { return textQField("subject", "m.subject", 200) } + +func hasQField() filterquery.FieldSpec { + return filterquery.FieldSpec{ + Name: "has", + Ops: []string{":"}, + Coerce: func(raw string, quoted bool) (any, error) { + if raw != "attachment" { + return nil, fmt.Errorf("unsupported has: value %q — v1 supports has:attachment", raw) + } + return raw, nil + }, + Emit: func(c *filterquery.Comparison, e *filterquery.EmitCtx) (string, error) { + return "COALESCE(jsonb_array_length(m.attachments_json), 0) > 0", nil + }, + } +} + +// createdValue carries date-coercion semantics: a date-only input (dayRange) +// makes comparisons cover the entire UTC calendar day. +type createdValue struct { + at time.Time + dayRange bool +} + +func createdQField() filterquery.FieldSpec { + return filterquery.FieldSpec{ + Name: "created", + Ops: []string{"=", "!=", "<", "<=", ">", ">="}, + Coerce: func(raw string, quoted bool) (any, error) { + if ts, err := time.Parse(time.RFC3339, raw); err == nil { + return createdValue{at: ts}, nil + } + if d, err := time.Parse("2006-01-02", raw); err == nil { + return createdValue{at: d, dayRange: true}, nil + } + return nil, fmt.Errorf("expected RFC3339 or YYYY-MM-DD, got %q", raw) + }, + Emit: func(c *filterquery.Comparison, e *filterquery.EmitCtx) (string, error) { + v := c.Value.(createdValue) + if !v.dayRange { + return "m.created_at " + c.Op + " " + e.PH(v.at), nil + } + + end := v.at.AddDate(0, 0, 1) + switch c.Op { + case "=": + return "(m.created_at >= " + e.PH(v.at) + " AND m.created_at < " + e.PH(end) + ")", nil + case "!=": + return "(m.created_at < " + e.PH(v.at) + " OR m.created_at >= " + e.PH(end) + ")", nil + case "<": + return "m.created_at < " + e.PH(v.at), nil + case "<=": + return "m.created_at < " + e.PH(end), nil + case ">": + return "m.created_at >= " + e.PH(end), nil + default: // ">=" + return "m.created_at >= " + e.PH(v.at), nil + } + }, + } +} diff --git a/internal/identity/filter_registry_test.go b/internal/identity/filter_registry_test.go new file mode 100644 index 00000000..4af5bd06 --- /dev/null +++ b/internal/identity/filter_registry_test.go @@ -0,0 +1,191 @@ +package identity + +import ( + "reflect" + "strings" + "testing" + "time" + + "github.com/tokencanopy/e2a/internal/filterquery" +) + +func compileQ(t *testing.T, q string, start int) (string, []any) { + t.Helper() + frag, args, err := filterquery.Compile(q, MessagesQRegistry(), filterquery.PostgresDialect{}, start) + if err != nil { + t.Fatalf("Compile(%q): %v", q, err) + } + return frag, args +} + +func TestMessagesQRegistrySingleton(t *testing.T) { + t.Parallel() + + const callers = 32 + registries := make(chan *filterquery.Registry, callers) + for range callers { + go func() { registries <- MessagesQRegistry() }() + } + + first := <-registries + for range callers - 1 { + if got := <-registries; got != first { + t.Fatal("MessagesQRegistry returned distinct registries") + } + } +} + +func TestLabelField(t *testing.T) { + t.Parallel() + + frag, args := compileQ(t, `label:urgent`, 1) + if frag != `(m.labels @> $1)` || !reflect.DeepEqual(args, []any{[]string{"urgent"}}) { + t.Errorf("frag=%s args=%v", frag, args) + } + if _, _, err := filterquery.Compile(`label:UPPER`, MessagesQRegistry(), filterquery.PostgresDialect{}, 1); err == nil { + t.Error("uppercase label: want rejection (charset)") + } + if _, _, err := filterquery.Compile(`label = "urgent"`, MessagesQRegistry(), filterquery.PostgresDialect{}, 1); err == nil { + t.Error("label=: want operator rejection") + } + if _, _, err := filterquery.Compile(`label:e2a:held`, MessagesQRegistry(), filterquery.PostgresDialect{}, 1); err != nil { + t.Errorf("system label filter should work: %v", err) + } +} + +func TestFromFieldMatchesFlatParam(t *testing.T) { + t.Parallel() + + frag, args := compileQ(t, `from:alice@x.com`, 1) + if frag != `(m.sender ILIKE $1 ESCAPE '\')` { + t.Errorf("frag = %s", frag) + } + if !reflect.DeepEqual(args, []any{"%alice@x.com%"}) { + t.Errorf("args = %v", args) + } + + frag, args = compileQ(t, `from:"*@x_%.com\\tail"`, 1) + if !reflect.DeepEqual(args, []any{`%%@x\_\%.com\\tail%`}) { + t.Errorf("wildcard args = %v", args) + } + + for _, op := range []string{"=", "!="} { + frag, args = compileQ(t, `from `+op+` "a*b%_"`, 1) + wantFrag := `(LOWER(m.sender) ` + op + ` LOWER($1))` + if frag != wantFrag || !reflect.DeepEqual(args, []any{"a*b%_"}) { + t.Errorf("exact %s: frag=%s args=%v", op, frag, args) + } + } +} + +func TestTextFieldLengthBounds(t *testing.T) { + t.Parallel() + + for _, field := range []string{"from", "subject"} { + field := field + t.Run(field, func(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + value string + valid bool + }{ + {"200 ASCII bytes", strings.Repeat("a", 200), true}, + {"201 ASCII bytes", strings.Repeat("a", 201), false}, + {"200 UTF-8 bytes", strings.Repeat("é", 100), true}, + {"202 UTF-8 bytes", strings.Repeat("é", 101), false}, + } { + tc := tc + t.Run(tc.name, func(t *testing.T) { + _, _, err := filterquery.Compile(field+`:"`+tc.value+`"`, MessagesQRegistry(), filterquery.PostgresDialect{}, 1) + if tc.valid && err != nil { + t.Fatalf("%s %s value: %v", tc.name, field, err) + } + if !tc.valid && err == nil { + t.Errorf("%s %s value: want rejection", tc.name, field) + } + }) + } + }) + } +} + +func TestSubjectField(t *testing.T) { + t.Parallel() + + frag, args := compileQ(t, `subject:quarterly`, 1) + if frag != `(m.subject ILIKE $1 ESCAPE '\')` || !reflect.DeepEqual(args, []any{"%quarterly%"}) { + t.Errorf("frag=%s args=%v", frag, args) + } + + frag, args = compileQ(t, `subject:"*@x_%.com\\tail"`, 1) + if !reflect.DeepEqual(args, []any{`%%@x\_\%.com\\tail%`}) { + t.Errorf("wildcard args = %v", args) + } + + for _, op := range []string{"=", "!="} { + frag, args = compileQ(t, `subject `+op+` "a*b%_"`, 1) + wantFrag := `(LOWER(m.subject) ` + op + ` LOWER($1))` + if frag != wantFrag || !reflect.DeepEqual(args, []any{"a*b%_"}) { + t.Errorf("exact %s: frag=%s args=%v", op, frag, args) + } + } +} + +func TestHasAttachment(t *testing.T) { + t.Parallel() + + frag, args := compileQ(t, `has:attachment`, 1) + if frag != `(COALESCE(jsonb_array_length(m.attachments_json), 0) > 0)` || len(args) != 0 { + t.Errorf("frag=%s args=%v", frag, args) + } + if _, _, err := filterquery.Compile(`has:body`, MessagesQRegistry(), filterquery.PostgresDialect{}, 1); err == nil { + t.Error("has:body: want rejection") + } +} + +func TestCreatedField(t *testing.T) { + t.Parallel() + + start := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC) + end := start.AddDate(0, 0, 1) + for _, tc := range []struct { + name string + q string + frag string + args []any + }{ + {"equal", `created = "2026-07-01"`, `((m.created_at >= $1 AND m.created_at < $2))`, []any{start, end}}, + {"not equal", `created != "2026-07-01"`, `((m.created_at < $1 OR m.created_at >= $2))`, []any{start, end}}, + {"before", `created<2026-07-01`, `(m.created_at < $1)`, []any{start}}, + {"through day", `created<=2026-07-01`, `(m.created_at < $1)`, []any{end}}, + {"after day", `created>2026-07-01`, `(m.created_at >= $1)`, []any{end}}, + {"on or after", `created>=2026-07-01`, `(m.created_at >= $1)`, []any{start}}, + } { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + frag, args := compileQ(t, tc.q, 1) + if frag != tc.frag || !reflect.DeepEqual(args, tc.args) { + t.Errorf("frag=%s args=%v, want frag=%s args=%v", frag, args, tc.frag, tc.args) + } + }) + } + + ts := "2026-07-25T10:30:00Z" + frag, args := compileQ(t, `created<`+ts, 1) + want, err := time.Parse(time.RFC3339, ts) + if err != nil { + t.Fatal(err) + } + if frag != `(m.created_at < $1)` || !reflect.DeepEqual(args, []any{want}) { + t.Errorf("rfc3339 frag=%s args=%v", frag, args) + } + frag, args = compileQ(t, `created=`+ts, 1) + if frag != `(m.created_at = $1)` || !reflect.DeepEqual(args, []any{want}) { + t.Errorf("rfc3339 equality frag=%s args=%v", frag, args) + } + if _, _, err := filterquery.Compile(`created>yesterday`, MessagesQRegistry(), filterquery.PostgresDialect{}, 1); err == nil { + t.Error("bad date: want rejection") + } +} From bc499a759b2ecafc0530a73a09427da7c906a75f Mon Sep 17 00:00:00 2001 From: jiashuoz Date: Sun, 26 Jul 2026 11:57:54 -0700 Subject: [PATCH 02/12] feat(identity): q predicate store plumbing + differential SQL tests Co-Authored-By: Kimi --- .../plans/2026-07-25-filter-query-language.md | 40 ++- internal/identity/filter_differential_test.go | 233 ++++++++++++++++++ internal/identity/store.go | 14 ++ 3 files changed, 279 insertions(+), 8 deletions(-) create mode 100644 internal/identity/filter_differential_test.go diff --git a/docs/superpowers/plans/2026-07-25-filter-query-language.md b/docs/superpowers/plans/2026-07-25-filter-query-language.md index b61d0ee7..f4ab0f7a 100644 --- a/docs/superpowers/plans/2026-07-25-filter-query-language.md +++ b/docs/superpowers/plans/2026-07-25-filter-query-language.md @@ -2380,25 +2380,49 @@ Co-Authored-By: Kimi " **Interfaces:** - Consumes: `MessagesQRegistry()` (Task 9), `filterquery.Expr`. -- Produces: `MessageListFilter.QEmit func(startIdx int) (fragment string, args []interface{})` — the handler (Task 11) sets it. +- Produces: `MessageListFilter.Q *filterquery.Expr` — the handler (Task 11) sets it and the Postgres store emits it at the correct placeholder offset. + +**Binding corrections (supersede the illustrative callback/evaluator/Root +sections below):** + +- Pass the validated expression as data (`Q *filterquery.Expr`), not an + errorless callback. `GetMessagesByAgent` calls `Q.Emit` with + `PostgresDialect{}` after all flat filters, propagates any emission error, + and appends its fragment/arguments. Add a store error-path test and a + flat-filter composition test that proves placeholder numbering. +- Do **not** add `Expr.Root()` or any other mutable-AST exposure. The generic + package deliberately keeps a validated expression encapsulated; production + API must not be widened solely for a test. +- Replace the AST-sharing "naive evaluator" below with an independent fixture + oracle: each query declares expected fixture keys/IDs explicitly. Cover + precedence, NOT, labels, case-insensitive exact/substring text matching, + escaped `%`/`_`/`\`, `*` wildcard behavior, attachments, Unicode, and all + date-only boundaries. Include fixtures immediately before the day, exactly + at its first midnight, exactly at the following midnight, and after it so + `<`, `<=`, `=`, `!=`, `>`, and `>=` cannot share a mistaken boundary. +- Build attachment fixtures with `encoding/json` (or a valid fixed JSON + array); never use the invalid `fmt.Sprintf(...[:{}])` example below. +- The old Step 2 evaluator and Step 3 `Expr.Root()` snippets are retained only + as historical illustration and must not be implemented. - [ ] **Step 1: Add the filter field and the store splice** In `MessageListFilter` (store.go ~3661, after `Labels []string`): ```go - // QEmit, when non-nil, emits the validated q-expression predicate with - // placeholders numbered from the given 1-based start index. The store - // invokes it after the built-in filters so $n numbering stays correct. - // Built by the handler from internal/filterquery.Expr. - QEmit func(startIdx int) (fragment string, args []interface{}) + // Q is a parsed and validated q-expression. The Postgres store emits it + // after built-in filters so placeholder numbering stays correct. + Q *filterquery.Expr ``` In `GetMessagesByAgent`, immediately after the `if len(f.Labels) > 0 { … }` block: ```go - if f.QEmit != nil { - frag, qargs := f.QEmit(len(args) + 1) + if f.Q != nil { + frag, qargs, err := f.Q.Emit(filterquery.PostgresDialect{}, len(args)+1) + if err != nil { + return nil, fmt.Errorf("emit q filter: %w", err) + } if frag != "" { query += " AND " + frag args = append(args, qargs...) diff --git a/internal/identity/filter_differential_test.go b/internal/identity/filter_differential_test.go new file mode 100644 index 00000000..b007ca7e --- /dev/null +++ b/internal/identity/filter_differential_test.go @@ -0,0 +1,233 @@ +package identity_test + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "reflect" + "sort" + "strings" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/tokencanopy/e2a/internal/filterquery" + "github.com/tokencanopy/e2a/internal/identity" + "github.com/tokencanopy/e2a/internal/testutil" +) + +type qFixture struct { + key string + sender string + subject string + labels []string + created time.Time + attachments int + conversation string + id string +} + +func seedQAgent(t *testing.T, store *identity.Store, ctx context.Context) string { + t.Helper() + const domain = "qdiff.example.com" + user, err := store.CreateOrGetUser(ctx, "owner-qdiff@example.com", "Owner", "google-qdiff") + if err != nil { + t.Fatalf("CreateOrGetUser: %v", err) + } + if _, err := store.ClaimOrCreateDomain(ctx, domain, user.ID); err != nil { + t.Fatalf("ClaimOrCreateDomain: %v", err) + } + if err := store.VerifyDomain(ctx, domain, user.ID); err != nil { + t.Fatalf("VerifyDomain: %v", err) + } + agent, err := store.CreateAgent(ctx, "bot@"+domain, domain, "", "https://example.com/webhook", "", user.ID) + if err != nil { + t.Fatalf("CreateAgent: %v", err) + } + return agent.ID +} + +func seedQFixtures(t *testing.T, pool *pgxpool.Pool, store *identity.Store, agentID string) map[string]qFixture { + t.Helper() + ctx := context.Background() + day := time.Date(2026, time.July, 1, 0, 0, 0, 0, time.UTC) + fixtures := []qFixture{ + {key: "before", sender: "before@example.com", subject: "xnowy", labels: []string{"archive"}, created: day.Add(-time.Nanosecond)}, + {key: "start", sender: "start@example.com", subject: "100 percent", labels: []string{"start"}, created: day}, + {key: "alice", sender: "alice@corp.com", subject: "Quarterly report", labels: []string{"urgent", "q3"}, created: day.Add(time.Hour), conversation: "qdiff-conversation"}, + {key: "alert", sender: "bob@alerts.io", subject: "CPU alert", labels: []string{"alerts"}, created: day.Add(2 * time.Hour), attachments: 2}, + {key: "newsletter", sender: "carol@news.net", subject: "Weekly digest", labels: []string{"newsletter"}, created: day.Add(3 * time.Hour)}, + {key: "follow", sender: "ALICE@corp.com", subject: "Follow-up", labels: []string{"follow-up"}, created: day.Add(4 * time.Hour)}, + {key: "empty", sender: "dave@x.com", subject: "", labels: []string{}, created: day.Add(5 * time.Hour)}, + {key: "literal", sender: "eve@percent.com", subject: "100% sure _now_ slash\\path", labels: []string{"urgent"}, created: day.Add(6 * time.Hour)}, + {key: "wildcard", sender: "frank@star.com", subject: "a*b literal", labels: []string{}, created: day.Add(7 * time.Hour)}, + {key: "unicode", sender: "日本語@例.jp", subject: "こんにちは 世界", labels: []string{"urgent", "日本"}, created: day.Add(8 * time.Hour)}, + {key: "end", sender: "end@example.com", subject: "At next day", labels: []string{"end"}, created: day.AddDate(0, 0, 1)}, + {key: "after", sender: "after@example.com", subject: "After next day", labels: []string{"after"}, created: day.AddDate(0, 0, 1).Add(time.Microsecond)}, + } + + for i := range fixtures { + fx := &fixtures[i] + message, err := store.CreateInboundMessage(ctx, "", agentID, fx.sender, "bot@qdiff.example.com", + fmt.Sprintf("", fx.key), fx.subject, fx.conversation, "", []byte("From: "+fx.sender+"\r\nSubject: "+fx.subject+"\r\n\r\nx"), + nil, nil, false, "", nil, nil, nil, identity.InboundScreening{}) + if err != nil { + t.Fatalf("seed %s: %v", fx.key, err) + } + fx.id = message.ID + + attachmentsValue := make([]map[string]any, fx.attachments) + for attachment := range attachmentsValue { + attachmentsValue[attachment] = map[string]any{ + "filename": fmt.Sprintf("attachment-%d.pdf", attachment), + "content_type": "application/pdf", + "index": attachment, + "size_bytes": 10, + } + } + attachments, err := json.Marshal(attachmentsValue) + if err != nil { + t.Fatalf("marshal attachments for %s: %v", fx.key, err) + } + if _, err := pool.Exec(ctx, `UPDATE messages SET labels = $1, attachments_json = $2::jsonb, created_at = $3 WHERE id = $4`, fx.labels, attachments, fx.created, fx.id); err != nil { + t.Fatalf("set fixture %s: %v", fx.key, err) + } + } + + byKey := make(map[string]qFixture, len(fixtures)) + for _, fx := range fixtures { + byKey[fx.key] = fx + } + return byKey +} + +func fixtureIDs(t *testing.T, byKey map[string]qFixture, keys ...string) []string { + t.Helper() + ids := make([]string, 0, len(keys)) + for _, key := range keys { + fx, ok := byKey[key] + if !ok { + t.Fatalf("unknown fixture key %q", key) + } + ids = append(ids, fx.id) + } + sort.Strings(ids) + return ids +} + +func listedIDs(messages []identity.Message) []string { + ids := make([]string, 0, len(messages)) + for _, message := range messages { + ids = append(ids, message.ID) + } + sort.Strings(ids) + return ids +} + +func TestQFilterDifferential(t *testing.T) { + pool := testutil.TestDB(t) + store := identity.NewStore(pool) + ctx := context.Background() + agentID := seedQAgent(t, store, ctx) + byKey := seedQFixtures(t, pool, store, agentID) + + allDay := []string{"start", "alice", "alert", "newsletter", "follow", "empty", "literal", "wildcard", "unicode"} + allButUrgent := []string{"before", "start", "alert", "newsletter", "follow", "empty", "wildcard", "end", "after"} + allButAlice := []string{"before", "start", "alert", "newsletter", "empty", "literal", "wildcard", "unicode", "end", "after"} + allAfterStart := append(append([]string{}, allDay...), "end", "after") + allOutsideDay := []string{"before", "end", "after"} + queries := []struct { + q string + keys []string + }{ + {q: `label:urgent`, keys: []string{"alice", "literal", "unicode"}}, + {q: `label:urgent OR label:alerts`, keys: []string{"alice", "alert", "literal", "unicode"}}, + {q: `label:urgent OR label:alerts AND has:attachment`, keys: []string{"alert"}}, + {q: `(label:urgent OR label:follow-up) AND NOT has:attachment`, keys: []string{"alice", "follow", "literal", "unicode"}}, + {q: `NOT label:urgent`, keys: allButUrgent}, + {q: `from:alice`, keys: []string{"alice", "follow"}}, + {q: `from:*@corp.com`, keys: []string{"alice", "follow"}}, + {q: `from = "alice@corp.com"`, keys: []string{"alice", "follow"}}, + {q: `from != "alice@corp.com"`, keys: allButAlice}, + {q: `subject:100%`, keys: []string{"literal"}}, + {q: `subject:_now_`, keys: []string{"literal"}}, + {q: `subject:"\\path"`, keys: []string{"literal"}}, + {q: `subject:"a*b literal"`, keys: []string{"wildcard"}}, + {q: `subject:こんにちは`, keys: []string{"unicode"}}, + {q: `has:attachment`, keys: []string{"alert"}}, + {q: `created<2026-07-01`, keys: []string{"before"}}, + {q: `created<=2026-07-01`, keys: append([]string{"before"}, allDay...)}, + {q: `created=2026-07-01`, keys: allDay}, + {q: `created!=2026-07-01`, keys: allOutsideDay}, + {q: `created>2026-07-01`, keys: []string{"end", "after"}}, + {q: `created>=2026-07-01`, keys: allAfterStart}, + } + + for _, tc := range queries { + t.Run(tc.q, func(t *testing.T) { + expr, err := filterquery.Parse(tc.q, identity.MessagesQRegistry()) + if err != nil { + t.Fatalf("Parse(%q): %v", tc.q, err) + } + messages, err := store.GetMessagesByAgent(ctx, identity.MessageListFilter{ + AgentID: agentID, Direction: "inbound", Status: "all", Limit: 100, Q: expr, + }) + if err != nil { + t.Fatalf("GetMessagesByAgent(%q): %v", tc.q, err) + } + if got, want := listedIDs(messages), fixtureIDs(t, byKey, tc.keys...); !reflect.DeepEqual(got, want) { + t.Errorf("q=%q IDs=%v, want %v", tc.q, got, want) + } + }) + } +} + +func TestQFilterComposesAfterFlatFilters(t *testing.T) { + pool := testutil.TestDB(t) + store := identity.NewStore(pool) + ctx := context.Background() + agentID := seedQAgent(t, store, ctx) + byKey := seedQFixtures(t, pool, store, agentID) + expr, err := filterquery.Parse(`from:corp.com`, identity.MessagesQRegistry()) + if err != nil { + t.Fatalf("Parse: %v", err) + } + day := time.Date(2026, time.July, 1, 0, 0, 0, 0, time.UTC) + messages, err := store.GetMessagesByAgent(ctx, identity.MessageListFilter{ + AgentID: agentID, Direction: "inbound", Status: "all", Limit: 100, + From: "alice", SubjectContains: "quarter", ConversationID: "qdiff-conversation", + Since: day, Until: day.AddDate(0, 0, 1), Labels: []string{"urgent"}, Q: expr, + }) + if err != nil { + t.Fatalf("GetMessagesByAgent: %v", err) + } + if got, want := listedIDs(messages), fixtureIDs(t, byKey, "alice"); !reflect.DeepEqual(got, want) { + t.Errorf("IDs=%v, want %v", got, want) + } +} + +func TestQFilterPropagatesEmissionErrorBeforeQuery(t *testing.T) { + sentinel := errors.New("emit failure") + registry, err := filterquery.NewRegistry(filterquery.FieldSpec{ + Name: "boom", Ops: []string{":"}, + Coerce: func(raw string, quoted bool) (any, error) { return raw, nil }, + Emit: func(*filterquery.Comparison, *filterquery.EmitCtx) (string, error) { return "", sentinel }, + }) + if err != nil { + t.Fatalf("NewRegistry: %v", err) + } + expr, err := filterquery.Parse(`boom:value`, registry) + if err != nil { + t.Fatalf("Parse: %v", err) + } + + store := identity.NewStore(nil) + _, err = store.GetMessagesByAgent(context.Background(), identity.MessageListFilter{AgentID: "agent_not_queried", Q: expr}) + if !errors.Is(err, sentinel) { + t.Fatalf("GetMessagesByAgent error = %v, want wrapped %v", err, sentinel) + } + if !strings.Contains(err.Error(), "emit q filter") { + t.Errorf("error = %q, want q-emission context", err) + } +} diff --git a/internal/identity/store.go b/internal/identity/store.go index f40c07ef..b1440639 100644 --- a/internal/identity/store.go +++ b/internal/identity/store.go @@ -20,6 +20,7 @@ import ( "github.com/tokencanopy/e2a/internal/dkim" "github.com/tokencanopy/e2a/internal/emailauth" "github.com/tokencanopy/e2a/internal/eventpayload" + "github.com/tokencanopy/e2a/internal/filterquery" "github.com/tokencanopy/e2a/internal/inboundpolicy" "github.com/tokencanopy/e2a/internal/messagelifecycle" "golang.org/x/net/idna" @@ -3676,6 +3677,9 @@ type MessageListFilter struct { // rows. Handler-layer validates each entry against the same charset // rule used on writes so callers can't smuggle SQL through here. Labels []string + // Q is a parsed and validated q-expression. The Postgres store emits it + // after built-in filters so placeholder numbering stays correct. + Q *filterquery.Expr // Deleted flips the query to the TRASH view: only soft-deleted rows. // False (default) lists indefinitely retained live rows only. Deleted bool @@ -3804,6 +3808,16 @@ func (s *Store) GetMessagesByAgent(ctx context.Context, f MessageListFilter) ([] query += fmt.Sprintf(` AND m.labels @> $%d`, len(args)+1) args = append(args, f.Labels) } + if f.Q != nil { + frag, qargs, err := f.Q.Emit(filterquery.PostgresDialect{}, len(args)+1) + if err != nil { + return nil, fmt.Errorf("emit q filter: %w", err) + } + if frag != "" { + query += " AND " + frag + args = append(args, qargs...) + } + } cursorCmp := ">" sortDir := "ASC" From cebb35a17e07cc14e4a2c809f2774d1c14d28c20 Mon Sep 17 00:00:00 2001 From: jiashuoz Date: Sun, 26 Jul 2026 12:05:03 -0700 Subject: [PATCH 03/12] feat(api): q filter param on list_messages with cursor pinning Co-Authored-By: Kimi --- .../plans/2026-07-25-filter-query-language.md | 43 ++-- internal/httpapi/messages.go | 21 ++ internal/httpapi/messages_q_test.go | 194 ++++++++++++++++++ 3 files changed, 243 insertions(+), 15 deletions(-) create mode 100644 internal/httpapi/messages_q_test.go diff --git a/docs/superpowers/plans/2026-07-25-filter-query-language.md b/docs/superpowers/plans/2026-07-25-filter-query-language.md index f4ab0f7a..0da1a495 100644 --- a/docs/superpowers/plans/2026-07-25-filter-query-language.md +++ b/docs/superpowers/plans/2026-07-25-filter-query-language.md @@ -2749,9 +2749,27 @@ Co-Authored-By: Kimi " - Test: `internal/httpapi/messages_q_test.go` **Interfaces:** -- Consumes: `identity.MessagesQRegistry()` (Task 9), `filterquery.Parse/Expr`, `MessageListFilter.QEmit` (Task 10). +- Consumes: `identity.MessagesQRegistry()` (Task 9), `filterquery.Parse/Expr`, `MessageListFilter.Q` (Task 10). - Produces: public `q` query param on `GET /v1/agents/{email}/messages`; 400 `invalid_filter` contract. +**Binding corrections and coverage requirements:** + +- Count the 500-character `q` cap with `utf8.RuneCountInString`, consistent + with JSON Schema/OpenAPI character semantics. Test 500 Unicode code points + accepted and 501 rejected, in addition to ASCII oversize. +- Parse once in the handler and pass the resulting `*filterquery.Expr` + directly as `MessageListFilter.Q`. Do not preflight emission and do not + recreate an errorless callback; Task 10 made the store responsible for + dialect emission and error propagation. +- Pin the exact raw `q` string into pagination cursors and compare it on every + continuation. Tests must cover identical replay success, changed `q` + rejection as `invalid_cursor`, and adding/removing `q` across pages. +- Exercise the real HTTP/Huma stack. Invalid syntax, unknown fields, forbidden + operators, and length caps return 400 `invalid_filter`; positioned parser + errors retain their `(at column N)` text. Captured store filters must prove + `Q` is non-nil and emits the expected SQL/args, while flat filters remain set + for AND composition. + - [ ] **Step 1: Write the failing handler tests** ```go @@ -2773,12 +2791,13 @@ func TestQParamCursorPinning(t *testing.T) { func TestQParamReachesStore(t *testing.T) { // q=label:urgent → stubbed ListMessages receives MessageListFilter with - // non-nil QEmit; invoking QEmit(1) yields "(m.labels @> $1)" and + // non-nil Q; invoking Q.Emit(PostgresDialect{}, 1) yields + // "(m.labels @> $1)" and // []interface{}{[]string{"urgent"}} } func TestQComposesWithFlatParams(t *testing.T) { - // q=label:urgent&from=alice → both From="alice" and QEmit set (AND + // q=label:urgent&from=alice → both From="alice" and Q set (AND // composition happens in the store, Task 10 covered the SQL) } ``` @@ -2807,28 +2826,22 @@ In `messagesCursor` (~395): In `handleListMessages`, after the existing filter validation (~line 686, near `normalizeLabelFilter`): ```go - var qEmit func(int) (string, []interface{}) + var qExpr *filterquery.Expr if in.Q != "" { - if len(in.Q) > 500 { + if utf8.RuneCountInString(in.Q) > 500 { return nil, NewError(http.StatusBadRequest, "invalid_filter", "q filter too long (max 500 chars)") } expr, err := filterquery.Parse(in.Q, identity.MessagesQRegistry()) if err != nil { return nil, NewError(http.StatusBadRequest, "invalid_filter", err.Error()) } - // Preflight: prove the expression emits (defensive — Validate already - // guarantees it) so the store closure can ignore the error. - if _, _, err := expr.Emit(filterquery.PostgresDialect{}, 1); err != nil { - return nil, NewError(http.StatusBadRequest, "invalid_filter", err.Error()) - } - qEmit = func(start int) (string, []interface{}) { - frag, args, _ := expr.Emit(filterquery.PostgresDialect{}, start) - return frag, args - } + qExpr = expr } ``` -Pass `QEmit: qEmit` in the `identity.MessageListFilter{…}` literal (~737), include `Q: in.Q` when encoding the cursor, and add to the cursor-mismatch check (~723) alongside the existing comparisons: +Pass `Q: qExpr` in the `identity.MessageListFilter{…}` literal (~737), include +the raw query as `Q: in.Q` when encoding the cursor, and add it to the +cursor-mismatch check (~723) alongside the existing comparisons: ```go cur.Q != in.Q || diff --git a/internal/httpapi/messages.go b/internal/httpapi/messages.go index b643d7be..8b7179aa 100644 --- a/internal/httpapi/messages.go +++ b/internal/httpapi/messages.go @@ -13,6 +13,7 @@ import ( "github.com/tokencanopy/e2a/internal/agent" "github.com/tokencanopy/e2a/internal/emailauth" "github.com/tokencanopy/e2a/internal/eventpayload" + "github.com/tokencanopy/e2a/internal/filterquery" "github.com/tokencanopy/e2a/internal/identity" "github.com/tokencanopy/e2a/internal/mailparse" ) @@ -366,6 +367,7 @@ type ListMessagesInput struct { SubjectContains string `query:"subject_contains" doc:"Case-insensitive substring match on subject."` ConversationID string `query:"conversation_id"` Labels []string `query:"labels" doc:"Comma-separated list (e.g. labels=urgent,follow-up); AND-matched — a message must carry every given label."` + Q string `query:"q" doc:"Boolean filter expression (AIP-160-derived). v1 fields: label, from, subject, has, created. Operators: : = != < <= > >= with AND / OR / NOT and parentheses; whitespace is implicit AND and binds looser than OR (e.g. 'label:urgent OR (from:alerts AND NOT has:attachment) created>=2026-07-01'). Composes with (ANDs) the flat filters. Unknown fields/operators are rejected with a positioned invalid_filter error. Max 500 chars."` Since string `query:"since" doc:"RFC3339; created_at >= since."` Until string `query:"until" doc:"RFC3339; created_at < until."` Cursor string `query:"cursor"` @@ -393,6 +395,7 @@ type messagesCursor struct { Since string `json:"sn,omitempty"` Until string `json:"un,omitempty"` Labels []string `json:"lb,omitempty"` + Q string `json:"q,omitempty"` Deleted bool `json:"dl,omitempty"` } @@ -688,6 +691,21 @@ func (s *Server) handleListMessages(ctx context.Context, in *ListMessagesInput) return nil, NewError(http.StatusBadRequest, "invalid_filter", err.Error()) } + var qExpr *filterquery.Expr + if in.Q != "" { + if !utf8.ValidString(in.Q) || strings.IndexByte(in.Q, 0) >= 0 { + return nil, NewError(http.StatusBadRequest, "invalid_filter", "q filter must be valid UTF-8 and must not contain NUL") + } + if utf8.RuneCountInString(in.Q) > 500 { + return nil, NewError(http.StatusBadRequest, "invalid_filter", "q filter too long (max 500 chars)") + } + expr, err := filterquery.Parse(in.Q, identity.MessagesQRegistry()) + if err != nil { + return nil, NewError(http.StatusBadRequest, "invalid_filter", err.Error()) + } + qExpr = expr + } + // Time range. since, err := parseRFC3339Filter(in.Since, "since") if err != nil { @@ -719,6 +737,7 @@ func (s *Server) handleListMessages(ctx context.Context, in *ListMessagesInput) cur.From != in.From || cur.SubjectContains != in.SubjectContains || cur.ConversationID != in.ConversationID || cur.Since != rfc3339OrEmpty(since) || cur.Until != rfc3339OrEmpty(until) || + cur.Q != in.Q || cur.Deleted != in.Deleted || !stringSlicesEqual(cur.Labels, labelsFilter) { return nil, NewError(http.StatusBadRequest, "invalid_cursor", @@ -748,6 +767,7 @@ func (s *Server) handleListMessages(ctx context.Context, in *ListMessagesInput) Since: since, Until: until, Labels: labelsFilter, + Q: qExpr, Deleted: in.Deleted, }) if err != nil { @@ -771,6 +791,7 @@ func (s *Server) handleListMessages(ctx context.Context, in *ListMessagesInput) Status: status, Direction: direction, AgentID: ag.ID, Sort: sort, From: in.From, SubjectContains: in.SubjectContains, ConversationID: in.ConversationID, Since: rfc3339OrEmpty(since), Until: rfc3339OrEmpty(until), Labels: labelsFilter, + Q: in.Q, Deleted: in.Deleted, }) if err != nil { diff --git a/internal/httpapi/messages_q_test.go b/internal/httpapi/messages_q_test.go new file mode 100644 index 00000000..3995ebbe --- /dev/null +++ b/internal/httpapi/messages_q_test.go @@ -0,0 +1,194 @@ +package httpapi + +import ( + "context" + "net/url" + "reflect" + "strings" + "sync" + "testing" + "unicode/utf8" + + "github.com/tokencanopy/e2a/internal/filterquery" + "github.com/tokencanopy/e2a/internal/identity" +) + +func messagesQURL(srvURL, q string) string { + values := url.Values{} + values.Set("direction", "inbound") + values.Set("read_status", "all") + values.Set("q", q) + return srvURL + "/v1/agents/support%40acme.com/messages?" + values.Encode() +} + +func filterError(t *testing.T, body map[string]any) map[string]any { + t.Helper() + errBody, ok := body["error"].(map[string]any) + if !ok { + t.Fatalf("expected error envelope, got %v", body) + } + if errBody["code"] != "invalid_filter" { + t.Fatalf("error code = %v, want invalid_filter (body %v)", errBody["code"], body) + } + return errBody +} + +func TestQParamInvalid(t *testing.T) { + valid500Unicode := "subject:" + strings.Repeat("界", 60) + valid500Unicode += strings.Repeat(" ", 500-utf8.RuneCountInString(valid500Unicode)) + if got := utf8.RuneCountInString(valid500Unicode); got != 500 { + t.Fatalf("valid Unicode query length = %d, want 500", got) + } + if len(strings.Repeat("界", 60)) >= 200 { + t.Fatal("test query subject value must remain under the 200-byte field cap") + } + + tests := []struct { + name string + q string + wantInError string + wantOK bool + }{ + {name: "unknown field", q: "unknown:thing", wantInError: `unknown field "unknown"`}, + {name: "forbidden operator", q: "label=urgent", wantInError: `operator "=" is not allowed on field "label"`}, + {name: "syntax retains column", q: "label:", wantInError: "(at column 7)"}, + {name: "ASCII over code point limit", q: "label:" + strings.Repeat("a", 501), wantInError: "q filter too long (max 500 chars)"}, + {name: "Unicode over code point limit", q: strings.Repeat("界", 501), wantInError: "q filter too long (max 500 chars)"}, + {name: "NUL is invalid", q: "subject:\x00", wantInError: "q filter must be valid UTF-8 and must not contain NUL"}, + {name: "malformed UTF-8 is invalid", q: string([]byte("subject:\xff")), wantInError: "q filter must be valid UTF-8 and must not contain NUL"}, + {name: "Unicode code point limit accepts bytes over cap", q: valid500Unicode, wantOK: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + srv := testServer(t) + code, body := getJSON(t, messagesQURL(srv.URL, tc.q), "good") + if tc.wantOK { + if code != 200 { + t.Fatalf("status = %d, want 200 (body %v)", code, body) + } + return + } + if code != 400 { + t.Fatalf("status = %d, want 400 (body %v)", code, body) + } + errBody := filterError(t, body) + message, _ := errBody["message"].(string) + if !strings.Contains(message, tc.wantInError) { + t.Errorf("error message = %q, want it to contain %q", message, tc.wantInError) + } + }) + } +} + +func TestQParamCursorPinning(t *testing.T) { + srv := testServer(t) + page1URL := messagesQURL(srv.URL, "label:urgent") + "&limit=1" + code, body := getJSON(t, page1URL, "good") + if code != 200 { + t.Fatalf("page 1 status = %d, want 200 (body %v)", code, body) + } + cursor, ok := body["next_cursor"].(string) + if !ok || cursor == "" { + t.Fatalf("page 1 next_cursor = %v, want non-empty string", body["next_cursor"]) + } + + continuation := func(q string) (int, map[string]any) { + return getJSON(t, messagesQURL(srv.URL, q)+"&limit=1&cursor="+url.QueryEscape(cursor), "good") + } + if code, body := continuation("label:urgent"); code != 200 { + t.Fatalf("identical q continuation status = %d, want 200 (body %v)", code, body) + } + for _, q := range []string{"label:other", ""} { + code, body := continuation(q) + if code != 400 { + t.Fatalf("continuation q=%q status = %d, want 400 (body %v)", q, code, body) + } + if errBody, _ := body["error"].(map[string]any); errBody["code"] != "invalid_cursor" { + t.Fatalf("continuation q=%q error = %v, want invalid_cursor", q, body) + } + } + + // A cursor created without q must also reject adding q on page two. + code, body = getJSON(t, srv.URL+"/v1/agents/support%40acme.com/messages?direction=inbound&read_status=all&limit=1", "good") + if code != 200 { + t.Fatalf("no-q page 1 status = %d, want 200 (body %v)", code, body) + } + noQCursor := body["next_cursor"].(string) + code, body = getJSON(t, messagesQURL(srv.URL, "label:urgent")+"&limit=1&cursor="+url.QueryEscape(noQCursor), "good") + if code != 400 { + t.Fatalf("adding q continuation status = %d, want 400 (body %v)", code, body) + } + if errBody, _ := body["error"].(map[string]any); errBody["code"] != "invalid_cursor" { + t.Fatalf("adding q continuation error = %v, want invalid_cursor", body) + } +} + +func TestQParamReachesStore(t *testing.T) { + var captured struct { + sync.Mutex + filter identity.MessageListFilter + } + srv := testServer(t, func(deps *Deps) { + deps.ListMessages = func(_ context.Context, f identity.MessageListFilter) ([]identity.Message, error) { + captured.Lock() + captured.filter = f + captured.Unlock() + return nil, nil + } + }) + + code, body := getJSON(t, messagesQURL(srv.URL, "label:urgent"), "good") + if code != 200 { + t.Fatalf("status = %d, want 200 (body %v)", code, body) + } + captured.Lock() + filter := captured.filter + captured.Unlock() + if filter.Q == nil { + t.Fatal("store filter Q = nil, want parsed expression") + } + fragment, args, err := filter.Q.Emit(filterquery.PostgresDialect{}, 1) + if err != nil { + t.Fatalf("Q.Emit: %v", err) + } + if fragment != "(m.labels @> $1)" { + t.Errorf("Q SQL = %q, want %q", fragment, "(m.labels @> $1)") + } + if want := []any{[]string{"urgent"}}; !reflect.DeepEqual(args, want) { + t.Errorf("Q args = %#v, want %#v", args, want) + } +} + +func TestQComposesWithFlatParams(t *testing.T) { + var captured struct { + sync.Mutex + filter identity.MessageListFilter + } + srv := testServer(t, func(deps *Deps) { + deps.ListMessages = func(_ context.Context, f identity.MessageListFilter) ([]identity.Message, error) { + captured.Lock() + captured.filter = f + captured.Unlock() + return nil, nil + } + }) + values := url.Values{} + values.Set("direction", "inbound") + values.Set("read_status", "all") + values.Set("from", "alice") + values.Set("q", "label:urgent") + code, body := getJSON(t, srv.URL+"/v1/agents/support%40acme.com/messages?"+values.Encode(), "good") + if code != 200 { + t.Fatalf("status = %d, want 200 (body %v)", code, body) + } + captured.Lock() + filter := captured.filter + captured.Unlock() + if filter.From != "alice" { + t.Errorf("store filter From = %q, want alice", filter.From) + } + if filter.Q == nil { + t.Fatal("store filter Q = nil, want parsed expression alongside From") + } +} From a04a6093a0973a2171828d9ff9df3c6cbf6bfcdf Mon Sep 17 00:00:00 2001 From: jiashuoz Date: Sun, 26 Jul 2026 12:15:47 -0700 Subject: [PATCH 04/12] fix(filterquery): defer attachment filtering --- .../plans/2026-07-25-filter-query-language.md | 65 ++++++------------- ...2026-07-25-filter-query-language-design.md | 28 +++++--- internal/filterquery/lexer_test.go | 2 +- internal/filterquery/parser_test.go | 2 +- internal/httpapi/messages.go | 2 +- internal/httpapi/messages_q_test.go | 1 + internal/identity/filter_differential_test.go | 22 +------ internal/identity/filter_registry.go | 17 ----- internal/identity/filter_registry_test.go | 12 ++-- 9 files changed, 49 insertions(+), 102 deletions(-) diff --git a/docs/superpowers/plans/2026-07-25-filter-query-language.md b/docs/superpowers/plans/2026-07-25-filter-query-language.md index 0da1a495..811891ac 100644 --- a/docs/superpowers/plans/2026-07-25-filter-query-language.md +++ b/docs/superpowers/plans/2026-07-25-filter-query-language.md @@ -25,6 +25,7 @@ - Hard caps: input ≤ 500 chars (handler), nesting depth ≤ 64, nodes ≤ 512. Violations return positioned errors, never panics. - Placeholders are always 1-based `$n`, numbered from a caller-supplied start index; identifiers come only from registry constants; user values are always bound args. - DSL semantics MUST match flat params: `from:` = `m.sender ILIKE … ESCAPE '\'` (same as flat `from`), `subject:` = `m.subject ILIKE … ESCAPE '\'` (same as flat `subject_contains`), both via the existing `escapeLikePattern` helper; `*` in values maps to `%` (after escaping literals). +- Attachment filtering is deferred from v1. Inbound attachments are canonical in raw MIME while outbound attachments use `attachments_json`; an exact `has:attachment` needs a normalized count introduced by a blue/green-safe expand/backfill/contract rollout (nullable field + all writers, exact resumable Go backfill, then query exposure after old writers are gone). - `label:` values must match `^[a-z0-9:_-]{1,64}$`; `e2a:` system labels are filterable (read-only on writes, unchanged). - DB-backed tests need Postgres: `make docker-up` (pg at localhost:5433), then `go test ./internal/identity/ -run `. - Commit style: `feat(filterquery): …` / `feat(api): …` with trailer `Co-Authored-By: Kimi `. Commit at every task boundary. @@ -53,7 +54,7 @@ package filterquery import "testing" func TestLexBasics(t *testing.T) { - toks, err := lex(`label:urgent OR (from:"alice@x.com" AND NOT has:attachment)`) + toks, err := lex(`label:urgent OR (from:"alice@x.com" AND NOT subject:newsletter)`) if err != nil { t.Fatalf("lex: %v", err) } @@ -443,7 +444,7 @@ func TestParsePrecedence(t *testing.T) { `-a:x`: `(not (a : x))`, `a:x AND (b:y OR c:z)`: `(and (a : x) (or (b : y) (c : z)))`, `(a:x OR b:y) AND NOT c:z`: `(and (or (a : x) (b : y)) (not (c : z)))`, - `label:urgent OR (from:alerts AND NOT has:attachment) created>=2026-07-01`: `(and (or (label : urgent) (and (from : alerts) (not (has : attachment)))) (created >= 2026-07-01))`, + `label:urgent OR (from:alerts AND NOT subject:newsletter) created>=2026-07-01`: `(and (or (label : urgent) (and (from : alerts) (not (subject : newsletter)))) (created >= 2026-07-01))`, } for q, want := range cases { if got := parseToString(t, q); got != want { @@ -2146,13 +2147,13 @@ func TestSubjectField(t *testing.T) { } } -func TestHasAttachment(t *testing.T) { - frag, args := compileQ(t, `has:attachment`, 1) - if frag != `(COALESCE(jsonb_array_length(m.attachments_json), 0) > 0)` || len(args) != 0 { - t.Errorf("frag=%s args=%v", frag, args) +func TestHasAttachmentIsDeferred(t *testing.T) { + _, _, err := filterquery.Compile(`has:attachment`, MessagesQRegistry(), filterquery.PostgresDialect{}, 1) + if err == nil { + t.Fatal("has:attachment: want unknown-field rejection") } - if _, _, err := filterquery.Compile(`has:body`, MessagesQRegistry(), filterquery.PostgresDialect{}, 1); err == nil { - t.Error("has:body: want rejection") + if got, want := err.Error(), `unknown field "has" — supported fields: created, from, label, subject`; !strings.Contains(got, want) { + t.Errorf("error = %q, want substring %q", got, want) } } @@ -2227,7 +2228,6 @@ func MessagesQRegistry() *filterquery.Registry { labelQField(), fromQField(), subjectQField(), - hasQField(), createdQField(), ) if err != nil { @@ -2293,22 +2293,6 @@ func textQField(name, column string, maxLen int) filterquery.FieldSpec { func fromQField() filterquery.FieldSpec { return textQField("from", "m.sender", 200) } func subjectQField() filterquery.FieldSpec { return textQField("subject", "m.subject", 200) } -func hasQField() filterquery.FieldSpec { - return filterquery.FieldSpec{ - Name: "has", - Ops: []string{":"}, - Coerce: func(raw string, quoted bool) (any, error) { - if raw != "attachment" { - return nil, fmt.Errorf("unsupported has: value %q — v1 supports has:attachment", raw) - } - return raw, nil - }, - Emit: func(c *filterquery.Comparison, e *filterquery.EmitCtx) (string, error) { - return "COALESCE(jsonb_array_length(m.attachments_json), 0) > 0", nil - }, - } -} - // createdValue carries date-coercion semantics: a date-only input (dayRange) // makes "=" mean "that UTC day", not "that exact midnight second". type createdValue struct { @@ -2396,12 +2380,10 @@ sections below):** - Replace the AST-sharing "naive evaluator" below with an independent fixture oracle: each query declares expected fixture keys/IDs explicitly. Cover precedence, NOT, labels, case-insensitive exact/substring text matching, - escaped `%`/`_`/`\`, `*` wildcard behavior, attachments, Unicode, and all + escaped `%`/`_`/`\`, `*` wildcard behavior, Unicode, and all date-only boundaries. Include fixtures immediately before the day, exactly at its first midnight, exactly at the following midnight, and after it so `<`, `<=`, `=`, `!=`, `>`, and `>=` cannot share a mistaken boundary. -- Build attachment fixtures with `encoding/json` (or a valid fixed JSON - array); never use the invalid `fmt.Sprintf(...[:{}])` example below. - The old Step 2 evaluator and Step 3 `Expr.Root()` snippets are retained only as historical illustration and must not be implemented. @@ -2461,7 +2443,6 @@ type fixtureMsg struct { subject string labels []string // nil = NULL labels column created time.Time - attachments int } func seedQFixtures(t *testing.T, store *Store, agentID string) []fixtureMsg { @@ -2470,7 +2451,7 @@ func seedQFixtures(t *testing.T, store *Store, agentID string) []fixtureMsg { day := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC) fixtures := []fixtureMsg{ {sender: "alice@corp.com", subject: "Quarterly report", labels: []string{"urgent", "q3"}, created: day.Add(1 * time.Hour)}, - {sender: "bob@alerts.io", subject: "CPU alert", labels: []string{"alerts"}, created: day.Add(2 * time.Hour), attachments: 2}, + {sender: "bob@alerts.io", subject: "CPU alert", labels: []string{"alerts"}, created: day.Add(2 * time.Hour)}, {sender: "carol@news.net", subject: "Weekly digest", labels: []string{"newsletter"}, created: day.Add(3 * time.Hour)}, {sender: "ALICE@corp.com", subject: "Follow-up", labels: []string{"follow-up"}, created: day.Add(4 * time.Hour)}, {sender: "dave@x.com", subject: "", labels: nil, created: day.Add(5 * time.Hour)}, @@ -2491,12 +2472,6 @@ func seedQFixtures(t *testing.T, store *Store, agentID string) []fixtureMsg { t.Fatalf("labels %d: %v", i, err) } } - if fx.attachments > 0 { - att := fmt.Sprintf(`[{"filename":"a.pdf","content_type":"application/pdf","index":0,"size_bytes":10},{"filename":"b.pdf","content_type":"application/pdf","index":1,"size_bytes":10}][:{}]`, fx.attachments) - if _, err := store.pool.Exec(ctx, `UPDATE messages SET attachments_json = $1::jsonb WHERE id = $2`, att, m.ID); err != nil { - t.Fatalf("attachments %d: %v", i, err) - } - } if _, err := store.pool.Exec(ctx, `UPDATE messages SET created_at = $1 WHERE id = $2`, fx.created, m.ID); err != nil { t.Fatalf("created %d: %v", i, err) } @@ -2598,8 +2573,6 @@ func evalComparison(c *filterquery.Comparison, r fixtureMsg) bool { default: return !strings.EqualFold(r.subject, v) } - case "has": - return r.attachments > 0 case "created": cv := c.Value.(createdValue) at := cv.at @@ -2647,7 +2620,6 @@ func TestQDifferential(t *testing.T) { `label:urgent OR label:alerts`, `label:urgent AND NOT label:newsletter`, `NOT label:urgent`, - `(label:urgent OR label:follow-up) AND NOT has:attachment`, `from:alice`, `from:ALICE`, `from:*@corp.com`, @@ -2658,11 +2630,10 @@ func TestQDifferential(t *testing.T) { `subject:_now_`, // literal underscores `subject:"a*b literal"`, `subject:こんにちは`, - `has:attachment`, `created = "2026-07-01"`, // day range: all but the next-day row `created>=2026-07-02`, `created<2026-07-01T05:00:00Z`, - `label:urgent OR (from:alerts AND NOT has:attachment) created>=2026-07-01`, + `label:urgent OR (from:alerts AND NOT subject:newsletter) created>=2026-07-01`, } for _, q := range queries { expr, err := filterquery.Parse(q, MessagesQRegistry()) @@ -2766,9 +2737,10 @@ Co-Authored-By: Kimi " rejection as `invalid_cursor`, and adding/removing `q` across pages. - Exercise the real HTTP/Huma stack. Invalid syntax, unknown fields, forbidden operators, and length caps return 400 `invalid_filter`; positioned parser - errors retain their `(at column N)` text. Captured store filters must prove - `Q` is non-nil and emits the expected SQL/args, while flat filters remain set - for AND composition. + errors retain their `(at column N)` text. `has:attachment` must be covered as + an unknown field whose diagnostic lists only `created, from, label, subject`. + Captured store filters must prove `Q` is non-nil and emits the expected + SQL/args, while flat filters remain set for AND composition. - [ ] **Step 1: Write the failing handler tests** @@ -2780,6 +2752,7 @@ package httpapi func TestQParamInvalid(t *testing.T) { // q with an unknown field → 400 invalid_filter naming the field + // q=has:attachment → 400 and supported fields are exactly created/from/label/subject // q longer than 500 chars → 400 invalid_filter // q with a parse error → 400 with "(at column N)" } @@ -2814,7 +2787,7 @@ Expected: FAIL — `q` not a known query param / QEmit never set. In `ListMessagesInput` (messages.go ~368, after `Labels`): ```go - Q string `query:"q" doc:"Boolean filter expression (AIP-160-derived). v1 fields: label, from, subject, has, created. Operators: : = != < <= > >= with AND / OR / NOT and parentheses; whitespace is implicit AND and binds looser than OR (e.g. 'label:urgent OR (from:alerts AND NOT has:attachment) created>=2026-07-01'). Composes with (ANDs) the flat filters. Unknown fields/operators are rejected with a positioned invalid_filter error. Max 500 chars."` + Q string `query:"q" doc:"Boolean filter expression (AIP-160-derived). v1 fields: label, from, subject, created. Operators: : = != < <= > >= with AND / OR / NOT and parentheses; whitespace is implicit AND and binds looser than OR (e.g. 'label:urgent OR (from:alerts AND NOT subject:newsletter) created>=2026-07-01'). Composes with (ANDs) the flat filters. Unknown fields/operators are rejected with a positioned invalid_filter error. Max 500 chars."` ``` In `messagesCursor` (~395): @@ -2912,7 +2885,7 @@ In the `list_messages` `inputSchema` (after `labels`, ~line 435): .max(500) .optional() .describe( - "Boolean filter expression (AIP-160-derived). v1 fields: label, from, subject, has, created. Operators : = != < <= > >= with AND/OR/NOT and parentheses; whitespace is implicit AND (binds looser than OR). Example: 'label:urgent OR (from:alerts AND NOT has:attachment) created>=2026-07-01'. Composes (AND) with the flat filters (labels, from_, subject_contains, since, until). Invalid expressions are rejected with a positioned invalid_filter error.", + "Boolean filter expression (AIP-160-derived). v1 fields: label, from, subject, created. Operators : = != < <= > >= with AND/OR/NOT and parentheses; whitespace is implicit AND (binds looser than OR). Example: 'label:urgent OR (from:alerts AND NOT subject:newsletter) created>=2026-07-01'. Composes (AND) with the flat filters (labels, from_, subject_contains, since, until). Invalid expressions are rejected with a positioned invalid_filter error.", ), ``` diff --git a/docs/superpowers/specs/2026-07-25-filter-query-language-design.md b/docs/superpowers/specs/2026-07-25-filter-query-language-design.md index 4871009a..8b1d6ca8 100644 --- a/docs/superpowers/specs/2026-07-25-filter-query-language-design.md +++ b/docs/superpowers/specs/2026-07-25-filter-query-language-design.md @@ -33,7 +33,7 @@ expression, backed by a small, dependency-free, schema-agnostic Go package Postgres SQL. **Non-goals (v1):** full-text/body search, `to`/`cc` fields, attachment -name/size filters, bare-term (unqualified) search, custom functions +presence/name/size filters, bare-term (unqualified) search, custom functions (`hasAttachment()`), SDK-side parsing or validation, Spanner/MySQL dialects (the `Dialect` interface admits them later), extraction as a standalone OSS library (the design only keeps that door open). @@ -88,8 +88,8 @@ Five files, five stages: identifier quoting, case-insensitive-match operator (ILIKE vs LOWER()). - e2a's registry lives in `internal/identity` (`messagesFieldRegistry()`): maps `label` → `m.labels` ops, `from` → `m.sender` (matching the existing - flat `from` filter), - `created` → `m.created_at`, `has:attachment` → attachment JSONB length. + flat `from` filter), `subject` → `m.subject`, and `created` → + `m.created_at`. - CI guard: a tiny test asserting `go list -deps ./internal/filterquery` contains no `internal/identity` (or any non-stdlib) import. @@ -100,25 +100,33 @@ Five files, five stages: | `label` | `:` | `label:urgent` = message carries label. Exclusion via `NOT label:x`. Value must pass the existing label charset rule (`[a-z0-9:_-]+`, ≤64). | | `from` | `:` `=` `!=` | `:` = case-insensitive substring (ILIKE) on `m.sender` — identical to the flat `from` filter. `=`/`!=` = case-insensitive exact. | | `subject` | `:` `=` `!=` | Same pattern on `subject`. NULL subjects never match `:`/`=` (SQL NULL semantics; document it). | -| `has` | `:` | Value `attachment` only: `has:attachment`. | | `created` | `=` `!=` `<` `<=` `>` `>=` | On `created_at`. Values: RFC3339 or `YYYY-MM-DD`. A date-only value denotes that whole UTC day: `=`/`!=` test membership in that day's `[midnight, next-midnight)` range, `<=` includes the whole day, and `>` begins at the next midnight. Full RFC3339 values are exact. | Everything else (`to:`, `body:`, bare text, …) parses but fails validation: -`unknown field 'to' — v1 supports: label, from, subject, has, created`. +`unknown field 'to' — supported fields: created, from, label, subject`. + +Attachment filtering is deliberately deferred. Inbound attachments are +canonical in the raw MIME message while outbound attachments use +`attachments_json`, so the existing JSON column cannot implement +`has:attachment` correctly. Shipping it requires a normalized attachment-count +field introduced with a blue/green-safe expand/backfill/contract rollout: +first make every live writer populate a nullable count and run an exact, +resumable Go backfill with `mailparse.Attachments`; only after old writers are +gone and the backfill is complete may the query field rely on that count. `*` inside quoted strings maps to ILIKE `%` for `from:`/`subject:`; literal user `%`, `_`, `\` are escaped and the predicate carries `ESCAPE '\'`. ## Emission pipeline (worked example) -Input: `label:urgent OR (from:alerts AND NOT has:attachment) created>=2026-07-01` +Input: `label:urgent OR (from:alerts AND NOT subject:newsletter) created>=2026-07-01` AST (precedence: NOT > OR > implicit AND > explicit AND): ``` And( Or( Has{label,"urgent"}, - And( Has{from,"alerts"}, Not(Has{has,"attachment"}) ) ), + And( Has{from,"alerts"}, Not(Has{subject,"newsletter"}) ) ), GreaterEq{created,2026-07-01T00:00:00Z} ) ``` @@ -127,9 +135,9 @@ Emitted (store already bound `$1`=agent_id, `$2..$3` flat filters): ```sql AND ( ( (m.labels @> $4) OR ( (m.sender ILIKE $5 ESCAPE '\') - AND NOT (COALESCE(jsonb_array_length(m.attachments_json),0) > 0) ) ) - AND (m.created_at >= $6) ) --- args += []string{"urgent"}, "%alerts%", 2026-07-01T00:00:00Z + AND NOT (m.subject ILIKE $6 ESCAPE '\') ) ) + AND (m.created_at >= $7) ) +-- args += []string{"urgent"}, "%alerts%", "%newsletter%", 2026-07-01T00:00:00Z ``` Rules that make this safe and correct: diff --git a/internal/filterquery/lexer_test.go b/internal/filterquery/lexer_test.go index 5b08755c..3d408263 100644 --- a/internal/filterquery/lexer_test.go +++ b/internal/filterquery/lexer_test.go @@ -3,7 +3,7 @@ package filterquery import "testing" func TestLexBasics(t *testing.T) { - toks, err := lex(`label:urgent OR (from:"alice@x.com" AND NOT has:attachment)`) + toks, err := lex(`label:urgent OR (from:"alice@x.com" AND NOT subject:newsletter)`) if err != nil { t.Fatalf("lex: %v", err) } diff --git a/internal/filterquery/parser_test.go b/internal/filterquery/parser_test.go index d82350ab..4cd5375e 100644 --- a/internal/filterquery/parser_test.go +++ b/internal/filterquery/parser_test.go @@ -27,7 +27,7 @@ func TestParsePrecedence(t *testing.T) { `-a:x`: `(not (a : x))`, `a:x AND (b:y OR c:z)`: `(and (a : x) (or (b : y) (c : z)))`, `(a:x OR b:y) AND NOT c:z`: `(and (or (a : x) (b : y)) (not (c : z)))`, - `label:urgent OR (from:alerts AND NOT has:attachment) created>=2026-07-01`: `(and (or (label : urgent) (and (from : alerts) (not (has : attachment)))) (created >= 2026-07-01))`, + `label:urgent OR (from:alerts AND NOT subject:newsletter) created>=2026-07-01`: `(and (or (label : urgent) (and (from : alerts) (not (subject : newsletter)))) (created >= 2026-07-01))`, } for q, want := range cases { if got := parseToString(t, q); got != want { diff --git a/internal/httpapi/messages.go b/internal/httpapi/messages.go index 8b7179aa..de271bcb 100644 --- a/internal/httpapi/messages.go +++ b/internal/httpapi/messages.go @@ -367,7 +367,7 @@ type ListMessagesInput struct { SubjectContains string `query:"subject_contains" doc:"Case-insensitive substring match on subject."` ConversationID string `query:"conversation_id"` Labels []string `query:"labels" doc:"Comma-separated list (e.g. labels=urgent,follow-up); AND-matched — a message must carry every given label."` - Q string `query:"q" doc:"Boolean filter expression (AIP-160-derived). v1 fields: label, from, subject, has, created. Operators: : = != < <= > >= with AND / OR / NOT and parentheses; whitespace is implicit AND and binds looser than OR (e.g. 'label:urgent OR (from:alerts AND NOT has:attachment) created>=2026-07-01'). Composes with (ANDs) the flat filters. Unknown fields/operators are rejected with a positioned invalid_filter error. Max 500 chars."` + Q string `query:"q" doc:"Boolean filter expression (AIP-160-derived). v1 fields: label, from, subject, created. Operators: : = != < <= > >= with AND / OR / NOT and parentheses; whitespace is implicit AND and binds looser than OR (e.g. 'label:urgent OR (from:alerts AND NOT subject:newsletter) created>=2026-07-01'). Composes with (ANDs) the flat filters. Unknown fields/operators are rejected with a positioned invalid_filter error. Max 500 chars."` Since string `query:"since" doc:"RFC3339; created_at >= since."` Until string `query:"until" doc:"RFC3339; created_at < until."` Cursor string `query:"cursor"` diff --git a/internal/httpapi/messages_q_test.go b/internal/httpapi/messages_q_test.go index 3995ebbe..a4db6f44 100644 --- a/internal/httpapi/messages_q_test.go +++ b/internal/httpapi/messages_q_test.go @@ -50,6 +50,7 @@ func TestQParamInvalid(t *testing.T) { wantOK bool }{ {name: "unknown field", q: "unknown:thing", wantInError: `unknown field "unknown"`}, + {name: "attachment filtering deferred", q: "has:attachment", wantInError: `unknown field "has" — supported fields: created, from, label, subject`}, {name: "forbidden operator", q: "label=urgent", wantInError: `operator "=" is not allowed on field "label"`}, {name: "syntax retains column", q: "label:", wantInError: "(at column 7)"}, {name: "ASCII over code point limit", q: "label:" + strings.Repeat("a", 501), wantInError: "q filter too long (max 500 chars)"}, diff --git a/internal/identity/filter_differential_test.go b/internal/identity/filter_differential_test.go index b007ca7e..60c6f30c 100644 --- a/internal/identity/filter_differential_test.go +++ b/internal/identity/filter_differential_test.go @@ -2,7 +2,6 @@ package identity_test import ( "context" - "encoding/json" "errors" "fmt" "reflect" @@ -23,7 +22,6 @@ type qFixture struct { subject string labels []string created time.Time - attachments int conversation string id string } @@ -56,7 +54,7 @@ func seedQFixtures(t *testing.T, pool *pgxpool.Pool, store *identity.Store, agen {key: "before", sender: "before@example.com", subject: "xnowy", labels: []string{"archive"}, created: day.Add(-time.Nanosecond)}, {key: "start", sender: "start@example.com", subject: "100 percent", labels: []string{"start"}, created: day}, {key: "alice", sender: "alice@corp.com", subject: "Quarterly report", labels: []string{"urgent", "q3"}, created: day.Add(time.Hour), conversation: "qdiff-conversation"}, - {key: "alert", sender: "bob@alerts.io", subject: "CPU alert", labels: []string{"alerts"}, created: day.Add(2 * time.Hour), attachments: 2}, + {key: "alert", sender: "bob@alerts.io", subject: "CPU alert", labels: []string{"alerts"}, created: day.Add(2 * time.Hour)}, {key: "newsletter", sender: "carol@news.net", subject: "Weekly digest", labels: []string{"newsletter"}, created: day.Add(3 * time.Hour)}, {key: "follow", sender: "ALICE@corp.com", subject: "Follow-up", labels: []string{"follow-up"}, created: day.Add(4 * time.Hour)}, {key: "empty", sender: "dave@x.com", subject: "", labels: []string{}, created: day.Add(5 * time.Hour)}, @@ -77,20 +75,7 @@ func seedQFixtures(t *testing.T, pool *pgxpool.Pool, store *identity.Store, agen } fx.id = message.ID - attachmentsValue := make([]map[string]any, fx.attachments) - for attachment := range attachmentsValue { - attachmentsValue[attachment] = map[string]any{ - "filename": fmt.Sprintf("attachment-%d.pdf", attachment), - "content_type": "application/pdf", - "index": attachment, - "size_bytes": 10, - } - } - attachments, err := json.Marshal(attachmentsValue) - if err != nil { - t.Fatalf("marshal attachments for %s: %v", fx.key, err) - } - if _, err := pool.Exec(ctx, `UPDATE messages SET labels = $1, attachments_json = $2::jsonb, created_at = $3 WHERE id = $4`, fx.labels, attachments, fx.created, fx.id); err != nil { + if _, err := pool.Exec(ctx, `UPDATE messages SET labels = $1, created_at = $2 WHERE id = $3`, fx.labels, fx.created, fx.id); err != nil { t.Fatalf("set fixture %s: %v", fx.key, err) } } @@ -143,8 +128,6 @@ func TestQFilterDifferential(t *testing.T) { }{ {q: `label:urgent`, keys: []string{"alice", "literal", "unicode"}}, {q: `label:urgent OR label:alerts`, keys: []string{"alice", "alert", "literal", "unicode"}}, - {q: `label:urgent OR label:alerts AND has:attachment`, keys: []string{"alert"}}, - {q: `(label:urgent OR label:follow-up) AND NOT has:attachment`, keys: []string{"alice", "follow", "literal", "unicode"}}, {q: `NOT label:urgent`, keys: allButUrgent}, {q: `from:alice`, keys: []string{"alice", "follow"}}, {q: `from:*@corp.com`, keys: []string{"alice", "follow"}}, @@ -155,7 +138,6 @@ func TestQFilterDifferential(t *testing.T) { {q: `subject:"\\path"`, keys: []string{"literal"}}, {q: `subject:"a*b literal"`, keys: []string{"wildcard"}}, {q: `subject:こんにちは`, keys: []string{"unicode"}}, - {q: `has:attachment`, keys: []string{"alert"}}, {q: `created<2026-07-01`, keys: []string{"before"}}, {q: `created<=2026-07-01`, keys: append([]string{"before"}, allDay...)}, {q: `created=2026-07-01`, keys: allDay}, diff --git a/internal/identity/filter_registry.go b/internal/identity/filter_registry.go index b942d807..c47ce0fb 100644 --- a/internal/identity/filter_registry.go +++ b/internal/identity/filter_registry.go @@ -24,7 +24,6 @@ func MessagesQRegistry() *filterquery.Registry { labelQField(), fromQField(), subjectQField(), - hasQField(), createdQField(), ) if err != nil { @@ -89,22 +88,6 @@ func textQField(name, column string, maxLen int) filterquery.FieldSpec { func fromQField() filterquery.FieldSpec { return textQField("from", "m.sender", 200) } func subjectQField() filterquery.FieldSpec { return textQField("subject", "m.subject", 200) } -func hasQField() filterquery.FieldSpec { - return filterquery.FieldSpec{ - Name: "has", - Ops: []string{":"}, - Coerce: func(raw string, quoted bool) (any, error) { - if raw != "attachment" { - return nil, fmt.Errorf("unsupported has: value %q — v1 supports has:attachment", raw) - } - return raw, nil - }, - Emit: func(c *filterquery.Comparison, e *filterquery.EmitCtx) (string, error) { - return "COALESCE(jsonb_array_length(m.attachments_json), 0) > 0", nil - }, - } -} - // createdValue carries date-coercion semantics: a date-only input (dayRange) // makes comparisons cover the entire UTC calendar day. type createdValue struct { diff --git a/internal/identity/filter_registry_test.go b/internal/identity/filter_registry_test.go index 4af5bd06..13e9ee81 100644 --- a/internal/identity/filter_registry_test.go +++ b/internal/identity/filter_registry_test.go @@ -132,15 +132,15 @@ func TestSubjectField(t *testing.T) { } } -func TestHasAttachment(t *testing.T) { +func TestHasAttachmentIsDeferred(t *testing.T) { t.Parallel() - frag, args := compileQ(t, `has:attachment`, 1) - if frag != `(COALESCE(jsonb_array_length(m.attachments_json), 0) > 0)` || len(args) != 0 { - t.Errorf("frag=%s args=%v", frag, args) + _, _, err := filterquery.Compile(`has:attachment`, MessagesQRegistry(), filterquery.PostgresDialect{}, 1) + if err == nil { + t.Fatal("has:attachment: want unknown-field rejection") } - if _, _, err := filterquery.Compile(`has:body`, MessagesQRegistry(), filterquery.PostgresDialect{}, 1); err == nil { - t.Error("has:body: want rejection") + if got, want := err.Error(), `unknown field "has" — supported fields: created, from, label, subject`; !strings.Contains(got, want) { + t.Errorf("error = %q, want substring %q", got, want) } } From 3b3c4a9a7a5c829c32cb213b073b8eee7499ac76 Mon Sep 17 00:00:00 2001 From: jiashuoz Date: Sun, 26 Jul 2026 12:19:55 -0700 Subject: [PATCH 05/12] feat(api): regenerate OpenAPI with q param Co-Authored-By: Kimi --- api/openapi.yaml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/api/openapi.yaml b/api/openapi.yaml index c95f37ba..04ccd722 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -4950,6 +4950,13 @@ paths: type: - array - "null" + - description: "Boolean filter expression (AIP-160-derived). v1 fields: label, from, subject, created. Operators: : = != < <= > >= with AND / OR / NOT and parentheses; whitespace is implicit AND and binds looser than OR (e.g. 'label:urgent OR (from:alerts AND NOT subject:newsletter) created>=2026-07-01'). Composes with (ANDs) the flat filters. Unknown fields/operators are rejected with a positioned invalid_filter error. Max 500 chars." + explode: false + in: query + name: q + schema: + description: "Boolean filter expression (AIP-160-derived). v1 fields: label, from, subject, created. Operators: : = != < <= > >= with AND / OR / NOT and parentheses; whitespace is implicit AND and binds looser than OR (e.g. 'label:urgent OR (from:alerts AND NOT subject:newsletter) created>=2026-07-01'). Composes with (ANDs) the flat filters. Unknown fields/operators are rejected with a positioned invalid_filter error. Max 500 chars." + type: string - description: RFC3339; created_at >= since. explode: false in: query From c91b0fda07369f8e4f8fb55113c1547518152b22 Mon Sep 17 00:00:00 2001 From: jiashuoz Date: Sun, 26 Jul 2026 12:28:31 -0700 Subject: [PATCH 06/12] feat(sdks,cli): q filter param across python/ts SDKs and CLI Co-Authored-By: Kimi --- cli/src/__tests__/args.test.ts | 4 ++++ cli/src/__tests__/messages.test.ts | 21 +++++++++++++++++++ cli/src/bin/e2a.ts | 4 +++- cli/src/commands/messages.ts | 4 +++- sdks/python/src/e2a/v1/client.py | 2 ++ .../src/e2a/v1/generated/api/messages_api.py | 17 +++++++++++++++ sdks/python/tests/test_v1_client.py | 8 +++++++ sdks/typescript/src/v1/client.ts | 3 ++- .../src/v1/generated/apis/MessagesApi.ts | 9 +++++++- .../src/v1/generated/types/ObjectParamAPI.ts | 11 ++++++++-- .../src/v1/generated/types/ObservableAPI.ts | 10 +++++---- .../src/v1/generated/types/PromiseAPI.ts | 10 +++++---- sdks/typescript/test/v1/client.test.ts | 8 +++++++ 13 files changed, 97 insertions(+), 14 deletions(-) diff --git a/cli/src/__tests__/args.test.ts b/cli/src/__tests__/args.test.ts index 1ba730fa..40ac1d48 100644 --- a/cli/src/__tests__/args.test.ts +++ b/cli/src/__tests__/args.test.ts @@ -103,6 +103,10 @@ describe("getFlag", () => { // --to should still work expect(getFlag(args, "--to")).toBe("a@b.com"); }); + + it("extracts a messages list q expression verbatim", () => { + expect(getFlag(["--q", "label:urgent"], "--q")).toBe("label:urgent"); + }); }); describe("getFlags", () => { diff --git a/cli/src/__tests__/messages.test.ts b/cli/src/__tests__/messages.test.ts index 19cedd30..a134443e 100644 --- a/cli/src/__tests__/messages.test.ts +++ b/cli/src/__tests__/messages.test.ts @@ -72,6 +72,27 @@ describe("messages commands", () => { ); }); + it("passes q through verbatim and omits it when unset", async () => { + mockList.mockReturnValue(summaries()); + const { messagesList } = await import("../commands/messages.js"); + + await messagesList({ q: "label:urgent" }); + expect(mockList).toHaveBeenCalledWith("bot@agents.e2a.dev", { + sort: "asc", + readStatus: "all", + q: "label:urgent", + limit: 100, + }); + + mockList.mockClear(); + await messagesList({}); + expect(mockList).toHaveBeenCalledWith("bot@agents.e2a.dev", { + sort: "asc", + readStatus: "all", + limit: 100, + }); + }); + it("sanitizes TSV delimiters out of the sender-controlled From field", async () => { mockList.mockReturnValue( summaries({ diff --git a/cli/src/bin/e2a.ts b/cli/src/bin/e2a.ts index 7b0178ff..4549ac78 100644 --- a/cli/src/bin/e2a.ts +++ b/cli/src/bin/e2a.ts @@ -74,6 +74,7 @@ Usage: --direction inbound|outbound|all --since Messages created AT or after this timestamp (inclusive — dedup by message id when cursoring) + --q Boolean filter expression (see API filtering docs) --conversation Filter to one conversation (alias: --conversation-id) --read-status unread|read|all (default all — safe for poll loops) --limit Stop after n messages @@ -470,13 +471,14 @@ async function main() { if (sub === "list") { checkFlags(rest, [ "--direction", "--since", "--conversation", "--conversation-id", - "--read-status", "--limit", "--agent", "--json", + "--read-status", "--limit", "--agent", "--q", "--json", ]); getPositionals(rest, 0, "usage: e2a messages list [options]"); await messagesList({ agent: getFlagChecked(rest, "--agent"), direction: getFlagChecked(rest, "--direction"), since: getFlagChecked(rest, "--since"), + q: getFlagChecked(rest, "--q"), conversation: getConversationId(rest), readStatus: getFlagChecked(rest, "--read-status"), limit: getFlagChecked(rest, "--limit"), diff --git a/cli/src/commands/messages.ts b/cli/src/commands/messages.ts index 3fb76724..e5901069 100644 --- a/cli/src/commands/messages.ts +++ b/cli/src/commands/messages.ts @@ -6,6 +6,7 @@ export interface MessagesListOptions { agent?: string; direction?: string; since?: string; + q?: string; conversation?: string; limit?: string; readStatus?: string; @@ -26,7 +27,7 @@ export interface MessagesLifecycleOptions { } const LIST_USAGE = - "usage: e2a messages list [--direction inbound|outbound|all] [--since ] [--conversation ] [--read-status unread|read|all] [--limit ] [--agent ] [--json]"; + "usage: e2a messages list [--direction inbound|outbound|all] [--since ] [--q ] [--conversation ] [--read-status unread|read|all] [--limit ] [--agent ] [--json]"; const GET_USAGE = "usage: e2a messages get [--text] [--agent ] [--json]"; const LIFECYCLE_USAGE = "usage: e2a messages lifecycle (beta) [--agent ] [--limit <1-100>] [--cursor ] [--json]"; @@ -61,6 +62,7 @@ export async function messagesList(opts: MessagesListOptions): Promise { params.readStatus = opts.readStatus as (typeof READ_STATUSES)[number]; } if (opts.since) params.since = opts.since; + if (opts.q !== undefined) params.q = opts.q; if (opts.conversation) params.conversationId = opts.conversation; let max: number | undefined; diff --git a/sdks/python/src/e2a/v1/client.py b/sdks/python/src/e2a/v1/client.py index e0cbae32..c8d79cb4 100644 --- a/sdks/python/src/e2a/v1/client.py +++ b/sdks/python/src/e2a/v1/client.py @@ -448,6 +448,7 @@ def list( subject_contains: Optional[str] = None, conversation_id: Optional[str] = None, labels: Optional[List[str]] = None, + q: Optional[str] = None, since: Optional[str] = None, until: Optional[str] = None, limit: Optional[int] = None, @@ -468,6 +469,7 @@ async def fetch(cursor: Optional[str]) -> Page: subject_contains=subject_contains, conversation_id=conversation_id, labels=labels, + q=q, since=since, until=until, cursor=cursor, diff --git a/sdks/python/src/e2a/v1/generated/api/messages_api.py b/sdks/python/src/e2a/v1/generated/api/messages_api.py index 7fdd0fd9..33a62d11 100644 --- a/sdks/python/src/e2a/v1/generated/api/messages_api.py +++ b/sdks/python/src/e2a/v1/generated/api/messages_api.py @@ -1624,6 +1624,7 @@ async def list_messages( subject_contains: Annotated[Optional[StrictStr], Field(description="Case-insensitive substring match on subject.")] = None, conversation_id: Optional[StrictStr] = None, labels: Annotated[Optional[List[StrictStr]], Field(description="Comma-separated list (e.g. labels=urgent,follow-up); AND-matched — a message must carry every given label.")] = None, + q: Annotated[Optional[StrictStr], Field(description="Boolean filter expression (AIP-160-derived). v1 fields: label, from, subject, created. Operators: : = != < <= > >= with AND / OR / NOT and parentheses; whitespace is implicit AND and binds looser than OR (e.g. 'label:urgent OR (from:alerts AND NOT subject:newsletter) created>=2026-07-01'). Composes with (ANDs) the flat filters. Unknown fields/operators are rejected with a positioned invalid_filter error. Max 500 chars.")] = None, since: Annotated[Optional[StrictStr], Field(description="RFC3339; created_at >= since.")] = None, until: Annotated[Optional[StrictStr], Field(description="RFC3339; created_at < until.")] = None, cursor: Optional[StrictStr] = None, @@ -1662,6 +1663,8 @@ async def list_messages( :type conversation_id: str :param labels: Comma-separated list (e.g. labels=urgent,follow-up); AND-matched — a message must carry every given label. :type labels: List[str] + :param q: Boolean filter expression (AIP-160-derived). v1 fields: label, from, subject, created. Operators: : = != < <= > >= with AND / OR / NOT and parentheses; whitespace is implicit AND and binds looser than OR (e.g. 'label:urgent OR (from:alerts AND NOT subject:newsletter) created>=2026-07-01'). Composes with (ANDs) the flat filters. Unknown fields/operators are rejected with a positioned invalid_filter error. Max 500 chars. + :type q: str :param since: RFC3339; created_at >= since. :type since: str :param until: RFC3339; created_at < until. @@ -1703,6 +1706,7 @@ async def list_messages( subject_contains=subject_contains, conversation_id=conversation_id, labels=labels, + q=q, since=since, until=until, cursor=cursor, @@ -1739,6 +1743,7 @@ async def list_messages_with_http_info( subject_contains: Annotated[Optional[StrictStr], Field(description="Case-insensitive substring match on subject.")] = None, conversation_id: Optional[StrictStr] = None, labels: Annotated[Optional[List[StrictStr]], Field(description="Comma-separated list (e.g. labels=urgent,follow-up); AND-matched — a message must carry every given label.")] = None, + q: Annotated[Optional[StrictStr], Field(description="Boolean filter expression (AIP-160-derived). v1 fields: label, from, subject, created. Operators: : = != < <= > >= with AND / OR / NOT and parentheses; whitespace is implicit AND and binds looser than OR (e.g. 'label:urgent OR (from:alerts AND NOT subject:newsletter) created>=2026-07-01'). Composes with (ANDs) the flat filters. Unknown fields/operators are rejected with a positioned invalid_filter error. Max 500 chars.")] = None, since: Annotated[Optional[StrictStr], Field(description="RFC3339; created_at >= since.")] = None, until: Annotated[Optional[StrictStr], Field(description="RFC3339; created_at < until.")] = None, cursor: Optional[StrictStr] = None, @@ -1777,6 +1782,8 @@ async def list_messages_with_http_info( :type conversation_id: str :param labels: Comma-separated list (e.g. labels=urgent,follow-up); AND-matched — a message must carry every given label. :type labels: List[str] + :param q: Boolean filter expression (AIP-160-derived). v1 fields: label, from, subject, created. Operators: : = != < <= > >= with AND / OR / NOT and parentheses; whitespace is implicit AND and binds looser than OR (e.g. 'label:urgent OR (from:alerts AND NOT subject:newsletter) created>=2026-07-01'). Composes with (ANDs) the flat filters. Unknown fields/operators are rejected with a positioned invalid_filter error. Max 500 chars. + :type q: str :param since: RFC3339; created_at >= since. :type since: str :param until: RFC3339; created_at < until. @@ -1818,6 +1825,7 @@ async def list_messages_with_http_info( subject_contains=subject_contains, conversation_id=conversation_id, labels=labels, + q=q, since=since, until=until, cursor=cursor, @@ -1854,6 +1862,7 @@ async def list_messages_without_preload_content( subject_contains: Annotated[Optional[StrictStr], Field(description="Case-insensitive substring match on subject.")] = None, conversation_id: Optional[StrictStr] = None, labels: Annotated[Optional[List[StrictStr]], Field(description="Comma-separated list (e.g. labels=urgent,follow-up); AND-matched — a message must carry every given label.")] = None, + q: Annotated[Optional[StrictStr], Field(description="Boolean filter expression (AIP-160-derived). v1 fields: label, from, subject, created. Operators: : = != < <= > >= with AND / OR / NOT and parentheses; whitespace is implicit AND and binds looser than OR (e.g. 'label:urgent OR (from:alerts AND NOT subject:newsletter) created>=2026-07-01'). Composes with (ANDs) the flat filters. Unknown fields/operators are rejected with a positioned invalid_filter error. Max 500 chars.")] = None, since: Annotated[Optional[StrictStr], Field(description="RFC3339; created_at >= since.")] = None, until: Annotated[Optional[StrictStr], Field(description="RFC3339; created_at < until.")] = None, cursor: Optional[StrictStr] = None, @@ -1892,6 +1901,8 @@ async def list_messages_without_preload_content( :type conversation_id: str :param labels: Comma-separated list (e.g. labels=urgent,follow-up); AND-matched — a message must carry every given label. :type labels: List[str] + :param q: Boolean filter expression (AIP-160-derived). v1 fields: label, from, subject, created. Operators: : = != < <= > >= with AND / OR / NOT and parentheses; whitespace is implicit AND and binds looser than OR (e.g. 'label:urgent OR (from:alerts AND NOT subject:newsletter) created>=2026-07-01'). Composes with (ANDs) the flat filters. Unknown fields/operators are rejected with a positioned invalid_filter error. Max 500 chars. + :type q: str :param since: RFC3339; created_at >= since. :type since: str :param until: RFC3339; created_at < until. @@ -1933,6 +1944,7 @@ async def list_messages_without_preload_content( subject_contains=subject_contains, conversation_id=conversation_id, labels=labels, + q=q, since=since, until=until, cursor=cursor, @@ -1964,6 +1976,7 @@ def _list_messages_serialize( subject_contains, conversation_id, labels, + q, since, until, cursor, @@ -2022,6 +2035,10 @@ def _list_messages_serialize( _query_params.append(('labels', labels)) + if q is not None: + + _query_params.append(('q', q)) + if since is not None: _query_params.append(('since', since)) diff --git a/sdks/python/tests/test_v1_client.py b/sdks/python/tests/test_v1_client.py index 4dbd2c34..daecd5e2 100644 --- a/sdks/python/tests/test_v1_client.py +++ b/sdks/python/tests/test_v1_client.py @@ -770,6 +770,14 @@ async def test_messages_list_threads_cursor(httpx_mock): assert "cursor=cur_2" in str(reqs[1].url) +@pytest.mark.anyio +async def test_messages_list_passes_q_verbatim(httpx_mock): + httpx_mock.add_response(json={"items": [], "next_cursor": None}) + async with _client() as c: + await c.messages.list("bot@test.dev", q="label:urgent").to_list(limit=10) + assert httpx_mock.get_requests()[-1].url.params["q"] == "label:urgent" + + @pytest.mark.anyio async def test_messages_get_lifecycle_uses_read_path_and_parses_contract(httpx_mock): httpx_mock.add_response( diff --git a/sdks/typescript/src/v1/client.ts b/sdks/typescript/src/v1/client.ts index 2b90494f..bb18cdaa 100644 --- a/sdks/typescript/src/v1/client.ts +++ b/sdks/typescript/src/v1/client.ts @@ -340,6 +340,7 @@ export interface ListMessagesParams { subjectContains?: string; conversationId?: string; labels?: string[]; + q?: string; since?: string; until?: string; limit?: number; @@ -354,7 +355,7 @@ class MessagesResource { return new AutoPager(async (cursor) => { const page = await call(() => this.api.listMessages(email, params.direction, params.readStatus, params.sort, params.from_, - params.subjectContains, params.conversationId, params.labels, params.since, params.until, + params.subjectContains, params.conversationId, params.labels, params.q, params.since, params.until, cursor, params.limit, params.deleted), ); return { items: page.items ?? [], next_cursor: page.nextCursor }; diff --git a/sdks/typescript/src/v1/generated/apis/MessagesApi.ts b/sdks/typescript/src/v1/generated/apis/MessagesApi.ts index 4ef4edc8..0a8ae6c3 100644 --- a/sdks/typescript/src/v1/generated/apis/MessagesApi.ts +++ b/sdks/typescript/src/v1/generated/apis/MessagesApi.ts @@ -342,13 +342,14 @@ export class MessagesApiRequestFactory extends BaseAPIRequestFactory { * @param subjectContains Case-insensitive substring match on subject. * @param conversationId * @param labels Comma-separated list (e.g. labels=urgent,follow-up); AND-matched — a message must carry every given label. + * @param q Boolean filter expression (AIP-160-derived). v1 fields: label, from, subject, created. Operators: : = != < <= > >= with AND / OR / NOT and parentheses; whitespace is implicit AND and binds looser than OR (e.g. \'label:urgent OR (from:alerts AND NOT subject:newsletter) created>=2026-07-01\'). Composes with (ANDs) the flat filters. Unknown fields/operators are rejected with a positioned invalid_filter error. Max 500 chars. * @param since RFC3339; created_at >= since. * @param until RFC3339; created_at < until. * @param cursor * @param limit * @param deleted List the trash instead: messages that were soft-deleted and are restorable until purged (30 days after deletion by default, deployment-configurable). Defaults to false (live messages only). */ - public async listMessages(email: string, direction?: 'inbound' | 'outbound' | 'all', readStatus?: 'unread' | 'read' | 'all', sort?: 'asc' | 'desc', from_?: string, subjectContains?: string, conversationId?: string, labels?: Array, since?: string, until?: string, cursor?: string, limit?: number, deleted?: boolean, _options?: Configuration): Promise { + public async listMessages(email: string, direction?: 'inbound' | 'outbound' | 'all', readStatus?: 'unread' | 'read' | 'all', sort?: 'asc' | 'desc', from_?: string, subjectContains?: string, conversationId?: string, labels?: Array, q?: string, since?: string, until?: string, cursor?: string, limit?: number, deleted?: boolean, _options?: Configuration): Promise { let _config = _options || this.configuration; // verify required parameter 'email' is not null or undefined @@ -369,6 +370,7 @@ export class MessagesApiRequestFactory extends BaseAPIRequestFactory { + // Path Params const localVarPath = '/v1/agents/{email}/messages' .replace('{' + 'email' + '}', encodeURIComponent(String(email))); @@ -412,6 +414,11 @@ export class MessagesApiRequestFactory extends BaseAPIRequestFactory { requestContext.setQueryParam("labels", ObjectSerializer.serialize(labels, "Array", "")); } + // Query Params + if (q !== undefined) { + requestContext.setQueryParam("q", ObjectSerializer.serialize(q, "string", "")); + } + // Query Params if (since !== undefined) { requestContext.setQueryParam("since", ObjectSerializer.serialize(since, "string", "")); diff --git a/sdks/typescript/src/v1/generated/types/ObjectParamAPI.ts b/sdks/typescript/src/v1/generated/types/ObjectParamAPI.ts index 04f62e6d..06336959 100644 --- a/sdks/typescript/src/v1/generated/types/ObjectParamAPI.ts +++ b/sdks/typescript/src/v1/generated/types/ObjectParamAPI.ts @@ -1453,6 +1453,13 @@ export interface MessagesApiListMessagesRequest { * @memberof MessagesApilistMessages */ labels?: Array + /** + * Boolean filter expression (AIP-160-derived). v1 fields: label, from, subject, created. Operators: : = != < <= > >= with AND / OR / NOT and parentheses; whitespace is implicit AND and binds looser than OR (e.g. \'label:urgent OR (from:alerts AND NOT subject:newsletter) created>=2026-07-01\'). Composes with (ANDs) the flat filters. Unknown fields/operators are rejected with a positioned invalid_filter error. Max 500 chars. + * Defaults to: undefined + * @type string + * @memberof MessagesApilistMessages + */ + q?: string /** * RFC3339; created_at >= since. * Defaults to: undefined @@ -1702,7 +1709,7 @@ export class ObjectMessagesApi { * @param param the request object */ public listMessagesWithHttpInfo(param: MessagesApiListMessagesRequest, options?: ConfigurationOptions): Promise> { - return this.api.listMessagesWithHttpInfo(param.email, param.direction, param.readStatus, param.sort, param.from_, param.subjectContains, param.conversationId, param.labels, param.since, param.until, param.cursor, param.limit, param.deleted, options).toPromise(); + return this.api.listMessagesWithHttpInfo(param.email, param.direction, param.readStatus, param.sort, param.from_, param.subjectContains, param.conversationId, param.labels, param.q, param.since, param.until, param.cursor, param.limit, param.deleted, options).toPromise(); } /** @@ -1711,7 +1718,7 @@ export class ObjectMessagesApi { * @param param the request object */ public listMessages(param: MessagesApiListMessagesRequest, options?: ConfigurationOptions): Promise { - return this.api.listMessages(param.email, param.direction, param.readStatus, param.sort, param.from_, param.subjectContains, param.conversationId, param.labels, param.since, param.until, param.cursor, param.limit, param.deleted, options).toPromise(); + return this.api.listMessages(param.email, param.direction, param.readStatus, param.sort, param.from_, param.subjectContains, param.conversationId, param.labels, param.q, param.since, param.until, param.cursor, param.limit, param.deleted, options).toPromise(); } /** diff --git a/sdks/typescript/src/v1/generated/types/ObservableAPI.ts b/sdks/typescript/src/v1/generated/types/ObservableAPI.ts index 622dad1a..8636f8c4 100644 --- a/sdks/typescript/src/v1/generated/types/ObservableAPI.ts +++ b/sdks/typescript/src/v1/generated/types/ObservableAPI.ts @@ -1527,16 +1527,17 @@ export class ObservableMessagesApi { * @param [subjectContains] Case-insensitive substring match on subject. * @param [conversationId] * @param [labels] Comma-separated list (e.g. labels=urgent,follow-up); AND-matched — a message must carry every given label. + * @param [q] Boolean filter expression (AIP-160-derived). v1 fields: label, from, subject, created. Operators: : = != < <= > >= with AND / OR / NOT and parentheses; whitespace is implicit AND and binds looser than OR (e.g. \'label:urgent OR (from:alerts AND NOT subject:newsletter) created>=2026-07-01\'). Composes with (ANDs) the flat filters. Unknown fields/operators are rejected with a positioned invalid_filter error. Max 500 chars. * @param [since] RFC3339; created_at >= since. * @param [until] RFC3339; created_at < until. * @param [cursor] * @param [limit] * @param [deleted] List the trash instead: messages that were soft-deleted and are restorable until purged (30 days after deletion by default, deployment-configurable). Defaults to false (live messages only). */ - public listMessagesWithHttpInfo(email: string, direction?: 'inbound' | 'outbound' | 'all', readStatus?: 'unread' | 'read' | 'all', sort?: 'asc' | 'desc', from_?: string, subjectContains?: string, conversationId?: string, labels?: Array, since?: string, until?: string, cursor?: string, limit?: number, deleted?: boolean, _options?: ConfigurationOptions): Observable> { + public listMessagesWithHttpInfo(email: string, direction?: 'inbound' | 'outbound' | 'all', readStatus?: 'unread' | 'read' | 'all', sort?: 'asc' | 'desc', from_?: string, subjectContains?: string, conversationId?: string, labels?: Array, q?: string, since?: string, until?: string, cursor?: string, limit?: number, deleted?: boolean, _options?: ConfigurationOptions): Observable> { const _config = mergeConfiguration(this.configuration, _options); - const requestContextPromise = this.requestFactory.listMessages(email, direction, readStatus, sort, from_, subjectContains, conversationId, labels, since, until, cursor, limit, deleted, _config); + const requestContextPromise = this.requestFactory.listMessages(email, direction, readStatus, sort, from_, subjectContains, conversationId, labels, q, since, until, cursor, limit, deleted, _config); // build promise chain let middlewarePreObservable = from(requestContextPromise); for (const middleware of _config.middleware) { @@ -1564,14 +1565,15 @@ export class ObservableMessagesApi { * @param [subjectContains] Case-insensitive substring match on subject. * @param [conversationId] * @param [labels] Comma-separated list (e.g. labels=urgent,follow-up); AND-matched — a message must carry every given label. + * @param [q] Boolean filter expression (AIP-160-derived). v1 fields: label, from, subject, created. Operators: : = != < <= > >= with AND / OR / NOT and parentheses; whitespace is implicit AND and binds looser than OR (e.g. \'label:urgent OR (from:alerts AND NOT subject:newsletter) created>=2026-07-01\'). Composes with (ANDs) the flat filters. Unknown fields/operators are rejected with a positioned invalid_filter error. Max 500 chars. * @param [since] RFC3339; created_at >= since. * @param [until] RFC3339; created_at < until. * @param [cursor] * @param [limit] * @param [deleted] List the trash instead: messages that were soft-deleted and are restorable until purged (30 days after deletion by default, deployment-configurable). Defaults to false (live messages only). */ - public listMessages(email: string, direction?: 'inbound' | 'outbound' | 'all', readStatus?: 'unread' | 'read' | 'all', sort?: 'asc' | 'desc', from_?: string, subjectContains?: string, conversationId?: string, labels?: Array, since?: string, until?: string, cursor?: string, limit?: number, deleted?: boolean, _options?: ConfigurationOptions): Observable { - return this.listMessagesWithHttpInfo(email, direction, readStatus, sort, from_, subjectContains, conversationId, labels, since, until, cursor, limit, deleted, _options).pipe(map((apiResponse: HttpInfo) => apiResponse.data)); + public listMessages(email: string, direction?: 'inbound' | 'outbound' | 'all', readStatus?: 'unread' | 'read' | 'all', sort?: 'asc' | 'desc', from_?: string, subjectContains?: string, conversationId?: string, labels?: Array, q?: string, since?: string, until?: string, cursor?: string, limit?: number, deleted?: boolean, _options?: ConfigurationOptions): Observable { + return this.listMessagesWithHttpInfo(email, direction, readStatus, sort, from_, subjectContains, conversationId, labels, q, since, until, cursor, limit, deleted, _options).pipe(map((apiResponse: HttpInfo) => apiResponse.data)); } /** diff --git a/sdks/typescript/src/v1/generated/types/PromiseAPI.ts b/sdks/typescript/src/v1/generated/types/PromiseAPI.ts index 926f6458..f7bbd5f8 100644 --- a/sdks/typescript/src/v1/generated/types/PromiseAPI.ts +++ b/sdks/typescript/src/v1/generated/types/PromiseAPI.ts @@ -1108,15 +1108,16 @@ export class PromiseMessagesApi { * @param [subjectContains] Case-insensitive substring match on subject. * @param [conversationId] * @param [labels] Comma-separated list (e.g. labels=urgent,follow-up); AND-matched — a message must carry every given label. + * @param [q] Boolean filter expression (AIP-160-derived). v1 fields: label, from, subject, created. Operators: : = != < <= > >= with AND / OR / NOT and parentheses; whitespace is implicit AND and binds looser than OR (e.g. \'label:urgent OR (from:alerts AND NOT subject:newsletter) created>=2026-07-01\'). Composes with (ANDs) the flat filters. Unknown fields/operators are rejected with a positioned invalid_filter error. Max 500 chars. * @param [since] RFC3339; created_at >= since. * @param [until] RFC3339; created_at < until. * @param [cursor] * @param [limit] * @param [deleted] List the trash instead: messages that were soft-deleted and are restorable until purged (30 days after deletion by default, deployment-configurable). Defaults to false (live messages only). */ - public listMessagesWithHttpInfo(email: string, direction?: 'inbound' | 'outbound' | 'all', readStatus?: 'unread' | 'read' | 'all', sort?: 'asc' | 'desc', from_?: string, subjectContains?: string, conversationId?: string, labels?: Array, since?: string, until?: string, cursor?: string, limit?: number, deleted?: boolean, _options?: PromiseConfigurationOptions): Promise> { + public listMessagesWithHttpInfo(email: string, direction?: 'inbound' | 'outbound' | 'all', readStatus?: 'unread' | 'read' | 'all', sort?: 'asc' | 'desc', from_?: string, subjectContains?: string, conversationId?: string, labels?: Array, q?: string, since?: string, until?: string, cursor?: string, limit?: number, deleted?: boolean, _options?: PromiseConfigurationOptions): Promise> { const observableOptions = wrapOptions(_options); - const result = this.api.listMessagesWithHttpInfo(email, direction, readStatus, sort, from_, subjectContains, conversationId, labels, since, until, cursor, limit, deleted, observableOptions); + const result = this.api.listMessagesWithHttpInfo(email, direction, readStatus, sort, from_, subjectContains, conversationId, labels, q, since, until, cursor, limit, deleted, observableOptions); return result.toPromise(); } @@ -1131,15 +1132,16 @@ export class PromiseMessagesApi { * @param [subjectContains] Case-insensitive substring match on subject. * @param [conversationId] * @param [labels] Comma-separated list (e.g. labels=urgent,follow-up); AND-matched — a message must carry every given label. + * @param [q] Boolean filter expression (AIP-160-derived). v1 fields: label, from, subject, created. Operators: : = != < <= > >= with AND / OR / NOT and parentheses; whitespace is implicit AND and binds looser than OR (e.g. \'label:urgent OR (from:alerts AND NOT subject:newsletter) created>=2026-07-01\'). Composes with (ANDs) the flat filters. Unknown fields/operators are rejected with a positioned invalid_filter error. Max 500 chars. * @param [since] RFC3339; created_at >= since. * @param [until] RFC3339; created_at < until. * @param [cursor] * @param [limit] * @param [deleted] List the trash instead: messages that were soft-deleted and are restorable until purged (30 days after deletion by default, deployment-configurable). Defaults to false (live messages only). */ - public listMessages(email: string, direction?: 'inbound' | 'outbound' | 'all', readStatus?: 'unread' | 'read' | 'all', sort?: 'asc' | 'desc', from_?: string, subjectContains?: string, conversationId?: string, labels?: Array, since?: string, until?: string, cursor?: string, limit?: number, deleted?: boolean, _options?: PromiseConfigurationOptions): Promise { + public listMessages(email: string, direction?: 'inbound' | 'outbound' | 'all', readStatus?: 'unread' | 'read' | 'all', sort?: 'asc' | 'desc', from_?: string, subjectContains?: string, conversationId?: string, labels?: Array, q?: string, since?: string, until?: string, cursor?: string, limit?: number, deleted?: boolean, _options?: PromiseConfigurationOptions): Promise { const observableOptions = wrapOptions(_options); - const result = this.api.listMessages(email, direction, readStatus, sort, from_, subjectContains, conversationId, labels, since, until, cursor, limit, deleted, observableOptions); + const result = this.api.listMessages(email, direction, readStatus, sort, from_, subjectContains, conversationId, labels, q, since, until, cursor, limit, deleted, observableOptions); return result.toPromise(); } diff --git a/sdks/typescript/test/v1/client.test.ts b/sdks/typescript/test/v1/client.test.ts index 61c496c9..73deea7b 100644 --- a/sdks/typescript/test/v1/client.test.ts +++ b/sdks/typescript/test/v1/client.test.ts @@ -407,6 +407,14 @@ describe("E2AClient", () => { expect(url.searchParams.has("from_")).toBe(false); }); + it("messages.list serializes q as the wire query", async () => { + globalThis.fetch = mockFetch(200, { items: [], next_cursor: null }); + + await client.messages.list("bot@test.dev", { q: "label:urgent" }).page(); + + expect(new URL(lastCall().url).searchParams.get("q")).toBe("label:urgent"); + }); + it("messages.list({ deleted: true }) lists the trash", async () => { globalThis.fetch = mockFetch(200, { items: [], next_cursor: null }); await client.messages.list("bot@test.dev", { deleted: true }).toArray({ limit: 10 }); From 5c71ea64d3c0ad5504fa83695f5db3ce182f789e Mon Sep 17 00:00:00 2001 From: jiashuoz Date: Sun, 26 Jul 2026 12:39:55 -0700 Subject: [PATCH 07/12] feat(mcp): q filter on list_messages Co-Authored-By: Kimi --- .../plans/2026-07-25-filter-query-language.md | 5 ++ mcp/src/client.ts | 24 +++--- mcp/src/tools/messages.ts | 12 ++- mcp/tests/client.types.ts | 6 ++ mcp/tests/list-messages-q.test.ts | 74 +++++++++++++++++++ 5 files changed, 105 insertions(+), 16 deletions(-) create mode 100644 mcp/tests/list-messages-q.test.ts diff --git a/docs/superpowers/plans/2026-07-25-filter-query-language.md b/docs/superpowers/plans/2026-07-25-filter-query-language.md index 811891ac..9adaeb64 100644 --- a/docs/superpowers/plans/2026-07-25-filter-query-language.md +++ b/docs/superpowers/plans/2026-07-25-filter-query-language.md @@ -2875,6 +2875,11 @@ Co-Authored-By: Kimi " **Interfaces:** - Consumes: regenerated TS SDK `ListMessagesParams.q` (Task 14 runs `make generate-sdk`; if the MCP typecheck needs it first, run `make generate-sdk-ts` before this task's implementation step and commit it in Task 14). +**Binding correction:** Zod's plain `.max(500)` counts UTF-16 code units, while +the HTTP API counts Unicode code points. Use a schema refinement based on +`Array.from(value).length <= 500` with a clear max-length message. Test both +ASCII 500/501 and astral-Unicode 500/501 boundaries. + - [ ] **Step 1: Add the schema field + passthrough + test** In the `list_messages` `inputSchema` (after `labels`, ~line 435): diff --git a/mcp/src/client.ts b/mcp/src/client.ts index c736b887..dfe94281 100644 --- a/mcp/src/client.ts +++ b/mcp/src/client.ts @@ -46,12 +46,20 @@ import type { DeleteApiKeyResult, Page, PageMessageLifecycleTransition, + ListMessagesParams, } from "@e2a/sdk/v1"; import type { McpConfig } from "./config.js"; import type { Scope } from "./tools/tiers.js"; const DEFAULT_LIST_LIMIT = 1000; +// Keep the SDK-owned message-filter contract in one place. MCP adds only the +// pagination cursor (consumed by AutoPager) and the agent-address selector. +type McpListMessagesParams = ListMessagesParams & { + cursor?: string; + explicitAddress?: string; +}; + /** Per-call options for unsafe writes. */ export interface SendOpts { idempotencyKey?: string; @@ -273,21 +281,7 @@ export class McpClient { // Cursor pagination (§6a #3): returns ONE page + next_cursor. `limit` is the // page size; pass a prior response's next_cursor as `cursor` for the next page. - listMessages(params: { - direction?: "inbound" | "outbound" | "all"; - readStatus?: "unread" | "read" | "all"; - sort?: "asc" | "desc"; - from_?: string; - subjectContains?: string; - conversationId?: string; - since?: string; - until?: string; - labels?: Array; - cursor?: string; - limit?: number; - deleted?: boolean; - explicitAddress?: string; - }): Promise> { + listMessages(params: McpListMessagesParams): Promise> { const { explicitAddress, cursor, ...rest } = params; return this.sdk.messages.list(this.resolveAddress(explicitAddress), rest).page(cursor); } diff --git a/mcp/src/tools/messages.ts b/mcp/src/tools/messages.ts index e349f2c6..06843b38 100644 --- a/mcp/src/tools/messages.ts +++ b/mcp/src/tools/messages.ts @@ -385,7 +385,7 @@ export function registerMessageTools(server: McpServer, client: McpClient): void title: "List messages (inbox or sent)", annotations: { readOnlyHint: true }, description: - "List the agent's messages, newest first by default. Use `direction` to pick the folder: `inbound` (the Inbox — received mail, the default), `outbound` (the Sent folder — mail this agent sent, including held drafts), or `all` (both). Filter received mail by `read_status` (unread/read/all; default unread — applies to inbound only; sent mail has no read-state). **Cursor-paginated:** returns one page in `messages` plus a `next_cursor` when more remain — pass it back as `cursor` for the next page (keep the same filters + sort). Pass `sort: \"asc\"` for FIFO order (oldest first) to drain in arrival order. **Search filters** (`from_`, `subject_contains`, `conversation_id`, `since`, `until`) narrow server-side — use them instead of paging the whole folder. In inbound summaries, `header_from` is the claimed RFC 5322 From address; a non-null `verified_domain` means DMARC passed for that From domain. It does not authenticate the mailbox local part, a person, or message content. Returns summaries only — use `get_message` for the full body and SPF/DKIM/DMARC evidence.", + "List the agent's messages, newest first by default. Use `direction` to pick the folder: `inbound` (the Inbox — received mail, the default), `outbound` (the Sent folder — mail this agent sent, including held drafts), or `all` (both). Filter received mail by `read_status` (unread/read/all; default unread — applies to inbound only; sent mail has no read-state). **Cursor-paginated:** returns one page in `messages` plus a `next_cursor` when more remain — pass it back as `cursor` for the next page (keep the same filters + sort). Pass `sort: \"asc\"` for FIFO order (oldest first) to drain in arrival order. **Search filters** (`from_`, `subject_contains`, `conversation_id`, `since`, `until`, `q` (boolean expression)) narrow server-side — use them instead of paging the whole folder. In inbound summaries, `header_from` is the claimed RFC 5322 From address; a non-null `verified_domain` means DMARC passed for that From domain. It does not authenticate the mailbox local part, a person, or message content. Returns summaries only — use `get_message` for the full body and SPF/DKIM/DMARC evidence.", inputSchema: strictInputSchema({ direction: z .enum(["inbound", "outbound", "all"]) @@ -438,6 +438,15 @@ export function registerMessageTools(server: McpServer, client: McpClient): void .describe( "AND-match filter on labels. A row is returned only if ALL given labels are present. Use lowercase strings matching `[a-z0-9:_-]+`; `e2a:*` system labels can be filtered even though setting them is server-only.", ), + q: z + .string() + .refine((value) => Array.from(value).length <= 500, { + message: "q must contain at most 500 Unicode code points", + }) + .optional() + .describe( + "Boolean filter expression (AIP-160-derived). v1 fields: label, from, subject, created. Operators : = != < <= > >= with AND/OR/NOT and parentheses; whitespace is implicit AND (binds looser than OR). Example: 'label:urgent OR (from:alerts AND NOT subject:newsletter) created>=2026-07-01'. Composes (AND) with the flat filters (labels, from_, subject_contains, since, until). Invalid expressions are rejected with a positioned invalid_filter error.", + ), deleted: z .boolean() .optional() @@ -463,6 +472,7 @@ export function registerMessageTools(server: McpServer, client: McpClient): void ...(args.since !== undefined ? { since: args.since } : {}), ...(args.until !== undefined ? { until: args.until } : {}), ...(args.labels !== undefined ? { labels: args.labels } : {}), + ...(args.q !== undefined ? { q: args.q } : {}), ...(args.deleted !== undefined ? { deleted: args.deleted } : {}), ...(args.email !== undefined ? { explicitAddress: args.email } : {}), }); diff --git a/mcp/tests/client.types.ts b/mcp/tests/client.types.ts index 7fae3bef..4eeac410 100644 --- a/mcp/tests/client.types.ts +++ b/mcp/tests/client.types.ts @@ -6,6 +6,12 @@ type ListMessagesParams = Parameters[0]; const senderFilter: ListMessagesParams = { from_: "alice@example.com" }; void senderFilter; +// The MCP wrapper must preserve generated SDK q filters while adding only its +// cursor/address transport fields. This assignment fails to typecheck if the +// handwritten wrapper parameter shape drifts from the SDK again. +const queryFilter: ListMessagesParams = { q: "label:urgent" }; +void queryFilter; + // The MCP tool and its wrapper follow the SDK's reserved-word-safe spelling. // @ts-expect-error `from` is the REST wire name, not the MCP input name. const removedSenderFilter: ListMessagesParams = { from: "alice@example.com" }; diff --git a/mcp/tests/list-messages-q.test.ts b/mcp/tests/list-messages-q.test.ts new file mode 100644 index 00000000..1a9eba50 --- /dev/null +++ b/mcp/tests/list-messages-q.test.ts @@ -0,0 +1,74 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { McpClient } from "../src/client.js"; +import { buildServer } from "../src/server.js"; + +function makeStubClient() { + const client = new McpClient({} as never, "bot@example.com", "account"); + const listMessages = vi + .spyOn(client, "listMessages") + .mockResolvedValue({ items: [], next_cursor: undefined }); + return { client, listMessages }; +} + +async function connect(stub: McpClient): Promise { + const server = buildServer({ client: stub, version: "0.0.0-test" }); + const client = new Client({ name: "list-messages-q-test", version: "0.0.0" }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]); + return client; +} + +describe("list_messages q filter", () => { + let stub: ReturnType; + let client: Client; + + beforeEach(async () => { + stub = makeStubClient(); + client = await connect(stub.client); + }); + + it("documents only the v1 q fields", async () => { + const { tools } = await client.listTools(); + const tool = tools.find((candidate) => candidate.name === "list_messages"); + const properties = (tool?.inputSchema as { + properties?: Record; + }).properties; + const q = properties?.q; + + expect(q?.description).toContain("v1 fields: label, from, subject, created."); + expect(q?.description).not.toContain("has"); + }); + + it("accepts q at 500 Unicode code points and rejects 501", async () => { + const accepted = ["a".repeat(500), "😀".repeat(500)]; + const rejected = ["a".repeat(501), "😀".repeat(501)]; + + for (const q of accepted) { + expect(Array.from(q)).toHaveLength(500); + const result = await client.callTool({ name: "list_messages", arguments: { q } }); + expect(result.isError).not.toBe(true); + expect(stub.listMessages).toHaveBeenLastCalledWith({ q }); + } + + for (const q of rejected) { + expect(Array.from(q)).toHaveLength(501); + const result = await client.callTool({ name: "list_messages", arguments: { q } }); + expect(result.isError).toBe(true); + expect((result.content as Array<{ text: string }>)[0]?.text).toMatch(/500 Unicode code points/i); + } + }); + + it("passes q through verbatim and omits it when absent", async () => { + const q = "label:urgent OR (from:alerts AND NOT subject:newsletter) created>=2026-07-01"; + await client.callTool({ name: "list_messages", arguments: { q } }); + expect(stub.listMessages).toHaveBeenLastCalledWith({ q }); + + stub.listMessages.mockClear(); + await client.callTool({ name: "list_messages", arguments: {} }); + const params = stub.listMessages.mock.calls[0]?.[0]; + expect(params).toEqual({}); + expect(params).not.toHaveProperty("q"); + }); +}); From 550d7ebfbad36e91fecac74949a05f44705b4c3a Mon Sep 17 00:00:00 2001 From: jiashuoz Date: Sun, 26 Jul 2026 12:49:48 -0700 Subject: [PATCH 08/12] docs,e2e: filtering reference and q e2e case Co-Authored-By: Kimi --- docs/api.md | 5 +- docs/filtering.md | 82 +++++++++++++++++++ .../e2e-prod/suites/14-v030-features.test.ts | 70 +++++++++++++++- 3 files changed, 154 insertions(+), 3 deletions(-) create mode 100644 docs/filtering.md diff --git a/docs/api.md b/docs/api.md index e72c07c5..697a0730 100644 --- a/docs/api.md +++ b/docs/api.md @@ -415,8 +415,9 @@ single message. - `GET …/messages` — list inbound + outbound with filters (`direction`, `read_status`, `sort`, `from`, `subject_contains`, `conversation_id`, `labels`, - `since`, `until`) and cursor pagination. Held outbound drafts appear with - `status=pending_review`. + `since`, `until`) and cursor pagination. `q` adds boolean composition; see + [message filtering](filtering.md) for its grammar and v1 fields. Held outbound + drafts appear with `status=pending_review`. - `POST …/messages` — send a new email (a new thread). Returns `202 Accepted` for every non-terminal outcome — `pending_review` when the agent's protection policy holds it for review, or `accepted` when the async pipeline durably queues it — diff --git a/docs/filtering.md b/docs/filtering.md new file mode 100644 index 00000000..56ac6261 --- /dev/null +++ b/docs/filtering.md @@ -0,0 +1,82 @@ +# Message filtering (`q`) + +`GET /v1/agents/{email}/messages?q=…` accepts a small, boolean filter +language. It is an addition to—not a replacement for—the existing flat list +parameters. See the [filter-query design](superpowers/specs/2026-07-25-filter-query-language-design.md) +for rationale and rollout details. + +## Syntax + +Keywords are uppercase and case-sensitive: `AND`, `OR`, and `NOT`. + +```ebnf +query = expression [ WS ] ; +expression = sequence { WS* "AND" WS* sequence } ; +sequence = factor { WS factor } ; +factor = term { WS* "OR" WS* term } ; +term = [ "NOT" WS | "-" ] simple ; +simple = restriction | "(" WS* expression WS* ")" ; +restriction = member [ WS* comparator WS* value ] ; +member = ( TEXT | STRING ) { "." TEXT } ; +comparator = ":" | "=" | "!=" | "<" | "<=" | ">" | ">=" ; +value = STRING | TEXT { ( "." | ":" ) TEXT } ; +``` + +Whitespace between expressions is implicit `AND`. Binding, from tightest to +loosest, is **`NOT` > `OR` > implicit `AND` > explicit `AND`**. Use +parentheses when that is not what you mean. A restriction without a comparator +is syntactically a bare term, but v1 rejects it: qualify the value with a field. + +`STRING` is double-quoted. Quote values containing whitespace or reserved +operator/parenthesis characters; `.` and `:` may remain unquoted in values +(RFC3339 timestamps, email addresses, and `e2a:` labels). Only `\"` (a literal +quote) and `\\` (a literal backslash) are valid escapes. For example: +`subject:"build \"green\""`. A quoted field name is syntactically accepted, +but it must still resolve exactly to a supported v1 field name. + +## V1 fields + +| Field | Operators | Meaning | +| --- | --- | --- | +| `label` | `:` | Message has the label. Values match `[a-z0-9:_-]+` and are at most 64 characters. Use `NOT label:newsletter` to exclude it. | +| `from` | `:` `=` `!=` | Sender. `:` is case-insensitive substring matching; `=` and `!=` are case-insensitive exact matching. | +| `subject` | `:` `=` `!=` | Subject, with the same matching rules as `from`. A NULL subject matches none of these predicates. | +| `created` | `=` `!=` `<` `<=` `>` `>=` | Creation time. Value is RFC3339 or a `YYYY-MM-DD` UTC calendar date. | + +For `from:` and `subject:`, `*` matches any sequence of characters. Literal +`%`, `_`, and `\` are escaped, so they never become SQL wildcards. The wildcard +is only meaningful for `:`; `=` and `!=` remain exact comparisons. + +A date-only `created` value denotes the full UTC day `[00:00, next 00:00)`: + +- `created=2026-07-01` matches that day; `!=` matches outside it. +- `<` is before that day's midnight; `<=` is before the following midnight. +- `>` starts at the following midnight; `>=` starts at that day's midnight. + +Examples: + +```text +label:urgent OR label:follow-up +label:urgent AND NOT label:newsletter +(from:"alerts.example" OR subject:"release *") created>=2026-07-01 +created=2026-07-01 +``` + +## Composition, errors, and limits + +`q` is ANDed with every flat list filter (`direction`, `read_status`, `from`, +`labels`, `since`, and so on). A pagination cursor is tied to the complete +filter identity, including `q`; start a new query if any filter changes. + +Invalid syntax, unknown fields, unsupported operators, invalid values, and +limit violations return HTTP 400 with error code `invalid_filter`. Parse and +validation errors identify their source position, for example `at column 12`. + +The request accepts at most **500 Unicode code points**, nesting depth **64**, +and **512** AST nodes. Invalid UTF-8 and NUL are rejected. + +`has:attachment` is intentionally deferred. Inbound attachments are canonical +in raw MIME while outbound attachments currently use JSON, so a correct filter +needs a normalized attachment-count field. It will ship only after a +blue/green-safe expand, backfill, and contract rollout makes that field reliable +for every live writer. diff --git a/tests/e2e-prod/suites/14-v030-features.test.ts b/tests/e2e-prod/suites/14-v030-features.test.ts index 8fa0368e..e50ba012 100644 --- a/tests/e2e-prod/suites/14-v030-features.test.ts +++ b/tests/e2e-prod/suites/14-v030-features.test.ts @@ -23,7 +23,8 @@ import { test, after } from "node:test"; import assert from "node:assert/strict"; import { ApiClient } from "../harness/client.ts"; -import { cleanup } from "../harness/cleanup.ts"; +import { cleanup, track, untrack } from "../harness/cleanup.ts"; +import { uniqueSlug, uniqueSubject } from "../harness/fixtures.ts"; import { info, warn, writeReport } from "../harness/report.ts"; const client = new ApiClient(); @@ -228,6 +229,73 @@ test("agent messages: ?labels= filter accepted without error", async () => { assert.equal(r.status, 200, `expected 200, got ${r.status}: ${r.raw.slice(0, 200)}`); }); +test("agent messages: q OR union and AND NOT difference on isolated loopback messages", async () => { + // Do not use the shared primary inbox: this creates a throwaway agent, sends + // only to itself (the providerless loopback path), and deletes the agent in + // finally so its messages cascade away even if an assertion fails. + const email = `${uniqueSlug("qfilter")}@${client.env.sharedDomain}`; + const created = await client.post<{ email: string }>("/v1/agents", { + body: { email, name: "q filter e2e" }, + expect: 201, + }); + const agentEmail = created.body!.email; + track("agent", agentEmail); + + try { + const receiveLoopback = async (subject: string): Promise => { + await client.post(`/v1/agents/${encodeURIComponent(agentEmail)}/messages`, { + body: { to: [agentEmail], subject, text: "q filter loopback fixture" }, + expect: [200, 202], + }); + // The inbound copy can arrive asynchronously. Explicitly request all + // read states and a 100-item page so defaults, pagination, and unrelated + // messages cannot hide either unique fixture. + for (let attempt = 0; attempt < 12; attempt++) { + const listed = await client.get<{ items?: Array<{ id: string; subject?: string }> }>( + `/v1/agents/${encodeURIComponent(agentEmail)}/messages`, + { query: { direction: "inbound", read_status: "all", limit: 100 } }, + ); + assert.equal(listed.status, 200, `list loopback inbox: ${listed.status} ${listed.raw.slice(0, 200)}`); + const message = listed.body?.items?.find((item) => item.subject === subject); + if (message?.id) return message.id; + await new Promise((resolve) => setTimeout(resolve, 500)); + } + throw new Error(`loopback message ${JSON.stringify(subject)} did not arrive for ${agentEmail}`); + }; + + const idA = await receiveLoopback(uniqueSubject("q filter a")); + const idB = await receiveLoopback(uniqueSubject("q filter b")); + const labelA = uniqueSlug("q-a"); + const labelB = uniqueSlug("q-b"); + for (const [id, label] of [[idA, labelA], [idB, labelB]] as const) { + const updated = await client.patch<{ labels?: string[] }>( + `/v1/agents/${encodeURIComponent(agentEmail)}/messages/${encodeURIComponent(id)}`, + { body: { add_labels: [label] }, expect: 200 }, + ); + assert.ok(updated.body?.labels?.includes(label), `message ${id} received label ${label}`); + } + + const matchedIDs = async (q: string): Promise => { + const listed = await client.get<{ items?: Array<{ id: string }> }>( + `/v1/agents/${encodeURIComponent(agentEmail)}/messages`, + { query: { q, direction: "inbound", read_status: "all", limit: 100 }, expect: 200 }, + ); + return (listed.body?.items ?? []).map((item) => item.id).sort(); + }; + + assert.deepEqual(await matchedIDs(`label:${labelA} OR label:${labelB}`), [idA, idB].sort(), "q OR returns the label union"); + assert.deepEqual(await matchedIDs(`label:${labelA} AND NOT label:${labelB}`), [idA], "q AND NOT returns only label A"); + } finally { + const deleted = await client.delete(`/v1/agents/${encodeURIComponent(agentEmail)}?confirm=DELETE&permanent=true`); + const cleanupSucceeded = [200, 204, 404].includes(deleted.status); + assert.ok( + cleanupSucceeded, + `delete throwaway q-filter agent: ${deleted.status} ${deleted.raw.slice(0, 200)}`, + ); + if (cleanupSucceeded) untrack("agent", agentEmail); + } +}); + test("agent messages: ?labels= filter enforces the 50-value cap (400)", async () => { const email = client.env.primaryAgentEmail; // The ?labels= filter is capped server-side at 50 values (maxLabelsPerOp); From 7e059287dae62b6370c500ed66287a4e3db1154a Mon Sep 17 00:00:00 2001 From: jiashuoz Date: Tue, 28 Jul 2026 17:19:06 -0700 Subject: [PATCH 09/12] fix(api): name message expression filter --- internal/httpapi/messages.go | 26 +- internal/httpapi/messages_filter_test.go | 245 ++++++++++++++++++ internal/httpapi/messages_q_test.go | 195 -------------- internal/identity/filter_differential_test.go | 14 +- internal/identity/filter_registry.go | 4 +- internal/identity/filter_registry_test.go | 44 ++-- internal/identity/store.go | 21 +- 7 files changed, 301 insertions(+), 248 deletions(-) create mode 100644 internal/httpapi/messages_filter_test.go delete mode 100644 internal/httpapi/messages_q_test.go diff --git a/internal/httpapi/messages.go b/internal/httpapi/messages.go index de271bcb..08075b64 100644 --- a/internal/httpapi/messages.go +++ b/internal/httpapi/messages.go @@ -367,12 +367,12 @@ type ListMessagesInput struct { SubjectContains string `query:"subject_contains" doc:"Case-insensitive substring match on subject."` ConversationID string `query:"conversation_id"` Labels []string `query:"labels" doc:"Comma-separated list (e.g. labels=urgent,follow-up); AND-matched — a message must carry every given label."` - Q string `query:"q" doc:"Boolean filter expression (AIP-160-derived). v1 fields: label, from, subject, created. Operators: : = != < <= > >= with AND / OR / NOT and parentheses; whitespace is implicit AND and binds looser than OR (e.g. 'label:urgent OR (from:alerts AND NOT subject:newsletter) created>=2026-07-01'). Composes with (ANDs) the flat filters. Unknown fields/operators are rejected with a positioned invalid_filter error. Max 500 chars."` Since string `query:"since" doc:"RFC3339; created_at >= since."` Until string `query:"until" doc:"RFC3339; created_at < until."` Cursor string `query:"cursor"` Limit int `query:"limit" minimum:"1" maximum:"100" default:"100"` Deleted bool `query:"deleted" doc:"List the trash instead: messages that were soft-deleted and are restorable until purged (30 days after deletion by default, deployment-configurable). Defaults to false (live messages only)."` + Filter string `query:"filter" doc:"Boolean filter expression (AIP-160-derived). v1 fields: label, from, subject, created. Operators: : = != < <= > >= with AND / OR / NOT and parentheses; whitespace is implicit AND and binds looser than OR. Composes with (ANDs) the flat filters. Unknown fields/operators are rejected with a positioned invalid_filter error. Max 500 chars."` } type listMessagesOutput struct { @@ -395,7 +395,7 @@ type messagesCursor struct { Since string `json:"sn,omitempty"` Until string `json:"un,omitempty"` Labels []string `json:"lb,omitempty"` - Q string `json:"q,omitempty"` + Filter string `json:"fl,omitempty"` Deleted bool `json:"dl,omitempty"` } @@ -691,19 +691,19 @@ func (s *Server) handleListMessages(ctx context.Context, in *ListMessagesInput) return nil, NewError(http.StatusBadRequest, "invalid_filter", err.Error()) } - var qExpr *filterquery.Expr - if in.Q != "" { - if !utf8.ValidString(in.Q) || strings.IndexByte(in.Q, 0) >= 0 { - return nil, NewError(http.StatusBadRequest, "invalid_filter", "q filter must be valid UTF-8 and must not contain NUL") + var filterExpr *filterquery.Expr + if in.Filter != "" { + if !utf8.ValidString(in.Filter) || strings.IndexByte(in.Filter, 0) >= 0 { + return nil, NewError(http.StatusBadRequest, "invalid_filter", "filter must be valid UTF-8 and must not contain NUL") } - if utf8.RuneCountInString(in.Q) > 500 { - return nil, NewError(http.StatusBadRequest, "invalid_filter", "q filter too long (max 500 chars)") + if utf8.RuneCountInString(in.Filter) > 500 { + return nil, NewError(http.StatusBadRequest, "invalid_filter", "filter too long (max 500 chars)") } - expr, err := filterquery.Parse(in.Q, identity.MessagesQRegistry()) + expr, err := filterquery.Parse(in.Filter, identity.MessagesFilterRegistry()) if err != nil { return nil, NewError(http.StatusBadRequest, "invalid_filter", err.Error()) } - qExpr = expr + filterExpr = expr } // Time range. @@ -737,7 +737,7 @@ func (s *Server) handleListMessages(ctx context.Context, in *ListMessagesInput) cur.From != in.From || cur.SubjectContains != in.SubjectContains || cur.ConversationID != in.ConversationID || cur.Since != rfc3339OrEmpty(since) || cur.Until != rfc3339OrEmpty(until) || - cur.Q != in.Q || + cur.Filter != in.Filter || cur.Deleted != in.Deleted || !stringSlicesEqual(cur.Labels, labelsFilter) { return nil, NewError(http.StatusBadRequest, "invalid_cursor", @@ -767,7 +767,7 @@ func (s *Server) handleListMessages(ctx context.Context, in *ListMessagesInput) Since: since, Until: until, Labels: labelsFilter, - Q: qExpr, + Filter: filterExpr, Deleted: in.Deleted, }) if err != nil { @@ -791,7 +791,7 @@ func (s *Server) handleListMessages(ctx context.Context, in *ListMessagesInput) Status: status, Direction: direction, AgentID: ag.ID, Sort: sort, From: in.From, SubjectContains: in.SubjectContains, ConversationID: in.ConversationID, Since: rfc3339OrEmpty(since), Until: rfc3339OrEmpty(until), Labels: labelsFilter, - Q: in.Q, + Filter: in.Filter, Deleted: in.Deleted, }) if err != nil { diff --git a/internal/httpapi/messages_filter_test.go b/internal/httpapi/messages_filter_test.go new file mode 100644 index 00000000..c59fabb5 --- /dev/null +++ b/internal/httpapi/messages_filter_test.go @@ -0,0 +1,245 @@ +package httpapi + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "reflect" + "strings" + "sync" + "testing" + "time" + "unicode/utf8" + + "github.com/tokencanopy/e2a/internal/filterquery" + "github.com/tokencanopy/e2a/internal/identity" +) + +func newMessagesFilterTestServer(t *testing.T, opts ...func(*Deps)) *httptest.Server { + t.Helper() + base := []func(*Deps){ + func(deps *Deps) { + deps.GetAgent = func(_ context.Context, address string) (*identity.AgentIdentity, error) { + if address != "bot@example.com" { + return nil, errors.New("not found") + } + return &identity.AgentIdentity{ID: "bot@example.com", Email: "bot@example.com", UserID: "u_1", DomainVerified: true}, nil + } + deps.GetAgentAnyState = deps.GetAgent + deps.ListMessages = func(_ context.Context, f identity.MessageListFilter) ([]identity.Message, error) { + if f.AgentID != "bot@example.com" { + return nil, errors.New("unexpected agent") + } + all := []identity.Message{ + {ID: "msg_b", Direction: "inbound", Sender: "b@x.com", Recipient: "bot@example.com", Subject: "B", InboxStatus: "unread", CreatedAt: time.Unix(1700000200, 0).UTC()}, + {ID: "msg_a", Direction: "inbound", Sender: "a@x.com", Recipient: "bot@example.com", Subject: "A", InboxStatus: "unread", CreatedAt: time.Unix(1700000100, 0).UTC()}, + } + if f.AfterID == "msg_b" { + return all[1:], nil + } + if f.Limit > 0 && len(all) > f.Limit { + return all[:f.Limit], nil + } + return all, nil + } + }, + } + return testServer(t, append(base, opts...)...) +} + +func messagesFilterURL(serverURL, filter string) string { + values := url.Values{ + "direction": {"inbound"}, + "read_status": {"all"}, + } + if filter != "" { + values.Set("filter", filter) + } + return serverURL + "/v1/agents/bot@example.com/messages?" + values.Encode() +} + +func filterError(t *testing.T, body map[string]any) map[string]any { + t.Helper() + errBody, ok := body["error"].(map[string]any) + if !ok { + t.Fatalf("expected error envelope, got %v", body) + } + if errBody["code"] != "invalid_filter" { + t.Fatalf("error code = %v, want invalid_filter (body %v)", errBody["code"], body) + } + return errBody +} + +func TestFilterParamInvalid(t *testing.T) { + valid500Unicode := "subject:" + strings.Repeat("界", 60) + valid500Unicode += strings.Repeat(" ", 500-utf8.RuneCountInString(valid500Unicode)) + if got := utf8.RuneCountInString(valid500Unicode); got != 500 { + t.Fatalf("valid Unicode query length = %d, want 500", got) + } + if len(strings.Repeat("界", 60)) >= 200 { + t.Fatal("test query subject value must remain under the 200-byte field cap") + } + + tests := []struct { + name string + filter string + wantInError string + wantOK bool + }{ + {name: "unknown field", filter: "unknown:thing", wantInError: `unknown field "unknown"`}, + {name: "attachment filtering deferred", filter: "has:attachment", wantInError: `unknown field "has" — supported fields: created, from, label, subject`}, + {name: "forbidden operator", filter: "label=urgent", wantInError: `operator "=" is not allowed on field "label"`}, + {name: "syntax retains column", filter: "label:", wantInError: "(at column 7)"}, + {name: "ASCII over code point limit", filter: "label:" + strings.Repeat("a", 501), wantInError: "filter too long (max 500 chars)"}, + {name: "Unicode over code point limit", filter: strings.Repeat("界", 501), wantInError: "filter too long (max 500 chars)"}, + {name: "NUL is invalid", filter: "subject:\x00", wantInError: "filter must be valid UTF-8 and must not contain NUL"}, + {name: "malformed UTF-8 is invalid", filter: string([]byte("subject:\xff")), wantInError: "filter must be valid UTF-8 and must not contain NUL"}, + {name: "Unicode code point limit accepts bytes over cap", filter: valid500Unicode, wantOK: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + srv := newMessagesFilterTestServer(t) + code, body := getJSON(t, messagesFilterURL(srv.URL, tc.filter), "good") + if tc.wantOK { + if code != 200 { + t.Fatalf("status = %d, want 200 (body %v)", code, body) + } + return + } + if code != 400 { + t.Fatalf("status = %d, want 400 (body %v)", code, body) + } + errBody := filterError(t, body) + message, _ := errBody["message"].(string) + if !strings.Contains(message, tc.wantInError) { + t.Errorf("error message = %q, want it to contain %q", message, tc.wantInError) + } + }) + } +} + +func TestFilterParamCursorPinning(t *testing.T) { + srv := newMessagesFilterTestServer(t) + page1URL := messagesFilterURL(srv.URL, "label:urgent") + "&limit=1" + code, body := getJSON(t, page1URL, "good") + if code != 200 { + t.Fatalf("page 1 status = %d, want 200 (body %v)", code, body) + } + cursor, ok := body["next_cursor"].(string) + if !ok || cursor == "" { + t.Fatalf("page 1 next_cursor = %v, want non-empty string", body["next_cursor"]) + } + + continuation := func(filter string) (int, map[string]any) { + return getJSON(t, messagesFilterURL(srv.URL, filter)+"&limit=1&cursor="+url.QueryEscape(cursor), "good") + } + if code, body := continuation("label:urgent"); code != 200 { + t.Fatalf("identical filter continuation status = %d, want 200 (body %v)", code, body) + } + for _, filter := range []string{"label:other", ""} { + code, body := continuation(filter) + if code != 400 { + t.Fatalf("continuation filter=%q status = %d, want 400 (body %v)", filter, code, body) + } + if errBody, _ := body["error"].(map[string]any); errBody["code"] != "invalid_cursor" { + t.Fatalf("continuation filter=%q error = %v, want invalid_cursor", filter, body) + } + } + + // A cursor created without filter must also reject adding filter on page two. + code, body = getJSON(t, messagesFilterURL(srv.URL, "")+"&limit=1", "good") + if code != 200 { + t.Fatalf("no-filter page 1 status = %d, want 200 (body %v)", code, body) + } + noFilterCursor := body["next_cursor"].(string) + code, body = getJSON(t, messagesFilterURL(srv.URL, "label:urgent")+"&limit=1&cursor="+url.QueryEscape(noFilterCursor), "good") + if code != 400 { + t.Fatalf("adding filter continuation status = %d, want 400 (body %v)", code, body) + } + if errBody, _ := body["error"].(map[string]any); errBody["code"] != "invalid_cursor" { + t.Fatalf("adding filter continuation error = %v, want invalid_cursor", body) + } +} + +func TestListMessagesQIsNotAStructuredFilterAlias(t *testing.T) { + srv := newMessagesFilterTestServer(t) + code, body := getJSON(t, + srv.URL+"/v1/agents/bot@example.com/messages?direction=inbound&read_status=all&q=unknown:value", + "good", + ) + if code != http.StatusOK { + t.Fatalf("status = %d, body = %v; q must be ignored as an unknown query parameter", code, body) + } +} + +func TestFilterParamReachesStore(t *testing.T) { + var captured struct { + sync.Mutex + filter identity.MessageListFilter + } + srv := newMessagesFilterTestServer(t, func(deps *Deps) { + deps.ListMessages = func(_ context.Context, f identity.MessageListFilter) ([]identity.Message, error) { + captured.Lock() + captured.filter = f + captured.Unlock() + return nil, nil + } + }) + + code, body := getJSON(t, messagesFilterURL(srv.URL, "label:urgent"), "good") + if code != 200 { + t.Fatalf("status = %d, want 200 (body %v)", code, body) + } + captured.Lock() + filter := captured.filter + captured.Unlock() + if filter.Filter == nil { + t.Fatal("store Filter = nil, want parsed expression") + } + fragment, args, err := filter.Filter.Emit(filterquery.PostgresDialect{}, 1) + if err != nil { + t.Fatalf("Filter.Emit: %v", err) + } + if fragment != "(m.labels @> $1)" { + t.Errorf("Filter SQL = %q, want %q", fragment, "(m.labels @> $1)") + } + if want := []any{[]string{"urgent"}}; !reflect.DeepEqual(args, want) { + t.Errorf("Filter args = %#v, want %#v", args, want) + } +} + +func TestFilterComposesWithFlatParams(t *testing.T) { + var captured struct { + sync.Mutex + filter identity.MessageListFilter + } + srv := newMessagesFilterTestServer(t, func(deps *Deps) { + deps.ListMessages = func(_ context.Context, f identity.MessageListFilter) ([]identity.Message, error) { + captured.Lock() + captured.filter = f + captured.Unlock() + return nil, nil + } + }) + values := url.Values{} + values.Set("direction", "inbound") + values.Set("read_status", "all") + values.Set("from", "alice") + values.Set("filter", "label:urgent") + code, body := getJSON(t, srv.URL+"/v1/agents/bot@example.com/messages?"+values.Encode(), "good") + if code != 200 { + t.Fatalf("status = %d, want 200 (body %v)", code, body) + } + captured.Lock() + filter := captured.filter + captured.Unlock() + if filter.From != "alice" { + t.Errorf("store filter From = %q, want alice", filter.From) + } + if filter.Filter == nil { + t.Fatal("store Filter = nil, want parsed expression alongside From") + } +} diff --git a/internal/httpapi/messages_q_test.go b/internal/httpapi/messages_q_test.go deleted file mode 100644 index a4db6f44..00000000 --- a/internal/httpapi/messages_q_test.go +++ /dev/null @@ -1,195 +0,0 @@ -package httpapi - -import ( - "context" - "net/url" - "reflect" - "strings" - "sync" - "testing" - "unicode/utf8" - - "github.com/tokencanopy/e2a/internal/filterquery" - "github.com/tokencanopy/e2a/internal/identity" -) - -func messagesQURL(srvURL, q string) string { - values := url.Values{} - values.Set("direction", "inbound") - values.Set("read_status", "all") - values.Set("q", q) - return srvURL + "/v1/agents/support%40acme.com/messages?" + values.Encode() -} - -func filterError(t *testing.T, body map[string]any) map[string]any { - t.Helper() - errBody, ok := body["error"].(map[string]any) - if !ok { - t.Fatalf("expected error envelope, got %v", body) - } - if errBody["code"] != "invalid_filter" { - t.Fatalf("error code = %v, want invalid_filter (body %v)", errBody["code"], body) - } - return errBody -} - -func TestQParamInvalid(t *testing.T) { - valid500Unicode := "subject:" + strings.Repeat("界", 60) - valid500Unicode += strings.Repeat(" ", 500-utf8.RuneCountInString(valid500Unicode)) - if got := utf8.RuneCountInString(valid500Unicode); got != 500 { - t.Fatalf("valid Unicode query length = %d, want 500", got) - } - if len(strings.Repeat("界", 60)) >= 200 { - t.Fatal("test query subject value must remain under the 200-byte field cap") - } - - tests := []struct { - name string - q string - wantInError string - wantOK bool - }{ - {name: "unknown field", q: "unknown:thing", wantInError: `unknown field "unknown"`}, - {name: "attachment filtering deferred", q: "has:attachment", wantInError: `unknown field "has" — supported fields: created, from, label, subject`}, - {name: "forbidden operator", q: "label=urgent", wantInError: `operator "=" is not allowed on field "label"`}, - {name: "syntax retains column", q: "label:", wantInError: "(at column 7)"}, - {name: "ASCII over code point limit", q: "label:" + strings.Repeat("a", 501), wantInError: "q filter too long (max 500 chars)"}, - {name: "Unicode over code point limit", q: strings.Repeat("界", 501), wantInError: "q filter too long (max 500 chars)"}, - {name: "NUL is invalid", q: "subject:\x00", wantInError: "q filter must be valid UTF-8 and must not contain NUL"}, - {name: "malformed UTF-8 is invalid", q: string([]byte("subject:\xff")), wantInError: "q filter must be valid UTF-8 and must not contain NUL"}, - {name: "Unicode code point limit accepts bytes over cap", q: valid500Unicode, wantOK: true}, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - srv := testServer(t) - code, body := getJSON(t, messagesQURL(srv.URL, tc.q), "good") - if tc.wantOK { - if code != 200 { - t.Fatalf("status = %d, want 200 (body %v)", code, body) - } - return - } - if code != 400 { - t.Fatalf("status = %d, want 400 (body %v)", code, body) - } - errBody := filterError(t, body) - message, _ := errBody["message"].(string) - if !strings.Contains(message, tc.wantInError) { - t.Errorf("error message = %q, want it to contain %q", message, tc.wantInError) - } - }) - } -} - -func TestQParamCursorPinning(t *testing.T) { - srv := testServer(t) - page1URL := messagesQURL(srv.URL, "label:urgent") + "&limit=1" - code, body := getJSON(t, page1URL, "good") - if code != 200 { - t.Fatalf("page 1 status = %d, want 200 (body %v)", code, body) - } - cursor, ok := body["next_cursor"].(string) - if !ok || cursor == "" { - t.Fatalf("page 1 next_cursor = %v, want non-empty string", body["next_cursor"]) - } - - continuation := func(q string) (int, map[string]any) { - return getJSON(t, messagesQURL(srv.URL, q)+"&limit=1&cursor="+url.QueryEscape(cursor), "good") - } - if code, body := continuation("label:urgent"); code != 200 { - t.Fatalf("identical q continuation status = %d, want 200 (body %v)", code, body) - } - for _, q := range []string{"label:other", ""} { - code, body := continuation(q) - if code != 400 { - t.Fatalf("continuation q=%q status = %d, want 400 (body %v)", q, code, body) - } - if errBody, _ := body["error"].(map[string]any); errBody["code"] != "invalid_cursor" { - t.Fatalf("continuation q=%q error = %v, want invalid_cursor", q, body) - } - } - - // A cursor created without q must also reject adding q on page two. - code, body = getJSON(t, srv.URL+"/v1/agents/support%40acme.com/messages?direction=inbound&read_status=all&limit=1", "good") - if code != 200 { - t.Fatalf("no-q page 1 status = %d, want 200 (body %v)", code, body) - } - noQCursor := body["next_cursor"].(string) - code, body = getJSON(t, messagesQURL(srv.URL, "label:urgent")+"&limit=1&cursor="+url.QueryEscape(noQCursor), "good") - if code != 400 { - t.Fatalf("adding q continuation status = %d, want 400 (body %v)", code, body) - } - if errBody, _ := body["error"].(map[string]any); errBody["code"] != "invalid_cursor" { - t.Fatalf("adding q continuation error = %v, want invalid_cursor", body) - } -} - -func TestQParamReachesStore(t *testing.T) { - var captured struct { - sync.Mutex - filter identity.MessageListFilter - } - srv := testServer(t, func(deps *Deps) { - deps.ListMessages = func(_ context.Context, f identity.MessageListFilter) ([]identity.Message, error) { - captured.Lock() - captured.filter = f - captured.Unlock() - return nil, nil - } - }) - - code, body := getJSON(t, messagesQURL(srv.URL, "label:urgent"), "good") - if code != 200 { - t.Fatalf("status = %d, want 200 (body %v)", code, body) - } - captured.Lock() - filter := captured.filter - captured.Unlock() - if filter.Q == nil { - t.Fatal("store filter Q = nil, want parsed expression") - } - fragment, args, err := filter.Q.Emit(filterquery.PostgresDialect{}, 1) - if err != nil { - t.Fatalf("Q.Emit: %v", err) - } - if fragment != "(m.labels @> $1)" { - t.Errorf("Q SQL = %q, want %q", fragment, "(m.labels @> $1)") - } - if want := []any{[]string{"urgent"}}; !reflect.DeepEqual(args, want) { - t.Errorf("Q args = %#v, want %#v", args, want) - } -} - -func TestQComposesWithFlatParams(t *testing.T) { - var captured struct { - sync.Mutex - filter identity.MessageListFilter - } - srv := testServer(t, func(deps *Deps) { - deps.ListMessages = func(_ context.Context, f identity.MessageListFilter) ([]identity.Message, error) { - captured.Lock() - captured.filter = f - captured.Unlock() - return nil, nil - } - }) - values := url.Values{} - values.Set("direction", "inbound") - values.Set("read_status", "all") - values.Set("from", "alice") - values.Set("q", "label:urgent") - code, body := getJSON(t, srv.URL+"/v1/agents/support%40acme.com/messages?"+values.Encode(), "good") - if code != 200 { - t.Fatalf("status = %d, want 200 (body %v)", code, body) - } - captured.Lock() - filter := captured.filter - captured.Unlock() - if filter.From != "alice" { - t.Errorf("store filter From = %q, want alice", filter.From) - } - if filter.Q == nil { - t.Fatal("store filter Q = nil, want parsed expression alongside From") - } -} diff --git a/internal/identity/filter_differential_test.go b/internal/identity/filter_differential_test.go index 60c6f30c..de655d92 100644 --- a/internal/identity/filter_differential_test.go +++ b/internal/identity/filter_differential_test.go @@ -148,12 +148,12 @@ func TestQFilterDifferential(t *testing.T) { for _, tc := range queries { t.Run(tc.q, func(t *testing.T) { - expr, err := filterquery.Parse(tc.q, identity.MessagesQRegistry()) + expr, err := filterquery.Parse(tc.q, identity.MessagesFilterRegistry()) if err != nil { t.Fatalf("Parse(%q): %v", tc.q, err) } messages, err := store.GetMessagesByAgent(ctx, identity.MessageListFilter{ - AgentID: agentID, Direction: "inbound", Status: "all", Limit: 100, Q: expr, + AgentID: agentID, Direction: "inbound", Status: "all", Limit: 100, Filter: expr, }) if err != nil { t.Fatalf("GetMessagesByAgent(%q): %v", tc.q, err) @@ -171,7 +171,7 @@ func TestQFilterComposesAfterFlatFilters(t *testing.T) { ctx := context.Background() agentID := seedQAgent(t, store, ctx) byKey := seedQFixtures(t, pool, store, agentID) - expr, err := filterquery.Parse(`from:corp.com`, identity.MessagesQRegistry()) + expr, err := filterquery.Parse(`from:corp.com`, identity.MessagesFilterRegistry()) if err != nil { t.Fatalf("Parse: %v", err) } @@ -179,7 +179,7 @@ func TestQFilterComposesAfterFlatFilters(t *testing.T) { messages, err := store.GetMessagesByAgent(ctx, identity.MessageListFilter{ AgentID: agentID, Direction: "inbound", Status: "all", Limit: 100, From: "alice", SubjectContains: "quarter", ConversationID: "qdiff-conversation", - Since: day, Until: day.AddDate(0, 0, 1), Labels: []string{"urgent"}, Q: expr, + Since: day, Until: day.AddDate(0, 0, 1), Labels: []string{"urgent"}, Filter: expr, }) if err != nil { t.Fatalf("GetMessagesByAgent: %v", err) @@ -205,11 +205,11 @@ func TestQFilterPropagatesEmissionErrorBeforeQuery(t *testing.T) { } store := identity.NewStore(nil) - _, err = store.GetMessagesByAgent(context.Background(), identity.MessageListFilter{AgentID: "agent_not_queried", Q: expr}) + _, err = store.GetMessagesByAgent(context.Background(), identity.MessageListFilter{AgentID: "agent_not_queried", Filter: expr}) if !errors.Is(err, sentinel) { t.Fatalf("GetMessagesByAgent error = %v, want wrapped %v", err, sentinel) } - if !strings.Contains(err.Error(), "emit q filter") { - t.Errorf("error = %q, want q-emission context", err) + if !strings.Contains(err.Error(), "emit message filter") { + t.Errorf("error = %q, want message-filter emission context", err) } } diff --git a/internal/identity/filter_registry.go b/internal/identity/filter_registry.go index c47ce0fb..6700bcb4 100644 --- a/internal/identity/filter_registry.go +++ b/internal/identity/filter_registry.go @@ -17,8 +17,8 @@ var ( qRegistry *filterquery.Registry ) -// MessagesQRegistry returns the shared field registry for list_messages q. -func MessagesQRegistry() *filterquery.Registry { +// MessagesFilterRegistry returns the shared field registry for list_messages filters. +func MessagesFilterRegistry() *filterquery.Registry { qRegistryOnce.Do(func() { reg, err := filterquery.NewRegistry( labelQField(), diff --git a/internal/identity/filter_registry_test.go b/internal/identity/filter_registry_test.go index 13e9ee81..0031067d 100644 --- a/internal/identity/filter_registry_test.go +++ b/internal/identity/filter_registry_test.go @@ -9,28 +9,28 @@ import ( "github.com/tokencanopy/e2a/internal/filterquery" ) -func compileQ(t *testing.T, q string, start int) (string, []any) { +func compileFilter(t *testing.T, filter string, start int) (string, []any) { t.Helper() - frag, args, err := filterquery.Compile(q, MessagesQRegistry(), filterquery.PostgresDialect{}, start) + frag, args, err := filterquery.Compile(filter, MessagesFilterRegistry(), filterquery.PostgresDialect{}, start) if err != nil { - t.Fatalf("Compile(%q): %v", q, err) + t.Fatalf("Compile(%q): %v", filter, err) } return frag, args } -func TestMessagesQRegistrySingleton(t *testing.T) { +func TestMessagesFilterRegistrySingleton(t *testing.T) { t.Parallel() const callers = 32 registries := make(chan *filterquery.Registry, callers) for range callers { - go func() { registries <- MessagesQRegistry() }() + go func() { registries <- MessagesFilterRegistry() }() } first := <-registries for range callers - 1 { if got := <-registries; got != first { - t.Fatal("MessagesQRegistry returned distinct registries") + t.Fatal("MessagesFilterRegistry returned distinct registries") } } } @@ -38,17 +38,17 @@ func TestMessagesQRegistrySingleton(t *testing.T) { func TestLabelField(t *testing.T) { t.Parallel() - frag, args := compileQ(t, `label:urgent`, 1) + frag, args := compileFilter(t, `label:urgent`, 1) if frag != `(m.labels @> $1)` || !reflect.DeepEqual(args, []any{[]string{"urgent"}}) { t.Errorf("frag=%s args=%v", frag, args) } - if _, _, err := filterquery.Compile(`label:UPPER`, MessagesQRegistry(), filterquery.PostgresDialect{}, 1); err == nil { + if _, _, err := filterquery.Compile(`label:UPPER`, MessagesFilterRegistry(), filterquery.PostgresDialect{}, 1); err == nil { t.Error("uppercase label: want rejection (charset)") } - if _, _, err := filterquery.Compile(`label = "urgent"`, MessagesQRegistry(), filterquery.PostgresDialect{}, 1); err == nil { + if _, _, err := filterquery.Compile(`label = "urgent"`, MessagesFilterRegistry(), filterquery.PostgresDialect{}, 1); err == nil { t.Error("label=: want operator rejection") } - if _, _, err := filterquery.Compile(`label:e2a:held`, MessagesQRegistry(), filterquery.PostgresDialect{}, 1); err != nil { + if _, _, err := filterquery.Compile(`label:e2a:held`, MessagesFilterRegistry(), filterquery.PostgresDialect{}, 1); err != nil { t.Errorf("system label filter should work: %v", err) } } @@ -56,7 +56,7 @@ func TestLabelField(t *testing.T) { func TestFromFieldMatchesFlatParam(t *testing.T) { t.Parallel() - frag, args := compileQ(t, `from:alice@x.com`, 1) + frag, args := compileFilter(t, `from:alice@x.com`, 1) if frag != `(m.sender ILIKE $1 ESCAPE '\')` { t.Errorf("frag = %s", frag) } @@ -64,13 +64,13 @@ func TestFromFieldMatchesFlatParam(t *testing.T) { t.Errorf("args = %v", args) } - frag, args = compileQ(t, `from:"*@x_%.com\\tail"`, 1) + frag, args = compileFilter(t, `from:"*@x_%.com\\tail"`, 1) if !reflect.DeepEqual(args, []any{`%%@x\_\%.com\\tail%`}) { t.Errorf("wildcard args = %v", args) } for _, op := range []string{"=", "!="} { - frag, args = compileQ(t, `from `+op+` "a*b%_"`, 1) + frag, args = compileFilter(t, `from `+op+` "a*b%_"`, 1) wantFrag := `(LOWER(m.sender) ` + op + ` LOWER($1))` if frag != wantFrag || !reflect.DeepEqual(args, []any{"a*b%_"}) { t.Errorf("exact %s: frag=%s args=%v", op, frag, args) @@ -97,7 +97,7 @@ func TestTextFieldLengthBounds(t *testing.T) { } { tc := tc t.Run(tc.name, func(t *testing.T) { - _, _, err := filterquery.Compile(field+`:"`+tc.value+`"`, MessagesQRegistry(), filterquery.PostgresDialect{}, 1) + _, _, err := filterquery.Compile(field+`:"`+tc.value+`"`, MessagesFilterRegistry(), filterquery.PostgresDialect{}, 1) if tc.valid && err != nil { t.Fatalf("%s %s value: %v", tc.name, field, err) } @@ -113,18 +113,18 @@ func TestTextFieldLengthBounds(t *testing.T) { func TestSubjectField(t *testing.T) { t.Parallel() - frag, args := compileQ(t, `subject:quarterly`, 1) + frag, args := compileFilter(t, `subject:quarterly`, 1) if frag != `(m.subject ILIKE $1 ESCAPE '\')` || !reflect.DeepEqual(args, []any{"%quarterly%"}) { t.Errorf("frag=%s args=%v", frag, args) } - frag, args = compileQ(t, `subject:"*@x_%.com\\tail"`, 1) + frag, args = compileFilter(t, `subject:"*@x_%.com\\tail"`, 1) if !reflect.DeepEqual(args, []any{`%%@x\_\%.com\\tail%`}) { t.Errorf("wildcard args = %v", args) } for _, op := range []string{"=", "!="} { - frag, args = compileQ(t, `subject `+op+` "a*b%_"`, 1) + frag, args = compileFilter(t, `subject `+op+` "a*b%_"`, 1) wantFrag := `(LOWER(m.subject) ` + op + ` LOWER($1))` if frag != wantFrag || !reflect.DeepEqual(args, []any{"a*b%_"}) { t.Errorf("exact %s: frag=%s args=%v", op, frag, args) @@ -135,7 +135,7 @@ func TestSubjectField(t *testing.T) { func TestHasAttachmentIsDeferred(t *testing.T) { t.Parallel() - _, _, err := filterquery.Compile(`has:attachment`, MessagesQRegistry(), filterquery.PostgresDialect{}, 1) + _, _, err := filterquery.Compile(`has:attachment`, MessagesFilterRegistry(), filterquery.PostgresDialect{}, 1) if err == nil { t.Fatal("has:attachment: want unknown-field rejection") } @@ -165,7 +165,7 @@ func TestCreatedField(t *testing.T) { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() - frag, args := compileQ(t, tc.q, 1) + frag, args := compileFilter(t, tc.q, 1) if frag != tc.frag || !reflect.DeepEqual(args, tc.args) { t.Errorf("frag=%s args=%v, want frag=%s args=%v", frag, args, tc.frag, tc.args) } @@ -173,7 +173,7 @@ func TestCreatedField(t *testing.T) { } ts := "2026-07-25T10:30:00Z" - frag, args := compileQ(t, `created<`+ts, 1) + frag, args := compileFilter(t, `created<`+ts, 1) want, err := time.Parse(time.RFC3339, ts) if err != nil { t.Fatal(err) @@ -181,11 +181,11 @@ func TestCreatedField(t *testing.T) { if frag != `(m.created_at < $1)` || !reflect.DeepEqual(args, []any{want}) { t.Errorf("rfc3339 frag=%s args=%v", frag, args) } - frag, args = compileQ(t, `created=`+ts, 1) + frag, args = compileFilter(t, `created=`+ts, 1) if frag != `(m.created_at = $1)` || !reflect.DeepEqual(args, []any{want}) { t.Errorf("rfc3339 equality frag=%s args=%v", frag, args) } - if _, _, err := filterquery.Compile(`created>yesterday`, MessagesQRegistry(), filterquery.PostgresDialect{}, 1); err == nil { + if _, _, err := filterquery.Compile(`created>yesterday`, MessagesFilterRegistry(), filterquery.PostgresDialect{}, 1); err == nil { t.Error("bad date: want rejection") } } diff --git a/internal/identity/store.go b/internal/identity/store.go index b1440639..3a11c7f3 100644 --- a/internal/identity/store.go +++ b/internal/identity/store.go @@ -3677,9 +3677,9 @@ type MessageListFilter struct { // rows. Handler-layer validates each entry against the same charset // rule used on writes so callers can't smuggle SQL through here. Labels []string - // Q is a parsed and validated q-expression. The Postgres store emits it - // after built-in filters so placeholder numbering stays correct. - Q *filterquery.Expr + // Filter is a parsed and registry-validated expression. The store emits it + // after built-in filters so placeholder numbering remains contiguous. + Filter *filterquery.Expr // Deleted flips the query to the TRASH view: only soft-deleted rows. // False (default) lists indefinitely retained live rows only. Deleted bool @@ -3808,14 +3808,17 @@ func (s *Store) GetMessagesByAgent(ctx context.Context, f MessageListFilter) ([] query += fmt.Sprintf(` AND m.labels @> $%d`, len(args)+1) args = append(args, f.Labels) } - if f.Q != nil { - frag, qargs, err := f.Q.Emit(filterquery.PostgresDialect{}, len(args)+1) + if f.Filter != nil { + fragment, filterArgs, err := f.Filter.Emit( + filterquery.PostgresDialect{}, + len(args)+1, + ) if err != nil { - return nil, fmt.Errorf("emit q filter: %w", err) + return nil, fmt.Errorf("emit message filter: %w", err) } - if frag != "" { - query += " AND " + frag - args = append(args, qargs...) + if fragment != "" { + query += " AND " + fragment + args = append(args, filterArgs...) } } From 977aec43b1df1a90f42cafde70484b0a3ec55c61 Mon Sep 17 00:00:00 2001 From: jiashuoz Date: Tue, 28 Jul 2026 17:28:07 -0700 Subject: [PATCH 10/12] fix(sdk): preserve message list parameter order --- api/openapi.yaml | 14 ++++---- sdks/python/src/e2a/v1/client.py | 4 +-- .../src/e2a/v1/generated/api/messages_api.py | 34 +++++++++---------- sdks/python/tests/test_v1_client.py | 11 ++++-- sdks/typescript/src/v1/client.ts | 23 +++++++++---- .../src/v1/generated/apis/MessagesApi.ts | 14 ++++---- .../src/v1/generated/types/ObjectParamAPI.ts | 18 +++++----- .../src/v1/generated/types/ObservableAPI.ts | 12 +++---- .../src/v1/generated/types/PromiseAPI.ts | 12 +++---- sdks/typescript/test/v1/client.test.ts | 33 ++++++++++++++++-- 10 files changed, 109 insertions(+), 66 deletions(-) diff --git a/api/openapi.yaml b/api/openapi.yaml index 04ccd722..f471801b 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -4950,13 +4950,6 @@ paths: type: - array - "null" - - description: "Boolean filter expression (AIP-160-derived). v1 fields: label, from, subject, created. Operators: : = != < <= > >= with AND / OR / NOT and parentheses; whitespace is implicit AND and binds looser than OR (e.g. 'label:urgent OR (from:alerts AND NOT subject:newsletter) created>=2026-07-01'). Composes with (ANDs) the flat filters. Unknown fields/operators are rejected with a positioned invalid_filter error. Max 500 chars." - explode: false - in: query - name: q - schema: - description: "Boolean filter expression (AIP-160-derived). v1 fields: label, from, subject, created. Operators: : = != < <= > >= with AND / OR / NOT and parentheses; whitespace is implicit AND and binds looser than OR (e.g. 'label:urgent OR (from:alerts AND NOT subject:newsletter) created>=2026-07-01'). Composes with (ANDs) the flat filters. Unknown fields/operators are rejected with a positioned invalid_filter error. Max 500 chars." - type: string - description: RFC3339; created_at >= since. explode: false in: query @@ -4992,6 +4985,13 @@ paths: schema: description: "List the trash instead: messages that were soft-deleted and are restorable until purged (30 days after deletion by default, deployment-configurable). Defaults to false (live messages only)." type: boolean + - description: "Boolean filter expression (AIP-160-derived). v1 fields: label, from, subject, created. Operators: : = != < <= > >= with AND / OR / NOT and parentheses; whitespace is implicit AND and binds looser than OR. Composes with (ANDs) the flat filters. Unknown fields/operators are rejected with a positioned invalid_filter error. Max 500 chars." + explode: false + in: query + name: filter + schema: + description: "Boolean filter expression (AIP-160-derived). v1 fields: label, from, subject, created. Operators: : = != < <= > >= with AND / OR / NOT and parentheses; whitespace is implicit AND and binds looser than OR. Composes with (ANDs) the flat filters. Unknown fields/operators are rejected with a positioned invalid_filter error. Max 500 chars." + type: string responses: "200": content: diff --git a/sdks/python/src/e2a/v1/client.py b/sdks/python/src/e2a/v1/client.py index c8d79cb4..c9e0baea 100644 --- a/sdks/python/src/e2a/v1/client.py +++ b/sdks/python/src/e2a/v1/client.py @@ -448,11 +448,11 @@ def list( subject_contains: Optional[str] = None, conversation_id: Optional[str] = None, labels: Optional[List[str]] = None, - q: Optional[str] = None, since: Optional[str] = None, until: Optional[str] = None, limit: Optional[int] = None, deleted: Optional[bool] = None, + filter: Optional[str] = None, ) -> AutoPager[MessageSummaryView]: # `from` is a Python keyword; the generator is configured (via # --name-mappings/--parameter-name-mappings in generate-oag.sh) to expose @@ -469,12 +469,12 @@ async def fetch(cursor: Optional[str]) -> Page: subject_contains=subject_contains, conversation_id=conversation_id, labels=labels, - q=q, since=since, until=until, cursor=cursor, limit=limit, deleted=deleted, + filter=filter, _headers=h, ) ) diff --git a/sdks/python/src/e2a/v1/generated/api/messages_api.py b/sdks/python/src/e2a/v1/generated/api/messages_api.py index 33a62d11..19c45176 100644 --- a/sdks/python/src/e2a/v1/generated/api/messages_api.py +++ b/sdks/python/src/e2a/v1/generated/api/messages_api.py @@ -1624,12 +1624,12 @@ async def list_messages( subject_contains: Annotated[Optional[StrictStr], Field(description="Case-insensitive substring match on subject.")] = None, conversation_id: Optional[StrictStr] = None, labels: Annotated[Optional[List[StrictStr]], Field(description="Comma-separated list (e.g. labels=urgent,follow-up); AND-matched — a message must carry every given label.")] = None, - q: Annotated[Optional[StrictStr], Field(description="Boolean filter expression (AIP-160-derived). v1 fields: label, from, subject, created. Operators: : = != < <= > >= with AND / OR / NOT and parentheses; whitespace is implicit AND and binds looser than OR (e.g. 'label:urgent OR (from:alerts AND NOT subject:newsletter) created>=2026-07-01'). Composes with (ANDs) the flat filters. Unknown fields/operators are rejected with a positioned invalid_filter error. Max 500 chars.")] = None, since: Annotated[Optional[StrictStr], Field(description="RFC3339; created_at >= since.")] = None, until: Annotated[Optional[StrictStr], Field(description="RFC3339; created_at < until.")] = None, cursor: Optional[StrictStr] = None, limit: Optional[Annotated[int, Field(le=100, strict=True, ge=1)]] = None, deleted: Annotated[Optional[StrictBool], Field(description="List the trash instead: messages that were soft-deleted and are restorable until purged (30 days after deletion by default, deployment-configurable). Defaults to false (live messages only).")] = None, + filter: Annotated[Optional[StrictStr], Field(description="Boolean filter expression (AIP-160-derived). v1 fields: label, from, subject, created. Operators: : = != < <= > >= with AND / OR / NOT and parentheses; whitespace is implicit AND and binds looser than OR. Composes with (ANDs) the flat filters. Unknown fields/operators are rejected with a positioned invalid_filter error. Max 500 chars.")] = None, _request_timeout: Union[ None, Annotated[StrictFloat, Field(gt=0)], @@ -1663,8 +1663,6 @@ async def list_messages( :type conversation_id: str :param labels: Comma-separated list (e.g. labels=urgent,follow-up); AND-matched — a message must carry every given label. :type labels: List[str] - :param q: Boolean filter expression (AIP-160-derived). v1 fields: label, from, subject, created. Operators: : = != < <= > >= with AND / OR / NOT and parentheses; whitespace is implicit AND and binds looser than OR (e.g. 'label:urgent OR (from:alerts AND NOT subject:newsletter) created>=2026-07-01'). Composes with (ANDs) the flat filters. Unknown fields/operators are rejected with a positioned invalid_filter error. Max 500 chars. - :type q: str :param since: RFC3339; created_at >= since. :type since: str :param until: RFC3339; created_at < until. @@ -1675,6 +1673,8 @@ async def list_messages( :type limit: int :param deleted: List the trash instead: messages that were soft-deleted and are restorable until purged (30 days after deletion by default, deployment-configurable). Defaults to false (live messages only). :type deleted: bool + :param filter: Boolean filter expression (AIP-160-derived). v1 fields: label, from, subject, created. Operators: : = != < <= > >= with AND / OR / NOT and parentheses; whitespace is implicit AND and binds looser than OR. Composes with (ANDs) the flat filters. Unknown fields/operators are rejected with a positioned invalid_filter error. Max 500 chars. + :type filter: str :param _request_timeout: timeout setting for this request. If one number provided, it will be total request timeout. It can also be a pair (tuple) of @@ -1706,12 +1706,12 @@ async def list_messages( subject_contains=subject_contains, conversation_id=conversation_id, labels=labels, - q=q, since=since, until=until, cursor=cursor, limit=limit, deleted=deleted, + filter=filter, _request_auth=_request_auth, _content_type=_content_type, _headers=_headers, @@ -1743,12 +1743,12 @@ async def list_messages_with_http_info( subject_contains: Annotated[Optional[StrictStr], Field(description="Case-insensitive substring match on subject.")] = None, conversation_id: Optional[StrictStr] = None, labels: Annotated[Optional[List[StrictStr]], Field(description="Comma-separated list (e.g. labels=urgent,follow-up); AND-matched — a message must carry every given label.")] = None, - q: Annotated[Optional[StrictStr], Field(description="Boolean filter expression (AIP-160-derived). v1 fields: label, from, subject, created. Operators: : = != < <= > >= with AND / OR / NOT and parentheses; whitespace is implicit AND and binds looser than OR (e.g. 'label:urgent OR (from:alerts AND NOT subject:newsletter) created>=2026-07-01'). Composes with (ANDs) the flat filters. Unknown fields/operators are rejected with a positioned invalid_filter error. Max 500 chars.")] = None, since: Annotated[Optional[StrictStr], Field(description="RFC3339; created_at >= since.")] = None, until: Annotated[Optional[StrictStr], Field(description="RFC3339; created_at < until.")] = None, cursor: Optional[StrictStr] = None, limit: Optional[Annotated[int, Field(le=100, strict=True, ge=1)]] = None, deleted: Annotated[Optional[StrictBool], Field(description="List the trash instead: messages that were soft-deleted and are restorable until purged (30 days after deletion by default, deployment-configurable). Defaults to false (live messages only).")] = None, + filter: Annotated[Optional[StrictStr], Field(description="Boolean filter expression (AIP-160-derived). v1 fields: label, from, subject, created. Operators: : = != < <= > >= with AND / OR / NOT and parentheses; whitespace is implicit AND and binds looser than OR. Composes with (ANDs) the flat filters. Unknown fields/operators are rejected with a positioned invalid_filter error. Max 500 chars.")] = None, _request_timeout: Union[ None, Annotated[StrictFloat, Field(gt=0)], @@ -1782,8 +1782,6 @@ async def list_messages_with_http_info( :type conversation_id: str :param labels: Comma-separated list (e.g. labels=urgent,follow-up); AND-matched — a message must carry every given label. :type labels: List[str] - :param q: Boolean filter expression (AIP-160-derived). v1 fields: label, from, subject, created. Operators: : = != < <= > >= with AND / OR / NOT and parentheses; whitespace is implicit AND and binds looser than OR (e.g. 'label:urgent OR (from:alerts AND NOT subject:newsletter) created>=2026-07-01'). Composes with (ANDs) the flat filters. Unknown fields/operators are rejected with a positioned invalid_filter error. Max 500 chars. - :type q: str :param since: RFC3339; created_at >= since. :type since: str :param until: RFC3339; created_at < until. @@ -1794,6 +1792,8 @@ async def list_messages_with_http_info( :type limit: int :param deleted: List the trash instead: messages that were soft-deleted and are restorable until purged (30 days after deletion by default, deployment-configurable). Defaults to false (live messages only). :type deleted: bool + :param filter: Boolean filter expression (AIP-160-derived). v1 fields: label, from, subject, created. Operators: : = != < <= > >= with AND / OR / NOT and parentheses; whitespace is implicit AND and binds looser than OR. Composes with (ANDs) the flat filters. Unknown fields/operators are rejected with a positioned invalid_filter error. Max 500 chars. + :type filter: str :param _request_timeout: timeout setting for this request. If one number provided, it will be total request timeout. It can also be a pair (tuple) of @@ -1825,12 +1825,12 @@ async def list_messages_with_http_info( subject_contains=subject_contains, conversation_id=conversation_id, labels=labels, - q=q, since=since, until=until, cursor=cursor, limit=limit, deleted=deleted, + filter=filter, _request_auth=_request_auth, _content_type=_content_type, _headers=_headers, @@ -1862,12 +1862,12 @@ async def list_messages_without_preload_content( subject_contains: Annotated[Optional[StrictStr], Field(description="Case-insensitive substring match on subject.")] = None, conversation_id: Optional[StrictStr] = None, labels: Annotated[Optional[List[StrictStr]], Field(description="Comma-separated list (e.g. labels=urgent,follow-up); AND-matched — a message must carry every given label.")] = None, - q: Annotated[Optional[StrictStr], Field(description="Boolean filter expression (AIP-160-derived). v1 fields: label, from, subject, created. Operators: : = != < <= > >= with AND / OR / NOT and parentheses; whitespace is implicit AND and binds looser than OR (e.g. 'label:urgent OR (from:alerts AND NOT subject:newsletter) created>=2026-07-01'). Composes with (ANDs) the flat filters. Unknown fields/operators are rejected with a positioned invalid_filter error. Max 500 chars.")] = None, since: Annotated[Optional[StrictStr], Field(description="RFC3339; created_at >= since.")] = None, until: Annotated[Optional[StrictStr], Field(description="RFC3339; created_at < until.")] = None, cursor: Optional[StrictStr] = None, limit: Optional[Annotated[int, Field(le=100, strict=True, ge=1)]] = None, deleted: Annotated[Optional[StrictBool], Field(description="List the trash instead: messages that were soft-deleted and are restorable until purged (30 days after deletion by default, deployment-configurable). Defaults to false (live messages only).")] = None, + filter: Annotated[Optional[StrictStr], Field(description="Boolean filter expression (AIP-160-derived). v1 fields: label, from, subject, created. Operators: : = != < <= > >= with AND / OR / NOT and parentheses; whitespace is implicit AND and binds looser than OR. Composes with (ANDs) the flat filters. Unknown fields/operators are rejected with a positioned invalid_filter error. Max 500 chars.")] = None, _request_timeout: Union[ None, Annotated[StrictFloat, Field(gt=0)], @@ -1901,8 +1901,6 @@ async def list_messages_without_preload_content( :type conversation_id: str :param labels: Comma-separated list (e.g. labels=urgent,follow-up); AND-matched — a message must carry every given label. :type labels: List[str] - :param q: Boolean filter expression (AIP-160-derived). v1 fields: label, from, subject, created. Operators: : = != < <= > >= with AND / OR / NOT and parentheses; whitespace is implicit AND and binds looser than OR (e.g. 'label:urgent OR (from:alerts AND NOT subject:newsletter) created>=2026-07-01'). Composes with (ANDs) the flat filters. Unknown fields/operators are rejected with a positioned invalid_filter error. Max 500 chars. - :type q: str :param since: RFC3339; created_at >= since. :type since: str :param until: RFC3339; created_at < until. @@ -1913,6 +1911,8 @@ async def list_messages_without_preload_content( :type limit: int :param deleted: List the trash instead: messages that were soft-deleted and are restorable until purged (30 days after deletion by default, deployment-configurable). Defaults to false (live messages only). :type deleted: bool + :param filter: Boolean filter expression (AIP-160-derived). v1 fields: label, from, subject, created. Operators: : = != < <= > >= with AND / OR / NOT and parentheses; whitespace is implicit AND and binds looser than OR. Composes with (ANDs) the flat filters. Unknown fields/operators are rejected with a positioned invalid_filter error. Max 500 chars. + :type filter: str :param _request_timeout: timeout setting for this request. If one number provided, it will be total request timeout. It can also be a pair (tuple) of @@ -1944,12 +1944,12 @@ async def list_messages_without_preload_content( subject_contains=subject_contains, conversation_id=conversation_id, labels=labels, - q=q, since=since, until=until, cursor=cursor, limit=limit, deleted=deleted, + filter=filter, _request_auth=_request_auth, _content_type=_content_type, _headers=_headers, @@ -1976,12 +1976,12 @@ def _list_messages_serialize( subject_contains, conversation_id, labels, - q, since, until, cursor, limit, deleted, + filter, _request_auth, _content_type, _headers, @@ -2035,10 +2035,6 @@ def _list_messages_serialize( _query_params.append(('labels', labels)) - if q is not None: - - _query_params.append(('q', q)) - if since is not None: _query_params.append(('since', since)) @@ -2059,6 +2055,10 @@ def _list_messages_serialize( _query_params.append(('deleted', deleted)) + if filter is not None: + + _query_params.append(('filter', filter)) + # process the header parameters # process the form parameters # process the body parameter diff --git a/sdks/python/tests/test_v1_client.py b/sdks/python/tests/test_v1_client.py index daecd5e2..7c639bf9 100644 --- a/sdks/python/tests/test_v1_client.py +++ b/sdks/python/tests/test_v1_client.py @@ -771,11 +771,16 @@ async def test_messages_list_threads_cursor(httpx_mock): @pytest.mark.anyio -async def test_messages_list_passes_q_verbatim(httpx_mock): +async def test_messages_list_forwards_filter(httpx_mock): httpx_mock.add_response(json={"items": [], "next_cursor": None}) async with _client() as c: - await c.messages.list("bot@test.dev", q="label:urgent").to_list(limit=10) - assert httpx_mock.get_requests()[-1].url.params["q"] == "label:urgent" + await c.messages.list( + "bot@test.dev", deleted=True, filter="label:urgent" + ).to_list(limit=10) + request = httpx_mock.get_requests()[-1] + assert request.url.params["filter"] == "label:urgent" + assert "q" not in request.url.params + assert request.url.params["deleted"] == "true" @pytest.mark.anyio diff --git a/sdks/typescript/src/v1/client.ts b/sdks/typescript/src/v1/client.ts index bb18cdaa..d4708bad 100644 --- a/sdks/typescript/src/v1/client.ts +++ b/sdks/typescript/src/v1/client.ts @@ -340,12 +340,12 @@ export interface ListMessagesParams { subjectContains?: string; conversationId?: string; labels?: string[]; - q?: string; since?: string; until?: string; limit?: number; /** List soft-deleted messages in the trash instead of live messages. */ deleted?: boolean; + filter?: string; } class MessagesResource { @@ -353,11 +353,22 @@ class MessagesResource { list(email: string, params: ListMessagesParams = {}): AutoPager { return new AutoPager(async (cursor) => { - const page = await call(() => - this.api.listMessages(email, params.direction, params.readStatus, params.sort, params.from_, - params.subjectContains, params.conversationId, params.labels, params.q, params.since, params.until, - cursor, params.limit, params.deleted), - ); + const page = await call(() => this.api.listMessages( + email, + params.direction, + params.readStatus, + params.sort, + params.from_, + params.subjectContains, + params.conversationId, + params.labels, + params.since, + params.until, + cursor, + params.limit, + params.deleted, + params.filter, + )); return { items: page.items ?? [], next_cursor: page.nextCursor }; }); } diff --git a/sdks/typescript/src/v1/generated/apis/MessagesApi.ts b/sdks/typescript/src/v1/generated/apis/MessagesApi.ts index 0a8ae6c3..06a6de67 100644 --- a/sdks/typescript/src/v1/generated/apis/MessagesApi.ts +++ b/sdks/typescript/src/v1/generated/apis/MessagesApi.ts @@ -342,14 +342,14 @@ export class MessagesApiRequestFactory extends BaseAPIRequestFactory { * @param subjectContains Case-insensitive substring match on subject. * @param conversationId * @param labels Comma-separated list (e.g. labels=urgent,follow-up); AND-matched — a message must carry every given label. - * @param q Boolean filter expression (AIP-160-derived). v1 fields: label, from, subject, created. Operators: : = != < <= > >= with AND / OR / NOT and parentheses; whitespace is implicit AND and binds looser than OR (e.g. \'label:urgent OR (from:alerts AND NOT subject:newsletter) created>=2026-07-01\'). Composes with (ANDs) the flat filters. Unknown fields/operators are rejected with a positioned invalid_filter error. Max 500 chars. * @param since RFC3339; created_at >= since. * @param until RFC3339; created_at < until. * @param cursor * @param limit * @param deleted List the trash instead: messages that were soft-deleted and are restorable until purged (30 days after deletion by default, deployment-configurable). Defaults to false (live messages only). + * @param filter Boolean filter expression (AIP-160-derived). v1 fields: label, from, subject, created. Operators: : = != < <= > >= with AND / OR / NOT and parentheses; whitespace is implicit AND and binds looser than OR. Composes with (ANDs) the flat filters. Unknown fields/operators are rejected with a positioned invalid_filter error. Max 500 chars. */ - public async listMessages(email: string, direction?: 'inbound' | 'outbound' | 'all', readStatus?: 'unread' | 'read' | 'all', sort?: 'asc' | 'desc', from_?: string, subjectContains?: string, conversationId?: string, labels?: Array, q?: string, since?: string, until?: string, cursor?: string, limit?: number, deleted?: boolean, _options?: Configuration): Promise { + public async listMessages(email: string, direction?: 'inbound' | 'outbound' | 'all', readStatus?: 'unread' | 'read' | 'all', sort?: 'asc' | 'desc', from_?: string, subjectContains?: string, conversationId?: string, labels?: Array, since?: string, until?: string, cursor?: string, limit?: number, deleted?: boolean, filter?: string, _options?: Configuration): Promise { let _config = _options || this.configuration; // verify required parameter 'email' is not null or undefined @@ -414,11 +414,6 @@ export class MessagesApiRequestFactory extends BaseAPIRequestFactory { requestContext.setQueryParam("labels", ObjectSerializer.serialize(labels, "Array", "")); } - // Query Params - if (q !== undefined) { - requestContext.setQueryParam("q", ObjectSerializer.serialize(q, "string", "")); - } - // Query Params if (since !== undefined) { requestContext.setQueryParam("since", ObjectSerializer.serialize(since, "string", "")); @@ -444,6 +439,11 @@ export class MessagesApiRequestFactory extends BaseAPIRequestFactory { requestContext.setQueryParam("deleted", ObjectSerializer.serialize(deleted, "boolean", "")); } + // Query Params + if (filter !== undefined) { + requestContext.setQueryParam("filter", ObjectSerializer.serialize(filter, "string", "")); + } + let authMethod: SecurityAuthentication | undefined; // Apply auth methods diff --git a/sdks/typescript/src/v1/generated/types/ObjectParamAPI.ts b/sdks/typescript/src/v1/generated/types/ObjectParamAPI.ts index 06336959..b3447cb0 100644 --- a/sdks/typescript/src/v1/generated/types/ObjectParamAPI.ts +++ b/sdks/typescript/src/v1/generated/types/ObjectParamAPI.ts @@ -1453,13 +1453,6 @@ export interface MessagesApiListMessagesRequest { * @memberof MessagesApilistMessages */ labels?: Array - /** - * Boolean filter expression (AIP-160-derived). v1 fields: label, from, subject, created. Operators: : = != < <= > >= with AND / OR / NOT and parentheses; whitespace is implicit AND and binds looser than OR (e.g. \'label:urgent OR (from:alerts AND NOT subject:newsletter) created>=2026-07-01\'). Composes with (ANDs) the flat filters. Unknown fields/operators are rejected with a positioned invalid_filter error. Max 500 chars. - * Defaults to: undefined - * @type string - * @memberof MessagesApilistMessages - */ - q?: string /** * RFC3339; created_at >= since. * Defaults to: undefined @@ -1497,6 +1490,13 @@ export interface MessagesApiListMessagesRequest { * @memberof MessagesApilistMessages */ deleted?: boolean + /** + * Boolean filter expression (AIP-160-derived). v1 fields: label, from, subject, created. Operators: : = != < <= > >= with AND / OR / NOT and parentheses; whitespace is implicit AND and binds looser than OR. Composes with (ANDs) the flat filters. Unknown fields/operators are rejected with a positioned invalid_filter error. Max 500 chars. + * Defaults to: undefined + * @type string + * @memberof MessagesApilistMessages + */ + filter?: string } export interface MessagesApiReplyToMessageRequest { @@ -1709,7 +1709,7 @@ export class ObjectMessagesApi { * @param param the request object */ public listMessagesWithHttpInfo(param: MessagesApiListMessagesRequest, options?: ConfigurationOptions): Promise> { - return this.api.listMessagesWithHttpInfo(param.email, param.direction, param.readStatus, param.sort, param.from_, param.subjectContains, param.conversationId, param.labels, param.q, param.since, param.until, param.cursor, param.limit, param.deleted, options).toPromise(); + return this.api.listMessagesWithHttpInfo(param.email, param.direction, param.readStatus, param.sort, param.from_, param.subjectContains, param.conversationId, param.labels, param.since, param.until, param.cursor, param.limit, param.deleted, param.filter, options).toPromise(); } /** @@ -1718,7 +1718,7 @@ export class ObjectMessagesApi { * @param param the request object */ public listMessages(param: MessagesApiListMessagesRequest, options?: ConfigurationOptions): Promise { - return this.api.listMessages(param.email, param.direction, param.readStatus, param.sort, param.from_, param.subjectContains, param.conversationId, param.labels, param.q, param.since, param.until, param.cursor, param.limit, param.deleted, options).toPromise(); + return this.api.listMessages(param.email, param.direction, param.readStatus, param.sort, param.from_, param.subjectContains, param.conversationId, param.labels, param.since, param.until, param.cursor, param.limit, param.deleted, param.filter, options).toPromise(); } /** diff --git a/sdks/typescript/src/v1/generated/types/ObservableAPI.ts b/sdks/typescript/src/v1/generated/types/ObservableAPI.ts index 8636f8c4..e0fa19ab 100644 --- a/sdks/typescript/src/v1/generated/types/ObservableAPI.ts +++ b/sdks/typescript/src/v1/generated/types/ObservableAPI.ts @@ -1527,17 +1527,17 @@ export class ObservableMessagesApi { * @param [subjectContains] Case-insensitive substring match on subject. * @param [conversationId] * @param [labels] Comma-separated list (e.g. labels=urgent,follow-up); AND-matched — a message must carry every given label. - * @param [q] Boolean filter expression (AIP-160-derived). v1 fields: label, from, subject, created. Operators: : = != < <= > >= with AND / OR / NOT and parentheses; whitespace is implicit AND and binds looser than OR (e.g. \'label:urgent OR (from:alerts AND NOT subject:newsletter) created>=2026-07-01\'). Composes with (ANDs) the flat filters. Unknown fields/operators are rejected with a positioned invalid_filter error. Max 500 chars. * @param [since] RFC3339; created_at >= since. * @param [until] RFC3339; created_at < until. * @param [cursor] * @param [limit] * @param [deleted] List the trash instead: messages that were soft-deleted and are restorable until purged (30 days after deletion by default, deployment-configurable). Defaults to false (live messages only). + * @param [filter] Boolean filter expression (AIP-160-derived). v1 fields: label, from, subject, created. Operators: : = != < <= > >= with AND / OR / NOT and parentheses; whitespace is implicit AND and binds looser than OR. Composes with (ANDs) the flat filters. Unknown fields/operators are rejected with a positioned invalid_filter error. Max 500 chars. */ - public listMessagesWithHttpInfo(email: string, direction?: 'inbound' | 'outbound' | 'all', readStatus?: 'unread' | 'read' | 'all', sort?: 'asc' | 'desc', from_?: string, subjectContains?: string, conversationId?: string, labels?: Array, q?: string, since?: string, until?: string, cursor?: string, limit?: number, deleted?: boolean, _options?: ConfigurationOptions): Observable> { + public listMessagesWithHttpInfo(email: string, direction?: 'inbound' | 'outbound' | 'all', readStatus?: 'unread' | 'read' | 'all', sort?: 'asc' | 'desc', from_?: string, subjectContains?: string, conversationId?: string, labels?: Array, since?: string, until?: string, cursor?: string, limit?: number, deleted?: boolean, filter?: string, _options?: ConfigurationOptions): Observable> { const _config = mergeConfiguration(this.configuration, _options); - const requestContextPromise = this.requestFactory.listMessages(email, direction, readStatus, sort, from_, subjectContains, conversationId, labels, q, since, until, cursor, limit, deleted, _config); + const requestContextPromise = this.requestFactory.listMessages(email, direction, readStatus, sort, from_, subjectContains, conversationId, labels, since, until, cursor, limit, deleted, filter, _config); // build promise chain let middlewarePreObservable = from(requestContextPromise); for (const middleware of _config.middleware) { @@ -1565,15 +1565,15 @@ export class ObservableMessagesApi { * @param [subjectContains] Case-insensitive substring match on subject. * @param [conversationId] * @param [labels] Comma-separated list (e.g. labels=urgent,follow-up); AND-matched — a message must carry every given label. - * @param [q] Boolean filter expression (AIP-160-derived). v1 fields: label, from, subject, created. Operators: : = != < <= > >= with AND / OR / NOT and parentheses; whitespace is implicit AND and binds looser than OR (e.g. \'label:urgent OR (from:alerts AND NOT subject:newsletter) created>=2026-07-01\'). Composes with (ANDs) the flat filters. Unknown fields/operators are rejected with a positioned invalid_filter error. Max 500 chars. * @param [since] RFC3339; created_at >= since. * @param [until] RFC3339; created_at < until. * @param [cursor] * @param [limit] * @param [deleted] List the trash instead: messages that were soft-deleted and are restorable until purged (30 days after deletion by default, deployment-configurable). Defaults to false (live messages only). + * @param [filter] Boolean filter expression (AIP-160-derived). v1 fields: label, from, subject, created. Operators: : = != < <= > >= with AND / OR / NOT and parentheses; whitespace is implicit AND and binds looser than OR. Composes with (ANDs) the flat filters. Unknown fields/operators are rejected with a positioned invalid_filter error. Max 500 chars. */ - public listMessages(email: string, direction?: 'inbound' | 'outbound' | 'all', readStatus?: 'unread' | 'read' | 'all', sort?: 'asc' | 'desc', from_?: string, subjectContains?: string, conversationId?: string, labels?: Array, q?: string, since?: string, until?: string, cursor?: string, limit?: number, deleted?: boolean, _options?: ConfigurationOptions): Observable { - return this.listMessagesWithHttpInfo(email, direction, readStatus, sort, from_, subjectContains, conversationId, labels, q, since, until, cursor, limit, deleted, _options).pipe(map((apiResponse: HttpInfo) => apiResponse.data)); + public listMessages(email: string, direction?: 'inbound' | 'outbound' | 'all', readStatus?: 'unread' | 'read' | 'all', sort?: 'asc' | 'desc', from_?: string, subjectContains?: string, conversationId?: string, labels?: Array, since?: string, until?: string, cursor?: string, limit?: number, deleted?: boolean, filter?: string, _options?: ConfigurationOptions): Observable { + return this.listMessagesWithHttpInfo(email, direction, readStatus, sort, from_, subjectContains, conversationId, labels, since, until, cursor, limit, deleted, filter, _options).pipe(map((apiResponse: HttpInfo) => apiResponse.data)); } /** diff --git a/sdks/typescript/src/v1/generated/types/PromiseAPI.ts b/sdks/typescript/src/v1/generated/types/PromiseAPI.ts index f7bbd5f8..d84cdd82 100644 --- a/sdks/typescript/src/v1/generated/types/PromiseAPI.ts +++ b/sdks/typescript/src/v1/generated/types/PromiseAPI.ts @@ -1108,16 +1108,16 @@ export class PromiseMessagesApi { * @param [subjectContains] Case-insensitive substring match on subject. * @param [conversationId] * @param [labels] Comma-separated list (e.g. labels=urgent,follow-up); AND-matched — a message must carry every given label. - * @param [q] Boolean filter expression (AIP-160-derived). v1 fields: label, from, subject, created. Operators: : = != < <= > >= with AND / OR / NOT and parentheses; whitespace is implicit AND and binds looser than OR (e.g. \'label:urgent OR (from:alerts AND NOT subject:newsletter) created>=2026-07-01\'). Composes with (ANDs) the flat filters. Unknown fields/operators are rejected with a positioned invalid_filter error. Max 500 chars. * @param [since] RFC3339; created_at >= since. * @param [until] RFC3339; created_at < until. * @param [cursor] * @param [limit] * @param [deleted] List the trash instead: messages that were soft-deleted and are restorable until purged (30 days after deletion by default, deployment-configurable). Defaults to false (live messages only). + * @param [filter] Boolean filter expression (AIP-160-derived). v1 fields: label, from, subject, created. Operators: : = != < <= > >= with AND / OR / NOT and parentheses; whitespace is implicit AND and binds looser than OR. Composes with (ANDs) the flat filters. Unknown fields/operators are rejected with a positioned invalid_filter error. Max 500 chars. */ - public listMessagesWithHttpInfo(email: string, direction?: 'inbound' | 'outbound' | 'all', readStatus?: 'unread' | 'read' | 'all', sort?: 'asc' | 'desc', from_?: string, subjectContains?: string, conversationId?: string, labels?: Array, q?: string, since?: string, until?: string, cursor?: string, limit?: number, deleted?: boolean, _options?: PromiseConfigurationOptions): Promise> { + public listMessagesWithHttpInfo(email: string, direction?: 'inbound' | 'outbound' | 'all', readStatus?: 'unread' | 'read' | 'all', sort?: 'asc' | 'desc', from_?: string, subjectContains?: string, conversationId?: string, labels?: Array, since?: string, until?: string, cursor?: string, limit?: number, deleted?: boolean, filter?: string, _options?: PromiseConfigurationOptions): Promise> { const observableOptions = wrapOptions(_options); - const result = this.api.listMessagesWithHttpInfo(email, direction, readStatus, sort, from_, subjectContains, conversationId, labels, q, since, until, cursor, limit, deleted, observableOptions); + const result = this.api.listMessagesWithHttpInfo(email, direction, readStatus, sort, from_, subjectContains, conversationId, labels, since, until, cursor, limit, deleted, filter, observableOptions); return result.toPromise(); } @@ -1132,16 +1132,16 @@ export class PromiseMessagesApi { * @param [subjectContains] Case-insensitive substring match on subject. * @param [conversationId] * @param [labels] Comma-separated list (e.g. labels=urgent,follow-up); AND-matched — a message must carry every given label. - * @param [q] Boolean filter expression (AIP-160-derived). v1 fields: label, from, subject, created. Operators: : = != < <= > >= with AND / OR / NOT and parentheses; whitespace is implicit AND and binds looser than OR (e.g. \'label:urgent OR (from:alerts AND NOT subject:newsletter) created>=2026-07-01\'). Composes with (ANDs) the flat filters. Unknown fields/operators are rejected with a positioned invalid_filter error. Max 500 chars. * @param [since] RFC3339; created_at >= since. * @param [until] RFC3339; created_at < until. * @param [cursor] * @param [limit] * @param [deleted] List the trash instead: messages that were soft-deleted and are restorable until purged (30 days after deletion by default, deployment-configurable). Defaults to false (live messages only). + * @param [filter] Boolean filter expression (AIP-160-derived). v1 fields: label, from, subject, created. Operators: : = != < <= > >= with AND / OR / NOT and parentheses; whitespace is implicit AND and binds looser than OR. Composes with (ANDs) the flat filters. Unknown fields/operators are rejected with a positioned invalid_filter error. Max 500 chars. */ - public listMessages(email: string, direction?: 'inbound' | 'outbound' | 'all', readStatus?: 'unread' | 'read' | 'all', sort?: 'asc' | 'desc', from_?: string, subjectContains?: string, conversationId?: string, labels?: Array, q?: string, since?: string, until?: string, cursor?: string, limit?: number, deleted?: boolean, _options?: PromiseConfigurationOptions): Promise { + public listMessages(email: string, direction?: 'inbound' | 'outbound' | 'all', readStatus?: 'unread' | 'read' | 'all', sort?: 'asc' | 'desc', from_?: string, subjectContains?: string, conversationId?: string, labels?: Array, since?: string, until?: string, cursor?: string, limit?: number, deleted?: boolean, filter?: string, _options?: PromiseConfigurationOptions): Promise { const observableOptions = wrapOptions(_options); - const result = this.api.listMessages(email, direction, readStatus, sort, from_, subjectContains, conversationId, labels, q, since, until, cursor, limit, deleted, observableOptions); + const result = this.api.listMessages(email, direction, readStatus, sort, from_, subjectContains, conversationId, labels, since, until, cursor, limit, deleted, filter, observableOptions); return result.toPromise(); } diff --git a/sdks/typescript/test/v1/client.test.ts b/sdks/typescript/test/v1/client.test.ts index 73deea7b..48bd1f7d 100644 --- a/sdks/typescript/test/v1/client.test.ts +++ b/sdks/typescript/test/v1/client.test.ts @@ -407,12 +407,39 @@ describe("E2AClient", () => { expect(url.searchParams.has("from_")).toBe(false); }); - it("messages.list serializes q as the wire query", async () => { + it("sends the structured filter after all existing list parameters", async () => { globalThis.fetch = mockFetch(200, { items: [], next_cursor: null }); + const messages = client.messages as unknown as { + api: { listMessages: (...args: unknown[]) => Promise }; + }; + const listMessagesSpy = vi.spyOn(messages.api, "listMessages"); - await client.messages.list("bot@test.dev", { q: "label:urgent" }).page(); + await client.messages.list("bot@test.dev", { + direction: "all", + deleted: true, + filter: "label:urgent", + }).toArray({ limit: 10 }); - expect(new URL(lastCall().url).searchParams.get("q")).toBe("label:urgent"); + const url = new URL(lastCall().url); + expect(url.searchParams.get("filter")).toBe("label:urgent"); + expect(url.searchParams.has("q")).toBe(false); + expect(url.searchParams.get("deleted")).toBe("true"); + expect(listMessagesSpy).toHaveBeenCalledWith( + "bot@test.dev", + "all", + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + true, + "label:urgent", + ); }); it("messages.list({ deleted: true }) lists the trash", async () => { From 52703a67e77068c9282d0b9236ce94968beb9036 Mon Sep 17 00:00:00 2001 From: jiashuoz Date: Tue, 28 Jul 2026 17:31:17 -0700 Subject: [PATCH 11/12] fix(sdk): normalize generated filter whitespace --- sdks/python/scripts/generate-oag.sh | 6 ++++++ sdks/python/src/e2a/v1/generated/api/messages_api.py | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/sdks/python/scripts/generate-oag.sh b/sdks/python/scripts/generate-oag.sh index 1fe22f82..ec930b2a 100644 --- a/sdks/python/scripts/generate-oag.sh +++ b/sdks/python/scripts/generate-oag.sh @@ -73,6 +73,12 @@ perl -0pi -e 's/\n+\z/\n/' "$DEST/models/event_envelope.py" # Keep the expanded agents surface deterministic and diff-check clean. perl -pi -e 's/[ \t]+$//' "$DEST/api/agents_api.py" +# The newly appended message filter query block otherwise receives whitespace- +# only blank lines from OpenAPI Generator. Normalize this exact block without +# rewriting the historical generated API file. +perl -0pi -e 's/( if filter is not None:\n)[ \t]*\n( _query_params\.append\(\(\x27filter\x27, filter\)\)\n)[ \t]*\n/$1\n$2\n/' \ + "$DEST/api/messages_api.py" + perl -0pi -e 's/\n+\z/\n/' \ "$DEST/models/agent_suppression_added_data.py" \ "$DEST/models/agent_suppression_view.py" \ diff --git a/sdks/python/src/e2a/v1/generated/api/messages_api.py b/sdks/python/src/e2a/v1/generated/api/messages_api.py index 19c45176..d747baaa 100644 --- a/sdks/python/src/e2a/v1/generated/api/messages_api.py +++ b/sdks/python/src/e2a/v1/generated/api/messages_api.py @@ -2056,9 +2056,9 @@ def _list_messages_serialize( _query_params.append(('deleted', deleted)) if filter is not None: - + _query_params.append(('filter', filter)) - + # process the header parameters # process the form parameters # process the body parameter From 89194586ef5f90d39006ca535f94e79fc196783a Mon Sep 17 00:00:00 2001 From: jiashuoz Date: Tue, 28 Jul 2026 17:41:45 -0700 Subject: [PATCH 12/12] fix(clients): expose message filter consistently --- cli/src/__tests__/args.test.ts | 16 +++++++- cli/src/__tests__/messages.test.ts | 6 +-- cli/src/bin/e2a.ts | 6 +-- cli/src/commands/messages.ts | 6 +-- docs/api.md | 2 +- docs/filtering.md | 8 ++-- .../plans/2026-07-25-filter-query-language.md | 28 ++++++++----- ...2026-07-25-filter-query-language-design.md | 9 ++++- mcp/src/client.ts | 9 ++++- mcp/src/tools/messages.ts | 18 ++++----- mcp/tests/client.types.ts | 6 +-- ...q.test.ts => list-messages-filter.test.ts} | 40 ++++++++++--------- .../e2e-prod/suites/14-v030-features.test.ts | 26 ++++++------ 13 files changed, 106 insertions(+), 74 deletions(-) rename mcp/tests/{list-messages-q.test.ts => list-messages-filter.test.ts} (66%) diff --git a/cli/src/__tests__/args.test.ts b/cli/src/__tests__/args.test.ts index 40ac1d48..ef255c2d 100644 --- a/cli/src/__tests__/args.test.ts +++ b/cli/src/__tests__/args.test.ts @@ -104,8 +104,10 @@ describe("getFlag", () => { expect(getFlag(args, "--to")).toBe("a@b.com"); }); - it("extracts a messages list q expression verbatim", () => { - expect(getFlag(["--q", "label:urgent"], "--q")).toBe("label:urgent"); + it("extracts a messages list filter expression verbatim", () => { + expect( + getFlag(["messages", "list", "--filter", "label:urgent"], "--filter"), + ).toBe("label:urgent"); }); }); @@ -212,6 +214,16 @@ describe("checkFlags (FIX 1: single-dash flag typos)", () => { expect(() => checkFlags(["--json=true"], ["--json"])).toThrow("process.exit"); }); + it("rejects the retired legacy flag while accepting --filter", () => { + const retiredFilterFlag = `--${"q"}`; + expect(() => checkFlags([retiredFilterFlag, "label:urgent"], ["--filter"])).toThrow("process.exit"); + expect(mockExit).toHaveBeenCalledWith(2); + mockExit.mockClear(); + + expect(() => checkFlags(["--filter", "label:urgent"], ["--filter"])).not.toThrow(); + expect(mockExit).not.toHaveBeenCalled(); + }); + it("rejects the removed login --with-key flag", () => { expect(() => checkFlags(["--with-key"], [])).toThrow("process.exit"); expect(mockExit).toHaveBeenCalledWith(2); diff --git a/cli/src/__tests__/messages.test.ts b/cli/src/__tests__/messages.test.ts index a134443e..a3276d25 100644 --- a/cli/src/__tests__/messages.test.ts +++ b/cli/src/__tests__/messages.test.ts @@ -72,15 +72,15 @@ describe("messages commands", () => { ); }); - it("passes q through verbatim and omits it when unset", async () => { + it("passes filter through verbatim and omits it when unset", async () => { mockList.mockReturnValue(summaries()); const { messagesList } = await import("../commands/messages.js"); - await messagesList({ q: "label:urgent" }); + await messagesList({ filter: "label:urgent" }); expect(mockList).toHaveBeenCalledWith("bot@agents.e2a.dev", { sort: "asc", readStatus: "all", - q: "label:urgent", + filter: "label:urgent", limit: 100, }); diff --git a/cli/src/bin/e2a.ts b/cli/src/bin/e2a.ts index 4549ac78..0a37c778 100644 --- a/cli/src/bin/e2a.ts +++ b/cli/src/bin/e2a.ts @@ -74,7 +74,7 @@ Usage: --direction inbound|outbound|all --since Messages created AT or after this timestamp (inclusive — dedup by message id when cursoring) - --q Boolean filter expression (see API filtering docs) + --filter Boolean filter expression (see API filtering docs) --conversation Filter to one conversation (alias: --conversation-id) --read-status unread|read|all (default all — safe for poll loops) --limit Stop after n messages @@ -471,14 +471,14 @@ async function main() { if (sub === "list") { checkFlags(rest, [ "--direction", "--since", "--conversation", "--conversation-id", - "--read-status", "--limit", "--agent", "--q", "--json", + "--read-status", "--limit", "--agent", "--filter", "--json", ]); getPositionals(rest, 0, "usage: e2a messages list [options]"); await messagesList({ agent: getFlagChecked(rest, "--agent"), direction: getFlagChecked(rest, "--direction"), since: getFlagChecked(rest, "--since"), - q: getFlagChecked(rest, "--q"), + filter: getFlagChecked(rest, "--filter"), conversation: getConversationId(rest), readStatus: getFlagChecked(rest, "--read-status"), limit: getFlagChecked(rest, "--limit"), diff --git a/cli/src/commands/messages.ts b/cli/src/commands/messages.ts index e5901069..abd10604 100644 --- a/cli/src/commands/messages.ts +++ b/cli/src/commands/messages.ts @@ -6,7 +6,7 @@ export interface MessagesListOptions { agent?: string; direction?: string; since?: string; - q?: string; + filter?: string; conversation?: string; limit?: string; readStatus?: string; @@ -27,7 +27,7 @@ export interface MessagesLifecycleOptions { } const LIST_USAGE = - "usage: e2a messages list [--direction inbound|outbound|all] [--since ] [--q ] [--conversation ] [--read-status unread|read|all] [--limit ] [--agent ] [--json]"; + "usage: e2a messages list [--direction inbound|outbound|all] [--since ] [--filter ] [--conversation ] [--read-status unread|read|all] [--limit ] [--agent ] [--json]"; const GET_USAGE = "usage: e2a messages get [--text] [--agent ] [--json]"; const LIFECYCLE_USAGE = "usage: e2a messages lifecycle (beta) [--agent ] [--limit <1-100>] [--cursor ] [--json]"; @@ -62,7 +62,7 @@ export async function messagesList(opts: MessagesListOptions): Promise { params.readStatus = opts.readStatus as (typeof READ_STATUSES)[number]; } if (opts.since) params.since = opts.since; - if (opts.q !== undefined) params.q = opts.q; + if (opts.filter !== undefined) params.filter = opts.filter; if (opts.conversation) params.conversationId = opts.conversation; let max: number | undefined; diff --git a/docs/api.md b/docs/api.md index 697a0730..60296103 100644 --- a/docs/api.md +++ b/docs/api.md @@ -415,7 +415,7 @@ single message. - `GET …/messages` — list inbound + outbound with filters (`direction`, `read_status`, `sort`, `from`, `subject_contains`, `conversation_id`, `labels`, - `since`, `until`) and cursor pagination. `q` adds boolean composition; see + `since`, `until`) and cursor pagination. `filter` adds boolean composition; see [message filtering](filtering.md) for its grammar and v1 fields. Held outbound drafts appear with `status=pending_review`. - `POST …/messages` — send a new email (a new thread). Returns `202 Accepted` for diff --git a/docs/filtering.md b/docs/filtering.md index 56ac6261..378545e0 100644 --- a/docs/filtering.md +++ b/docs/filtering.md @@ -1,6 +1,6 @@ -# Message filtering (`q`) +# Message filtering (`filter`) -`GET /v1/agents/{email}/messages?q=…` accepts a small, boolean filter +`GET /v1/agents/{email}/messages?filter=…` accepts a small, boolean filter language. It is an addition to—not a replacement for—the existing flat list parameters. See the [filter-query design](superpowers/specs/2026-07-25-filter-query-language-design.md) for rationale and rollout details. @@ -64,9 +64,9 @@ created=2026-07-01 ## Composition, errors, and limits -`q` is ANDed with every flat list filter (`direction`, `read_status`, `from`, +`filter` is ANDed with every flat list filter (`direction`, `read_status`, `from`, `labels`, `since`, and so on). A pagination cursor is tied to the complete -filter identity, including `q`; start a new query if any filter changes. +filter identity, including `filter`; start a new query if any filter changes. Invalid syntax, unknown fields, unsupported operators, invalid values, and limit violations return HTTP 400 with error code `invalid_filter`. Parse and diff --git a/docs/superpowers/plans/2026-07-25-filter-query-language.md b/docs/superpowers/plans/2026-07-25-filter-query-language.md index 9adaeb64..1516e453 100644 --- a/docs/superpowers/plans/2026-07-25-filter-query-language.md +++ b/docs/superpowers/plans/2026-07-25-filter-query-language.md @@ -1,8 +1,14 @@ -# Filter Query Language (`q`) Implementation Plan +# Filter Query Language Implementation Plan + +> **Amendment (2026-07-28, PR #735):** The public message-list boolean-expression +> parameter is `filter`, not `q`. The grammar and implementation semantics in this +> historical plan are unchanged; its remaining `q` references describe the original +> proposed spelling only. The implemented API, SDKs, CLI, MCP tool, docs, and +> production E2E use `filter`. > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. -**Goal:** Add a boolean filter query language (`q` param) to `list_messages`, backed by a new dependency-free, schema-agnostic Go package `internal/filterquery` (parse → validate → emit parameterized Postgres SQL). +**Goal:** Add a boolean filter query language (`filter` param) to `list_messages`, backed by a new dependency-free, schema-agnostic Go package `internal/filterquery` (parse → validate → emit parameterized Postgres SQL). **Architecture:** Spec: `docs/superpowers/specs/2026-07-25-filter-query-language-design.md`. Hand-rolled recursive-descent parser over the AIP-160 EBNF; validation against an adopter-supplied `FieldRegistry`; emission through a `Dialect` interface. e2a's messages schema lives only in `internal/identity`'s registry. Ships as PR 1 (package only) + PR 2 (API surface + MCP + SDKs + CLI + docs). @@ -363,7 +369,7 @@ Expected: PASS (6 tests) ```bash git add internal/filterquery/ docs/superpowers/specs/2026-07-25-filter-query-language-design.md -git commit -m "feat(filterquery): lexer and error type for the q filter language +git commit -m "feat(filterquery): lexer and error type for the filter language Also corrects the spec's precedence note (NOT > OR > implicit AND > explicit AND, per the AIP-160 EBNF). @@ -2349,7 +2355,7 @@ Expected: PASS (unit tests need no DB) ```bash git add internal/identity/filter_registry.go internal/identity/filter_registry_test.go -git commit -m "feat(identity): messages field registry for the q filter language +git commit -m "feat(identity): messages field registry for the filter language Co-Authored-By: Kimi " ``` @@ -2403,7 +2409,7 @@ In `GetMessagesByAgent`, immediately after the `if len(f.Labels) > 0 { … }` bl if f.Q != nil { frag, qargs, err := f.Q.Emit(filterquery.PostgresDialect{}, len(args)+1) if err != nil { - return nil, fmt.Errorf("emit q filter: %w", err) + return nil, fmt.Errorf("emit filter: %w", err) } if frag != "" { query += " AND " + frag @@ -2802,7 +2808,7 @@ In `handleListMessages`, after the existing filter validation (~line 686, near ` var qExpr *filterquery.Expr if in.Q != "" { if utf8.RuneCountInString(in.Q) > 500 { - return nil, NewError(http.StatusBadRequest, "invalid_filter", "q filter too long (max 500 chars)") + return nil, NewError(http.StatusBadRequest, "invalid_filter", "filter too long (max 500 chars)") } expr, err := filterquery.Parse(in.Q, identity.MessagesQRegistry()) if err != nil { @@ -2833,7 +2839,7 @@ Expected: PASS ```bash git add internal/httpapi/ -git commit -m "feat(api): q filter param on list_messages with cursor pinning +git commit -m "feat(api): filter param on list_messages with cursor pinning Co-Authored-By: Kimi " ``` @@ -2919,14 +2925,14 @@ Expected: PASS ```bash git add mcp/ -git commit -m "feat(mcp): q filter on list_messages +git commit -m "feat(mcp): filter on list_messages Co-Authored-By: Kimi " ``` --- -### Task 14: SDK regen + CLI `--q` +### Task 14: SDK regen + CLI `--filter` **Files:** - Modify (generated): `sdks/python/src/e2a/v1/generated/**`, `sdks/typescript/src/v1/generated/**` @@ -2944,7 +2950,7 @@ Expected: `ListMessagesParams` (TS) and the messages API (Python) expose `q`; ch - [ ] **Step 2: CLI flag** -In `cli/src/commands/messages.ts` usage line, append ` [--q ]`; in the params building for `messages list`, add `q` when the flag is present (follow how `--since` maps into `params`). In `cli/src/__tests__/args.test.ts` add a case asserting `messages list --q "label:urgent"` puts `q: "label:urgent"` into the SDK params. +In `cli/src/commands/messages.ts` usage line, append ` [--filter ]`; in the params building for `messages list`, add `filter` when the flag is present (follow how `--since` maps into `params`). In `cli/src/__tests__/args.test.ts` add a case asserting `messages list --filter "label:urgent"` puts `filter: "label:urgent"` into the SDK params. - [ ] **Step 3: Run** @@ -2959,7 +2965,7 @@ Expected: PASS ```bash git add sdks/ cli/ -git commit -m "feat(sdks,cli): q filter param across python/ts SDKs and CLI +git commit -m "feat(sdks,cli): filter param across python/ts SDKs and CLI Co-Authored-By: Kimi " ``` diff --git a/docs/superpowers/specs/2026-07-25-filter-query-language-design.md b/docs/superpowers/specs/2026-07-25-filter-query-language-design.md index 8b1d6ca8..65a6b56a 100644 --- a/docs/superpowers/specs/2026-07-25-filter-query-language-design.md +++ b/docs/superpowers/specs/2026-07-25-filter-query-language-design.md @@ -4,6 +4,13 @@ Date: 2026-07-25 Status: Approved (design), pre-implementation Audience: e2a server, SDK, and MCP maintainers +## Amendment (2026-07-28): public parameter spelling + +PR #735 corrects the public message-list boolean-expression parameter from +`q` to `filter`. The grammar and semantics in this historical design are +unchanged. References to `q` below document the original proposal only; the +implemented API, SDKs, CLI, MCP tool, docs, and production E2E use `filter`. + ## Context and goals `GET /v1/agents/{email}/messages` today filters via flat params: `direction`, @@ -162,7 +169,7 @@ Rules that make this safe and correct: existing filter-identity rejection, same as other filters. - MCP `list_messages` gains optional `q` (pass-through, same validation). - SDKs regenerate from OpenAPI (Python `messages.list(..., q=...)`, TS - `{ q }`), CLI `messages list --q`, docs page documenting the grammar. + `{ filter }`), CLI `messages list --filter`, docs page documenting the grammar. ## Error model diff --git a/mcp/src/client.ts b/mcp/src/client.ts index dfe94281..d66cd3df 100644 --- a/mcp/src/client.ts +++ b/mcp/src/client.ts @@ -282,8 +282,13 @@ export class McpClient { // Cursor pagination (§6a #3): returns ONE page + next_cursor. `limit` is the // page size; pass a prior response's next_cursor as `cursor` for the next page. listMessages(params: McpListMessagesParams): Promise> { - const { explicitAddress, cursor, ...rest } = params; - return this.sdk.messages.list(this.resolveAddress(explicitAddress), rest).page(cursor); + const { explicitAddress, cursor, filter, ...rest } = params; + return this.sdk.messages + .list(this.resolveAddress(explicitAddress), { + ...rest, + ...(filter !== undefined ? { filter } : {}), + }) + .page(cursor); } restoreMessage(messageId: string, explicitAddress?: string): Promise { diff --git a/mcp/src/tools/messages.ts b/mcp/src/tools/messages.ts index 06843b38..7a2042bc 100644 --- a/mcp/src/tools/messages.ts +++ b/mcp/src/tools/messages.ts @@ -385,7 +385,7 @@ export function registerMessageTools(server: McpServer, client: McpClient): void title: "List messages (inbox or sent)", annotations: { readOnlyHint: true }, description: - "List the agent's messages, newest first by default. Use `direction` to pick the folder: `inbound` (the Inbox — received mail, the default), `outbound` (the Sent folder — mail this agent sent, including held drafts), or `all` (both). Filter received mail by `read_status` (unread/read/all; default unread — applies to inbound only; sent mail has no read-state). **Cursor-paginated:** returns one page in `messages` plus a `next_cursor` when more remain — pass it back as `cursor` for the next page (keep the same filters + sort). Pass `sort: \"asc\"` for FIFO order (oldest first) to drain in arrival order. **Search filters** (`from_`, `subject_contains`, `conversation_id`, `since`, `until`, `q` (boolean expression)) narrow server-side — use them instead of paging the whole folder. In inbound summaries, `header_from` is the claimed RFC 5322 From address; a non-null `verified_domain` means DMARC passed for that From domain. It does not authenticate the mailbox local part, a person, or message content. Returns summaries only — use `get_message` for the full body and SPF/DKIM/DMARC evidence.", + "List the agent's messages, newest first by default. Use `direction` to pick the folder: `inbound` (the Inbox — received mail, the default), `outbound` (the Sent folder — mail this agent sent, including held drafts), or `all` (both). Filter received mail by `read_status` (unread/read/all; default unread — applies to inbound only; sent mail has no read-state). **Cursor-paginated:** returns one page in `messages` plus a `next_cursor` when more remain — pass it back as `cursor` for the next page (keep the same filters + sort). Pass `sort: \"asc\"` for FIFO order (oldest first) to drain in arrival order. **Search filters** (`from_`, `subject_contains`, `conversation_id`, `since`, `until`, `filter` (boolean expression)) narrow server-side — use them instead of paging the whole folder. In inbound summaries, `header_from` is the claimed RFC 5322 From address; a non-null `verified_domain` means DMARC passed for that From domain. It does not authenticate the mailbox local part, a person, or message content. Returns summaries only — use `get_message` for the full body and SPF/DKIM/DMARC evidence.", inputSchema: strictInputSchema({ direction: z .enum(["inbound", "outbound", "all"]) @@ -438,19 +438,19 @@ export function registerMessageTools(server: McpServer, client: McpClient): void .describe( "AND-match filter on labels. A row is returned only if ALL given labels are present. Use lowercase strings matching `[a-z0-9:_-]+`; `e2a:*` system labels can be filtered even though setting them is server-only.", ), - q: z + deleted: z + .boolean() + .optional() + .describe("List the message trash instead of live messages."), + filter: z .string() - .refine((value) => Array.from(value).length <= 500, { - message: "q must contain at most 500 Unicode code points", + .refine((value) => [...value].length <= 500, { + message: "filter must contain at most 500 Unicode code points", }) .optional() .describe( "Boolean filter expression (AIP-160-derived). v1 fields: label, from, subject, created. Operators : = != < <= > >= with AND/OR/NOT and parentheses; whitespace is implicit AND (binds looser than OR). Example: 'label:urgent OR (from:alerts AND NOT subject:newsletter) created>=2026-07-01'. Composes (AND) with the flat filters (labels, from_, subject_contains, since, until). Invalid expressions are rejected with a positioned invalid_filter error.", ), - deleted: z - .boolean() - .optional() - .describe("List the message trash instead of live messages."), email: emailSelector, }), }, @@ -472,8 +472,8 @@ export function registerMessageTools(server: McpServer, client: McpClient): void ...(args.since !== undefined ? { since: args.since } : {}), ...(args.until !== undefined ? { until: args.until } : {}), ...(args.labels !== undefined ? { labels: args.labels } : {}), - ...(args.q !== undefined ? { q: args.q } : {}), ...(args.deleted !== undefined ? { deleted: args.deleted } : {}), + ...(args.filter !== undefined ? { filter: args.filter } : {}), ...(args.email !== undefined ? { explicitAddress: args.email } : {}), }); return { messages: page.items, ...(page.next_cursor ? { next_cursor: page.next_cursor } : {}) }; diff --git a/mcp/tests/client.types.ts b/mcp/tests/client.types.ts index 4eeac410..f128c2b6 100644 --- a/mcp/tests/client.types.ts +++ b/mcp/tests/client.types.ts @@ -6,11 +6,11 @@ type ListMessagesParams = Parameters[0]; const senderFilter: ListMessagesParams = { from_: "alice@example.com" }; void senderFilter; -// The MCP wrapper must preserve generated SDK q filters while adding only its +// The MCP wrapper must preserve generated SDK filter expressions while adding only its // cursor/address transport fields. This assignment fails to typecheck if the // handwritten wrapper parameter shape drifts from the SDK again. -const queryFilter: ListMessagesParams = { q: "label:urgent" }; -void queryFilter; +const expressionFilter: ListMessagesParams = { filter: "label:urgent" }; +void expressionFilter; // The MCP tool and its wrapper follow the SDK's reserved-word-safe spelling. // @ts-expect-error `from` is the REST wire name, not the MCP input name. diff --git a/mcp/tests/list-messages-q.test.ts b/mcp/tests/list-messages-filter.test.ts similarity index 66% rename from mcp/tests/list-messages-q.test.ts rename to mcp/tests/list-messages-filter.test.ts index 1a9eba50..821c7c2d 100644 --- a/mcp/tests/list-messages-q.test.ts +++ b/mcp/tests/list-messages-filter.test.ts @@ -14,13 +14,13 @@ function makeStubClient() { async function connect(stub: McpClient): Promise { const server = buildServer({ client: stub, version: "0.0.0-test" }); - const client = new Client({ name: "list-messages-q-test", version: "0.0.0" }); + const client = new Client({ name: "list-messages-filter-test", version: "0.0.0" }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]); return client; } -describe("list_messages q filter", () => { +describe("list_messages filter", () => { let stub: ReturnType; let client: Client; @@ -29,46 +29,48 @@ describe("list_messages q filter", () => { client = await connect(stub.client); }); - it("documents only the v1 q fields", async () => { + it("documents only the v1 filter fields", async () => { const { tools } = await client.listTools(); const tool = tools.find((candidate) => candidate.name === "list_messages"); const properties = (tool?.inputSchema as { properties?: Record; }).properties; - const q = properties?.q; + const filter = properties?.filter; - expect(q?.description).toContain("v1 fields: label, from, subject, created."); - expect(q?.description).not.toContain("has"); + expect(filter?.description).toContain("v1 fields: label, from, subject, created."); + expect(filter?.description).not.toContain("has"); }); - it("accepts q at 500 Unicode code points and rejects 501", async () => { + it("accepts filter at 500 Unicode code points and rejects 501", async () => { const accepted = ["a".repeat(500), "😀".repeat(500)]; const rejected = ["a".repeat(501), "😀".repeat(501)]; - for (const q of accepted) { - expect(Array.from(q)).toHaveLength(500); - const result = await client.callTool({ name: "list_messages", arguments: { q } }); + for (const filter of accepted) { + expect(Array.from(filter)).toHaveLength(500); + const result = await client.callTool({ name: "list_messages", arguments: { filter } }); expect(result.isError).not.toBe(true); - expect(stub.listMessages).toHaveBeenLastCalledWith({ q }); + expect(stub.listMessages).toHaveBeenLastCalledWith({ filter }); } - for (const q of rejected) { - expect(Array.from(q)).toHaveLength(501); - const result = await client.callTool({ name: "list_messages", arguments: { q } }); + for (const filter of rejected) { + expect(Array.from(filter)).toHaveLength(501); + const result = await client.callTool({ name: "list_messages", arguments: { filter } }); expect(result.isError).toBe(true); expect((result.content as Array<{ text: string }>)[0]?.text).toMatch(/500 Unicode code points/i); } }); - it("passes q through verbatim and omits it when absent", async () => { - const q = "label:urgent OR (from:alerts AND NOT subject:newsletter) created>=2026-07-01"; - await client.callTool({ name: "list_messages", arguments: { q } }); - expect(stub.listMessages).toHaveBeenLastCalledWith({ q }); + it("passes filter through verbatim and omits it when absent", async () => { + const filter = "label:urgent"; + await client.callTool({ name: "list_messages", arguments: { filter } }); + expect(stub.listMessages).toHaveBeenLastCalledWith( + expect.objectContaining({ filter: "label:urgent" }), + ); stub.listMessages.mockClear(); await client.callTool({ name: "list_messages", arguments: {} }); const params = stub.listMessages.mock.calls[0]?.[0]; expect(params).toEqual({}); - expect(params).not.toHaveProperty("q"); + expect(params).not.toHaveProperty("filter"); }); }); diff --git a/tests/e2e-prod/suites/14-v030-features.test.ts b/tests/e2e-prod/suites/14-v030-features.test.ts index e50ba012..dab9468f 100644 --- a/tests/e2e-prod/suites/14-v030-features.test.ts +++ b/tests/e2e-prod/suites/14-v030-features.test.ts @@ -229,13 +229,13 @@ test("agent messages: ?labels= filter accepted without error", async () => { assert.equal(r.status, 200, `expected 200, got ${r.status}: ${r.raw.slice(0, 200)}`); }); -test("agent messages: q OR union and AND NOT difference on isolated loopback messages", async () => { +test("agent messages: filter OR union and AND NOT difference on isolated loopback messages", async () => { // Do not use the shared primary inbox: this creates a throwaway agent, sends // only to itself (the providerless loopback path), and deletes the agent in // finally so its messages cascade away even if an assertion fails. - const email = `${uniqueSlug("qfilter")}@${client.env.sharedDomain}`; + const email = `${uniqueSlug("filter")}@${client.env.sharedDomain}`; const created = await client.post<{ email: string }>("/v1/agents", { - body: { email, name: "q filter e2e" }, + body: { email, name: "filter e2e" }, expect: 201, }); const agentEmail = created.body!.email; @@ -244,7 +244,7 @@ test("agent messages: q OR union and AND NOT difference on isolated loopback mes try { const receiveLoopback = async (subject: string): Promise => { await client.post(`/v1/agents/${encodeURIComponent(agentEmail)}/messages`, { - body: { to: [agentEmail], subject, text: "q filter loopback fixture" }, + body: { to: [agentEmail], subject, text: "filter loopback fixture" }, expect: [200, 202], }); // The inbound copy can arrive asynchronously. Explicitly request all @@ -263,10 +263,10 @@ test("agent messages: q OR union and AND NOT difference on isolated loopback mes throw new Error(`loopback message ${JSON.stringify(subject)} did not arrive for ${agentEmail}`); }; - const idA = await receiveLoopback(uniqueSubject("q filter a")); - const idB = await receiveLoopback(uniqueSubject("q filter b")); - const labelA = uniqueSlug("q-a"); - const labelB = uniqueSlug("q-b"); + const idA = await receiveLoopback(uniqueSubject("filter a")); + const idB = await receiveLoopback(uniqueSubject("filter b")); + const labelA = uniqueSlug("filter-a"); + const labelB = uniqueSlug("filter-b"); for (const [id, label] of [[idA, labelA], [idB, labelB]] as const) { const updated = await client.patch<{ labels?: string[] }>( `/v1/agents/${encodeURIComponent(agentEmail)}/messages/${encodeURIComponent(id)}`, @@ -275,22 +275,22 @@ test("agent messages: q OR union and AND NOT difference on isolated loopback mes assert.ok(updated.body?.labels?.includes(label), `message ${id} received label ${label}`); } - const matchedIDs = async (q: string): Promise => { + const matchedIDs = async (filter: string): Promise => { const listed = await client.get<{ items?: Array<{ id: string }> }>( `/v1/agents/${encodeURIComponent(agentEmail)}/messages`, - { query: { q, direction: "inbound", read_status: "all", limit: 100 }, expect: 200 }, + { query: { filter, direction: "inbound", read_status: "all", limit: 100 }, expect: 200 }, ); return (listed.body?.items ?? []).map((item) => item.id).sort(); }; - assert.deepEqual(await matchedIDs(`label:${labelA} OR label:${labelB}`), [idA, idB].sort(), "q OR returns the label union"); - assert.deepEqual(await matchedIDs(`label:${labelA} AND NOT label:${labelB}`), [idA], "q AND NOT returns only label A"); + assert.deepEqual(await matchedIDs(`label:${labelA} OR label:${labelB}`), [idA, idB].sort(), "filter OR returns the label union"); + assert.deepEqual(await matchedIDs(`label:${labelA} AND NOT label:${labelB}`), [idA], "filter AND NOT returns only label A"); } finally { const deleted = await client.delete(`/v1/agents/${encodeURIComponent(agentEmail)}?confirm=DELETE&permanent=true`); const cleanupSucceeded = [200, 204, 404].includes(deleted.status); assert.ok( cleanupSucceeded, - `delete throwaway q-filter agent: ${deleted.status} ${deleted.raw.slice(0, 200)}`, + `delete throwaway filter agent: ${deleted.status} ${deleted.raw.slice(0, 200)}`, ); if (cleanupSucceeded) untrack("agent", agentEmail); }