Skip to content

Add events, inbox, and stream-ticket commands on the account event feed - #726

Merged
jeremy merged 29 commits into
mainfrom
feed/events-inbox-commands
Sep 16, 2026
Merged

jeremy merged 29 commits into
mainfrom
feed/events-inbox-commands

Conversation

@jeremy

@jeremy jeremy commented Sep 16, 2026 •

Copy link
Copy Markdown
Member

The account-wide event feed is how an agent hears about activity it did not cause. This adds the three operations the SDK now generates:

Command Endpoint
basecamp events poll GET /events.json
basecamp inbox GET /inbox.json (agent principals only)
basecamp events ticket POST /events/stream_ticket.json

Contract: doc/api/sections/event_feed.md on bc3 master.

Why events poll and not bare events

basecamp events <id|url> already means one recording's audit trail, and that is a shipped surface. Overloading the same command by arity — no args means the account feed, one arg means a recording — would give two unrelated payloads and two unrelated flag sets one help page, which is exactly what STYLE.md's bare-group rule resists. So the feed lands as subcommands under the same noun, and inbox sits at the top level because it is its own endpoint rather than a filter over the feed.

Bare basecamp events now shows the group's help instead of refusing for a missing id, so the subcommands are discoverable. Passing an id still does what it did. That turns the <id|url> positional optional, which is a strict widening but still a .surface line change, so it is acknowledged in .surface-breaking.

NewEventsCmd joins the check-bare-groups.sh allowlist for the same reason recordings, assignments and notifications are on it: it performs an action and has subcommands.

Pagination is the body, not the Link walk

Every other listing in this CLI walks Link: rel=next page numbers. The feed's pagination is the response envelope: a durable position to resume from later, and a next continuation URL while the current walk has more to serve. One call is one page.

--all walks next to the end of the current walk, bounded by --max-pages (default 50 — the server serves up to 100 events a page, so that admits 5,000 before the walk reports itself capped). When it stops at the bound it says so in a notice instead of reading as complete. This is not a live tail: the live lane is a WebSocket the ticket opens, and the SDK's cable client carries a github.com/coder/websocket dependency the CLI does not take today, so --stream / events tail is deliberately out of scope here.

Filters are validated for shape only — integer-ness of ids, self or an id for performers, agent/person for actor types. The 100-id caps are the server's and are not mirrored here: a cap written into the client goes stale as a refusal of a request the server would have served. --types and --reasons pass through unvalidated because those catalogs grow.

Errors

Status Exit What the caller sees
400 server's own (9 for validation) The server's message, plus the remedy its reason names — re-enter for a malformed position, fix the filters for a malformed filter. No reason, no hint: the two remedies are opposite.
403 (inbox) 4 "The inbox is served to agent principals only", with the feed named as the alternative.
409 1 (usage) Both filter digests — which the position was minted for, which this request presented — and "re-enter with --since". The caller's to fix.
410 2 (not found) The resume URL, plus the lane's own re-entry point in the hint: the feed re-enters at the epoch the body names, the inbox at --since 0. Not the caller's mistake.

All of it lives in one classification helper (feedError), keyed by lane. basecamp/basecamp-sdk#912 has since merged and is adopted here: each lane owns the arm that recognizes its own 410, so one lane can no longer read the other's recovery by accident, and the 400's reason selects between the two opposite remedies. A 400 carrying no reason — an older server — still gets the server's message and no hint at all, because guessing between "re-enter" and "fix your filters" sends the caller to the wrong one.

Every command this code hands back carries the filters the poll used, because a position is bound to its filter set: a resume without them answers 409, and a recovery without --exclude-performers self walks an agent into its own activity. That covers the resume breadcrumb, the 409 hint, and the 410 hint — which prefers the filters the server's resume URL preserved, falling back to the request's own. A resume URL that fails the same origin check the walk applies to a continuation is neither displayed nor parsed.

The ticket is a credential

events ticket redacts both the ticket and the URL unless --show-secret. The URL embeds the ticket, both are replayable bearers for the ~2 minute window, and a mint that printed them by default would put a credential in every terminal scrollback and CI log. An unparseable URL, or one with no ticket parameter, is withheld whole rather than echoed — the ticket is opaque, so any component of a URL that did not parse could be carrying it.

The mint is excluded from the MCP surface. The dispatcher returns a result verbatim into a model transcript with no equivalent of --show-secret, and nothing on that surface could use the ticket anyway — opening the stream needs a WebSocket, which a JSON tool call is not. The sync script's existing exclusion list is held to a "still not JSON" assertion that exists for bodies the toolkit refuses to load, so the policy exclusion is its own list, named for the reason it exists.

Continuation URLs

The SDK's PollEventsOptionsFromURL documents an origin check as the caller's obligation, so --all checks a continuation's scheme and host against the configured API base before following it. Only the query is ever re-issued — the SDK builds the next request against the configured base URL, so a foreign origin never receives the bearer — but a server has no business steering a walk's filters somewhere else either.

SDK pin

The feed operations are unreleased, so go.mod pins a basecamp-sdk main pseudo-version (a8046263835c, the head carrying basecamp/basecamp-sdk#912) via make bump-sdk, and internal/mcpserver/model/ is re-synced from that same checkout. Re-pinning to the next SDK release tag is the operator's release step. The vendored OpenAPI diff is large because the sync also carries everything that landed on SDK main since the previous pin, not just the feed.

The re-sync brings the EventFeed tag with it, and an SDK tag no DomainSpec claims fails the catalog build, so it gets its own MCP domain rather than joining the account grab-bag.

Tests

Unit tests run every envelope and every error shape against the recording stub transport: single page, empty page, --all across a continuation (asserting the second request re-issues the continuation's own filters, not the first call's flags), the --max-pages cap, a refused cross-origin continuation, 400/403/409/410, and every flag the command refuses before a request leaves the process. stubRoute gained a bodies sequence because routes match on path alone and a position-based walk re-requests one path with a different query.

e2e/event_feed.bats covers the refusals and the --agent --help hints; e2e/smoke/smoke_event_feed.bats covers the three leaves in the pre-release suite. The smoke suite enters at --since now and prints no position, ticket or cable URL — a smoke trace is a file somebody keeps.

bin/ci is green apart from the TTY-detection tests (TestIsInteractive*, TestIsTerminal, TestDeleteConfirmable…, the wizard's first-run preconditions), which fail identically on an unmodified origin/main in this sandbox and are about the environment's pty, not this change.

Live canary

Against production, account read-only:

  • events poll --since now — exit 0, empty page with a position, no next.
  • events poll --since 0 --all --max-pages 2 — exit 0, 200 events over 2 pages, continuation followed and origin-checked against production, capped notice emitted.
  • events poll --position <malformed> — exit 9, "Unrecognized position. Resume with since=<id> or since=now."
  • inbox --since now — exit 4, the agents-only message (the profile is a person principal, which is the documented path).
  • events ticket — exit 0, expires_in: 120, ticket and URL redacted, notice shown.

One thing deliberately not fixed

Ten other files under e2e/smoke/ are named in no level array of run_smoke.sh and so never run: aliases, assignments, attachments, bookmarks, calendars, drafts, gauges, hillcharts, notes, notifications. smoke_event_feed.bats was the eleventh and is now wired in. The other ten are pre-existing, and wiring unrelated suites into the live release smoke is not this PR's call — but a check that every e2e/smoke/*.bats appears in an array would have caught all eleven, and is what would stop the twelfth.

The account event feed's three operations — poll events, poll the inbox,
mint a stream ticket — are not in an SDK release yet, so the pin is a
basecamp-sdk main pseudo-version until the next tag.

Re-syncing the vendored MCP model brings the EventFeed tag with it, and an
SDK tag no DomainSpec claims fails the catalog build. It gets its own
domain rather than joining the account grab-bag: the feed is a resumable
lane with its own contract — a position and a continuation URL instead of
page numbers — and the blurb is where an MCP client learns that.
…nt feed

The account-wide event feed is how an agent hears about activity it did not
cause. It is a different resource from `basecamp events <id>`, which is one
recording's audit trail, so it lands as subcommands under the same noun:
`events poll` for the catch-up poll lane and `events ticket` to mint a
live-stream credential, plus `inbox` at the top level for the addressed-items
lane, which is its own endpoint rather than a filter over the feed.

Pagination here is the response body, not the Link-header page walk every
other listing in this CLI uses: a page carries a durable position to resume
from and a continuation URL while the current walk has more to serve. One
call is one page; --all walks the continuation to the end of that walk,
bounded by --max-pages, and says so when it stops at the bound rather than at
the end. That is not a live tail — the live lane is a WebSocket the ticket
opens, and connecting it would mean taking a WebSocket dependency the CLI
does not have today.

The three documented failures get the taxonomy they deserve. A 409 is the
caller's to fix, so it exits as usage and names both filter digests. A 410 is
not, so it exits as not-found carrying the lane's own re-entry point: the
feed re-enters at the epoch the body names, the inbox at the earliest
retained item. A 400 keeps the server's message, because a malformed position
and a malformed filter have different fixes and only the body knows which.
The inbox's 403 says what it means — the lane is served to agent principals
only for now.

The ticket and its URL are redacted unless --show-secret: the URL embeds the
ticket, both are bearers for the ~2 minute window, and a mint that printed
them by default would put a credential in every terminal scrollback and log.

Bare `basecamp events` now shows the group's help instead of refusing for a
missing id, so the subcommands are discoverable. Passing an id still does
what it did.
Copilot AI balanced review requested due to automatic review settings September 16, 2026 12:24
@github-actions github-actions Bot added commands CLI command implementations sdk SDK wrapper and provenance tests Tests (unit and e2e) skills Agent skills docs deps labels Sep 16, 2026
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 16, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-16T20:22:39.371808Z dbbff8e Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

The SDK bump leaves the Nix vendor hash stale, which will fail the repository’s Nix build check.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Adds account-wide event polling, agent inbox access, and stream-ticket minting.

Changes:

  • Implements resumable event and inbox polling with bounded continuation handling.
  • Adds secure stream-ticket redaction and feed-specific errors.
  • Updates SDK/MCP models, CLI surface, documentation, and tests.

[!TIP]
If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.

File summaries
File Description
skills/basecamp/SKILL.md Documents event-feed workflows.
scripts/check-bare-groups.sh Allows the actionable events group.
internal/version/sdk-provenance.json Records the updated SDK revision.
internal/mcpserver/testdata/catalog_snapshot.txt Adds the EventFeed MCP domain snapshot.
internal/mcpserver/server_test.go Updates MCP tool count.
internal/mcpserver/model/PROVENANCE.json Updates model provenance.
internal/mcpserver/model/openapi.json Syncs event-feed API schemas.
internal/mcpserver/model/behavior-model.json Adds feed operations and secret metadata.
internal/mcpserver/domains.go Registers the EventFeed domain.
internal/mcpserver/catalog_test.go Updates catalog expectations.
internal/commands/recording_test.go Supports sequential stub responses.
internal/commands/mcp_test.go Updates MCP command expectations.
internal/commands/inbox.go Implements the inbox command.
internal/commands/feed.go Implements feed polling, errors, continuations, and tickets.
internal/commands/feed_test.go Tests event-feed behavior.
internal/commands/events.go Adds poll and ticket subcommands.
internal/commands/commands.go Adds commands to the catalog.
internal/commands/commands_test.go Registers inbox in catalog tests.
internal/cli/root.go Registers the inbox command.
go.sum Updates SDK checksums.
go.mod Updates the SDK dependency.
e2e/smoke/smoke_event_feed.bats Adds live smoke coverage.
e2e/events.bats Updates bare-events behavior tests.
e2e/event_feed.bats Adds validation E2E coverage.
API-COVERAGE.md Records the three covered endpoints.
.surface-breaking Acknowledges the widened events argument.
.surface Updates the CLI surface snapshot.
Review details
  • Files reviewed: 26/27 changed files
  • Comments generated: 2
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread go.mod Outdated
Comment thread internal/commands/feed.go Outdated
… flake

The two poll lanes number different things: --since takes an event id on the
feed and an addressed-item id on the inbox. One shared remedy string sent an
inbox caller looking for the wrong identifier, so the hint now comes from the
lane, which already carried the rest of its per-lane text.

The SDK pin also moved vendored dependencies, which invalidates the Nix
flake's vendorHash. Recomputed and verified against a Docker nix build.
Copilot AI review requested due to automatic review settings September 16, 2026 12:32

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4fb6a920d7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread internal/commands/feed.go Outdated
Comment thread internal/commands/feed.go Outdated
Comment thread internal/commands/feed.go

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 Needs a closer look

The optional events argument regresses agent-help flag filtering, and two updated documents retain contradictory version or coverage metadata.

Review details

Suppressed comments (3)

Previously missed (3) — in code that hasn't changed since the last review.

internal/commands/events.go:22

  • Making the identifier optional bypasses curatedInheritedFlags: internal/cli/help.go:418-433 suppresses --project only when Use contains <id|url>. As a result, basecamp events --agent --help now advertises project scope even though the recording lookup is account-scoped. Extend that detection to [id|url] and cover this optional-ID case in the help tests.
    API-COVERAGE.md:54
  • This new note describes the current pin but leaves the heading immediately above at “SDK version: v0.16.0,” contradicting both go.mod and internal/version/sdk-provenance.json (v0.18.1-0.20260916113525-e58b5823a8ca). Update the heading so the coverage document does not report two different current SDK versions.
    skills/basecamp/SKILL.md:146
  • Adding the event-feed commands here leaves the skill headline at line 84 saying “189 tracked in-scope endpoints” and omitting the event feed, while this PR updates API-COVERAGE.md to 195. Update that headline as part of the skill sync so agents receive consistent coverage metadata.
  • Files reviewed: 27/28 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

Three review findings, all real.

A 410's resume URL preserves the request's canonical filters, and the hint
threw them away: re-entering with a bare --since drops --types, --buckets and
the --exclude-performers self loop guard, so an agent recovering from an
expired position would start consuming its own activity. The hint now renders
the filters back out of the resume URL through the lane's own parser. A
resume URL that will not parse, or one carrying a value outside srv2's
alphabet, falls back to the bare re-entry — a hint is meant to be pasted, and
half-rendered server text is not.

The continuation origin check compared raw scheme and host strings, so a base
URL written with an explicit default port, or a host in different case,
rejected its own continuations and broke --all after the first page. It now
compares the scheme case-insensitively and reuses this package's
sameHostPort, which normalizes the omitted port to the scheme's default.

Both poll commands handed the whole envelope to the output writer, which is
one object: --count answered 1 for every page and --ids-only found no id to
print. The rows go through WithDisplayData, the seam the account-wide
listings already use for exactly this. An inbox row's identity is its
addressing id, because applying the feed's dedupe-by-event-id rule to the
inbox would discard every reason but one.
Copilot AI review requested due to automatic review settings September 16, 2026 12:41

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

The new live smoke suite is not wired into the smoke runner, and the skill’s account-wide command list omits events ticket.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

skills/basecamp/SKILL.md:146

  • The project-scope exceptions omit the new basecamp events ticket command, even though its implementation is account-scoped just like events poll and requires no --in. This rule can therefore lead agents to add an unnecessary project scope when minting a ticket.
  • Files reviewed: 27/28 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread e2e/smoke/smoke_event_feed.bats
The file was written but named in no level array, so `make smoke` never ran
it while the static coverage check — which scans files, not the arrays —
reported it covered. It joins Level 0: the ticket mint is a POST, but it
writes no record and leaves nothing behind for a parallel suite.

The inbox case is allowlisted as an accepted gap. The lane is served to agent
principals only, so a person's smoke token gets the 403 the test asserts
before marking the gap, and an unallowlisted unverifiable blocks the run.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 Needs a closer look

Resume URL validation is incomplete, and human-readable output omits the position required to resume either feed lane.

Review details

Suppressed comments (3)

Previously missed (2) — in code that hasn't changed since the last review.

internal/commands/feed.go:225

  • The pinned SDK requires callers to validate a resume URL's origin before parsing its query, but gone.Resume reaches Poll*OptionsFromURL here without the checkContinuation applied to next. A wrong-origin 410 can therefore steer the filters rendered into the pasteable recovery command, including the self-loop guard. Thread the configured base URL through this error path, preserve filters only after the same-origin check, and otherwise use the bare fallback.

This issue also appears on line 530 of the same file.
internal/commands/inbox.go:130

  • Styled/Markdown output replaces the envelope with inboxRows(items), which hides position, and this breadcrumb leaves only a literal placeholder. As a result, a normal terminal user cannot resume the inbox without rerunning with JSON. Render the shell-quoted returned position in this command.

internal/commands/feed.go:530

  • WithDisplayData(events) makes styled/Markdown output render only the rows, so the durable position is hidden, while this breadcrumb prints only a literal placeholder. A default terminal invocation therefore gives users no value with which to resume (and a capped walk even says to use the “position above”). Put the shell-quoted returned position into the breadcrumb so non-JSON output remains resumable.
						Cmd:         "basecamp events poll --position <position>",
  • Files reviewed: 29/30 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d61a138a67

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread internal/commands/feed.go Outdated
Comment thread internal/commands/feed.go Outdated
Comment thread internal/commands/feed.go Outdated
Comment thread skills/basecamp/SKILL.md
Comment thread skills/basecamp/SKILL.md Outdated
A position is bound to the filter set it was minted for, so every command
this code hands back has to carry the filters or it is a command that fails.
Three places did not.

The resume breadcrumb offered `--position <position>` alone. On a filtered
poll that is a different filter set, and the server answers 409 — the
breadcrumb advertised its own failure. It now renders the filters the poll
used, on both lanes.

The 409 hint did the same thing in the other direction: the caller has just
changed filters, and the remedy told them to re-enter without any. It now
repeats the filters they presented.

And a 410's resume URL is server text that this code displays as
authoritative and reads filters out of, which is acting on it — so it now
faces the same origin check the walk applies to a continuation. A resume URL
pointing elsewhere is neither shown nor parsed, and the re-entry falls back to
the filters the request was invoked with.

The filter rendering is one helper now, fed either by the lane's continuation
parser or by the request's own options.

The skill's two project-scope exception lists had drifted apart, and the feed
section described the inbox as the event filter set plus --reasons. It is not
a superset: --creators, --performers, --exclude-performers and --actor-types
are not flags there, and generated calls using them would fail. Both lanes
now list their own filters.
Copilot AI review requested due to automatic review settings September 16, 2026 13:01
@jeremy

jeremy commented Sep 16, 2026

Copy link
Copy Markdown
Member Author

@codex review

Copilot AI previously approved these changes Sep 16, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟢 Approved

The implementation is cohesive and thoroughly tested; only a minor non-blocking documentation inconsistency remains.

Review details

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

internal/output/envelope.go:618

  • This documentation says every format renders an informational notice, but write deliberately suppresses WithNotice in FormatQuiet/--agent unless it is diagnostic (lines 216–227). Document that exception so callers do not rely on informational notices being delivered in agent mode.
  • Files reviewed: 34/35 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 03edb00b33

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread internal/commands/feed.go Outdated
Comment thread scripts/check-bare-groups.sh
Comment thread internal/commands/feed.go
The scheme was not on the list of components checked, so a response that
echoed the ticket there printed it. A list of components to check is a list
to be caught out by; the finished string is checked instead, alongside the
decoded path, which is the one place an escaped copy could hide from it.
The documented way to resume is --position "$POSITION". An unset variable
makes that an empty string, which read the same as omitting the flag and
entered at the present — silently skipping the backlog the caller was
resuming. Both lanes now say so instead, following the blank-flag pattern
checkins already uses.
The allowlist gained events without the documented exception list gaining
it, so the enforced convention and the written one disagreed. events is
also the one plural noun in that set, so appending it to a list of commands
"without a sibling plural group" would have moved the contradiction rather
than fixed it.
Asserting on the finished string is what makes the class dead — it covers
components nobody enumerated, which is how the scheme was missed. But what
is known to be covered still has to be readable, so there is now a case per
component: scheme, userinfo, host, port, path, escaped path, a second
parameter, a repeated one, and the fragment. Each also checks the URL was
withheld whole rather than partially scrubbed.
Copilot AI review requested due to automatic review settings September 16, 2026 19:53
Copilot AI dismissed their stale review, a newer Copilot review was requested September 16, 2026 19:53
@jeremy

jeremy commented Sep 16, 2026

Copy link
Copy Markdown
Member Author

@codex review

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

Ticket redaction can expose the URL’s bearer when it differs from the separate ticket field, and two notice comments misstate quiet-mode behavior.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (2)

Previously missed (2) — in code that hasn't changed since the last review.

internal/commands/feed.go:505

  • --quiet/--agent deliberately suppresses this informational notice, so the claim that every format receives the resume notice is false. Quiet output still includes position in the data payload; describe that distinction instead.
    internal/output/envelope.go:618
  • This contract is inaccurate for FormatQuiet: write emits only Response.Data there and deliberately suppresses non-diagnostic WithNotice values. Document that exception so callers do not assume an informational notice survives --quiet/--agent.
  • Files reviewed: 35/36 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread internal/commands/feed.go
The assertion checked the body's ticket field only. A response whose URL
carries a different ticket than its body makes the URL's the operative
bearer — the one that would actually open the stream — and it survived in
the path because nothing was looking for it. The check now runs over the
body's ticket and the URL's own ticket values alike.
Copilot AI review requested due to automatic review settings September 16, 2026 20:02
@jeremy

jeremy commented Sep 16, 2026

Copy link
Copy Markdown
Member Author

@codex review

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 Needs a closer look

Explicit empty filter values currently become unfiltered account-wide polls instead of usage errors.

Review details

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

internal/commands/feed.go:231

  • Returning nil when every entry is empty makes an explicitly supplied narrowing filter indistinguishable from omitting it. pflag parses --types "$EMPTY" as an empty slice, so either feed lane sends no filter and polls everything; the same silently removes an empty --exclude-performers loop guard. Reject a changed filter flag with no non-empty values (while still allowing a trailing comma after a real value) before account or network work.
  • Files reviewed: 35/36 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3b6f8f358c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread internal/commands/feed.go
Comment thread internal/commands/feed.go
url.URL.String escapes per component and picks the encoding itself, so a
search for the raw secret misses whichever component it re-spelled: the
scheme got through that way, then a non-ASCII host. Decoding the finished
string normalizes every component at once, including the ones nobody has
named, and replaces the hand-placed path check rather than joining it.
An unset variable marks the flag provided but leaves it carrying nothing,
and dropping it silently widened the request: --buckets to account-wide,
and --exclude-performers to no loop guard at all, so an agent starts
reacting to its own activity. The guard reads the flags the caller set
rather than a list of names to keep in step, so a filter added later is
covered without being enrolled. A trailing comma after a real value is
still fine.
Copilot AI review requested due to automatic review settings September 16, 2026 20:16
@jeremy

jeremy commented Sep 16, 2026

Copy link
Copy Markdown
Member Author

@codex review

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟢 Approved

The implementation is complete, security-conscious, and comprehensively tested across CLI, MCP, output, and smoke-test surfaces.

Review details
  • Files reviewed: 35/36 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Breezy!

Reviewed commit: dbbff8e044

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@jeremy
jeremy merged commit 504b9d2 into main Sep 16, 2026
36 checks passed
@jeremy
jeremy deleted the feed/events-inbox-commands branch September 16, 2026 20:27
jorgemanrubia added a commit that referenced this pull request Sep 16, 2026
#726 put the account event feed on this server: poll_events and poll_inbox in
the eventfeed domain, through the mechanical dispatch every other operation
uses. That dispatch turns a failed call into its message and nothing more,
because the canonical SDK error keeps only the message, the hint and a
validation body's field errors. For the feed that throws the contract away.
Its refusals are the recovery instructions — a 410 names where to re-enter, a
409 names the two filter digests that disagree, a 400 says whether to re-enter
or to fix the filters — and a consumer that receives them as prose has lost
the lane it was following.

So the two poll lanes now run through EventFeedService, whose typed errors
carry those members back out, and they come back as typed in-band errors in the
vocabulary basecamp-mcp-server serves. The request is still built from the
vendored model, so parameters, validation and the describe schema stay the
model's.

Rebuilt on #726 and on basecamp-sdk#912, which it pins. 912 split the
refusals by lane, and each gets its own arm here:

- The feed's 410, *FeedPositionGoneError, carries resume and epoch_after_id.
- The inbox's 410, *InboxPositionGoneError, carries resume and never an epoch:
  that lane fences on 30-day retention and re-enters at since=0.
- Either lane's 400, *FeedRequestError, is typed from its reason. The remedy
  sentence is read only when the deploy predates the reason, and an unknown
  reason is not guessed at through the sentence.
- A 409 is filter_mismatch only when both digests are present.

These types unwrap to the canonical error, so a missing arm is not a compile
error: the refusal degrades to request_failed and silently drops the member a
consumer recovers from. That is the trap 912 sets for any consumer, and the
tests close it:

    inbox 410 arm removed:
    --- FAIL: TestFeedStalePositionsKeepTheirOwnRecoveryData/the_inbox_lane_resumes_at_since=0_with_no_epoch
        the 410 must carry its recovery data
    --- FAIL: TestFeedInbox410NeverCarriesAnEpoch

    400 reason ignored:
    --- FAIL: TestFeedTellsTheTwo400sApart/the_reason_names_a_filter_error_even_when_the_sentence_says_otherwise
    --- FAIL: TestFeedTellsTheTwo400sApart/the_reason_names_a_position_error_on_the_inbox

    unknown reason guessed through the sentence:
    --- FAIL: TestFeedTellsTheTwo400sApart/a_reason_this_code_does_not_know_is_not_guessed_at_through_the_sentence

Carried from review of the earlier revision of this PR, each proven red on the
old code there:

- A filter that normalizes to nothing (`buckets=,`, `types=,`) is refused, not
  dropped. Dropped, it left the dimension unset, which on the wire is no
  filter, and the caller got the whole account feed.
- agents_only is claimed only on the bodyless 403 the inbox's principal guard
  sends, never on a scope or access refusal a caller could repair.
- Argument refusals (a filter that narrows nothing, both entry points at
  once, a parameter buildRequest rejects) are typed invalid_arguments rather
  than prose.
- EventFeed() is part of the required dispatcher API, so a client that cannot
  reach the feed cannot build a server that advertises it.

The stream-ticket mint stays excluded, as #726 left it. Its result is a
replayable bearer and a URL embedding it, this dispatcher returns results
verbatim into a model transcript with no redaction, and nothing here could
open the WebSocket it is for. The earlier revision of this PR served it
read-only; that is not carried forward. Reversing a credential-exposure
decision is not something to do inside a rebase.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

commands CLI command implementations deps docs output Output formatting and presentation sdk SDK wrapper and provenance skills Agent skills tests Tests (unit and e2e)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants