Commit Graph
12901 Commits
Author SHA1 Message Date
dependabot[bot] 9c2f217ca2 chore: bump the coder-modules group across 3 directories with 2 updates (#23091)
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-16 00:37:06 +00:00
Kyle Carberry 3d9628c27e ci: split build artifacts into per-platform uploads (#23081)
Splits the single `coder` artifact (containing all platforms in a 1.3GB
zip) into individual artifacts per OS/arch/format.

## Problem

All CI build artifacts are uploaded as a single artifact named `coder`,
producing a 1.3GB zip containing every platform's binary. This makes it
impossible to download a single platform's binary without pulling the
entire bundle.

## Solution

Upload each platform/format combination as a separate artifact:

| Artifact Name | Contents |
|---|---|
| `coder-linux-amd64.tar.gz` | Linux amd64 tarball |
| `coder-linux-amd64.deb` | Linux amd64 deb package |
| `coder-linux-arm64.tar.gz` | Linux arm64 tarball |
| `coder-linux-arm64.deb` | Linux arm64 deb package |
| `coder-linux-armv7.tar.gz` | Linux armv7 tarball |
| `coder-linux-armv7.deb` | Linux armv7 deb package |
| `coder-windows-amd64.zip` | Windows amd64 zip |

## Plan

This is the first step toward letting customers install directly from
`main` via:

```bash
curl -L https://coder.com/install.sh | sh -s -- --unsafe-unstable
```

GitHub Actions artifact downloads require authentication even for public
repos, so the next steps are to add a small Cloudflare Worker (similar
to the one we already have for `install.sh`) that:

1. Lists artifacts via the GitHub API (unauthenticated) to find the
latest artifact ID for the requested platform
2. Calls the download endpoint with a GitHub token (CF Worker secret) to
get a 302 redirect to a time-limited Azure Blob URL
3. Redirects the caller to that URL (which requires no auth)

This gives us publicly accessible per-platform URLs that the
`--unsafe-unstable` flag would point at. The worker doesn't proxy the
binary itself — it only proxies the metadata API call (~1KB) and
redirects for the actual download.

This PR splits the artifacts so the worker can serve individual platform
downloads (~200MB each) instead of forcing a 1.3GB bundle.
2026-03-15 09:56:18 -04:00
Dean Sheather a2b8564c48 chore: update deploy to use EKS (#23084) 2026-03-16 00:55:09 +11:00
Mathias Fredriksson 1adc22fffd fix(agent/reaper): skip reaper tests in CI (#23068)
ForkReap's syscall.ForkExec and process-directed signals remain
flaky in CI despite the subprocess isolation added in #22894.
Restore the testutil.InCI() skip guard that was removed in that
change.

Fixes coder/internal#1402
2026-03-14 21:15:47 +01:00
Kyle Carberry 266c611716 refactor(site): consolidate Git panel diff viewers and polish UI (#23080)
## Summary

Refactors the Git panel in the Agents page to consolidate duplicated
diff viewer code and significantly improve the UI.

### Deduplication
- **RemoteDiffPanel** now uses the shared `DiffViewer` component instead
of duplicating file tree, lazy loading, scroll tracking, and layout
(~500 lines removed).
- Renamed `RepoChangesPanel` → `LocalDiffPanel`, `FilesChangedPanel` →
`RemoteDiffPanel` to reflect actual scope.
- Removed `headerLeft`/`headerRight` abstraction from `DiffViewer` —
each consumer owns its own header.
- Replaced hand-rolled `ChatDiffStatusResponse` with auto-generated
`ChatDiffStatus` from `typesGenerated.ts`.

### Tab Redesign
- Per-repo tabs: each local repo gets its own tab (`Working <repo>`)
instead of a single stacked view.
- PR tab shows state icon + PR title; branch-only tab shows branch icon.
- Tabs use `Button variant="outline"` matching the Git/Desktop tab
style.
- Radix `ScrollArea` with thin horizontal scrollbar for tab overflow.
- Diff style toggle and refresh button lifted to shared toolbar, always
visible.

### PR Header
- Compact sub-header: `base_branch ←`, state badge
(`Open`/`Draft`/`Merged`/`Closed`), diff stats, and `View PR` button.
- GitHub-style state-aware icons (green open, gray draft, purple merged,
red closed).
- New API fields synced: `base_branch`, `author_login`, `pr_number`,
`commits`, `approved`, `reviewer_count`.

### Local Changes Header  
- Compact sub-header: branch name, repo root path, diff stats, and
`Commit` button (styled to match `View PR`).
- `CircleDotIcon` (amber) for working changes tabs — universal
"modified" indicator.

### Visual Polish
- All text in sub-headers and buttons at 13px matching chat font size.
- All badges (`DiffStatBadge`, PR state, `View PR`, `Commit`) use
consistent `border-border-default`, `rounded-sm`, `leading-5`.
- No background color on diff viewer header bars.
- Tabs hidden when their view has no content; auto-switch when active
tab disappears.

### Stories
- New `GitPanel.stories.tsx` covering: open PR + working changes, draft
PR, merged PR, closed PR, branch only, working changes only, multiple
repos, empty state.
- Removed old `LocalDiffPanel.stories.tsx` and
`RemoteDiffPanel.stories.tsx`.
2026-03-14 15:21:30 -04:00
Kyle Carberry 83e4f9f93e fix(agents): narrow chat mutation query invalidation (#23078)
## Problem

Sending a message on the `/agents` page triggers a burst of redundant
HTTP requests. The root cause is that chat mutations call
`invalidateQueries({ queryKey: ["chats"] })` which, due to React Query's
default **prefix matching**, cascades to every query whose key starts
with `["chats"]`:

- `["chats", {archived: false}]` — infinite sidebar list
- `["chats", chatId]` — individual chat detail
- `["chats", chatId, "messages"]` — all messages
- `["chats", chatId, "diff-status"]` — diff status
- `["chats", chatId, "diff-contents"]` — diff contents
- `["chats", "costSummary", ...]` — cost summaries

All of these have active subscribers on the page, so each one fires a
network request. The WebSocket stream already delivers these updates in
real-time, making the HTTP refetches completely redundant.

## Fix

| Mutation | Before | After |
|---|---|---|
| `createChatMessage` | `invalidateQueries({ queryKey: chatsKey })` —
prefix cascade | **Removed** — WebSocket delivers messages + sidebar
updates |
| `interruptChat` | `invalidateQueries({ queryKey: chatsKey })` — prefix
cascade | **Removed** — WebSocket delivers status changes |
| `editChatMessage` | 3 broad invalidations including `chatsKey` prefix
| 2 targeted with `exact: true`: `chatKey(id)` + `chatMessagesKey(id)` |
| `promoteChatQueuedMessage` | 3 broad invalidations including
`chatsKey` prefix | 2 targeted with `exact: true`: `chatKey(id)` +
`chatMessagesKey(id)` |

`editChatMessage` keeps `chatMessagesKey` invalidation because editing
truncates messages server-side and the WebSocket can only insert/update,
never remove stale entries.

## Net effect

Sending a message previously triggered **5–7 HTTP requests**. Now it
triggers **zero** — the WebSocket handles everything.

## Tests

Added `describe("mutation invalidation scope")` with 8 test cases
asserting that each mutation only invalidates the queries it genuinely
needs.
2026-03-14 18:22:37 +00:00
Kyle Carberry ff9d061ae9 fix(site): prevent duplicate chat in agents sidebar on creation (#23077)
## Problem

When creating a new chat in the agents page (`/agents`), the chat could
appear multiple times in the sidebar. This was a race condition
triggered by the WebSocket `created` event handler.

## Root Cause

`updateInfiniteChatsCache` applies its updater function **independently
on each page** of the infinite query:

```ts
const nextPages = prev.pages.map((page) => updater(page));
```

When the `watchChats` WebSocket received a `"created"` event, the
handler checked `exists` only within the *current page*, then prepended
the new chat if not found:

```ts
updateInfiniteChatsCache(queryClient, (chats) => {
    const exists = chats.some((c) => c.id === updatedChat.id);
    // ...
    if (chatEvent.kind === "created") {
        return [updatedChat, ...chats]; // runs per page!
    }
});
```

Since a brand-new chat doesn't exist in any page, **every loaded page**
prepends it. After `pages.flat()`, the chat appears once per loaded page
in the sidebar.

## Fix

- Added `prependToInfiniteChatsCache` in `chats.ts` that checks across
**all pages** before prepending, and only adds to page 0.
- Split the WebSocket handler so `"created"` events use the new safe
prepend, while update events (`title_change`, `status_change`) continue
using `updateInfiniteChatsCache` (which is safe for `.map()` operations
that don't add entries).
2026-03-14 13:27:54 -04:00
Kyle Carberry 0d3e39a24e feat: add head_branch to pull request diff status (#23076)
Adds the `head_branch` field (the source/feature branch name of a PR) to
the diff status pipeline. Previously only `base_branch` (target branch)
and the head commit SHA were captured from the GitHub API, but not the
head branch name itself.

## Changes

- **Migration 438**: Add `head_branch` nullable TEXT column to
`chat_diff_statuses`
- **gitprovider**: Parse `head.ref` from the GitHub API response
(alongside `head.sha`) and add `HeadBranch` to `PRStatus`
- **gitsync**: Wire `HeadBranch` through `refreshOne()` into the DB
upsert params
- **worker**: Map `HeadBranch` in `chatDiffStatusFromRow()`
- **coderd**: Convert `HeadBranch` in `convertChatDiffStatus()`
- **codersdk**: Expose as `head_branch` (`*string`, omitempty) in
`ChatDiffStatus` API response
- **Tests**: Updated `github_test.go` pull JSON fixtures and assertions
2026-03-14 17:24:19 +00:00
Thomas Kosiewski 3f7f25b3ee fix(chats): enforce desktop connect authorization (#23073)
### Motivation

- The desktop watch handler opened a VNC stream using the chat's
workspace ID while only relying on workspace read permissions, allowing
read-only users to escalate to interactive desktop access.
- Enforce connect-level authorization so only actors with
`ActionApplicationConnect` or `ActionSSH` can open the desktop stream.

### Description

- Added an explicit workspace lookup in `watchChatDesktop` using
`GetWorkspaceByID` to obtain a workspace object for authorization.
- Require the requester to be authorized for either
`policy.ActionApplicationConnect` or `policy.ActionSSH` on the workspace
before proceeding to locate agents or connect to the VNC stream, and
return `403 Forbidden` when neither permission is present.
- The change is minimal and localized to `coderd/chats.go` and does not
alter other code paths or behavior when the requester has the necessary
connect permissions.

### Testing

- Ran `gofmt -w coderd/chats.go` to format the modified file, which
succeeded.
- Attempted to run the unit test `TestWatchChatDesktop/NoWorkspace` via
`go test` in this environment but the test run did not complete within
the environment constraints and did not produce a full pass result.
- Attempted to run the repository pre-commit/gen steps but they could
not complete due to missing developer tooling and services in this
environment (e.g. `sqlc`, `mockgen`, `protoc` plugins and test services
like Docker/Postgres), so full pre-commit validation did not finish
here.
- Code review and static validation confirm the added authorization
check properly prevents read-only access from opening the desktop VNC
stream.

------
[Codex
Task](https://chatgpt.com/codex/tasks/task_b_69b46a4ac5c4832ea9d330aeba43c32d)
2026-03-14 17:53:05 +01:00
Kyle Carberry ddd1e86a90 fix(site): prevent infinite scroll from spamming duplicate chat list requests (#23075)
## Problem

The agents sidebar infinite scroll was spamming the `/api/v2/chats`
endpoint with duplicate requests at the same offset, caused by the
`LoadMoreSentinel` component.

### Root cause

`onLoadMore` is an inline arrow function (`() => void
chatsQuery.fetchNextPage()`), creating a **new function reference on
every render**. The `useEffect` in `LoadMoreSentinel` depended on
`[onLoadMore]`, so it tore down and re-created the
`IntersectionObserver` on every render. Each new observer immediately
fired its callback when the sentinel was already visible, triggering
duplicate fetches.

## Fix

- Store `onLoadMore` and `isFetchingNextPage` in **refs** so the
observer callback always reads the latest values without needing to tear
down/re-create.
- Create the `IntersectionObserver` **once on mount** (empty deps
array).
- **Guard** against calling `onLoadMore` while `isFetchingNextPage` is
true.

## Tests

- **LoadMoreSentinel behavior tests** (6 tests): verifies no duplicate
calls across re-renders, proper `isFetchingNextPage` gating, ref-based
observer stability, and correct resume after fetch completes.
- **`infiniteChats` query factory tests** (6 tests): covers
`getNextPageParam` and `queryFn` offset computation to prevent
pagination regressions.
2026-03-14 12:12:11 -04:00
Michael Suchacz 969066b55e feat(site): improve cost analytics view (#23069)
Surfaces cache token data in the analytics views and fixes table
spacing.

### Changes

- **Cache token columns**: Added cache read and cache write token counts
to all analytics views (user and admin), from SQL queries through Go SDK
types to the frontend tables and summary cards.
- **Table spacing fix**: Replaced the bare React fragment in
`ChatCostSummaryView` with a `space-y-6` container so the model and chat
breakdown tables no longer overlap.

### Data flow

`chat_messages` table already stores `cache_read_tokens` and
`cache_creation_tokens` (and uses them for cost calculation). This PR
aggregates and displays them alongside input/output tokens in:

- Summary cards (6 cards: Total Cost, Input, Output, Cache Read, Cache
Write, Messages)
- Per-model breakdown table
- Per-chat breakdown table
- Admin per-user table
2026-03-14 01:22:00 -05:00
Michael Suchacz f6976fd6c1 chore(dogfood): bump mux to 1.4.3 (#23039)
## Summary
- bump the dogfood Mux module to 1.4.3
- enable the new restart logic and allow up to 10 restart attempts

## Testing
- terraform fmt -check -diff dogfood/coder/main.tf
- git diff --check -- dogfood/coder/main.tf
- terraform -chdir=dogfood/coder validate
2026-03-14 06:46:44 +01:00
Michael Suchacz cbb3841e81 test(chats): verify cost summaries survive model deletion (#23051) 2026-03-14 06:35:46 +01:00
36665e17b2 feat: add WatchAllWorkspaceBuilds endpoint for autostart scaletests (#22057)
This PR adds a `WatchAllWorkspaces` function with `watch-all-workspaces`
endpoint, which can be used to listen on a single global pubsub channel
for _all_ workspace build updates, and makes use of it in the autostart
scaletest.

This negates the need to use a workspace watch pubsub channel _per_
workspace, which has auth overhead associated with each call. This is
especially relevant in situations such as the autostart scaletest, where
we need to start/stop a set of workspaces before we can configure their
autostart config. The overhead associated with all the watch requests
skews the scaletest results and makes it harder to reason about the
performance of the autostart feature itself.

The autostart scaletest also no longer generates its own metrics nor
does it wait for all the workspaces to actually start via autostart. We
should update the scaletest dashboard after both PRs are merged to
measure autostart performance via the new metrics.



The new function/endpoint and its usage in the autostart scaletest are
gated behind an experiment feature flag, this is something we should
discuss whether we want to enable the endpoint in prod by default or
not. If so, we can remove the experiment.

---------

Signed-off-by: Callum Styan <callumstyan@gmail.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Callum Styan <callum@coder.com>
2026-03-13 20:37:41 -07:00
Kyle Carberry b492c42624 chore(dogfood): add Google Chrome to dogfood image (#23063)
Install Google Chrome stable directly from `dl.google.com`. Ubuntu 22.04
ships `chromium-browser` as a snap-only package, which does not work in
Docker containers.

```dockerfile
RUN wget -q https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb && \\
    apt-get install --yes ./google-chrome-stable_current_amd64.deb && \\
    rm google-chrome-stable_current_amd64.deb
```

Verified in a running dogfood workspace:
```
$ google-chrome --version
Google Chrome 146.0.7680.75
```
2026-03-13 19:22:58 -04:00
Kyle Carberry c5b8611c5a feat(gitsync): enrich PR status with author, base branch, review info (#23038)
## Summary

Adds 7 new fields to the PR status stored by gitsync, all sourced from
the existing GitHub API calls (**zero additional HTTP requests**):

| Field | Source | Purpose |
|---|---|---|
| `author_login` | `pull.user.login` | PR author username |
| `author_avatar_url` | `pull.user.avatar_url` | PR author avatar for UI
|
| `base_branch` | `pull.base.ref` | Target branch (e.g. `main`) |
| `pr_number` | `pull.number` | Explicit PR number |
| `commits` | `pull.commits` | Number of commits in PR |
| `approved` | Derived from reviews | True when ≥1 approved, no
outstanding changes requested |
| `reviewer_count` | Derived from reviews | Distinct reviewers with a
decisive state |

## Changes

- **`gitprovider/gitprovider.go`**: Added 7 fields to `PRStatus` struct.
- **`gitprovider/github.go`**: Expanded the anonymous struct in
`FetchPullRequestStatus` to decode new JSON fields. Replaced
`hasOutstandingChangesRequested()` with `summarizeReviews()` returning a
`reviewStats` struct with `changesRequested`, `approved`, and
`reviewerCount`.
- **Migration 000434**: Adds 7 columns to `chat_diff_statuses`.
- **`queries/chats.sql`**: Updated `UpsertChatDiffStatus`
INSERT/VALUES/ON CONFLICT.
- **`gitsync/gitsync.go`**: Maps new `PRStatus` fields into upsert
params.
- **`gitsync/worker.go`**: Maps new columns in row-to-model converter.
- **`codersdk/chats.go`**: Added fields to SDK `ChatDiffStatus` type.
- **`coderd/chats.go`**: Maps new DB fields in
`convertChatDiffStatus()`.
- Auto-generated: `models.go`, `queries.sql.go`, `dump.sql`,
`typesGenerated.ts`.
2026-03-13 18:54:07 -04:00
Jon Ayers f714f589c5 fix: fork gvisor to avoid integer overflow (#23055) 2026-03-13 16:26:12 -05:00
Mathias Fredriksson 72689c2552 fix(coderd): improve error handling in chattest, chattool, and chats (#23047)
- Use t.Errorf in chattest non-streaming helpers so encoding
  failures fail the test
- Thread testing.TB into writeResponsesAPIStreaming and log
  SSE write errors instead of silently dropping them
- Bump createworkspace DB error log from Warn to Error
- Use errors.Join for timeout + output error in execute.go
2026-03-13 21:41:24 +02:00
Hugo Dutka 85509733f3 feat: chat desktop frontend (#23006)
https://github.com/user-attachments/assets/26f9c210-01ad-4685-aff1-7629cf3854f1
2026-03-13 19:01:50 +00:00
Michael Suchacz eacabd8390 feat(site): add chat cost analytics frontend (#23037)
Add UI components for viewing and managing LLM chat cost analytics.

## Changes
- `UserAnalyticsDialog`: personal cost summary with 30-day date range
- `ChatCostSummaryView`: shared component for cost breakdowns by model
and chat
- `ConfigureAgentsDialog`: admin Usage tab with deployment-wide cost
rollup
- Storybook stories for all new and existing components
- Replace `ModelsSection.test.tsx`, `DashboardLayout.test.tsx`,
`AuditPage.test.tsx` with Storybook stories
- Cost-related API client methods and React Query hooks
- Analytics utilities for formatting microdollar values

Backend: #23036
2026-03-13 18:59:14 +00:00
Hugo Dutka 84527390c6 feat: chat desktop backend (#23005)
Implement the backend for the desktop feature for agents.

- Adds a new `/api/experimental/chats/$id/desktop` endpoint to coderd
which exposes a VNC stream from a
[portabledesktop](https://github.com/coder/portabledesktop) process
running inside the workspace
- Adds a new `spawn_computer_use_agent` tool to chatd, which spawns a
subagent that has access to the `computer` tool which lets it interact
with the `portabledesktop` process running inside the workspace
- Adds the plumbing to make the above possible

There's a follow up frontend PR here:
https://github.com/coder/coder/pull/23006
2026-03-13 19:49:34 +01:00
blinkagent[bot]andblink-so[bot] 67f5494665 fix(site): allow microphone access for Web Speech API on agents page (#23046)
## Problem

The `/agents` page has a voice input feature that uses the Web Speech
API (`webkitSpeechRecognition`), but clicking the mic button always
results in a `"not-allowed"` error — even when the browser has
microphone permission granted.

## Root Cause

The `secureHeaders()` function in `site/site.go` sets a
`Permissions-Policy` header with `microphone=()`, which completely
disables microphone access for the page at the HTTP level. This
overrides any browser-level mic permission grants and causes the Web
Speech API to immediately fire an `onerror` event with `error:
"not-allowed"`.

## Fix

Change `microphone=()` to `microphone=(self)`, which:
- Allows the Coder origin itself to use the microphone (enabling the Web
Speech API voice input)
- Still blocks cross-origin iframes from accessing the microphone

This is the minimal permission change needed — `(self)` is more
restrictive than removing the policy entirely, maintaining the security
intent of the original header.

## Testing

1. Navigate to `/agents`
2. Click the mic button in the chat input
3. Verify voice input works (browser will prompt for mic permission if
not already granted)
4. Verify `Permissions-Policy` response header now shows
`microphone=(self)` instead of `microphone=()`

---------

Co-authored-by: blink-so[bot] <211532188+blink-so[bot]@users.noreply.github.com>
2026-03-13 14:02:38 -04:00
Mathias Fredriksson 9d33c340ec fix(coderd): handle ignored errors across coderd packages (#22851)
Handle previously ignored error return values in coderd:

- coderd/chats.go: check sendEvent errors, log on failure
- coderd/chatd/chattest: thread testing.TB through server structs,
  replace log.Printf with t.Logf, check writeSSEEvent errors
- coderd/chatd/chattool/createworkspace.go: log UpdateChatWorkspace
  failure instead of discarding both return values
- coderd/chatd/chattool/execute.go: surface ProcessOutput error in
  the timeout message returned to the caller
- coderd/provisionerdserver: log stream.Send failure in the
  DownloadFile error helper
2026-03-13 19:53:20 +02:00
Mathias Fredriksson 3bd840fe27 build(scripts/lib): allow MAKE_TIMED=1 to show last 20 lines of failed jobs (#22985)
Refs #22978
2026-03-13 17:41:33 +00:00
Cian Johnston 03d0fc4f4c fix(coderd): strip markdown code fences from Anthropic task name responses (#23024)
- Adds `extractJSON()` to strip markdown code fences before JSON parsing and wire into the `json.Unmarshal` call in `generateFromAnthropic`.
- Accepts variadic `RequestOption` in `generateFromAnthropic` so tests can inject a mock Anthropic server via `WithBaseURL`.
- Adds table-driven cases covering bare JSON, fenced with/without language tag, surrounding whitespace, and multiline JSON.
- Adds end-to-end cases using `httptest.NewServer` to serve fake Anthropic SSE streams with bare and fenced responses.
2026-03-13 17:35:26 +00:00
Mathias Fredriksson efe114119f fix(agent/reaper): run reaper tests in isolated subprocesses (#22894)
Tests that call ForkReap or send signals to their own process now
re-exec as isolated subprocesses. This prevents ForkReap's
syscall.ForkExec and process-directed signals from interfering
with the parent test binary or other tests running in parallel.

Also:
- Wait for the reaper goroutine to fully exit between subtests
  to prevent overlapping reapers from competing on Wait4(-1).
- Register signal handlers synchronously before spawning the
  forwarding goroutine so no signal is lost between ForkExec
  and the handler being ready.
2026-03-13 19:33:02 +02:00
Michael Suchacz c3b6284955 feat: add chat cost analytics backend (#23036)
Add cost tracking for LLM chat interactions with microdollar precision.

## Changes
- Add `chatcost` package for per-message cost calculation using
`shopspring/decimal` for intermediate arithmetic
- **Ceil rounding policy**: fractional micros round UP to next whole
micro (applied once after summing all components)
- Database migration: `total_cost_micros` BIGINT column with historical
backfill and `created_at` index
- API endpoints: per-user cost summary and admin rollup under
`/api/experimental/chats/cost/`
- SDK types: `ChatCostSummary`, `ChatCostModelBreakdown`,
`ChatCostUserRollup`
- Fix `modeloptionsgen` to handle `decimal.Decimal` as opaque numeric
type
- Update frontend pricing test fixtures for string decimal types

## Design decisions
- `NULL` = unpriced (no matching model config), `0` = free
- Reasoning tokens included in output tokens (no double-counting)
- Integer microdollars (BIGINT) for storage and API responses
- Price config uses `decimal.Decimal` for exact parsing; totals use
`int64`

Frontend: #23037
2026-03-13 18:30:49 +01:00
Kyle Carberry 1152b61ebb fix(site): fix speech recognition race condition and silent errors (#23043)
## Problem

Clicking the microphone button on `/agents` briefly activates recording
then immediately stops, focusing the text input.

## Root causes

**1. Race condition in `start()`** — When aborting a previous
recognition instance, the old instance's `onend` fires
**asynchronously** in real browsers (unlike our mock which fires it
synchronously). The stale `onend` callback then sets `isRecording=false`
and nullifies `recognitionRef.current`, killing the new recording
session. The unit tests didn't catch this because the mock fires `onend`
synchronously inside `abort()`.

**2. Silent `onerror` handler** — The `onerror` callback completely
discarded the error event. If the browser denied mic permission or the
speech service was unreachable (Chrome sends audio to Google servers),
recording silently died with no feedback.

**3. No cleanup on unmount** — The hook leaked a running recognition
instance if the component unmounted while recording.

## Fixes

- Guard `onend`/`onerror` callbacks with `recognitionRef.current !==
recognition` so stale instances are ignored
- Expose `error: string | null` state from the hook; surface it in the
UI ("Mic access denied" / "Voice input failed")
- Add a cleanup `useEffect` that aborts recognition on unmount
- Added 4 new tests covering the race condition, error exposure, and
error clearing
2026-03-13 13:03:08 -04:00
Spike Curtis 5745ff7912 test: log DERP debug in CI (#23041)
related to: https://github.com/coder/internal/issues/1387

Sometimes our tests using DERP flake like the above, but we have no information at the DERP layer about why. This enables verbose DERP logging in CI.

Logs are kinda spammy, but most tests do not force DERP and so only use it for discovery (disco).

A test like TestWorkspaceAgentListeningPorts/OK_BlockDirect drops around 50 DERP packets e.g.

```
    t.go:111: 2026-03-13 15:20:52.201 [debu]  agent.net.tailnet.net.wgengine: magicsock: got derp-999 packet: "\x04\x00\x00\x00C}\xe0\xb9\x00\x00\x00\x00\x00\x00\x00\x00}\xae;l\xd9Hk8\xa8l\x1cK\xedO{狦)\x18NIw\xc4k\xd2-\x19\xbf\xfb\xdd\x17\xa9b\xac\xfd#\xf7\xcaC\xbe\vq(u=\xa7\x16\xe9\x9aLjS\x1fXL\x19y\xf4\x1dE%\xb3\xff\x9d:8\xa9\x95X\xfe\xf8\x95\x7f\x9dv$\f\xf4\xbe"
    t.go:111: 2026-03-13 15:20:52.201 [debu]  agent.net.tailnet.net.wgengine: magicsock: got derp-999 packet: "\x04\x00\x00\x00C}\xe0\xb9\x01\x00\x00\x00\x00\x00\x00\x00\x05x\xb6RD;\xb4\x80\xb6\x0f\xf6KŠc\xfb1\xbd\x06\xb70K3\x97`\x8d\xd2\x14\xed\xc5\xd6\xc6\xcaV\xbf\x878\xb2Ƥ\xf0\xd5\xf7\xc0\x1b\x9f\x04Y\x03\x17\xd4\x06\xee\xb2G\r){\x9f\xde\xe0(\xb5N\xfejR_\xf6q\xa4\xfaT\x9a\xd8\xcbk\xba\x16K"
    t.go:111: 2026-03-13 15:20:52.201 [debu]  agent.net.tailnet.net.wgengine: magicsock: got derp-999 packet: "\x04\x00\x00\x00C}\xe0\xb9\x02\x00\x00\x00\x00\x00\x00\x00\x1e\x93a\x15\xfev\x81'\xa9?\xe8nR\xce<\x91\x86\xcau@\xb9\xcfɩ\xef\xd1眓\x95\xf3*X^7\x99\x88\xb0|\x8cS\xe4@[\x16\xda\xca\xd4\xd9\x1dP\xd0\xfe\xd9r\x8c\xfcp~dP\xfaK\xe0\xf9y\xb2\x11\x15\xfe\xdcx鷽\xdeF\xf7\x92\xe8"

```
2026-03-13 12:50:39 -04:00
Mathias Fredriksson 4a79af1a0d refactor: add chat_message_role enum and content_version column (#23042)
Migration 000434 converts chat_messages.role from text to a Postgres
enum, rebuilds the partial index, and adds content_version smallint.
The column is backfilled with DEFAULT 0, then the default is dropped
so future inserts must set it explicitly.

Version 0 uses the role-aware heuristic from #22958. Version 1 (all
new inserts) stores []ChatMessagePart JSON for all roles, including
system messages. ParseContent takes database.ChatMessage directly
and dispatches on version internally. Unknown versions error.

All string(codersdk.ChatMessageRole*) casts at DB write sites are
replaced with database.ChatMessageRole* constants from sqlc.

Refs #22958
2026-03-13 16:47:36 +00:00
Mathias Fredriksson bdbcd3428b feat(coderd/chatd): unify chat storage on SDK parts and fix file-reference rendering (#22958)
File-reference parts in user messages were flattened to `TextContent` at
write time because fantasy has no file-reference content type. The
frontend never saw them as structured parts.

This moves all write paths (user, assistant, tool) from fantasy envelope
format to `codersdk.ChatMessagePart`. The streaming layer (`chatloop`)
is untouched, the conversion happens at the serialization boundary in
`persistStep`.

Old rows are still readable. `ParseContent` uses a structural heuristic
(`isFantasyEnvelopeFormat`) to distinguish legacy envelopes from SDK
parts. We chose this over try/fallback because fantasy envelopes
partially unmarshal into `ChatMessagePart` (the `type` field matches)
while silently losing content. A guard test enforces that no SDK part
can produce the envelope shape.

This is forward-only: new rows are unreadable by old code. Chat is
behind a feature flag so rollback risk is contained.

Also adds a typed `ChatMessageRole` to replace raw strings and
`fantasy.MessageRole*` casts at the persistence boundary. The type
covers `ChatMessage.Role`, `ChatStreamMessagePart.Role`, the
`PublishMessagePart` callback chain, and all DB write sites.
`fantasy.MessageRole*` remains only where we build `fantasy.Message`
structs for LLM dispatch.

Separately, `ProviderMetadata` was leaking to SSE clients via
`publishMessagePart`. `StripInternal` now runs on both the SSE and REST
paths, covering this.

Other cleanup:

- Old `db2sdk.contentBlockToPart` silently dropped metadata on
text/reasoning/tool-call content. New code preserves it.
- `providerMetadataToOptions` now logs warnings instead of silently
returning nil.
- `db2sdk` shrinks from ~250 lines of parallel conversion to ~15 lines
delegating to `chatprompt.ParseContent()`, removing the `fantasy` import
entirely.

Refs #22821
2026-03-13 17:53:26 +02:00
Danny Kopping 870583224d chore: deprecate injected MCP approach in AI Bridge (#23031)
_Disclaimer: implemented by a Coder Agent using Claude Opus 4.6._

Marks the injected MCP approach in AI Bridge as deprecated across the
codebase.

## Changes

- **`codersdk/deployment.go`**: Deprecated `ExternalAuthConfig.MCPURL`,
`.MCPToolAllowRegex`, `.MCPToolDenyRegex` fields; deprecated and hid the
`--aibridge-inject-coder-mcp-tools` server flag; deprecated
`AIBridgeConfig.InjectCoderMCPTools`.
- **`coderd/externalauth/externalauth.go`**: Deprecated `Config.MCPURL`,
`.MCPToolAllowRegex`, `.MCPToolDenyRegex`.
- **`enterprise/aibridgedserver/aibridgedserver.go`**: Added runtime
deprecation warning when `CODER_AIBRIDGE_INJECT_CODER_MCP_TOOLS` is
enabled; deprecated `getCoderMCPServerConfig`.
- **`enterprise/aibridged/mcp.go`**: Deprecated `MCPProxyBuilder`
interface and `MCPProxyFactory` struct.
- **`docs/ai-coder/ai-bridge/mcp.md`**: Added deprecation warning
banner.
2026-03-13 16:15:33 +02:00
Kacper Sawicki df2360f56a feat(coderd): add consolidated /debug/profile endpoint for pprof collection (#22892)
## Summary

Adds a new `GET /api/v2/debug/profile` endpoint that collects multiple
pprof profiles in a single request and returns them as a tar.gz archive.
This allows collecting profiles (including block and mutex) without
requiring `CODER_PPROF_ENABLE` to be set, and without restarting
`coderd`.

Closes #21679

## What it does

The endpoint:
- Temporarily enables block and mutex profiling (normally disabled at
runtime)
- Runs CPU profile and/or trace for a configurable duration (default
10s, max 60s)
- Collects snapshot profiles (heap, allocs, block, mutex, goroutine,
threadcreate)
- Returns a tar.gz archive containing all requested `.prof` files
- Uses an atomic bool to prevent concurrent collections (returns 409
Conflict)
- Is protected by the existing debug endpoint RBAC (owner-only)

**Supported profile types:** cpu, heap, allocs, block, mutex, goroutine,
threadcreate, trace

**Query parameters:**
- `duration`: How long to run timed profiles (default: `10s`, max:
`60s`)
- `profiles`: Comma-separated list of profile types (default:
`cpu,heap,allocs,block,mutex,goroutine`)

## Additional changes

- **SDK client method** (`codersdk.Client.DebugCollectProfile`) for easy
programmatic access
- **`coder support bundle --pprof` integration**: tries the consolidated
endpoint first, falls back to individual `/debug/pprof/*` endpoints for
older servers
- **8 new tests** covering defaults, custom profiles, trace+CPU,
validation errors, authorization, and conflict detection
2026-03-13 14:09:39 +00:00
Mathias Fredriksson cc6716c730 fix(scripts): allow graceful shutdown when --debug/dlv is used in develop.sh (#23023) 2026-03-13 15:40:53 +02:00
Mathias Fredriksson 836a2112b6 feat(dogfood): clean up named Docker volumes and Go build cache cron (#23026)
Docker keeps named volumes around, even if they're dangling. During
development users generate a lot of named coder- volumes and the like.
These are now cleaned on shutdown once they are 30 days old.

For long running workspaces, go build cache also accumulates, so now
we clean these up via cron as well. All cache older than 2 days is wiped
and the cron schedule is based on workspace ID to distribute the load.
2026-03-13 15:02:01 +02:00
Kyle Carberry 690e3a87d8 feat: move chat messages to dedicated /chats/{id}/messages endpoint (#23021)
## Summary

Moves the messages response out of `GET /chats/{id}` and into a
dedicated `GET /chats/{id}/messages` endpoint.

### Backend
- `GET /chats/{id}` now returns just the `Chat` object (no messages)
- `GET /chats/{id}/messages` is a new endpoint returning
`ChatMessagesResponse` with `messages` and `queued_messages`
- Added `ChatMessagesResponse` SDK type and `GetChatMessages` client
method

### Frontend
- `getChat()` API method returns `Chat` instead of `ChatWithMessages`
- Added `getChatMessages()` API method for the new endpoint
- Split `chatQuery` into two: `chatQuery` (metadata) and
`chatMessagesQuery` (messages)
- Updated all cache mutations, optimistic updates, and websocket
handlers
- Updated tests and stories

### Files changed
| File | Change |
|---|---|
| `coderd/coderd.go` | Register `GET /messages` route |
| `coderd/chats.go` | Simplify `getChat`, add `getChatMessages` handler
|
| `codersdk/chats.go` | New type + method, update `GetChat` return |
| `site/src/api/api.ts` | New method, update `getChat` |
| `site/src/api/queries/chats.ts` | New query, update cache mutations |
| `site/src/pages/AgentsPage/AgentDetail.tsx` | Use separate queries |
| `site/src/pages/AgentsPage/AgentDetail/ChatContext.ts` | Update types
and cache writes |
| `site/src/pages/AgentsPage/AgentsPage.tsx` | Update websocket cache
handler |
2026-03-13 08:35:46 -04:00
Kyle Carberry 0e7e0a959e feat(site): add voice-to-text input to agent chat (#23022)
## Summary

Adds a microphone button to the agent chat input for browser-native
voice-to-text transcription using the Web Speech API.

## Changes

### New: `site/src/hooks/useSpeechRecognition.ts`
- Custom React hook wrapping the Web Speech API (`SpeechRecognition` /
`webkitSpeechRecognition`)
- Feature-detects browser support via `isSupported`
- Provides `start()`, `stop()`, and `cancel()` controls
- Accumulates real-time transcript from interim and final recognition
results
- Inline TypeScript declarations for the Web Speech API types

### Modified: `site/src/pages/AgentsPage/AgentChatInput.tsx`
- **Mic button**: Appears to the right of the image attach button when
the browser supports the Speech Recognition API. Shows a microphone icon
when idle, X icon when recording.
- **Send button**: Transforms into a checkmark during recording to
accept the transcription. Always enabled during recording.
- **Editor sync**: Live-updates the Lexical editor with the
transcription as the user speaks. Preserves any pre-existing text.
- **Cancel**: Restores the editor to its pre-recording content.

## How it works

1. User clicks the mic button → recording starts, real-time transcript
appears in the editor
2. User clicks the checkmark (send button) → recording stops,
transcribed text stays
3. User clicks X (mic button) → recording stops, editor reverts to
original content
2026-03-13 08:14:15 -04:00
Mathias Fredriksson ff156772f2 fix(coderd/database): move context creation to first use in migration tests (#23032)
The timeout was started before the unbounded Stepper loop, so
under CI load the deadline could expire before reaching the
operations that actually use it.

Also bumps TestMigration000387 from WaitLong to WaitSuperLong.

Fixes coder/internal#1398
2026-03-13 14:03:40 +02:00
Atif Ali a5400b2208 docs: add updated grafana dashboard with Client information (#23027) 2026-03-13 11:54:52 +00:00
Jaayden Halko 4e2640e506 fix(site): WCAG 2.1 AA remediation — landmarks, semantics, and a11y tooling (#22746)
## Summary

Targeted WCAG 2.1 AA accessibility remediation — continuation of #22673
— addressing remaining semantic, landmark, and tooling gaps identified
in the frontend accessibility review.

### Changes

#### Document semantics (WCAG 3.1.1)
- **`site/index.html`**: Added `<html lang="en">` root wrapper so screen
readers and browser features correctly identify the document language.

#### Landmark & bypass (WCAG 1.3.1, 2.4.1)
- **`DashboardLayout.tsx`**: Replaced `<div id="main-content">` with
`<main id="main-content">` so assistive technology exposes a proper main
landmark and the skip link targets a semantic region.

#### Table header relationships (WCAG 1.3.1)
- **`Table.tsx`**: `TableHead` now renders `scope="col"` by default
(overridable via prop), giving data cells an explicit header
relationship.

#### Semantic interactive controls (WCAG 2.1.1, 4.1.2)
- **`AuditLogRow.tsx`**: Replaced `<div role="button" tabIndex={0}>`
with native `<button type="button">`, removing the manual keyboard
handler (native button provides Enter/Space for free).
- **`Autocomplete.tsx`**: Replaced clear `<span role="button"
tabIndex={0}>` with native `<button type="button" aria-label="Clear
selection">`.

#### Reduced motion (WCAG 2.3.3 best practice)
- **`index.css`**: Added global `@media (prefers-reduced-motion:
reduce)` block that suppresses non-essential animations and transitions.

#### Accessibility regression tooling
- **Storybook**: Added `@storybook/addon-a11y` (version-matched to
existing Storybook 10.x).
- **vitest-axe**: Added `vitest-axe` with setup wiring and an exemplar
`Table.axe.test.tsx` that runs axe-core assertions in vitest.

### Test plan

- 12 new/updated tests pass across 5 test files:
  - `DashboardLayout.test.tsx` — main landmark + skip link behavior
  - `Table.test.tsx` — scope default + override
  - `Table.axe.test.tsx` — axe-core violation scan
  - `AuditPage.test.tsx` — keyboard toggle with native button
  - `Autocomplete.test.tsx` — clear control semantics
- `pnpm lint` clean (biome, TypeScript, circular deps)
- Manual keyboard traversal: skip link → main content, audit row toggle,
autocomplete clear
2026-03-13 10:47:53 +00:00
Cian JohnstonandClaude Opus 4.6 6104a000d1 refactor(healthcheck): reduce test boilerplate with healthyChecker helper (#23028)
## Summary

Extract a `healthyChecker()` test helper that returns an all-healthy
baseline `testChecker` in `coderd/healthcheck`. Each `TestHealthcheck`
table-driven test case now only overrides the single report field being
tested, instead of repeating all 6 healthy report structs.

- Reduces `healthcheck_test.go` from 603 to 341 lines (~260 lines, 43%
reduction)
- Test coverage unchanged at 77.2%
- All test cases and assertions preserved exactly

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:40:57 +00:00
Cian Johnston 8714aa4637 fix(coderd): downgrade heartbeat ping errors for closed connections to debug (#23025)
- `coderd/httpapi/websocket.go`: add `net.ErrClosed` +
`websocket.CloseStatus` checks; extract `heartbeatCloseWith` with
`quartz.Clock` parameter for testability
- `coderd/httpapi/websocket_internal_test.go`: new test file
2026-03-13 10:38:39 +00:00
Atif Ali 7777072d7a feat(chatd): set User-Agent on all outgoing LLM requests (#22965) 2026-03-13 15:12:04 +05:00
christinandJaayden Halko f6f33fa480 fix: adjust highlight colors to pass WCAG AA contrast ratio (#22823)
Fixes ENG-2052

Updates 4 CSS custom properties in `site/src/index.css` that failed the
4.5:1 WCAG AA contrast requirement against their paired surfaces:

**Light theme:**
| Variable | Before | After | Contrast |
|---|---|---|---|
| `--highlight-orange` | `30 100% 54%` | `30 100% 32%` | 2.07 → 4.89 |
| `--highlight-grey` | `240 5% 65%` | `240 5% 45%` | 2.55 → 5.11 |
| `--highlight-magenta` | `295 68% 46%` | `295 68% 40%` | 4.33 → 5.40 |

**Dark theme:**
| Variable | Before | After | Contrast |
|---|---|---|---|
| `--highlight-grey` | `240 4% 46%` | `240 4% 53%` | 3.65 → 4.68 |

Only lightness values were changed; hue and saturation are preserved.

## Storybook stories to review

Chromatic will run automatically on this PR and generate visual diffs.
Key stories affected:

- **Badge** — `site/src/components/Badge/Badge.stories.tsx` (uses
`text-highlight-magenta`, `text-highlight-red`, `text-highlight-green`,
`text-highlight-purple`, `text-highlight-sky`)
- **StatusIndicator** —
`site/src/components/StatusIndicator/StatusIndicator.stories.tsx` (uses
`text-highlight-grey`, `bg-highlight-grey`)
- **InfoTooltip** —
`site/src/components/InfoTooltip/InfoTooltip.stories.tsx` (uses
`text-highlight-grey`)
- **FeatureStageBadge** —
`site/src/components/FeatureStageBadge/FeatureStageBadge.stories.tsx`
(uses `text-highlight-sky`)
- **Chart** — `site/src/components/Chart/Chart.stories.tsx`,
`ActiveUserChart`, `LicenseSeatConsumptionChart`, `UserEngagementChart`
(uses `highlight-purple`)
- **ManagedAgentsConsumption** —
`site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/ManagedAgentsConsumption.tsx`
(uses `bg-highlight-orange`)
- **Paywall** — `site/src/components/Paywall/Paywall.stories.tsx` (uses
`border-highlight-magenta`)
- **WorkspaceStatusIndicator** —
`site/src/modules/workspaces/WorkspaceStatusIndicator/WorkspaceStatusIndicator.stories.tsx`
- **Alert** — `site/src/components/Alert/Alert.stories.tsx`

---------

Co-authored-by: Jaayden Halko <jaayden.halko@gmail.com>
2026-03-13 10:20:27 +01:00
Kyle Carberry 84dc1a3482 fix: hide web_search tool — preserve ProviderExecuted on DB-loaded tool results (#23014) 2026-03-12 18:00:51 -05:00
Kyle Carberry 0e1846fe2a fix(agent): reap exited processes and scope process list by chat ID (#22944) 2026-03-12 14:51:05 -07:00
Danielle Maywood 322a94b23b fix(agents): add model form footer bottom padding (#23002) 2026-03-12 21:43:13 +00:00
Cian Johnston e9025f91e8 chore(db): remove 23 unused database methods (#22999)
Removes 22 database query methods with no callers outside generated code
and the dbauthz wrapper layer (~1,600 lines).

**Security keys (6)** — superseded by `cryptokeys` package:
`GetAppSecurityKey`, `UpsertAppSecurityKey`, `GetOAuthSigningKey`,
`UpsertOAuthSigningKey`, `GetCoordinatorResumeTokenSigningKey`,
`UpsertCoordinatorResumeTokenSigningKey`

**Superseded queries (4):**
- `GetProvisionerJobsByIDs` → `GetProvisionerJobsByIDsWithQueuePosition`
- `GetDeploymentDAUs` / `GetTemplateDAUs` →
`GetTemplateInsightsByInterval`
- `GetWorkspaceBuildParametersByBuildIDs` + its `GetAuthorized...`
variant → unused

**OAuth2 (2):**
`GetOAuth2ProviderAppByRegistrationToken`,
`UpdateOAuth2ProviderAppSecretByID`

**Chat (4)** — pre-wired with no callers:
`GetChatModelConfigByProviderAndModel`, `DeleteChatMessagesByChatID`,
`ListChatsByRootID`, `ListChildChatsByParentID`

**Other (6):**
`DeleteGitSSHKey`, `UpdateUserLinkedID`, `GetFileIDByTemplateVersionID`,
`GetTemplateVersionHasAITask`, `InsertUserGroupsByName`,
`RemoveUserFromAllGroups`
2026-03-12 21:32:57 +00:00
Rowan Smith 4b8c079eef fix: prevent ui error when last org member is removed (#22975)
closes #22974

created with the help of mux
2026-03-13 08:15:37 +11:00
Kyle Carberry 42c12176a0 fix(chatd): persist interrupted tool call steps instead of losing them (#23011)
## Problem

When a chat is interrupted while tools are executing, the step content
(text, reasoning, tool calls, and partial tool results) was being lost.
Two gaps existed:

1. **During tool execution**: `executeTools` returns with error results
for interrupted tools, but the subsequent `PersistStep(ctx, ...)` fails
on the canceled context and returns `ErrInterrupted` without persisting
anything.

2. **PersistStep race**: If the context is canceled between the
post-tool interrupt check and the `PersistStep` call, the same loss
occurs.

This is inconsistent with how we handle stream interruptions (which
properly flush and persist partial content via `persistInterruptedStep`)
and how [coder/blink](https://github.com/coder/blink) handles
interruptions (always inserting the response message regardless of
execution phase).

## Fix

Two changes in `chatloop.go`:

- **Post-tool-execution interrupt check**: After `executeTools` returns,
check if the context was interrupted and route through
`persistInterruptedStep` (which uses `context.WithoutCancel` internally)
to save the accumulated content.

- **PersistStep fallback**: If `PersistStep` returns `ErrInterrupted`,
retry via `persistInterruptedStep` so partial content is not lost.

## Tests

- `TestRun_InterruptedDuringToolExecutionPersistsStep`: Verifies that
when a tool is blocked and the chat is interrupted, the step (text +
reasoning + tool call + tool error result) is persisted via the
interrupt-safe path.

- `TestRun_PersistStepInterruptedFallback`: Verifies that when
`PersistStep` itself returns `ErrInterrupted`, the step is retried via
the fallback path and content is saved.
2026-03-12 16:59:16 -04:00