feat(coderd): wire advisor runtime to admin config (#24622)

## Summary

Wire the advisor runtime into `chatd`: read the admin config on every `runChat`, gate tool registration and system-prompt guidance on a **single eligibility boolean**, register the `advisor` built-in tool, and apply the exclusive-tool policy from PR 1.

## Motivation

This is the integration seam where PRs 1–3 come together into an actual user-visible feature. Gating is deliberately root-chat-only for the initial rollout; child/sub-agent chats still do not see the tool or the guidance block.

## Changes

### `coderd/x/chatd/chatd.go`
- `loadAdvisorConfig(ctx, logger)` reads the admin config (from PR 3) on each run. If `ModelConfigID` is set, it resolves the override model via `configCache.ModelConfigByID`; otherwise it falls back to the outer chat's model and provider options. Reasoning effort is plumbed into provider options via `applyAdvisorReasoningEffort`.
- One computed `advisorEligible` boolean drives **both** tool registration (after skill tools, before MCP tools) and guidance injection via `chatprompt.InsertSystem(prompt, chatadvisor.ParentGuidanceBlock)`.
- `setAdvisorPromptSnapshot` closures capture the outer prompt state at the right points in the lifecycle (`renderPlanPathPrompt`, `ReloadMessages`, `PrepareMessages`) so the advisor handoff uses the same context the outer model saw.
- `ExclusiveToolNames["advisor"] = true` is passed to `chatloop.Run()` so mixed batches are rejected cleanly (PR 1 machinery).
- `builtinToolNames["advisor"] = true` so metrics keep advisor distinct from the generic `mcp` label.

### Child-chat guard
- Child/sub-agent chats deliberately do not see the advisor tool or guidance block, to avoid recursion/cost blowups until the pattern is proven. This is covered by `TestAdvisorGating_ChildChat` (currently skipped pending a rewrite against the new `plan`/`explore` subagent infrastructure; core gating logic is still exercised by `TestAdvisorGating_Disabled` and `TestAdvisorGating_RootChat`).

## Stack context

This is **PR 4 of 6** in the advisor feature stack. It depends on PRs 1–3.

## Scope / non-goals

- No frontend changes. The feature is invocable via the backend but renders generically until PR 5.
- No separate provider runner; the nested advisor call reuses the existing model/provider path.
- No DB migration.

## Validation

- `go test ./coderd/x/chatd/... -run TestAdvisor`
- `go build ./...`
- `make lint`

---

<details>
<summary>📋 Implementation Plan (shared across the advisor stack)</summary>

# Plan: Add a Mux-style advisor tool to coder agents/chatd

## Outcome

Add a first-class `advisor` tool to agent chats in `coderd/x/chatd` that feels native to Coder:

- it is a built-in server-side tool, not an MCP/dynamic-tool workaround;
- it performs a nested **tool-less** model call for strategic advice;
- it is exposed only when eligible, and the prompt mentions it only when it is actually available;
- it is treated as a **planning-only** tool so it does not run alongside action tools in the same batch;
- it tracks usage/cost separately enough for operators to reason about it;
- it has a minimally polished UI in the Agents page;
- and it ships with explicit dogfooding evidence, including screenshots and repro videos.

## Design decisions to lock before coding

1. **Primary architecture:** native built-in tool in `chattool/`, backed by a small `chatadvisor` package.
2. **Nested model execution:** reuse chatd's existing model/provider stack for a one-step, tool-less advisor call rather than inventing a new provider pathway.
3. **Execution policy:** treat `advisor` as an exclusive/planning-only tool; mixed batches must return structured policy errors and force the model to retry cleanly.
4. **Availability:** initial rollout is for root agent chats only; disable for child/sub-agent chats until recursion/cost policy is proven.
5. **Prompt sync:** use one eligibility boolean to drive both tool registration and advisor guidance injection.
6. **Persistence/cost split:** MVP should keep advisor usage visible in result metadata and server metrics; only add DB schema if product/billing explicitly needs queryable advisor-specific cost.
7. **UI scope:** generic tool rendering is an acceptable temporary milestone during backend bring-up, but the release candidate should include a dedicated lightweight advisor renderer.

## Delivery model

The work should be executed as coordinated workstreams with one integration owner and parallel contributors for low-conflict areas. The integration owner should own `coderd/x/chatd/chatd.go` because prompt assembly, tool registration, and model resolution all converge there.

## Detailed workstreams

### Repo evidence used for this plan

<details>
<summary>Mux reference and current chatd seams</summary>

**Mux reference implementation**

- `src/node/services/tools/advisor.ts` — native advisor tool implementation.
- `src/common/constants/advisor.ts` — advisor prompt/constants and truncation policy.
- `src/common/utils/tools/tools.ts` — conditional tool registration.
- `src/node/services/streamContextBuilder.ts` — injects advisor guidance only when the tool is available.

**Current chatd seams**

- `coderd/x/chatd/chatd.go`
  - `processChat()` — tool assembly, prompt assembly, and chatloop invocation.
  - `resolveChatModel()` — current model/provider/key resolution seam.
  - `type Config struct` — server-level chatd configuration surface.
- `coderd/x/chatd/chatloop/chatloop.go`
  - `Run()` — main streaming/model loop.
  - `executeTools()` — built-in tool execution/batching seam.
- `coderd/x/chatd/chattool/` — built-in tool implementations.
- `site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx` — tool renderer dispatch.
- `site/src/pages/AgentsPage/components/ChatConversation/messageParsing.ts` and `ConversationTimeline.tsx` — tool/result merge and rendering flow.

</details>

### Workstream map and ownership

| Workstream | Primary owner | Main files | Can run in parallel? | Done when |
|---|---|---|---|---|
| 0. Integration + gating | Integration lead | `coderd/x/chatd/chatd.go` | No; central merge lane | Tool registration, prompt sync, and model selection are wired together |
| 1. Advisor runtime + tool | Backend agent | new `coderd/x/chatd/chatadvisor/`, new `coderd/x/chatd/chattool/advisor.go` | Yes | Tool can perform a tool-less advisor call in memory and return structured results |
| 2. Planning-only execution policy | Chatloop agent | `coderd/x/chatd/chatloop/chatloop.go`, related tests | Yes | Mixed `advisor` + action-tool batches are rejected cleanly and deterministically |
| 3. Metrics/usage/config | Backend/telemetry agent | `chatd.go`, `chatloop/metrics.go`, optional config plumbing | Partially; coordinate with integration lead | Advisor usage is separately visible in metadata/metrics and limits are enforced |
| 4. Frontend rendering | Frontend agent | `site/.../tools/Tool.tsx`, new `AdvisorTool.tsx`, stories | Yes after result schema stabilizes | Advisor renders as a readable card and story tests pass |
| 5. Dogfood + QA evidence | QA agent | dev server, Storybook, dogfood output | After backend + UI are usable | Repro videos, screenshots, and a concise QA report exist |

### Parallelization rules

- **Do not split `coderd/x/chatd/chatd.go` across multiple execution agents without an integration lead.** That file owns prompt building, tool registration, model resolution, and cost persistence.
- Workstreams 1 and 2 can be developed in parallel and then stacked onto the integration branch.
- Workstream 4 should begin once the backend result schema is agreed on, even if the backend is still behind a feature flag.
- Any agent that needs to re-check Mux behavior should clone `coder/mux` into a temporary directory (for example, `$(mktemp -d)/mux`) and inspect it read-only; do not vendor or copy code from Mux directly.

## Phase 0 — Preflight and guardrails

### Goals

- Align the team on the smallest shippable architecture.
- Prevent scope creep into MCP/dynamic-tool/sub-agent variants.
- Decide upfront what is MVP vs. follow-up.

### Tasks

1. **Confirm the MVP boundary.**
   - Ship a built-in advisor tool first.
   - Do **not** make MCP, dynamic tools, or sub-agents the primary implementation.
   - Do **not** add transient streaming phases in the first backend PR unless they fall out almost for free.

2. **Confirm local workflow hygiene before coding.**
   - Ensure the repo is using the project git hooks from `scripts/githooks`.
   - Do not bypass hooks with `--no-verify`.
   - Use `./scripts/develop.sh` for the full dev server rather than manual build/run commands.

3. **Lock the model-selection policy.**
   - **Recommended MVP:** advisor uses the same resolved provider/model/cost config as the current chat, with advisor-specific max-output and usage caps.
   - **Follow-up only if required:** add a separate `AdvisorModelConfigID`-style override that resolves through the existing `configCache`/model-config path. Do not invent a new free-form `provider:model` parser if chatd already stores provider/model separately.

4. **Lock the persistence policy.**
   - **Recommended MVP:** no DB migration. Persist advisor-visible metadata in the tool result and record separate metrics in memory/Prometheus.
   - **Only if product/billing explicitly asks for queryable advisor cost:** add a later DB migration or usage table, following the normal `queries/*.sql` + `make gen` workflow.

5. **Create an execution ADR note in the work item or tracking doc.**
   - Capture: built-in tool, tool-less nested call, root-chat-only rollout, exclusive execution policy, MVP no-DB-migration default.

### Quality gate

- Everyone on the team can state the same answers to these questions:
  - Is advisor a built-in tool? **Yes.**
  - Can advisor run with action tools in the same batch? **No.**
  - Does advisor get tools of its own? **No.**
  - Is a DB migration required for MVP? **No, unless billing insists.**

## Phase 1 — Build the advisor runtime and tool wrapper

### Goals

Create the core advisor implementation in a way that is easy to test and keeps `chattool/` thin.

### Files to add

- `coderd/x/chatd/chatadvisor/types.go`
- `coderd/x/chatd/chatadvisor/guidance.go`
- `coderd/x/chatd/chatadvisor/handoff.go`
- `coderd/x/chatd/chatadvisor/runtime.go`
- `coderd/x/chatd/chatadvisor/runner.go`
- `coderd/x/chatd/chattool/advisor.go`

### Responsibilities by file

1. **`types.go`**
   - Define the input/result schema used by the tool and UI.
   - Keep the result shape close to Mux so the UI and model both have predictable cases.
   - Recommended result variants:
     - `advice`
     - `limit_reached`
     - `error`

   Recommended shape:

   ```go
   type AdvisorArgs struct {
       Question string `json:"question"`
   }

   type AdvisorResult struct {
       Type          string              `json:"type"`
       Advice        string              `json:"advice,omitempty"`
       Error         string              `json:"error,omitempty"`
       AdvisorModel  string              `json:"advisor_model,omitempty"`
       RemainingUses int                 `json:"remaining_uses,omitempty"`
       Usage         *AdvisorUsageResult `json:"usage,omitempty"`
   }
   ```

2. **`guidance.go`**
   - Hold two strings:
     - the nested advisor system prompt;
     - the parent-agent guidance block to inject into the outer system prompt.
   - The nested advisor prompt must say, in plain language:
     - you are advising the parent agent;
     - you do not address the end user directly;
     - you do not claim actions happened;
     - you return concise strategic guidance and tradeoffs.

3. **`runtime.go`**
   - Define the per-run runtime state.
   - Recommended fields:
     - resolved model + model config;
     - provider keys/options reused from the outer chat;
     - `MaxUsesPerRun`;
     - `MaxOutputTokens`;
     - atomic/current call counter;
     - callback(s) to obtain the current prompt snapshot and current-step snapshot;
     - optional metrics/usage hook.
   - Add fail-fast validation for impossible config: nil model, non-positive limits, empty prompt builders, etc.

4. **`handoff.go`**
   - Build the advisor handoff message from:
     - the explicit question;
     - the exact prompt/messages the parent model just used;
     - the current step's text/reasoning snapshot, if available;
     - the most recent relevant tool outputs, if they are already in the prompt snapshot.
   - **Important:** use the already-prepared outer prompt tail, not a fresh DB reload. That keeps the advisor aligned with compaction and the exact context the outer model saw.
   - Apply hard truncation budgets with recent-context bias.

5. **`runner.go`**
   - Execute the nested advisor call.
   - **Recommended implementation:** call `chatloop.Run()` in an in-memory, one-step mode:
     - `Tools: nil`
     - `ProviderTools: nil`
     - `MaxSteps: 1`
     - `PersistStep`: capture the assistant output in memory instead of writing DB rows
   - Reuse the existing provider/model/cost path instead of building a second provider runner.
   - Assert that no tool definitions are passed to the nested call.

6. **`chattool/advisor.go`**
   - Keep this file thin and consistent with other built-ins.
   - Responsibilities:
     - decode `AdvisorArgs`;
     - validate `Question` is non-empty and bounded;
     - call the `chatadvisor` runner;
     - return a structured tool response.

### Defensive programming requirements

- Assert `Question` is non-empty after trimming.
- Assert runtime limits are positive.
- Assert the nested advisor call runs with zero tools/provider tools.
- Assert `AdvisorResult.Type` is one of the known variants before returning.
- Assert remaining uses never goes negative.

### Acceptance criteria

- A unit test can call the advisor tool with a fake model and receive a stable `advice` result.
- The nested advisor call is impossible to run with tools accidentally attached.
- The core logic lives in `chatadvisor/`, not embedded inside `chatd.go`.

## Phase 2 — Wire advisor into chatd and keep prompt/tool availability in sync

### Goals

Register the tool in the right place, expose it only when eligible, and inject system guidance only when the tool is present.

### Files to modify

- `coderd/x/chatd/chatd.go`
- optionally a small helper file if `chatd.go` becomes too crowded

### Tasks

1. **Compute one eligibility boolean in `processChat()`.**
   Recommended inputs:
   - server-level advisor enabled flag;
   - root chat only (`chat.ParentChatID == uuid.Nil` or equivalent existing root/child check);
   - a usable resolved model/provider exists;
   - optional experiment/workspace/org gate if product wants staged rollout.

2. **Create the runtime once per outer chat run.**
   - Use the model/config/keys resolved by `resolveChatModel()`.
   - Reuse provider options from the current chat's `ChatModelCallConfig`.
   - Set `MaxUsesPerRun` and `MaxOutputTokens` from advisor config defaults.

3. **Register the tool in the built-in tool block.**
   - Insert after the skill tools and before MCP tools in `processChat()`.
   - Record `builtinToolNames["advisor"] = true` so metrics stay bounded.

4. **Inject advisor guidance into the outer system prompt using the same boolean.**
   - Use `chatprompt.InsertSystem()` in the same prompt assembly path that already injects user/system instructions.
   - Place the block near the existing instruction insertion, before plan-path/skill context blocks.
   - Wrap the guidance in an explicit tag like `<advisor-guidance>` so it is easy to spot in tests and future refactors.

5. **Keep advisor out of child chats for the first release.**
   - That avoids recursion/cost blowups with `spawn_agent` / `wait_agent` flows.
   - Document this explicitly in the rollout notes and tests.

### Acceptance criteria

- If advisor is disabled, neither the tool nor the prompt guidance appears.
- If advisor is enabled, both the tool and the prompt guidance appear.
- Root chats can use advisor; child chats cannot.
- Built-in tool names include `advisor` so metrics do not collapse it into the generic `mcp` label.

## Phase 3 — Enforce planning-only execution policy in `chatloop`

### Goals

Prevent the model from calling `advisor` and action tools in the same execution batch.

### Files to modify

- `coderd/x/chatd/chatloop/chatloop.go`
- related chatloop tests

### Recommended implementation

Keep the MVP small; do **not** build a general policy engine yet.

1. Add a minimal field to `chatloop.RunOptions`, for example:

   ```go
   ExclusiveToolName *string
   ```

2. In `Run()` / `executeTools()`, detect the case where the exclusive tool appears in the same local-tool batch as any other locally executed tool.

3. When that happens, synthesize structured tool-result errors for the affected calls instead of executing anything in the batch.
   - `advisor` should receive a clear error like: _advisor must be called by itself before action tools_.
   - The sibling action tools should receive a paired policy error like: _this tool was skipped because advisor must run alone_.

4. Let the outer model see those tool errors and retry cleanly.
   - This is simpler and safer than partial execution or hidden deferral.
   - It preserves deterministic transcript history for debugging.

5. Pass the just-finished step snapshot into the tool execution context.
   - The advisor runtime should be able to see the current step's text/reasoning content, because that is often the best hint about what the outer model is trying to decide.

### Why this is the right fit

- It matches the intended semantics: advisor is consulted **before** taking action.
- It avoids subtle race conditions caused by concurrent built-in tool execution.
- It keeps the behavior easy to test with fake models.

### Acceptance criteria

- A model-emitted batch containing only `advisor` succeeds.
- A model-emitted batch containing `advisor` plus any other locally executed tool returns deterministic policy errors and executes nothing.
- Non-advisor tool execution stays unchanged for normal chats.

## Phase 4 — Usage limits, metrics, and configuration

### Goals

Make advisor safe to operate without over-designing billing/storage in the first release.

### Files to modify

- `coderd/x/chatd/chatd.go`
- `coderd/x/chatd/chatloop/metrics.go` as needed
- `coderd/x/chatd/chatd.go` `Config` struct and constructor path
- optional follow-up config/db files only if a separate advisor model or persistent billing is required

### Tasks

1. **Add explicit server config knobs for MVP.**
   Recommended fields on `chatd.Config` or a nested advisor config struct:
   - `AdvisorEnabled bool`
   - `AdvisorMaxUsesPerRun int`
   - `AdvisorMaxOutputTokens int64`

2. **Track usage per outer run.**
   - Reset the counter for each `processChat()` invocation.
   - Return `remaining_uses` in the tool result.
   - Return `limit_reached` when the cap is exhausted.

3. **Expose advisor usage metadata in the tool result.**
   - Include model name and token/cost summary if available.
   - Use the same `callConfig.Cost` calculation path as the outer chat for MVP if advisor reuses the same model.

4. **Record server-side metrics.**
   - Count advisor invocations, failures, and latency.
   - Ensure they show up under the built-in tool label `advisor`.

5. **Optional decision gate: separate advisor model.**
   - If product insists on a stronger/different advisor model, add a follow-up config hook that resolves another existing chat model config through the same `configCache` path.
   - Keep that out of the first landing PR unless it is required for acceptance.

6. **Optional decision gate: queryable advisor cost.**
   - If this becomes required, spin a follow-up DB task:
     - update `coderd/database/queries/*.sql`;
     - add migration files;
     - run `make gen`;
     - update audit mappings if a new auditable type/field is introduced.

### Acceptance criteria

- Advisor calls are capped per outer run.
- Limit exhaustion is user-visible in the tool result.
- Metrics distinguish advisor calls from other built-in tools.
- MVP does not require a schema migration unless explicitly approved.

## Phase 5 — Frontend rendering and Storybook coverage

### Goals

Make advisor feel intentional in the Agents UI without blocking the backend on fancy streaming UI.

### Files to modify

- `site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx`
- new `site/src/pages/AgentsPage/components/ChatElements/tools/AdvisorTool.tsx`
- Storybook story file(s) in the same tools directory

### Delivery strategy

1. **Intermediate milestone during backend bring-up:** rely on the existing generic tool renderer if needed.
   - This is acceptable only as a short-lived integration checkpoint.

2. **Release milestone:** add a dedicated lightweight `AdvisorTool` renderer.
   - Reuse existing primitives:
     - `ToolCollapsible`
     - `ToolIcon`
     - `Response` for markdown/prose rendering
     - `ScrollArea` if the advice can be long
   - Keep styling light and consistent with the Agents page.
   - Do not add unnecessary React memoization in `site/src/pages/AgentsPage/`; that area is already React-Compiler aware.

3. **Render the structured result states cleanly.**
   - `advice` — readable prose/markdown with optional metadata footer.
   - `limit_reached` — warning-style message.
   - `error` — error state with visible fallback text.
   - `running` — existing tool loading state/spinner is enough for MVP.

4. **Add Storybook coverage instead of ad-hoc component tests.**
   Recommended stories:
   - successful advice;
   - running/loading;
   - limit reached;
   - error.

5. **Keep the UI contract narrow.**
   - Prefer one text field like `advice` plus small metadata rather than a deeply nested schema.
   - That keeps the UI resilient to prompt iteration.

### Acceptance criteria

- The advisor tool card renders readable content rather than raw quoted JSON in the final release branch.
- Running, limit, and error states are visibly distinct.
- Storybook stories and play assertions cover the new states.
- Existing tool rendering flows remain unchanged.

## Phase 6 — Automated tests and validation gates

### Backend tests to add

1. **Advisor runtime/tool tests**
   - question validation;
   - tool-less nested execution assertion;
   - success result shaping;
   - limit-reached result shaping;
   - error result shaping.

2. **Prompt/gating tests in chatd**
   - advisor disabled ⇒ no tool, no guidance;
   - advisor enabled/root chat ⇒ tool + guidance;
   - child chat ⇒ advisor absent.

3. **Chatloop policy tests**
   - advisor alone runs;
   - advisor + action tool mixed batch returns deterministic policy errors;
   - non-advisor tools still execute normally.

4. **Usage/metrics tests**
   - per-run cap resets correctly;
   - builtin tool labeling includes `advisor`;
   - returned metadata includes model/usage summary when available.

### Frontend tests to add

- Storybook `play()` assertions for the advisor renderer states.
- Verify expand/collapse behavior and visible fallback text.
- Verify the message timeline still renders adjacent tools correctly.

### Recommended command sequence

Run these as the implementation matures, not only at the end:

1. Backend-focused gate after phases 1–4:
   - `make test RUN=TestAdvisor`
   - `make test RUN=TestChatloopAdvisor`
   - `make lint`

2. Frontend-focused gate after phase 5:
   - `pnpm test:storybook src/pages/AgentsPage/components/ChatElements/tools/AdvisorTool.stories.tsx`
   - `pnpm lint`
   - `pnpm format`

3. Final repo gate before handoff:
   - `make pre-commit`
   - run any additional targeted `make test RUN=...` selections covering touched chatd paths

> Use the exact new test names the implementing agents create; the names above are recommended anchors, not existing tests.

## Dogfooding plan

### Principle

Dogfood the change as a real agent feature, not just a unit-tested backend. Per the dogfood and `agent-browser` skills, the reviewer should get **watchable repro videos** plus screenshots that make the behavior obvious without reading logs.

### Required setup

1. Start the full dev environment with:
   - `./scripts/develop.sh`
2. If the frontend renderer changes, also start Storybook from `site/` with:
   - `pnpm storybook --no-open`
3. Use `agent-browser` directly — **never `npx agent-browser`**.
4. Use named browser sessions and an output folder such as:
   - `./dogfood-output/advisor/`
   - with subfolders `screenshots/` and `videos/`

### Evidence protocol

For every interactive scenario below:

1. Start video recording **before** the action.
2. Capture step-by-step screenshots at human pace.
3. Capture one annotated screenshot of the final state.
4. Stop the recording.
5. Note the exact pass/fail observation in the QA report.

For static UI states (for example Storybook error/limit cards), an annotated screenshot is sufficient; video is optional but still encouraged by this project’s review preference.

### Dogfood scenarios

#### Scenario A — Happy path in the real Agents UI

**Goal:** prove that a root agent chat can invoke advisor and produce a readable recommendation before taking further action.

Steps:

1. Open the Agents page with an advisor-enabled root chat.
2. Start a repro video.
3. Send a prompt that should reasonably trigger strategic planning, such as an architecture or multi-tradeoff question.
4. Capture screenshots of:
   - the prompt before send;
   - the running advisor state;
   - the completed advisor card and the assistant’s follow-up response.
5. Stop recording.

Pass criteria:

- advisor appears in the timeline;
- the rendered result is readable;
- the assistant can continue after consuming the advisor output.

#### Scenario B — Advisor unavailable path

**Goal:** prove the feature is truly gated.

Suggested variants (at least one is required, both are better):

- feature flag/config off;
- child/sub-agent chat.

Evidence:

- annotated screenshot of the chat/tool state showing advisor is absent;
- short video if toggling the gate live is part of the repro.

Pass criteria:

- no advisor tool is available;
- no advisor-specific prompt behavior leaks through.

#### Scenario C — UI states in Storybook

**Goal:** prove the renderer handles non-happy states cleanly.

Required story states:

- success/advice;
- running;
- limit reached;
- error.

Evidence:

- one screenshot per state;
- at least one short video showing collapse/expand behavior.

Pass criteria:

- success renders readable advice;
- limit/error have visible fallback text;
- the component behaves like the other tool cards.

#### Scenario D — Regression sweep of nearby tools

**Goal:** ensure advisor does not break the surrounding chat timeline.

Check at minimum:

- another existing built-in tool still renders correctly near advisor;
- sub-agent/tool cards still expand/collapse normally;
- no obvious console errors appear in the Agents page during the advisor flow.

Evidence:

- screenshots of adjacent tool cards;
- console/error capture if anything suspicious appears.

### `agent-browser` usage notes for the QA agent

- Prefer `agent-browser batch` for 2+ sequential commands when no intermediate parsing is needed.
- Use `snapshot -i` to discover interactive refs.
- Re-snapshot after navigation or major DOM changes.
- Avoid `wait --load networkidle` unless the page is known to go idle; prefer explicit element/text waits or short fixed waits.
- Record videos at human pace and include pauses that a reviewer can follow.

## Rollout plan

### Initial rollout

- Gate behind a server-side advisor-enabled flag.
- Enable only for selected internal/root agent chats first.
- Watch metrics for:
  - invocation count;
  - failure rate;
  - latency;
  - obvious retry loops.

### Expansion conditions

Expand beyond the initial rollout only after the following are true:

- mixed-batch policy behavior is stable;
- cost impact is understood;
- frontend UX is readable in production-like dogfood;
- no recursion surprises have appeared with sub-agent flows.

### Explicit non-goals for the first release

- advisor inside child/sub-agent chats;
- provider-agnostic streaming phase UI;
- MCP-based external advisor implementation;
- mandatory DB-backed advisor cost reporting.

## Final acceptance checklist

- [ ] `advisor` is a built-in chatd tool, not an MCP/dynamic-tool substitute.
- [ ] The nested advisor call is tool-less and bounded to one in-memory step.
- [ ] One eligibility boolean controls both tool registration and prompt guidance injection.
- [ ] Root chats can use advisor; child chats cannot in the initial rollout.
- [ ] Mixed advisor/action batches produce deterministic policy errors instead of partial execution.
- [ ] Per-run usage caps and limit-reached behavior work.
- [ ] Advisor usage is visible in metadata/metrics without forcing a DB migration for MVP.
- [ ] The Agents UI has a readable advisor card and Storybook coverage.
- [ ] Dogfooding produced screenshots and repro videos for the required scenarios.
- [ ] Validation commands (`make lint`, targeted `make test`, Storybook tests, `make pre-commit`) passed before handoff.

## Suggested PR split

1. **PR 1 — Backend foundation**
   - `chatadvisor/` package
   - `chattool/advisor.go`
   - `chatloop` exclusive policy
   - chatd gating/prompt sync
   - backend tests

2. **PR 2 — Frontend + QA**
   - advisor renderer
   - stories/play assertions
   - dogfood artifacts and QA notes

3. **PR 3 — Optional follow-ups only if demanded by stakeholders**
   - separate advisor model override
   - persistent advisor billing/queryability
   - transient phase-stream UX


</details>

---
_Generated with [`mux`](https://github.com/coder/mux) • Model: `anthropic:claude-opus-4-7` • Thinking: `max`_
This commit is contained in:
Thomas Kosiewski
2026-04-30 15:07:33 +02:00
committed by GitHub
parent 06bad73df4
commit 17409a515c
13 changed files with 2480 additions and 69 deletions
+2
View File
@@ -4315,6 +4315,8 @@ func (api *API) putChatAdvisorConfig(rw http.ResponseWriter, r *http.Request) {
return
}
publishChatConfigEvent(api.Logger, api.Pubsub, pubsub.ChatConfigEventAdvisorConfig, uuid.Nil)
rw.WriteHeader(http.StatusNoContent)
}
+11 -7
View File
@@ -9,8 +9,9 @@ import (
)
// ChatConfigEventChannel is the pubsub channel for chat config
// changes (providers, model configs, user prompts). All replicas
// subscribe to this channel to invalidate their local caches.
// changes (providers, model configs, user prompts, advisor config).
// All replicas subscribe to this channel to invalidate their local
// caches.
const ChatConfigEventChannel = "chat:config_change"
// HandleChatConfigEvent wraps a typed callback for ChatConfigEvent
@@ -32,21 +33,24 @@ func HandleChatConfigEvent(cb func(ctx context.Context, payload ChatConfigEvent,
}
// ChatConfigEvent is published when chat configuration changes
// (provider CRUD, model config CRUD, or user prompt updates).
// Subscribers use this to invalidate their local caches.
// (provider CRUD, model config CRUD, user prompt updates, or advisor
// config updates). Subscribers use this to invalidate their local
// caches.
type ChatConfigEvent struct {
Kind ChatConfigEventKind `json:"kind"`
// EntityID carries context for the invalidation:
// - For providers: uuid.Nil (all providers are invalidated).
// - For model configs: the specific config ID.
// - For user prompts: the user ID.
// - For advisor config: uuid.Nil (singleton site-config row).
EntityID uuid.UUID `json:"entity_id"`
}
type ChatConfigEventKind string
const (
ChatConfigEventProviders ChatConfigEventKind = "providers"
ChatConfigEventModelConfig ChatConfigEventKind = "model_config"
ChatConfigEventUserPrompt ChatConfigEventKind = "user_prompt"
ChatConfigEventProviders ChatConfigEventKind = "providers"
ChatConfigEventModelConfig ChatConfigEventKind = "model_config"
ChatConfigEventUserPrompt ChatConfigEventKind = "user_prompt"
ChatConfigEventAdvisorConfig ChatConfigEventKind = "advisor_config"
)
+459
View File
@@ -0,0 +1,459 @@
package chatd //nolint:testpackage // Accesses unexported advisor helpers.
import (
"context"
"database/sql"
"encoding/json"
"testing"
"time"
"charm.land/fantasy"
fantasyopenai "charm.land/fantasy/providers/openai"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/x/chatd/chatadvisor"
"github.com/coder/coder/v2/coderd/x/chatd/chatprovider"
"github.com/coder/coder/v2/coderd/x/chatd/chattest"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
"github.com/coder/quartz"
)
// advisorOverrideStubStore stubs only the database methods that
// resolveAdvisorModelOverride exercises. The prod code calls
// GetEnabledChatModelConfigByID so the query joins chat_providers and
// filters both enabled flags atomically; tests simulate that by returning
// configs the stub treats as enabled.
type advisorOverrideStubStore struct {
database.Store
getEnabledChatModelConfigByID func(context.Context, uuid.UUID) (database.ChatModelConfig, error)
}
func (s *advisorOverrideStubStore) GetEnabledChatModelConfigByID(
ctx context.Context,
id uuid.UUID,
) (database.ChatModelConfig, error) {
if s.getEnabledChatModelConfigByID == nil {
return database.ChatModelConfig{}, xerrors.New("unexpected GetEnabledChatModelConfigByID call")
}
return s.getEnabledChatModelConfigByID(ctx, id)
}
func newAdvisorTestServer(
ctx context.Context,
t *testing.T,
store database.Store,
) *Server {
t.Helper()
clock := quartz.NewMock(t)
return &Server{
db: store,
configCache: newChatConfigCache(ctx, store, clock),
}
}
// TestResolveAdvisorModelOverride covers the early-return, each fallback
// branch, and the success path. Prior tests only hit the ModelConfigID ==
// uuid.Nil early return, so the override body never executed.
func TestResolveAdvisorModelOverride(t *testing.T) {
t.Parallel()
fallbackModel := &chattest.FakeModel{ProviderName: "stub", ModelName: "stub"}
fallbackCallConfig := codersdk.ChatModelCallConfig{}
logger := slog.Make()
t.Run("NilModelConfigReturnsFallback", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
// Panic if the cache is consulted; the early return must skip it.
store := &advisorOverrideStubStore{}
p := newAdvisorTestServer(ctx, t, store)
gotModel, gotCfg := p.resolveAdvisorModelOverride(
ctx,
database.Chat{},
codersdk.AdvisorConfig{},
fallbackModel,
fallbackCallConfig,
chatprovider.ProviderAPIKeys{},
logger,
)
require.Equal(t, fallbackModel, gotModel)
require.Equal(t, fallbackCallConfig, gotCfg)
})
t.Run("ConfigLookupErrorReturnsFallback", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
store := &advisorOverrideStubStore{
getEnabledChatModelConfigByID: func(context.Context, uuid.UUID) (database.ChatModelConfig, error) {
return database.ChatModelConfig{}, xerrors.New("lookup failed")
},
}
p := newAdvisorTestServer(ctx, t, store)
gotModel, gotCfg := p.resolveAdvisorModelOverride(
ctx,
database.Chat{},
codersdk.AdvisorConfig{ModelConfigID: uuid.New()},
fallbackModel,
fallbackCallConfig,
chatprovider.ProviderAPIKeys{OpenAI: "sk-test"},
logger,
)
require.Equal(t, fallbackModel, gotModel)
require.Equal(t, fallbackCallConfig, gotCfg)
})
// Covers the sql.ErrNoRows branch separately from the generic-error
// branch above. GetEnabledChatModelConfigByID returns ErrNoRows when
// an admin disables the advisor model or its provider, and that case
// has a distinct log message. Without this test, removing the
// errors.Is(err, sql.ErrNoRows) check would still pass the sibling
// test.
t.Run("DisabledProviderReturnsFallback", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
store := &advisorOverrideStubStore{
getEnabledChatModelConfigByID: func(context.Context, uuid.UUID) (database.ChatModelConfig, error) {
return database.ChatModelConfig{}, sql.ErrNoRows
},
}
p := newAdvisorTestServer(ctx, t, store)
gotModel, gotCfg := p.resolveAdvisorModelOverride(
ctx,
database.Chat{},
codersdk.AdvisorConfig{ModelConfigID: uuid.New()},
fallbackModel,
fallbackCallConfig,
chatprovider.ProviderAPIKeys{OpenAI: "sk-test"},
logger,
)
require.Equal(t, fallbackModel, gotModel)
require.Equal(t, fallbackCallConfig, gotCfg)
})
t.Run("InvalidOptionsJSONReturnsFallback", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
configID := uuid.New()
store := &advisorOverrideStubStore{
getEnabledChatModelConfigByID: func(context.Context, uuid.UUID) (database.ChatModelConfig, error) {
return database.ChatModelConfig{
ID: configID,
Provider: "openai",
Model: "gpt-5.2",
Enabled: true,
CreatedAt: time.Unix(0, 0).UTC(),
UpdatedAt: time.Unix(0, 0).UTC(),
Options: []byte("not valid json"),
DisplayName: "gpt-5.2",
}, nil
},
}
p := newAdvisorTestServer(ctx, t, store)
gotModel, gotCfg := p.resolveAdvisorModelOverride(
ctx,
database.Chat{},
codersdk.AdvisorConfig{ModelConfigID: configID},
fallbackModel,
fallbackCallConfig,
chatprovider.ProviderAPIKeys{OpenAI: "sk-test"},
logger,
)
require.Equal(t, fallbackModel, gotModel)
require.Equal(t, fallbackCallConfig, gotCfg)
})
t.Run("MissingProviderKeyReturnsFallback", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
configID := uuid.New()
store := &advisorOverrideStubStore{
getEnabledChatModelConfigByID: func(context.Context, uuid.UUID) (database.ChatModelConfig, error) {
return database.ChatModelConfig{
ID: configID,
Provider: "openai",
Model: "gpt-5.2",
Enabled: true,
CreatedAt: time.Unix(0, 0).UTC(),
UpdatedAt: time.Unix(0, 0).UTC(),
DisplayName: "gpt-5.2",
}, nil
},
}
p := newAdvisorTestServer(ctx, t, store)
gotModel, gotCfg := p.resolveAdvisorModelOverride(
ctx,
database.Chat{},
codersdk.AdvisorConfig{ModelConfigID: configID},
fallbackModel,
fallbackCallConfig,
chatprovider.ProviderAPIKeys{},
logger,
)
require.Equal(t, fallbackModel, gotModel)
require.Equal(t, fallbackCallConfig, gotCfg)
})
t.Run("SuccessReturnsOverrideModelAndConfig", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
configID := uuid.New()
rawOptions, err := json.Marshal(codersdk.ChatModelCallConfig{
Temperature: func() *float64 { v := 0.42; return &v }(),
})
require.NoError(t, err)
store := &advisorOverrideStubStore{
getEnabledChatModelConfigByID: func(context.Context, uuid.UUID) (database.ChatModelConfig, error) {
return database.ChatModelConfig{
ID: configID,
Provider: "openai",
Model: "gpt-5.2",
Enabled: true,
CreatedAt: time.Unix(0, 0).UTC(),
UpdatedAt: time.Unix(0, 0).UTC(),
Options: rawOptions,
DisplayName: "gpt-5.2",
}, nil
},
}
p := newAdvisorTestServer(ctx, t, store)
gotModel, gotCfg := p.resolveAdvisorModelOverride(
ctx,
database.Chat{},
codersdk.AdvisorConfig{ModelConfigID: configID},
fallbackModel,
fallbackCallConfig,
chatprovider.ProviderAPIKeys{OpenAI: "sk-test"},
logger,
)
require.NotEqual(t, fantasy.LanguageModel(fallbackModel), gotModel,
"success path must return the override model, not the fallback")
require.NotNil(t, gotModel)
require.Equal(t, "openai", gotModel.Provider())
// Guard against ModelFromConfig silently ignoring the model field
// and returning a default. The override is only useful if the
// model name from the config row actually propagates.
require.Equal(t, "gpt-5.2", gotModel.Model())
require.NotNil(t, gotCfg.Temperature)
require.InDelta(t, 0.42, *gotCfg.Temperature, 1e-9)
})
}
// TestStripAdvisorGuidanceBlock exercises the filter that keeps the advisor
// from receiving the parent-facing advisor-guidance instruction in its nested
// context. The block references a tool the advisor cannot use, so forwarding
// it wastes context tokens and risks steering the advisor's reply.
func TestStripAdvisorGuidanceBlock(t *testing.T) {
t.Parallel()
t.Run("RemovesGuidanceSystemMessage", func(t *testing.T) {
t.Parallel()
msgs := []fantasy.Message{
{
Role: fantasy.MessageRoleSystem,
Content: []fantasy.MessagePart{
fantasy.TextPart{Text: "You are a helpful assistant."},
},
},
{
Role: fantasy.MessageRoleSystem,
Content: []fantasy.MessagePart{
fantasy.TextPart{Text: chatadvisor.ParentGuidanceBlock},
},
},
{
Role: fantasy.MessageRoleUser,
Content: []fantasy.MessagePart{
fantasy.TextPart{Text: "Help me plan."},
},
},
}
filtered := stripAdvisorGuidanceBlock(msgs)
require.Len(t, filtered, 2)
for _, msg := range filtered {
for _, part := range msg.Content {
if text, ok := part.(fantasy.TextPart); ok {
require.NotEqual(t, chatadvisor.ParentGuidanceBlock, text.Text,
"guidance block must not survive the filter")
}
}
}
})
t.Run("LeavesOtherSystemMessagesIntact", func(t *testing.T) {
t.Parallel()
msgs := []fantasy.Message{
{
Role: fantasy.MessageRoleSystem,
Content: []fantasy.MessagePart{
fantasy.TextPart{Text: "instruction file"},
},
},
{
Role: fantasy.MessageRoleUser,
Content: []fantasy.MessagePart{
fantasy.TextPart{Text: "hi"},
},
},
}
filtered := stripAdvisorGuidanceBlock(msgs)
require.Len(t, filtered, 2)
})
t.Run("IgnoresNonSystemRoleWithMatchingText", func(t *testing.T) {
t.Parallel()
// A user message echoing the guidance block must not be stripped:
// the filter only targets the system-role injection.
msgs := []fantasy.Message{
{
Role: fantasy.MessageRoleUser,
Content: []fantasy.MessagePart{
fantasy.TextPart{Text: chatadvisor.ParentGuidanceBlock},
},
},
}
filtered := stripAdvisorGuidanceBlock(msgs)
require.Len(t, filtered, 1)
})
}
// TestNewAdvisorRuntime covers the three defensive branches in
// newAdvisorRuntime that gate whether the runtime is created and with what
// bounds. Without this coverage a regression in any branch ships silently.
func TestNewAdvisorRuntime(t *testing.T) {
t.Parallel()
logger := slog.Make()
fallbackModel := &chattest.FakeModel{ProviderName: "openai", ModelName: "gpt-4"}
fallbackCallConfig := codersdk.ChatModelCallConfig{}
t.Run("ZeroMaxUsesDefaultsToMaxChatSteps", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
store := &advisorOverrideStubStore{}
p := newAdvisorTestServer(ctx, t, store)
rt := p.newAdvisorRuntime(
ctx,
database.Chat{},
codersdk.AdvisorConfig{
Enabled: true,
MaxUsesPerRun: 0,
MaxOutputTokens: 16384,
},
fallbackModel,
fallbackCallConfig,
chatprovider.ProviderAPIKeys{},
logger,
)
require.NotNil(t, rt, "zero max uses must default rather than bail out")
require.Equal(t, maxChatSteps, rt.RemainingUses(),
"zero max uses must be replaced with maxChatSteps")
})
t.Run("NegativeMaxUsesReturnsNil", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
store := &advisorOverrideStubStore{}
p := newAdvisorTestServer(ctx, t, store)
rt := p.newAdvisorRuntime(
ctx,
database.Chat{},
codersdk.AdvisorConfig{
Enabled: true,
MaxUsesPerRun: -1,
MaxOutputTokens: 16384,
},
fallbackModel,
fallbackCallConfig,
chatprovider.ProviderAPIKeys{},
logger,
)
require.Nil(t, rt, "negative max uses must disable the advisor")
})
t.Run("ZeroMaxOutputTokensDefaults", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
store := &advisorOverrideStubStore{}
p := newAdvisorTestServer(ctx, t, store)
rt := p.newAdvisorRuntime(
ctx,
database.Chat{},
codersdk.AdvisorConfig{
Enabled: true,
MaxUsesPerRun: 3,
MaxOutputTokens: 0,
},
fallbackModel,
fallbackCallConfig,
chatprovider.ProviderAPIKeys{},
logger,
)
require.NotNil(t, rt,
"zero max output tokens must default to defaultAdvisorMaxOutputTokens, not disable the advisor")
require.Equal(t, 3, rt.RemainingUses())
require.Equal(t, int64(defaultAdvisorMaxOutputTokens), rt.MaxOutputTokens(),
"zero max output tokens must be replaced with defaultAdvisorMaxOutputTokens")
})
// Guards the wiring from AdvisorConfig.ReasoningEffort through
// newAdvisorRuntime to ApplyReasoningEffortToOptions. A field swap,
// typo, or accidental deletion of the apply call would otherwise
// ship silently because chatprovider_test only covers the helper in
// isolation.
t.Run("ReasoningEffortReachesProviderOptions", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
store := &advisorOverrideStubStore{}
p := newAdvisorTestServer(ctx, t, store)
openAIModel := &chattest.FakeModel{
ProviderName: fantasyopenai.Name,
ModelName: "gpt-4",
}
rt := p.newAdvisorRuntime(
ctx,
database.Chat{},
codersdk.AdvisorConfig{
Enabled: true,
MaxUsesPerRun: 3,
MaxOutputTokens: 16384,
ReasoningEffort: "high",
},
openAIModel,
fallbackCallConfig,
chatprovider.ProviderAPIKeys{},
logger,
)
require.NotNil(t, rt)
providerOptions := rt.ProviderOptions()
require.NotNil(t, providerOptions,
"advisor runtime must seed provider options when reasoning effort is set")
opts, ok := providerOptions[fantasyopenai.Name].(*fantasyopenai.ResponsesProviderOptions)
require.True(t, ok,
"expected *ResponsesProviderOptions for Responses model, got %T",
providerOptions[fantasyopenai.Name])
require.NotNil(t, opts.ReasoningEffort,
"ReasoningEffort from AdvisorConfig must reach the provider options")
require.Equal(t, fantasyopenai.ReasoningEffortHigh, *opts.ReasoningEffort)
})
}
+22
View File
@@ -121,6 +121,28 @@ func (rt *Runtime) RemainingUses() int {
return int(remaining)
}
// MaxOutputTokens reports the resolved output-token cap applied to each
// advisor call. NewRuntime validates that this value is positive and that
// it matches ModelConfig.MaxOutputTokens when both are set, so the
// accessor always returns the value the runtime will actually send.
func (rt *Runtime) MaxOutputTokens() int64 {
if rt == nil {
return 0
}
return rt.cfg.MaxOutputTokens
}
// ProviderOptions reports the resolved provider options applied to each
// advisor call. NewRuntime clones the supplied options so the returned
// map reflects what nested calls will actually receive; callers must not
// mutate the map or its entries.
func (rt *Runtime) ProviderOptions() fantasy.ProviderOptions {
if rt == nil {
return nil
}
return rt.cfg.ProviderOptions
}
func (rt *Runtime) tryAcquire() bool {
for {
used := rt.used.Load()
+6 -1
View File
@@ -10,6 +10,11 @@ import (
"charm.land/fantasy"
)
// ToolName is the identifier the advisor tool registers under. The parent
// agent's exclusive-tool policy and the advisor-guidance block both reference
// this name, so keeping them synchronized requires a single source of truth.
const ToolName = "advisor"
// advisorQuestionMaxRunes caps the parent agent's question at a length
// that leaves room in the advisor prompt for system preamble and recent
// conversation context.
@@ -26,7 +31,7 @@ type ToolOptions struct {
// context, runs without tools, and is limited to a single model step.
func Tool(opts ToolOptions) fantasy.AgentTool {
return fantasy.NewAgentTool(
"advisor",
ToolName,
"Ask a separate advisor pass for strategic guidance about planning, architecture, tradeoffs, or debugging strategy. Provide a brief question. The advisor sees recent conversation context, runs without tools for a single step, and responds to the parent agent rather than the end user.",
func(ctx context.Context, args AdvisorArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) {
if opts.Runtime == nil {
+293 -9
View File
@@ -36,6 +36,7 @@ import (
"github.com/coder/coder/v2/coderd/util/xjson"
"github.com/coder/coder/v2/coderd/webpush"
"github.com/coder/coder/v2/coderd/workspacestats"
"github.com/coder/coder/v2/coderd/x/chatd/chatadvisor"
"github.com/coder/coder/v2/coderd/x/chatd/chatcost"
"github.com/coder/coder/v2/coderd/x/chatd/chatdebug"
"github.com/coder/coder/v2/coderd/x/chatd/chaterror"
@@ -118,6 +119,12 @@ const (
DefaultMaxChatsPerAcquire int32 = 10
defaultSubagentInstruction = "You are running as a delegated sub-agent chat. Complete the delegated task and provide clear, concise assistant responses for the parent agent."
// defaultAdvisorMaxOutputTokens caps the nested advisor response
// when the admin config omits the field (or sets it to <= 0).
// It is intentionally generous relative to the advisor's concise
// guidance remit so short plans are not truncated mid-reasoning.
defaultAdvisorMaxOutputTokens = 16384
)
var (
@@ -225,6 +232,192 @@ func (p *Server) chatTemplateAllowlist() map[uuid.UUID]bool {
return m
}
func (p *Server) loadAdvisorConfig(ctx context.Context, logger slog.Logger) codersdk.AdvisorConfig {
cfg, err := p.configCache.AdvisorConfig(ctx)
if err != nil {
logger.Warn(ctx, "failed to load advisor config", slog.Error(err))
return codersdk.AdvisorConfig{}
}
return cfg
}
// stripAdvisorGuidanceBlock removes any system message whose text content
// matches chatadvisor.ParentGuidanceBlock after whitespace normalization.
// The block is meant for the parent agent (it advertises the advisor tool)
// and would waste context tokens if forwarded to the advisor's nested run.
func stripAdvisorGuidanceBlock(msgs []fantasy.Message) []fantasy.Message {
filtered := msgs[:0]
for _, msg := range msgs {
if msg.Role == fantasy.MessageRoleSystem && isAdvisorGuidanceMessage(msg) {
continue
}
filtered = append(filtered, msg)
}
return filtered
}
func isAdvisorGuidanceMessage(msg fantasy.Message) bool {
if len(msg.Content) != 1 {
return false
}
text, ok := msg.Content[0].(fantasy.TextPart)
if !ok {
return false
}
return strings.TrimSpace(text.Text) == strings.TrimSpace(chatadvisor.ParentGuidanceBlock)
}
func (p *Server) resolveAdvisorModelOverride(
ctx context.Context,
chat database.Chat,
advisorCfg codersdk.AdvisorConfig,
fallbackModel fantasy.LanguageModel,
fallbackCallConfig codersdk.ChatModelCallConfig,
providerKeys chatprovider.ProviderAPIKeys,
logger slog.Logger,
) (fantasy.LanguageModel, codersdk.ChatModelCallConfig) {
if advisorCfg.ModelConfigID == uuid.Nil {
return fallbackModel, fallbackCallConfig
}
// GetEnabledChatModelConfigByID joins on chat_providers.enabled = TRUE
// and chat_model_configs.enabled = TRUE, so it returns sql.ErrNoRows
// the moment an admin disables either the model config or its provider.
// Using the cached ModelConfigByID here would keep resolving an override
// whose provider was just disabled, and an env or central fallback key
// would let ModelFromConfig succeed, silently routing advisor prompts
// to a provider the admin expects to be off.
overrideConfig, err := p.db.GetEnabledChatModelConfigByID(
ctx,
advisorCfg.ModelConfigID,
)
if err != nil {
if xerrors.Is(err, sql.ErrNoRows) {
logger.Warn(
ctx,
"advisor model config is disabled or unavailable, continuing with chat model",
slog.F("model_config_id", advisorCfg.ModelConfigID),
)
return fallbackModel, fallbackCallConfig
}
logger.Warn(
ctx,
"failed to resolve advisor model config, continuing with chat model",
slog.F("model_config_id", advisorCfg.ModelConfigID),
slog.Error(err),
)
return fallbackModel, fallbackCallConfig
}
overrideCallConfig := codersdk.ChatModelCallConfig{}
if len(overrideConfig.Options) > 0 {
if err := json.Unmarshal(overrideConfig.Options, &overrideCallConfig); err != nil {
logger.Warn(
ctx,
"failed to parse advisor model config, continuing with chat model",
slog.F("model_config_id", advisorCfg.ModelConfigID),
slog.Error(err),
)
return fallbackModel, fallbackCallConfig
}
}
overrideModel, err := chatprovider.ModelFromConfig(
overrideConfig.Provider,
overrideConfig.Model,
providerKeys,
chatprovider.UserAgent(),
chatprovider.CoderHeaders(chat),
nil,
)
if err != nil {
logger.Warn(
ctx,
"failed to create advisor override model, continuing with chat model",
slog.F("model_config_id", advisorCfg.ModelConfigID),
slog.Error(err),
)
return fallbackModel, fallbackCallConfig
}
return overrideModel, overrideCallConfig
}
func (p *Server) newAdvisorRuntime(
ctx context.Context,
chat database.Chat,
advisorCfg codersdk.AdvisorConfig,
fallbackModel fantasy.LanguageModel,
fallbackCallConfig codersdk.ChatModelCallConfig,
providerKeys chatprovider.ProviderAPIKeys,
logger slog.Logger,
) *chatadvisor.Runtime {
advisorModel, advisorCallConfig := p.resolveAdvisorModelOverride(
ctx,
chat,
advisorCfg,
fallbackModel,
fallbackCallConfig,
providerKeys,
logger,
)
maxUsesPerRun := advisorCfg.MaxUsesPerRun
switch {
case maxUsesPerRun == 0:
// Advisor config treats 0 as unlimited, but the runtime
// requires a positive bound. maxChatSteps is the
// effective upper bound because advisor can run at most
// once per loop step.
maxUsesPerRun = maxChatSteps
case maxUsesPerRun < 0:
logger.Warn(
ctx,
"invalid advisor max uses per run, continuing without advisor",
slog.F("max_uses_per_run", maxUsesPerRun),
)
return nil
}
maxOutputTokens := advisorCfg.MaxOutputTokens
if maxOutputTokens <= 0 {
maxOutputTokens = defaultAdvisorMaxOutputTokens
}
advisorCallConfig.MaxOutputTokens = ptr.Ref(maxOutputTokens)
providerOptions := chatprovider.ProviderOptionsFromChatModelConfig(
advisorModel,
advisorCallConfig.ProviderOptions,
)
// ProviderOptionsFromChatModelConfig returns nil when the model config
// has no provider_options block, so the helper seeds a minimal entry
// for the advisor model's provider before applying reasoning_effort.
// This keeps the per-provider dispatch in chatprovider so adding a new
// provider there propagates here automatically.
providerOptions = chatprovider.ApplyReasoningEffortToOptions(
providerOptions,
advisorModel,
advisorCfg.ReasoningEffort,
)
rt, err := chatadvisor.NewRuntime(chatadvisor.RuntimeConfig{
Model: advisorModel,
ModelConfig: advisorCallConfig,
ProviderOptions: providerOptions,
MaxUsesPerRun: maxUsesPerRun,
MaxOutputTokens: maxOutputTokens,
})
if err != nil {
logger.Warn(
ctx,
"failed to create advisor runtime, continuing without advisor",
slog.Error(err),
)
return nil
}
return rt
}
// cachedWorkspaceMCPTools stores workspace MCP tools discovered
// from a workspace agent, keyed by the agent ID that provided them.
type cachedWorkspaceMCPTools struct {
@@ -3754,6 +3947,8 @@ func New(cfg Config) *Server {
p.configCache.InvalidateModelConfig(ev.EntityID)
case coderdpubsub.ChatConfigEventUserPrompt:
p.configCache.InvalidateUserPrompt(ev.EntityID)
case coderdpubsub.ChatConfigEventAdvisorConfig:
p.configCache.InvalidateAdvisorConfig()
}
}),
)
@@ -6034,6 +6229,48 @@ func (p *Server) runChat(
}
planModeInstructions := p.loadPlanModeInstructions(ctx, currentPlanMode, logger)
advisorCfg := p.loadAdvisorConfig(ctx, logger)
var advisorRuntime *chatadvisor.Runtime
// Plan mode filters the advisor tool out of the turn's tool set via
// filterToolsForTurn, so enabling the runtime there would inject
// guidance and enforce advisor exclusivity for a tool the model
// cannot actually call. Explore chats (root or subagent) run under
// allowedExploreToolNames, whose policy does not include advisor, so
// registering the runtime there would inject guidance for a tool
// that is never exposed to the model.
if advisorCfg.Enabled && isRootChat && !isPlanModeTurn && !isExploreSubagent {
advisorRuntime = p.newAdvisorRuntime(
ctx,
chat,
advisorCfg,
model,
callConfig,
providerKeys,
logger,
)
}
var advisorPromptSnapshot []fantasy.Message
// setAdvisorPromptSnapshot captures the final prompt state the outer
// model sees so the advisor tool can forward it as nested context.
// It is invoked at four lifecycle points (after initial system-prompt
// assembly, inside PrepareMessages before and after instruction
// injection, and after ReloadMessages rebuilds the prompt) because
// the prompt mutates at each of them and the advisor must snapshot
// the post-mutation state. Removing any of those calls would leave
// the advisor with a stale view of the conversation.
//
// The no-op guard keeps the common disabled/filtered paths (advisor
// off, plan mode, explore, child chats) from paying an O(n) prompt
// clone per step for a snapshot that is never consumed.
setAdvisorPromptSnapshot := func(msgs []fantasy.Message) {
if advisorRuntime == nil {
return
}
advisorPromptSnapshot = slices.Clone(msgs)
}
chainInfo := resolveChainMode(messages)
result.PushSummaryModel = model
result.ProviderKeys = providerKeys
@@ -6336,6 +6573,10 @@ func (p *Server) runChat(
isRootChat: isRootChat,
},
)
// Inject advisor guidance when the advisor runtime is available.
if advisorRuntime != nil {
prompt = chatprompt.InsertSystem(prompt, chatadvisor.ParentGuidanceBlock)
}
if mcpCleanup != nil {
defer mcpCleanup()
}
@@ -6352,6 +6593,7 @@ func (p *Server) runChat(
instructionInjected := instruction != ""
prompt = renderPlanPathPrompt(prompt, resolvePlanPathBlock(ctx))
setAdvisorPromptSnapshot(prompt)
// Use the model config's context_limit as a fallback when the LLM
// provider doesn't include context_limit in its response metadata
// (which is the common case).
@@ -6742,6 +6984,24 @@ func (p *Server) runChat(
chattool.ReadSkillFile(skillOpts),
)
}
if advisorRuntime != nil {
tools = append(tools, chatadvisor.Tool(chatadvisor.ToolOptions{
Runtime: advisorRuntime,
GetConversationSnapshot: func() []fantasy.Message {
// The outer prompt contains ParentGuidanceBlock, which
// tells the parent when to call the advisor tool. That
// instruction is meaningless (and slightly confusing)
// when forwarded to the advisor, whose nested run has
// no tools. Strip it before handing the snapshot over.
return stripAdvisorGuidanceBlock(slices.Clone(advisorPromptSnapshot))
},
}))
}
var exclusiveToolNames map[string]bool
if advisorRuntime != nil {
exclusiveToolNames = map[string]bool{chatadvisor.ToolName: true}
}
// Record builtin tool names before appending MCP tools
// so the metrics layer can differentiate between built-in and MCP tools.
@@ -6905,15 +7165,16 @@ func (p *Server) runChat(
}()
loopErr = chatloop.Run(ctx, chatloop.RunOptions{
Model: model,
Messages: prompt,
Tools: tools,
ActiveTools: activeToolNames,
StopAfterTools: stopAfterBehaviorTools(currentPlanMode, chat.Mode, chat.ParentChatID),
MaxSteps: maxChatSteps,
Metrics: p.metrics,
Logger: loopLogger,
BuiltinToolNames: builtinToolNames,
Model: model,
Messages: prompt,
Tools: tools,
ActiveTools: activeToolNames,
StopAfterTools: stopAfterBehaviorTools(currentPlanMode, chat.Mode, chat.ParentChatID),
MaxSteps: maxChatSteps,
Metrics: p.metrics,
Logger: loopLogger,
BuiltinToolNames: builtinToolNames,
ExclusiveToolNames: exclusiveToolNames,
ModelConfig: callConfig,
ProviderOptions: providerOptions,
@@ -6988,7 +7249,19 @@ func (p *Server) runChat(
isRootChat: isRootChat,
},
)
// Re-inject advisor guidance after rebuilding system
// blocks so compaction/reload preserves the same
// system-message ordering as the initial prompt path.
if advisorRuntime != nil {
reloadedPrompt = chatprompt.InsertSystem(reloadedPrompt, chatadvisor.ParentGuidanceBlock)
}
reloadedPrompt = renderPlanPathPrompt(reloadedPrompt, resolvePlanPathBlock(reloadCtx))
// Snapshot the full reloaded prompt before chain-mode
// filtering so the advisor runs with complete
// assistant/tool context. The nested advisor call
// clears previous_response_id, so provider-side
// history is unavailable.
setAdvisorPromptSnapshot(reloadedPrompt)
if chainModeActive {
reloadedPrompt = filterPromptForChainMode(
reloadedPrompt,
@@ -7001,6 +7274,14 @@ func (p *Server) runChat(
chainModeActive = false
},
PrepareMessages: func(msgs []fantasy.Message) []fantasy.Message {
// Skip the snapshot update when chain mode is active;
// the chatloop passes in the chain-filtered prompt
// (system plus trailing user messages) and the advisor
// needs the full pre-chain history captured at the
// initial-prompt and ReloadMessages sites.
if !chainModeActive {
setAdvisorPromptSnapshot(msgs)
}
if instructionInjected || instruction == "" {
return nil
}
@@ -7009,6 +7290,9 @@ func (p *Server) runChat(
if skillIndex := chattool.FormatSkillIndex(skills); skillIndex != "" {
result = chatprompt.InsertSystem(result, skillIndex)
}
if !chainModeActive {
setAdvisorPromptSnapshot(result)
}
return result
},
OnRetry: func(
+798
View File
@@ -44,6 +44,7 @@ import (
"github.com/coder/coder/v2/coderd/util/slice"
"github.com/coder/coder/v2/coderd/workspacestats"
"github.com/coder/coder/v2/coderd/x/chatd"
"github.com/coder/coder/v2/coderd/x/chatd/chatadvisor"
"github.com/coder/coder/v2/coderd/x/chatd/chatprompt"
"github.com/coder/coder/v2/coderd/x/chatd/chattest"
"github.com/coder/coder/v2/coderd/x/chatd/chattool"
@@ -8852,3 +8853,800 @@ func TestAcquireChatsSkipsArchivedPendingChat(t *testing.T) {
require.Len(t, acquired, 1, "only the non-archived chat should be acquired")
require.Equal(t, activeChat.ID, acquired[0].ID)
}
func TestAdvisorGating_Disabled(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
ctx := testutil.Context(t, testutil.WaitLong)
var toolsMu sync.Mutex
var capturedTools []string
var capturedMessages []chattest.OpenAIMessage
openAIURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse {
if !req.Stream {
return chattest.OpenAINonStreamingResponse("title")
}
names := make([]string, 0, len(req.Tools))
for _, tool := range req.Tools {
names = append(names, tool.Function.Name)
}
toolsMu.Lock()
capturedTools = names
capturedMessages = append([]chattest.OpenAIMessage(nil), req.Messages...)
toolsMu.Unlock()
return chattest.OpenAIStreamingResponse(
chattest.OpenAITextChunks("advisor is not available")...,
)
})
user, org, model := seedChatDependenciesWithProvider(ctx, t, db, "openai-compat", openAIURL)
seedAdvisorConfig(ctx, t, db, codersdk.AdvisorConfig{
Enabled: false,
MaxUsesPerRun: 3,
MaxOutputTokens: 16384,
})
server := newActiveTestServer(t, db, ps)
chat, err := server.CreateChat(ctx, chatd.CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
Title: "advisor-disabled",
ModelConfigID: model.ID,
InitialUserContent: []codersdk.ChatMessagePart{
codersdk.ChatMessageText("hello"),
},
})
require.NoError(t, err)
require.Eventually(t, func() bool {
got, getErr := db.GetChatByID(ctx, chat.ID)
if getErr != nil {
return false
}
return got.Status == database.ChatStatusWaiting ||
got.Status == database.ChatStatusError
}, testutil.WaitLong, testutil.IntervalFast)
toolsMu.Lock()
tools := append([]string(nil), capturedTools...)
messages := append([]chattest.OpenAIMessage(nil), capturedMessages...)
toolsMu.Unlock()
require.NotEmpty(t, messages, "expected a streamed LLM request")
require.NotContains(t, tools, "advisor",
"advisor tool should not be registered when disabled")
for _, msg := range messages {
require.NotContains(t, msg.Content, chatadvisor.ParentGuidanceBlock,
"advisor guidance should not be injected when disabled")
}
}
func TestAdvisorGating_RootChat(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
ctx := testutil.Context(t, testutil.WaitLong)
var streamedCallCount atomic.Int32
var streamedCallsMu sync.Mutex
var firstCallTools []string
var firstCallMessages []chattest.OpenAIMessage
var secondCallMessages []chattest.OpenAIMessage
openAIURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse {
if !req.Stream {
return chattest.OpenAINonStreamingResponse("title")
}
switch streamedCallCount.Add(1) {
case 1:
names := make([]string, 0, len(req.Tools))
for _, tool := range req.Tools {
names = append(names, tool.Function.Name)
}
streamedCallsMu.Lock()
firstCallTools = names
firstCallMessages = append([]chattest.OpenAIMessage(nil), req.Messages...)
streamedCallsMu.Unlock()
advisorChunk := chattest.OpenAIToolCallChunk(
"advisor",
`{"question":"help me plan"}`,
)
readChunk := chattest.OpenAIToolCallChunk(
"read_file",
`{"path":"/tmp/test.txt"}`,
)
mergedChunk := advisorChunk
readCall := readChunk.Choices[0].ToolCalls[0]
readCall.Index = 1
mergedChunk.Choices[0].ToolCalls = append(
mergedChunk.Choices[0].ToolCalls,
readCall,
)
return chattest.OpenAIStreamingResponse(mergedChunk)
case 2:
streamedCallsMu.Lock()
secondCallMessages = append([]chattest.OpenAIMessage(nil), req.Messages...)
streamedCallsMu.Unlock()
}
return chattest.OpenAIStreamingResponse(
chattest.OpenAITextChunks("done")...,
)
})
user, org, model := seedChatDependenciesWithProvider(ctx, t, db, "openai-compat", openAIURL)
seedAdvisorConfig(ctx, t, db, codersdk.AdvisorConfig{
Enabled: true,
MaxUsesPerRun: 3,
MaxOutputTokens: 16384,
})
server := newActiveTestServer(t, db, ps)
chat, err := server.CreateChat(ctx, chatd.CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
Title: "advisor-root",
ModelConfigID: model.ID,
InitialUserContent: []codersdk.ChatMessagePart{
codersdk.ChatMessageText("help me plan this"),
},
})
require.NoError(t, err)
require.Eventually(t, func() bool {
got, getErr := db.GetChatByID(ctx, chat.ID)
if getErr != nil {
return false
}
if got.Status != database.ChatStatusWaiting &&
got.Status != database.ChatStatusError {
return false
}
return streamedCallCount.Load() >= 2
}, testutil.WaitLong, testutil.IntervalFast)
streamedCallsMu.Lock()
tools := append([]string(nil), firstCallTools...)
messages := append([]chattest.OpenAIMessage(nil), firstCallMessages...)
secondMessages := append([]chattest.OpenAIMessage(nil), secondCallMessages...)
streamedCallsMu.Unlock()
// Exactly two streamed LLM calls are expected: the first that
// returned the mixed advisor + read_file batch, and the second
// that received the exclusive-policy rejection. A third call
// would indicate that either tool had slipped past the exclusive
// policy; the >= 2 wait would have missed that regression.
require.Equal(t, int32(2), streamedCallCount.Load(),
"exclusive policy must block execution of both tools; no third call expected")
require.NotEmpty(t, messages, "expected a first streamed LLM request")
require.NotEmpty(t, secondMessages, "expected a second streamed LLM request")
require.Contains(t, tools, "advisor",
"advisor tool should be registered for root chats when enabled")
var hasGuidance bool
for _, msg := range messages {
if strings.Contains(msg.Content, chatadvisor.ParentGuidanceBlock) {
hasGuidance = true
break
}
}
require.True(t, hasGuidance,
"root chat should contain advisor guidance in the prompt")
var hasExclusiveAdvisorError bool
var hasSkippedToolError bool
for _, msg := range secondMessages {
if strings.Contains(msg.Content, "advisor must be called alone") {
hasExclusiveAdvisorError = true
}
if strings.Contains(msg.Content, "this tool was skipped because advisor must run alone") {
hasSkippedToolError = true
}
}
require.True(t, hasExclusiveAdvisorError,
"mixed advisor batches should surface the exclusive advisor error")
require.True(t, hasSkippedToolError,
"mixed advisor batches should skip sibling tools with an explanatory error")
}
// TestAdvisorHappyPath_RootChat walks the advisor tool end-to-end:
// parent calls advisor alone, the nested advisor call produces text, and
// the structured result flows back into the parent conversation. The
// exclusive-policy test above only proves the rejection path; this test
// covers the glue from chatd wiring -> chatadvisor.Tool -> Runtime.Run ->
// nested model call -> structured result back to the outer model.
func TestAdvisorHappyPath_RootChat(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
ctx := testutil.Context(t, testutil.WaitLong)
const advisorReply = "break the problem into smaller pieces first"
var (
streamedCallCount atomic.Int32
streamedCallsMu sync.Mutex
advisorCallSeen atomic.Bool
advisorMessages []chattest.OpenAIMessage
finalCallMessages []chattest.OpenAIMessage
)
openAIURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse {
if !req.Stream {
return chattest.OpenAINonStreamingResponse("title")
}
switch streamedCallCount.Add(1) {
case 1:
// Parent turn 1: call advisor solo.
return chattest.OpenAIStreamingResponse(chattest.OpenAIToolCallChunk(
"advisor",
`{"question":"how should I approach this refactor?"}`,
))
case 2:
// Nested advisor turn. The nested call has no tools because
// chatadvisor.RunAdvisor runs with MaxSteps=1 and no tool
// set.
require.Empty(t, req.Tools,
"advisor's nested call must run without tools")
streamedCallsMu.Lock()
advisorMessages = append([]chattest.OpenAIMessage(nil), req.Messages...)
streamedCallsMu.Unlock()
advisorCallSeen.Store(true)
return chattest.OpenAIStreamingResponse(
chattest.OpenAITextChunks(advisorReply)...,
)
default:
// Parent turn 2: observe the advisor tool result and close
// out with a final text reply.
streamedCallsMu.Lock()
finalCallMessages = append([]chattest.OpenAIMessage(nil), req.Messages...)
streamedCallsMu.Unlock()
return chattest.OpenAIStreamingResponse(
chattest.OpenAITextChunks("acknowledged")...,
)
}
})
user, org, model := seedChatDependenciesWithProvider(ctx, t, db, "openai-compat", openAIURL)
seedAdvisorConfig(ctx, t, db, codersdk.AdvisorConfig{
Enabled: true,
MaxUsesPerRun: 3,
MaxOutputTokens: 16384,
})
server := newActiveTestServer(t, db, ps)
chat, err := server.CreateChat(ctx, chatd.CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
Title: "advisor-happy-path",
ModelConfigID: model.ID,
InitialUserContent: []codersdk.ChatMessagePart{
codersdk.ChatMessageText("help me refactor this module"),
},
})
require.NoError(t, err)
require.Eventually(t, func() bool {
got, getErr := db.GetChatByID(ctx, chat.ID)
if getErr != nil {
return false
}
if got.Status != database.ChatStatusWaiting &&
got.Status != database.ChatStatusError {
return false
}
return streamedCallCount.Load() >= 3
}, testutil.WaitLong, testutil.IntervalFast)
streamedCallsMu.Lock()
gotAdvisorMessages := append([]chattest.OpenAIMessage(nil), advisorMessages...)
gotFinalMessages := append([]chattest.OpenAIMessage(nil), finalCallMessages...)
streamedCallsMu.Unlock()
require.True(t, advisorCallSeen.Load(),
"the nested advisor call must execute; missing it means the tool never ran")
require.NotEmpty(t, gotAdvisorMessages,
"advisor call must receive the nested prompt messages")
require.NotEmpty(t, gotFinalMessages,
"parent must make a follow-up call after the advisor result")
var advisorSawQuestion bool
var advisorSawUserTurn bool
for _, msg := range gotAdvisorMessages {
if strings.Contains(msg.Content, "how should I approach this refactor?") {
advisorSawQuestion = true
}
if msg.Role == "user" && strings.Contains(msg.Content, "help me refactor this module") {
advisorSawUserTurn = true
}
}
require.True(t, advisorSawQuestion,
"advisor must receive the parent's question verbatim")
require.True(t, advisorSawUserTurn,
"advisor must receive the parent's conversation snapshot as nested context")
for _, msg := range gotAdvisorMessages {
require.NotContains(t, msg.Content, chatadvisor.ParentGuidanceBlock,
"ParentGuidanceBlock must be stripped before reaching the advisor")
}
var parentSawAdvisorResult bool
for _, msg := range gotFinalMessages {
if msg.Role == "tool" && strings.Contains(msg.Content, advisorReply) {
parentSawAdvisorResult = true
break
}
}
require.True(t, parentSawAdvisorResult,
"parent must see the advisor reply in its continuation call")
}
// TestAdvisorGating_ChildChat guards the second dimension of the advisor
// eligibility condition: even with advisor enabled, a chat whose
// ParentChatID is set must not register the advisor tool or receive the
// advisor guidance block. Without this coverage, a refactor that removes
// or weakens the !chat.ParentChatID.Valid guard would leak advisor into
// child chats, and the recursive advisor-inside-subagent cost risk the
// guard exists to prevent would ship silently.
//
// The earlier version of this test drove the gating path through
// spawn_agent, which made it dependent on subagent wiring that changed
// repeatedly upstream. This version seeds the parent chat directly in the
// database and asks the server to create a child chat with a valid
// ParentChatID, exercising the same gating path with no subagent tooling
// in the way.
func TestAdvisorGating_ChildChat(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
ctx := testutil.Context(t, testutil.WaitLong)
var toolsMu sync.Mutex
var capturedTools []string
var capturedMessages []chattest.OpenAIMessage
openAIURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse {
if !req.Stream {
return chattest.OpenAINonStreamingResponse("title")
}
names := make([]string, 0, len(req.Tools))
for _, tool := range req.Tools {
names = append(names, tool.Function.Name)
}
toolsMu.Lock()
capturedTools = names
capturedMessages = append([]chattest.OpenAIMessage(nil), req.Messages...)
toolsMu.Unlock()
return chattest.OpenAIStreamingResponse(
chattest.OpenAITextChunks("done")...,
)
})
user, org, model := seedChatDependenciesWithProvider(ctx, t, db, "openai-compat", openAIURL)
seedAdvisorConfig(ctx, t, db, codersdk.AdvisorConfig{
Enabled: true,
MaxUsesPerRun: 3,
MaxOutputTokens: 16384,
})
// Seed the parent chat directly in the database so the test server
// never executes the root turn. That keeps this test focused on the
// child-chat gating path without depending on subagent wiring.
parent, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{
OrganizationID: org.ID,
OwnerID: user.ID,
Status: database.ChatStatusWaiting,
ClientType: database.ChatClientTypeUi,
LastModelConfigID: model.ID,
Title: "advisor-root-parent",
})
require.NoError(t, err)
server := newActiveTestServer(t, db, ps)
childChat, err := server.CreateChat(ctx, chatd.CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
Title: "advisor-child",
ModelConfigID: model.ID,
ParentChatID: uuid.NullUUID{UUID: parent.ID, Valid: true},
RootChatID: uuid.NullUUID{UUID: parent.ID, Valid: true},
InitialUserContent: []codersdk.ChatMessagePart{
codersdk.ChatMessageText("hi"),
},
})
require.NoError(t, err)
require.Eventually(t, func() bool {
got, getErr := db.GetChatByID(ctx, childChat.ID)
if getErr != nil {
return false
}
return got.Status == database.ChatStatusWaiting ||
got.Status == database.ChatStatusError
}, testutil.WaitLong, testutil.IntervalFast)
toolsMu.Lock()
tools := append([]string(nil), capturedTools...)
messages := append([]chattest.OpenAIMessage(nil), capturedMessages...)
toolsMu.Unlock()
require.NotEmpty(t, messages, "expected a streamed LLM request for the child chat")
require.NotContains(t, tools, chatadvisor.ToolName,
"advisor tool must not be registered for child chats even when enabled")
for _, msg := range messages {
require.NotContains(t, msg.Content, chatadvisor.ParentGuidanceBlock,
"child chat must not contain advisor guidance")
}
}
// TestAdvisorGating_PlanMode guards the third dimension of the advisor
// eligibility condition: plan-mode turns must not register the advisor tool
// or inject the parent guidance block. Without this test, deleting the
// !isPlanModeTurn guard would still leave the other two gating tests green
// even though advisor would now leak into plan mode.
func TestAdvisorGating_PlanMode(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
ctx := testutil.Context(t, testutil.WaitLong)
var toolsMu sync.Mutex
var capturedTools []string
var capturedMessages []chattest.OpenAIMessage
openAIURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse {
if !req.Stream {
return chattest.OpenAINonStreamingResponse("title")
}
names := make([]string, 0, len(req.Tools))
for _, tool := range req.Tools {
names = append(names, tool.Function.Name)
}
toolsMu.Lock()
capturedTools = names
capturedMessages = append([]chattest.OpenAIMessage(nil), req.Messages...)
toolsMu.Unlock()
return chattest.OpenAIStreamingResponse(
chattest.OpenAITextChunks("plan mode reply")...,
)
})
user, org, model := seedChatDependenciesWithProvider(ctx, t, db, "openai-compat", openAIURL)
seedAdvisorConfig(ctx, t, db, codersdk.AdvisorConfig{
Enabled: true,
MaxUsesPerRun: 3,
MaxOutputTokens: 16384,
})
server := newActiveTestServer(t, db, ps)
chat, err := server.CreateChat(ctx, chatd.CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
Title: "advisor-plan-mode",
ModelConfigID: model.ID,
PlanMode: database.NullChatPlanMode{ChatPlanMode: database.ChatPlanModePlan, Valid: true},
InitialUserContent: []codersdk.ChatMessagePart{
codersdk.ChatMessageText("draft a plan"),
},
})
require.NoError(t, err)
require.Eventually(t, func() bool {
got, getErr := db.GetChatByID(ctx, chat.ID)
if getErr != nil {
return false
}
return got.Status == database.ChatStatusWaiting ||
got.Status == database.ChatStatusError
}, testutil.WaitLong, testutil.IntervalFast)
toolsMu.Lock()
tools := append([]string(nil), capturedTools...)
messages := append([]chattest.OpenAIMessage(nil), capturedMessages...)
toolsMu.Unlock()
require.NotEmpty(t, messages, "expected a streamed LLM request")
require.NotContains(t, tools, "advisor",
"plan-mode turns must not register the advisor tool even when enabled")
for _, msg := range messages {
require.NotContains(t, msg.Content, chatadvisor.ParentGuidanceBlock,
"plan-mode turns must not inject advisor guidance")
}
}
// TestAdvisorGating_ExploreSubagent guards the fourth dimension of the
// advisor eligibility condition: Explore chats (root or subagent) run
// under allowedExploreToolNames, whose policy does not include advisor,
// so the runtime must not register the advisor tool or inject the
// parent guidance block there. Without this test, deleting the
// !isExploreSubagent guard would leave the other gating tests green
// while leaking advisor into explore chats.
func TestAdvisorGating_ExploreSubagent(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
ctx := testutil.Context(t, testutil.WaitLong)
var toolsMu sync.Mutex
var capturedTools []string
var capturedMessages []chattest.OpenAIMessage
openAIURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse {
if !req.Stream {
return chattest.OpenAINonStreamingResponse("title")
}
names := make([]string, 0, len(req.Tools))
for _, tool := range req.Tools {
names = append(names, tool.Function.Name)
}
toolsMu.Lock()
capturedTools = names
capturedMessages = append([]chattest.OpenAIMessage(nil), req.Messages...)
toolsMu.Unlock()
return chattest.OpenAIStreamingResponse(
chattest.OpenAITextChunks("explore reply")...,
)
})
user, org, model := seedChatDependenciesWithProvider(ctx, t, db, "openai-compat", openAIURL)
seedAdvisorConfig(ctx, t, db, codersdk.AdvisorConfig{
Enabled: true,
MaxUsesPerRun: 3,
MaxOutputTokens: 16384,
})
server := newActiveTestServer(t, db, ps)
chat, err := server.CreateChat(ctx, chatd.CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
Title: "advisor-explore",
ModelConfigID: model.ID,
ChatMode: database.NullChatMode{
ChatMode: database.ChatModeExplore,
Valid: true,
},
InitialUserContent: []codersdk.ChatMessagePart{
codersdk.ChatMessageText("inspect the codebase"),
},
})
require.NoError(t, err)
require.Eventually(t, func() bool {
got, getErr := db.GetChatByID(ctx, chat.ID)
if getErr != nil {
return false
}
return got.Status == database.ChatStatusWaiting ||
got.Status == database.ChatStatusError
}, testutil.WaitLong, testutil.IntervalFast)
toolsMu.Lock()
tools := append([]string(nil), capturedTools...)
messages := append([]chattest.OpenAIMessage(nil), capturedMessages...)
toolsMu.Unlock()
require.NotEmpty(t, messages, "expected a streamed LLM request")
require.NotContains(t, tools, chatadvisor.ToolName,
"explore chats must not register the advisor tool even when enabled")
for _, msg := range messages {
require.NotContains(t, msg.Content, chatadvisor.ParentGuidanceBlock,
"explore chats must not inject advisor guidance")
}
}
// TestAdvisorChainMode_SnapshotKeepsFullHistory exercises the advisor
// runtime together with chain mode and asserts the snapshot captured for
// the nested advisor call retains the full pre-chain prompt. Chain mode
// otherwise strips assistant and tool turns from the prompt the outer
// loop sees, so a regression that moves setAdvisorPromptSnapshot behind
// filterPromptForChainMode, or drops the !chainModeActive guards in
// PrepareMessages, would leak the filtered view into the advisor's
// nested call. The advisor would then only see the trailing user
// message, losing the context the outer model had been building on.
func TestAdvisorChainMode_SnapshotKeepsFullHistory(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
ctx := testutil.Context(t, testutil.WaitLong)
const (
turn1User = "help me refactor this module"
turn1Reply = "happy to help, tell me more"
turn1RespID = "resp_turn1_advisor_chain"
turn2User = "follow up question"
advisorReply = "narrow the scope to one module"
finalReply = "acknowledged"
)
var (
requestsMu sync.Mutex
requests []recordedOpenAIRequest
advisorRequestRaw []byte
advisorCallSeen atomic.Bool
)
openAIURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse {
if !req.Stream {
return chattest.OpenAINonStreamingResponse("title")
}
// The advisor's nested call runs with no tools (MaxSteps=1,
// empty tool set). Parent calls always carry the chat's tool
// set, which includes the advisor tool.
isAdvisorNested := len(req.Tools) == 0
requestsMu.Lock()
requests = append(requests, recordOpenAIRequest(req))
if isAdvisorNested {
advisorRequestRaw = append([]byte(nil), req.RawBody...)
advisorCallSeen.Store(true)
}
requestsMu.Unlock()
if isAdvisorNested {
return chattest.OpenAIStreamingResponse(
chattest.OpenAITextChunks(advisorReply)...,
)
}
// Turn 1 parent request: no previous_response_id yet, so chain
// mode cannot activate. Respond with a plain text reply and
// tag the stored response id so turn 2 can chain off it.
if req.PreviousResponseID == nil {
resp := chattest.OpenAIStreamingResponse(
chattest.OpenAITextChunks(turn1Reply)...,
)
resp.ResponseID = turn1RespID
return resp
}
// Turn 2 parent: chain mode is active. On the first pass call
// advisor; on the continuation after the tool result arrives,
// close out with a final text reply.
var hasAdvisorResult bool
for _, m := range req.Messages {
if m.Role == "tool" && strings.Contains(m.Content, advisorReply) {
hasAdvisorResult = true
break
}
}
if !hasAdvisorResult {
return chattest.OpenAIStreamingResponse(chattest.OpenAIToolCallChunk(
"advisor",
`{"question":"should I keep going?"}`,
))
}
return chattest.OpenAIStreamingResponse(
chattest.OpenAITextChunks(finalReply)...,
)
})
user, org, _ := seedChatDependenciesWithProvider(ctx, t, db, "openai", openAIURL)
storeEnabled := true
// The OpenAI Responses API is the only provider code path where
// chain mode activates. Store=true is the switch that routes this
// provider/model through the Responses API and lets
// IsResponsesStoreEnabled return true.
responsesModel := insertChatModelConfigWithCallConfig(
ctx, t, db, user.ID, "openai", "gpt-4o",
codersdk.ChatModelCallConfig{
ProviderOptions: &codersdk.ChatModelProviderOptions{
OpenAI: &codersdk.ChatModelOpenAIProviderOptions{
Store: &storeEnabled,
},
},
},
)
seedAdvisorConfig(ctx, t, db, codersdk.AdvisorConfig{
Enabled: true,
MaxUsesPerRun: 3,
MaxOutputTokens: 16384,
})
server := newActiveTestServer(t, db, ps)
chat, err := server.CreateChat(ctx, chatd.CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
Title: "advisor-chain-mode",
ModelConfigID: responsesModel.ID,
InitialUserContent: []codersdk.ChatMessagePart{
codersdk.ChatMessageText(turn1User),
},
})
require.NoError(t, err)
// Turn 1 must settle before turn 2 starts so the assistant row
// with ProviderResponseID is visible to resolveChainMode.
waitForChatProcessed(ctx, t, db, chat.ID, server)
turn1Chat, err := db.GetChatByID(ctx, chat.ID)
require.NoError(t, err)
require.Equal(t, database.ChatStatusWaiting, turn1Chat.Status,
"turn 1 must complete before turn 2 can be sent; last_error=%q", turn1Chat.LastError.String)
_, err = server.SendMessage(ctx, chatd.SendMessageOptions{
ChatID: chat.ID,
CreatedBy: user.ID,
Content: []codersdk.ChatMessagePart{
codersdk.ChatMessageText(turn2User),
},
})
require.NoError(t, err)
require.Eventually(t, func() bool {
if !advisorCallSeen.Load() {
return false
}
got, getErr := db.GetChatByID(ctx, chat.ID)
if getErr != nil {
return false
}
return got.Status == database.ChatStatusWaiting ||
got.Status == database.ChatStatusError
}, testutil.WaitLong, testutil.IntervalFast)
requestsMu.Lock()
gotAdvisorBody := append([]byte(nil), advisorRequestRaw...)
gotRequests := append([]recordedOpenAIRequest(nil), requests...)
requestsMu.Unlock()
// Chain mode must have actually fired on turn 2, otherwise this
// test degenerates to TestAdvisorHappyPath_RootChat.
var chainModeActivated bool
for _, r := range gotRequests {
if r.PreviousResponseID != nil && *r.PreviousResponseID == turn1RespID {
chainModeActivated = true
break
}
}
require.True(t, chainModeActivated,
"turn 2 parent request must carry previous_response_id; without it this test does not exercise chain mode")
require.True(t, advisorCallSeen.Load(),
"the nested advisor call must execute under chain mode")
require.NotEmpty(t, gotAdvisorBody,
"advisor call must receive a non-empty request body")
// The core assertion: the advisor snapshot must retain turn 1
// context. Chain mode filtering strips assistant and tool turns
// from the prompt the outer loop sees, so if that filtered view
// leaked into the snapshot the advisor would only see turn 2's
// trailing user message. The advisor's nested call goes through
// the OpenAI Responses API, which encodes its prompt in the
// "input" field rather than "messages", so we inspect the raw
// request body for both turn-1 substrings.
require.Contains(t, string(gotAdvisorBody), turn1User,
"advisor snapshot must retain the turn 1 user message even when chain mode is active")
require.Contains(t, string(gotAdvisorBody), turn1Reply,
"advisor snapshot must retain the turn 1 assistant message even when chain mode is active")
}
func seedAdvisorConfig(
ctx context.Context,
t *testing.T,
db database.Store,
cfg codersdk.AdvisorConfig,
) {
t.Helper()
data, err := json.Marshal(cfg)
require.NoError(t, err)
err = db.UpsertChatAdvisorConfig(
dbauthz.AsSystemRestricted(ctx),
string(data),
)
require.NoError(t, err)
}
+139
View File
@@ -720,6 +720,145 @@ func ReasoningEffortFromChat(provider string, value *string) *string {
}
}
// ApplyReasoningEffortToOptions applies the given reasoning_effort to every
// provider entry in providerOptions that understands it. When model is
// non-nil and the options map has no entry for the model's provider, this
// function seeds a minimal provider-specific options struct so the mutation
// still lands. Callers that produced providerOptions from a chat model
// config with no provider_options block would otherwise see
// reasoning_effort silently dropped.
//
// The returned map is the (possibly newly-allocated) providerOptions; the
// input is mutated in-place when non-nil.
func ApplyReasoningEffortToOptions(
providerOptions fantasy.ProviderOptions,
model fantasy.LanguageModel,
reasoningEffort string,
) fantasy.ProviderOptions {
reasoningEffort = strings.TrimSpace(reasoningEffort)
if reasoningEffort == "" {
return providerOptions
}
if model != nil {
providerOptions = seedProviderOptionsForModel(providerOptions, model)
}
if providerOptions == nil {
return nil
}
applyReasoningEffortDispatch(providerOptions, reasoningEffort)
return providerOptions
}
// seedProviderOptionsForModel ensures providerOptions has an entry for the
// given model's provider, allocating a minimal options struct when absent.
// Returns the possibly newly-allocated options map. Unknown providers are
// left untouched so callers get their input back unchanged.
func seedProviderOptionsForModel(
providerOptions fantasy.ProviderOptions,
model fantasy.LanguageModel,
) fantasy.ProviderOptions {
provider := model.Provider()
var seed fantasy.ProviderOptionsData
switch provider {
case fantasyopenai.Name:
if fantasyopenai.IsResponsesModel(model.Model()) {
seed = &fantasyopenai.ResponsesProviderOptions{}
} else {
seed = &fantasyopenai.ProviderOptions{}
}
case fantasyanthropic.Name:
seed = &fantasyanthropic.ProviderOptions{}
case fantasyopenaicompat.Name:
seed = &fantasyopenaicompat.ProviderOptions{}
case fantasyopenrouter.Name:
seed = &fantasyopenrouter.ProviderOptions{}
case fantasyvercel.Name:
seed = &fantasyvercel.ProviderOptions{}
default:
return providerOptions
}
if providerOptions == nil {
providerOptions = fantasy.ProviderOptions{}
}
if _, ok := providerOptions[provider]; !ok {
providerOptions[provider] = seed
}
return providerOptions
}
// applyReasoningEffortDispatch routes the normalized reasoning_effort to
// every provider entry present in providerOptions. Adding a new provider
// here (and only here) keeps chatd callers in sync automatically.
func applyReasoningEffortDispatch(
providerOptions fantasy.ProviderOptions,
reasoningEffort string,
) {
if normalized := ReasoningEffortFromChat(
fantasyopenai.Name,
&reasoningEffort,
); normalized != nil {
effort := fantasyopenai.ReasoningEffort(*normalized)
if raw, ok := providerOptions[fantasyopenai.Name]; ok {
switch opts := raw.(type) {
case *fantasyopenai.ProviderOptions:
opts.ReasoningEffort = &effort
case *fantasyopenai.ResponsesProviderOptions:
opts.ReasoningEffort = &effort
}
}
if raw, ok := providerOptions[fantasyopenaicompat.Name]; ok {
if opts, ok := raw.(*fantasyopenaicompat.ProviderOptions); ok {
opts.ReasoningEffort = &effort
}
}
}
if normalized := ReasoningEffortFromChat(
fantasyanthropic.Name,
&reasoningEffort,
); normalized != nil {
if raw, ok := providerOptions[fantasyanthropic.Name]; ok {
if opts, ok := raw.(*fantasyanthropic.ProviderOptions); ok {
effort := fantasyanthropic.Effort(*normalized)
opts.Effort = &effort
}
}
}
if normalized := ReasoningEffortFromChat(
fantasyopenrouter.Name,
&reasoningEffort,
); normalized != nil {
if raw, ok := providerOptions[fantasyopenrouter.Name]; ok {
if opts, ok := raw.(*fantasyopenrouter.ProviderOptions); ok {
if opts.Reasoning == nil {
opts.Reasoning = &fantasyopenrouter.ReasoningOptions{}
}
effort := fantasyopenrouter.ReasoningEffort(*normalized)
opts.Reasoning.Effort = &effort
}
}
}
if normalized := ReasoningEffortFromChat(
fantasyvercel.Name,
&reasoningEffort,
); normalized != nil {
if raw, ok := providerOptions[fantasyvercel.Name]; ok {
if opts, ok := raw.(*fantasyvercel.ProviderOptions); ok {
if opts.Reasoning == nil {
opts.Reasoning = &fantasyvercel.ReasoningOptions{}
}
effort := fantasyvercel.ReasoningEffort(*normalized)
opts.Reasoning.Effort = &effort
}
}
}
}
// OpenAITextVerbosityFromChat normalizes chat-config text verbosity values for
// OpenAI and returns the canonical provider verbosity value.
func OpenAITextVerbosityFromChat(value *string) *fantasyopenai.TextVerbosity {
@@ -11,6 +11,7 @@ import (
fantasyanthropic "charm.land/fantasy/providers/anthropic"
fantasybedrock "charm.land/fantasy/providers/bedrock"
fantasyopenai "charm.land/fantasy/providers/openai"
fantasyopenaicompat "charm.land/fantasy/providers/openaicompat"
fantasyopenrouter "charm.land/fantasy/providers/openrouter"
fantasyvercel "charm.land/fantasy/providers/vercel"
"github.com/google/uuid"
@@ -1391,3 +1392,212 @@ func TestMergeMissingProviderOptions_OpenRouterNested(t *testing.T) {
require.Equal(t, []string{"int8"}, options.OpenRouter.Provider.Quantizations)
require.Equal(t, "latency", *options.OpenRouter.Provider.Sort)
}
// TestApplyReasoningEffortToOptions covers every provider's mutation branch
// plus the seeding path for missing provider entries. A typo or wrong type
// assertion in any branch fails a unit test here rather than silently
// dropping the admin-configured reasoning effort in chatd callers.
func TestApplyReasoningEffortToOptions(t *testing.T) {
t.Parallel()
t.Run("NilOptionsAndNilModelIsNoOp", func(t *testing.T) {
t.Parallel()
// Must not panic when options and model are both nil.
got := chatprovider.ApplyReasoningEffortToOptions(nil, nil, "medium")
require.Nil(t, got)
})
t.Run("EmptyEffortReturnsInputUnchanged", func(t *testing.T) {
t.Parallel()
model := &chattest.FakeModel{ProviderName: fantasyopenai.Name, ModelName: "gpt-4"}
got := chatprovider.ApplyReasoningEffortToOptions(nil, model, " ")
require.Nil(t, got)
})
t.Run("EmptyEffortPreservesExistingOptions", func(t *testing.T) {
t.Parallel()
effort := fantasyopenai.ReasoningEffortLow
opts := &fantasyopenai.ProviderOptions{ReasoningEffort: &effort}
providerOptions := fantasy.ProviderOptions{fantasyopenai.Name: opts}
got := chatprovider.ApplyReasoningEffortToOptions(providerOptions, nil, "")
require.NotNil(t, opts.ReasoningEffort)
require.Equal(t, fantasyopenai.ReasoningEffortLow, *opts.ReasoningEffort)
// The input map must be returned untouched rather than allocated anew.
require.Len(t, got, 1)
})
t.Run("UnrecognizedEffortLeavesOptionsUntouched", func(t *testing.T) {
t.Parallel()
opts := &fantasyopenai.ProviderOptions{}
providerOptions := fantasy.ProviderOptions{fantasyopenai.Name: opts}
chatprovider.ApplyReasoningEffortToOptions(providerOptions, nil, "not-a-real-effort")
require.Nil(t, opts.ReasoningEffort)
})
t.Run("OpenAIProviderOptions", func(t *testing.T) {
t.Parallel()
opts := &fantasyopenai.ProviderOptions{}
providerOptions := fantasy.ProviderOptions{fantasyopenai.Name: opts}
chatprovider.ApplyReasoningEffortToOptions(providerOptions, nil, "medium")
require.NotNil(t, opts.ReasoningEffort)
require.Equal(t, fantasyopenai.ReasoningEffortMedium, *opts.ReasoningEffort)
})
t.Run("OpenAIResponsesProviderOptions", func(t *testing.T) {
t.Parallel()
opts := &fantasyopenai.ResponsesProviderOptions{}
providerOptions := fantasy.ProviderOptions{fantasyopenai.Name: opts}
chatprovider.ApplyReasoningEffortToOptions(providerOptions, nil, "medium")
require.NotNil(t, opts.ReasoningEffort)
require.Equal(t, fantasyopenai.ReasoningEffortMedium, *opts.ReasoningEffort)
})
t.Run("OpenAICompatProviderOptions", func(t *testing.T) {
t.Parallel()
opts := &fantasyopenaicompat.ProviderOptions{}
providerOptions := fantasy.ProviderOptions{fantasyopenaicompat.Name: opts}
chatprovider.ApplyReasoningEffortToOptions(providerOptions, nil, "medium")
require.NotNil(t, opts.ReasoningEffort)
require.Equal(t, fantasyopenai.ReasoningEffortMedium, *opts.ReasoningEffort)
})
t.Run("AnthropicProviderOptions", func(t *testing.T) {
t.Parallel()
opts := &fantasyanthropic.ProviderOptions{}
providerOptions := fantasy.ProviderOptions{fantasyanthropic.Name: opts}
chatprovider.ApplyReasoningEffortToOptions(providerOptions, nil, "high")
require.NotNil(t, opts.Effort)
require.Equal(t, fantasyanthropic.EffortHigh, *opts.Effort)
})
t.Run("OpenRouterAllocatesReasoningOptions", func(t *testing.T) {
t.Parallel()
opts := &fantasyopenrouter.ProviderOptions{}
providerOptions := fantasy.ProviderOptions{fantasyopenrouter.Name: opts}
chatprovider.ApplyReasoningEffortToOptions(providerOptions, nil, "medium")
require.NotNil(t, opts.Reasoning, "Reasoning container must be allocated")
require.NotNil(t, opts.Reasoning.Effort)
require.Equal(t, fantasyopenrouter.ReasoningEffort("medium"), *opts.Reasoning.Effort)
})
t.Run("OpenRouterPreservesExistingReasoningContainer", func(t *testing.T) {
t.Parallel()
enabled := true
opts := &fantasyopenrouter.ProviderOptions{
Reasoning: &fantasyopenrouter.ReasoningOptions{Enabled: &enabled},
}
providerOptions := fantasy.ProviderOptions{fantasyopenrouter.Name: opts}
chatprovider.ApplyReasoningEffortToOptions(providerOptions, nil, "high")
require.NotNil(t, opts.Reasoning.Enabled)
require.True(t, *opts.Reasoning.Enabled)
require.NotNil(t, opts.Reasoning.Effort)
require.Equal(t, fantasyopenrouter.ReasoningEffort("high"), *opts.Reasoning.Effort)
})
t.Run("VercelAllocatesReasoningOptions", func(t *testing.T) {
t.Parallel()
opts := &fantasyvercel.ProviderOptions{}
providerOptions := fantasy.ProviderOptions{fantasyvercel.Name: opts}
chatprovider.ApplyReasoningEffortToOptions(providerOptions, nil, "minimal")
require.NotNil(t, opts.Reasoning)
require.NotNil(t, opts.Reasoning.Effort)
require.Equal(t, fantasyvercel.ReasoningEffortMinimal, *opts.Reasoning.Effort)
})
t.Run("MultipleProvidersReceiveMutations", func(t *testing.T) {
t.Parallel()
openaiOpts := &fantasyopenai.ProviderOptions{}
anthropicOpts := &fantasyanthropic.ProviderOptions{}
providerOptions := fantasy.ProviderOptions{
fantasyopenai.Name: openaiOpts,
fantasyanthropic.Name: anthropicOpts,
}
chatprovider.ApplyReasoningEffortToOptions(providerOptions, nil, "high")
require.NotNil(t, openaiOpts.ReasoningEffort)
require.Equal(t, fantasyopenai.ReasoningEffortHigh, *openaiOpts.ReasoningEffort)
require.NotNil(t, anthropicOpts.Effort)
require.Equal(t, fantasyanthropic.EffortHigh, *anthropicOpts.Effort)
})
t.Run("SeedsOpenAICompletionsWhenModelHasNoOptions", func(t *testing.T) {
t.Parallel()
// A model name absent from the Responses allowlist must seed
// the completions options struct so reasoning_effort lands.
model := &chattest.FakeModel{ProviderName: fantasyopenai.Name, ModelName: "not-a-real-openai-model"}
got := chatprovider.ApplyReasoningEffortToOptions(nil, model, "medium")
require.NotNil(t, got)
opts, ok := got[fantasyopenai.Name].(*fantasyopenai.ProviderOptions)
require.True(t, ok, "expected *ProviderOptions for non-Responses model, got %T", got[fantasyopenai.Name])
require.NotNil(t, opts.ReasoningEffort)
require.Equal(t, fantasyopenai.ReasoningEffortMedium, *opts.ReasoningEffort)
})
t.Run("SeedsOpenAIResponsesWhenModelIsResponsesModel", func(t *testing.T) {
t.Parallel()
// A model name in the Responses allowlist must seed the
// Responses-specific options struct so the provider routes to
// the Responses endpoint.
model := &chattest.FakeModel{ProviderName: fantasyopenai.Name, ModelName: "gpt-4"}
got := chatprovider.ApplyReasoningEffortToOptions(nil, model, "medium")
require.NotNil(t, got)
opts, ok := got[fantasyopenai.Name].(*fantasyopenai.ResponsesProviderOptions)
require.True(t, ok, "expected *ResponsesProviderOptions for Responses model, got %T", got[fantasyopenai.Name])
require.NotNil(t, opts.ReasoningEffort)
require.Equal(t, fantasyopenai.ReasoningEffortMedium, *opts.ReasoningEffort)
})
t.Run("SeedsAnthropicWhenModelHasNoOptions", func(t *testing.T) {
t.Parallel()
model := &chattest.FakeModel{ProviderName: fantasyanthropic.Name, ModelName: "claude-3-5"}
got := chatprovider.ApplyReasoningEffortToOptions(nil, model, "high")
require.NotNil(t, got)
opts, ok := got[fantasyanthropic.Name].(*fantasyanthropic.ProviderOptions)
require.True(t, ok)
require.NotNil(t, opts.Effort)
require.Equal(t, fantasyanthropic.EffortHigh, *opts.Effort)
})
t.Run("SeedsOpenRouterWhenModelHasNoOptions", func(t *testing.T) {
t.Parallel()
model := &chattest.FakeModel{ProviderName: fantasyopenrouter.Name, ModelName: "openrouter-x"}
got := chatprovider.ApplyReasoningEffortToOptions(nil, model, "low")
require.NotNil(t, got)
opts, ok := got[fantasyopenrouter.Name].(*fantasyopenrouter.ProviderOptions)
require.True(t, ok)
require.NotNil(t, opts.Reasoning)
require.NotNil(t, opts.Reasoning.Effort)
require.Equal(t, fantasyopenrouter.ReasoningEffort("low"), *opts.Reasoning.Effort)
})
t.Run("UnknownProviderReturnsInputUnchanged", func(t *testing.T) {
t.Parallel()
model := &chattest.FakeModel{ProviderName: "unknown", ModelName: "x"}
got := chatprovider.ApplyReasoningEffortToOptions(nil, model, "medium")
require.Nil(t, got)
})
t.Run("PreservesExistingProviderEntry", func(t *testing.T) {
t.Parallel()
existing := &fantasyopenai.ProviderOptions{}
existingEffort := fantasyopenai.ReasoningEffortLow
existing.ReasoningEffort = &existingEffort
providerOptions := fantasy.ProviderOptions{fantasyopenai.Name: existing}
model := &chattest.FakeModel{ProviderName: fantasyopenai.Name, ModelName: "gpt-4"}
got := chatprovider.ApplyReasoningEffortToOptions(providerOptions, model, "medium")
require.Same(t, existing, got[fantasyopenai.Name],
"existing provider entry must not be replaced")
// The reasoning effort on the existing entry is overwritten.
require.Equal(t, fantasyopenai.ReasoningEffortMedium, *existing.ReasoningEffort)
})
}
+149 -49
View File
@@ -1,8 +1,10 @@
package chattest
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/http/httptest"
@@ -53,6 +55,10 @@ type OpenAIRequest struct {
Prompt []interface{} `json:"prompt,omitempty"` // Responses API input or prompt.
Store *bool `json:"store,omitempty"`
PreviousResponseID *string `json:"previous_response_id,omitempty"`
// RawBody holds the original request body so callers can inspect
// fields the typed struct does not expose, such as the Responses
// API "input" payload. It is populated before JSON decoding.
RawBody []byte `json:"-"`
// TODO: encoding/json ignores inline tags. Add custom UnmarshalJSON to capture unknown keys.
Options map[string]interface{} `json:",inline"` //nolint:revive
}
@@ -205,12 +211,18 @@ func NewOpenAI(t testing.TB, handler OpenAIHandler) string {
}
func (s *openAIServer) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
var req OpenAIRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
if err := json.NewDecoder(bytes.NewReader(body)).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
req.Request = r
req.RawBody = body
s.mu.Lock()
s.request = &req
@@ -221,12 +233,18 @@ func (s *openAIServer) handleChatCompletions(w http.ResponseWriter, r *http.Requ
}
func (s *openAIServer) handleResponses(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
var req OpenAIRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
if err := json.NewDecoder(bytes.NewReader(body)).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
req.Request = r
req.RawBody = body
s.mu.Lock()
s.request = &req
@@ -407,8 +425,18 @@ func writeResponsesAPIStreaming(t testing.TB, w http.ResponseWriter, r *http.Req
responseModel := "gpt-4"
sequenceNumber := int64(0)
textOffset := 0
itemIDs := make(map[int]string)
itemTexts := make(map[int]string)
// outputs tracks per-output-index state so the done-event emission
// at stream close can distinguish message items (text) from
// function_call items (tool invocation).
type outputItemState struct {
itemType string // "message" or "function_call"
itemID string
text string // accumulated text for message items
callID string // call_id for function_call items
toolName string // function name for function_call items
arguments string // accumulated arguments for function_call items
}
outputs := make(map[int]*outputItemState)
writeEvent := func(eventType string, payload map[string]interface{}) bool {
payload["type"] = eventType
@@ -574,50 +602,73 @@ func writeResponsesAPIStreaming(t testing.TB, w http.ResponseWriter, r *http.Req
return
case chunk, ok = <-resp.StreamingChunks:
if !ok {
indices := make([]int, 0, len(itemIDs))
for outputIndex := range itemIDs {
indices := make([]int, 0, len(outputs))
for outputIndex := range outputs {
indices = append(indices, outputIndex)
}
sort.Ints(indices)
for _, outputIndex := range indices {
itemID := itemIDs[outputIndex]
text := itemTexts[outputIndex]
if !writeEvent("response.output_text.done", map[string]interface{}{
"item_id": itemID,
"output_index": outputIndex,
"content_index": 0,
"text": text,
"logprobs": []interface{}{},
}) {
return
}
if !writeEvent("response.content_part.done", map[string]interface{}{
"item_id": itemID,
"output_index": outputIndex,
"content_index": 0,
"part": map[string]interface{}{
"type": "output_text",
"text": text,
},
}) {
return
}
if !writeEvent("response.output_item.done", map[string]interface{}{
"output_index": outputIndex,
"item": map[string]interface{}{
"type": "message",
"id": itemID,
"role": "assistant",
"status": "completed",
"content": []interface{}{
map[string]interface{}{
"type": "output_text",
"text": text,
state := outputs[outputIndex]
switch state.itemType {
case "function_call":
if !writeEvent("response.function_call_arguments.done", map[string]interface{}{
"item_id": state.itemID,
"output_index": outputIndex,
"arguments": state.arguments,
}) {
return
}
if !writeEvent("response.output_item.done", map[string]interface{}{
"output_index": outputIndex,
"item": map[string]interface{}{
"type": "function_call",
"id": state.itemID,
"status": "completed",
"call_id": state.callID,
"name": state.toolName,
"arguments": state.arguments,
},
}) {
return
}
default:
if !writeEvent("response.output_text.done", map[string]interface{}{
"item_id": state.itemID,
"output_index": outputIndex,
"content_index": 0,
"text": state.text,
"logprobs": []interface{}{},
}) {
return
}
if !writeEvent("response.content_part.done", map[string]interface{}{
"item_id": state.itemID,
"output_index": outputIndex,
"content_index": 0,
"part": map[string]interface{}{
"type": "output_text",
"text": state.text,
},
}) {
return
}
if !writeEvent("response.output_item.done", map[string]interface{}{
"output_index": outputIndex,
"item": map[string]interface{}{
"type": "message",
"id": state.itemID,
"role": "assistant",
"status": "completed",
"content": []interface{}{
map[string]interface{}{
"type": "output_text",
"text": state.text,
},
},
},
},
}) {
return
}) {
return
}
}
}
if !writeEvent("response.completed", map[string]interface{}{
@@ -645,15 +696,64 @@ func writeResponsesAPIStreaming(t testing.TB, w http.ResponseWriter, r *http.Req
outputIndex = choice.Index
}
outputIndex += textOffset
itemID, found := itemIDs[outputIndex]
if len(choice.ToolCalls) > 0 {
for _, tc := range choice.ToolCalls {
// Each tool call within a chunk owns a distinct
// output item, so discriminate by the streaming
// tc.Index. Without this, multiple tool calls in
// one chunk collide on outputIndex and later
// calls inherit the first call's id and name.
toolOutputIndex := outputIndex + tc.Index
state, found := outputs[toolOutputIndex]
if !found {
state = &outputItemState{
itemType: "function_call",
itemID: fmt.Sprintf("fc_%s", uuid.New().String()[:8]),
callID: tc.ID,
toolName: tc.Function.Name,
}
outputs[toolOutputIndex] = state
if !writeEvent("response.output_item.added", map[string]interface{}{
"output_index": toolOutputIndex,
"item": map[string]interface{}{
"type": "function_call",
"id": state.itemID,
"status": "in_progress",
"call_id": state.callID,
"name": state.toolName,
"arguments": "",
},
}) {
return
}
}
if tc.Function.Arguments != "" {
state.arguments += tc.Function.Arguments
if !writeEvent("response.function_call_arguments.delta", map[string]interface{}{
"item_id": state.itemID,
"output_index": toolOutputIndex,
"delta": tc.Function.Arguments,
}) {
return
}
}
}
continue
}
state, found := outputs[outputIndex]
if !found {
itemID = fmt.Sprintf("msg_%s", uuid.New().String()[:8])
itemIDs[outputIndex] = itemID
state = &outputItemState{
itemType: "message",
itemID: fmt.Sprintf("msg_%s", uuid.New().String()[:8]),
}
outputs[outputIndex] = state
if !writeEvent("response.output_item.added", map[string]interface{}{
"output_index": outputIndex,
"item": map[string]interface{}{
"type": "message",
"id": itemID,
"id": state.itemID,
"role": "assistant",
"status": "in_progress",
"content": []interface{}{},
@@ -662,7 +762,7 @@ func writeResponsesAPIStreaming(t testing.TB, w http.ResponseWriter, r *http.Req
return
}
if !writeEvent("response.content_part.added", map[string]interface{}{
"item_id": itemID,
"item_id": state.itemID,
"output_index": outputIndex,
"content_index": 0,
"part": map[string]interface{}{
@@ -674,9 +774,9 @@ func writeResponsesAPIStreaming(t testing.TB, w http.ResponseWriter, r *http.Req
}
}
itemTexts[outputIndex] += choice.Delta
state.text += choice.Delta
if !writeEvent("response.output_text.delta", map[string]interface{}{
"item_id": itemID,
"item_id": state.itemID,
"output_index": outputIndex,
"content_index": 0,
"delta": choice.Delta,
+57
View File
@@ -237,6 +237,63 @@ func TestOpenAI_ToolCalls(t *testing.T) {
require.GreaterOrEqual(t, requestCount.Load(), int32(2), "expected follow-up model call after tool execution")
}
func TestOpenAI_ToolCalls_ResponsesAPI(t *testing.T) {
t.Parallel()
var requestCount atomic.Int32
serverURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse {
switch requestCount.Add(1) {
case 1:
return chattest.OpenAIStreamingResponse(
chattest.OpenAIToolCallChunk("get_weather", `{"location":"San Francisco"}`),
)
default:
return chattest.OpenAIStreamingResponse(
chattest.OpenAITextChunks("The weather in San Francisco is 72F.")...,
)
}
})
client, err := fantasyopenai.New(
fantasyopenai.WithAPIKey("test-key"),
fantasyopenai.WithBaseURL(serverURL),
fantasyopenai.WithUseResponsesAPI(),
)
require.NoError(t, err)
ctx := context.Background()
model, err := client.LanguageModel(ctx, "gpt-4")
require.NoError(t, err)
type weatherInput struct {
Location string `json:"location"`
}
var toolCallCount atomic.Int32
weatherTool := fantasy.NewAgentTool(
"get_weather",
"Get weather for a location.",
func(ctx context.Context, input weatherInput, _ fantasy.ToolCall) (fantasy.ToolResponse, error) {
toolCallCount.Add(1)
require.Equal(t, "San Francisco", input.Location)
return fantasy.NewTextResponse("72F"), nil
},
)
agent := fantasy.NewAgent(
model,
fantasy.WithSystemPrompt("You are a helpful assistant."),
fantasy.WithTools(weatherTool),
)
result, err := agent.Stream(ctx, fantasy.AgentStreamCall{
Prompt: "What's the weather in San Francisco?",
})
require.NoError(t, err)
require.NotNil(t, result)
require.Equal(t, int32(1), toolCallCount.Load(), "expected exactly one tool execution")
require.GreaterOrEqual(t, requestCount.Load(), int32(2), "expected follow-up model call after tool execution")
}
func TestOpenAI_NonStreaming_ResponsesAPI(t *testing.T) {
t.Parallel()
+110 -3
View File
@@ -3,6 +3,7 @@ package chatd
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"slices"
@@ -14,13 +15,15 @@ import (
"tailscale.com/util/singleflight"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/quartz"
)
const (
chatConfigProvidersTTL = 10 * time.Second
chatConfigModelConfigTTL = 10 * time.Second
chatConfigUserPromptTTL = 5 * time.Second
chatConfigProvidersTTL = 10 * time.Second
chatConfigModelConfigTTL = 10 * time.Second
chatConfigUserPromptTTL = 5 * time.Second
chatConfigAdvisorConfigTTL = 10 * time.Second
// Bound user-prompt cache cardinality so one-shot users do not
// accumulate forever in long-lived chatd processes.
chatConfigUserPromptEntryLimit = 64 * 1024
@@ -31,6 +34,11 @@ type cachedProviders struct {
expiresAt time.Time
}
type cachedAdvisorConfig struct {
config codersdk.AdvisorConfig
expiresAt time.Time
}
type cachedModelConfig struct {
config database.ChatModelConfig
expiresAt time.Time
@@ -82,6 +90,11 @@ type chatConfigCache struct {
userPromptEpoch uint64
userPrompts *tlru.Cache[uuid.UUID, string]
userPromptFetches singleflight.Group[string, string]
// Advisor configuration (singleton).
advisorConfig *cachedAdvisorConfig
advisorConfigGeneration uint64
advisorConfigFetches singleflight.Group[string, codersdk.AdvisorConfig]
}
func newChatConfigCache(ctx context.Context, db database.Store, clock quartz.Clock) *chatConfigCache {
@@ -410,3 +423,97 @@ func (c *chatConfigCache) InvalidateUserPrompt(userID uuid.UUID) {
c.userPromptEpoch++
c.mu.Unlock()
}
// InvalidateAdvisorConfig drops the cached advisor configuration so the
// next AdvisorConfig call re-fetches from the database. Called from the
// ChatConfigEvent subscriber after an admin writes
// PUT /api/experimental/chats/config/advisor; without this the cache
// could serve stale enabled/model/limits for up to
// chatConfigAdvisorConfigTTL. Bumping the generation counter also
// discards any in-flight fill started before the invalidation, so a
// stale DB read cannot re-cache the pre-update value.
func (c *chatConfigCache) InvalidateAdvisorConfig() {
c.mu.Lock()
c.advisorConfig = nil
c.advisorConfigGeneration++
c.mu.Unlock()
}
// AdvisorConfig returns the deployment-wide advisor configuration. The
// underlying site-config row changes on the order of hours or days, so
// this cache saves a per-turn DB round trip on chats that reference the
// advisor. Parse errors and lookup errors are surfaced to the caller;
// callers that prefer silent fallback handle that at the call site.
func (c *chatConfigCache) AdvisorConfig(ctx context.Context) (codersdk.AdvisorConfig, error) {
if config, ok := c.cachedAdvisorConfig(); ok {
return config, nil
}
generation := c.advisorConfigGenerationSnapshot()
config, err := singleflightDoChan(
ctx,
&c.advisorConfigFetches,
fmt.Sprintf("%d:advisor", generation),
func() (codersdk.AdvisorConfig, error) {
if cached, ok := c.cachedAdvisorConfig(); ok {
return cached, nil
}
raw, err := c.db.GetChatAdvisorConfig(c.ctx)
if err != nil {
return codersdk.AdvisorConfig{}, err
}
var cfg codersdk.AdvisorConfig
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
return codersdk.AdvisorConfig{}, err
}
c.storeAdvisorConfig(generation, cfg)
return cfg, nil
},
)
if err != nil {
return codersdk.AdvisorConfig{}, err
}
return config, nil
}
func (c *chatConfigCache) cachedAdvisorConfig() (codersdk.AdvisorConfig, bool) {
c.mu.RLock()
entry := c.advisorConfig
c.mu.RUnlock()
if entry == nil {
return codersdk.AdvisorConfig{}, false
}
if c.clock.Now().Before(entry.expiresAt) {
return entry.config, true
}
c.mu.Lock()
if current := c.advisorConfig; current != nil && !c.clock.Now().Before(current.expiresAt) {
c.advisorConfig = nil
}
c.mu.Unlock()
return codersdk.AdvisorConfig{}, false
}
func (c *chatConfigCache) advisorConfigGenerationSnapshot() uint64 {
c.mu.RLock()
generation := c.advisorConfigGeneration
c.mu.RUnlock()
return generation
}
func (c *chatConfigCache) storeAdvisorConfig(generation uint64, config codersdk.AdvisorConfig) {
c.mu.Lock()
defer c.mu.Unlock()
if c.advisorConfigGeneration != generation {
return
}
c.advisorConfig = &cachedAdvisorConfig{
config: config,
expiresAt: c.clock.Now().Add(chatConfigAdvisorConfigTTL),
}
}
+224
View File
@@ -14,6 +14,7 @@ import (
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
"github.com/coder/quartz"
)
@@ -25,11 +26,13 @@ type stubChatConfigStore struct {
getChatModelConfigByID func(context.Context, uuid.UUID) (database.ChatModelConfig, error)
getDefaultChatModelConfig func(context.Context) (database.ChatModelConfig, error)
getUserChatCustomPrompt func(context.Context, uuid.UUID) (string, error)
getChatAdvisorConfig func(context.Context) (string, error)
enabledProvidersCalls atomic.Int32
modelConfigByIDCalls atomic.Int32
defaultModelConfigCall atomic.Int32
userPromptCalls atomic.Int32
advisorConfigCalls atomic.Int32
}
func (s *stubChatConfigStore) GetEnabledChatProviders(ctx context.Context) ([]database.ChatProvider, error) {
@@ -64,6 +67,14 @@ func (s *stubChatConfigStore) GetUserChatCustomPrompt(ctx context.Context, userI
return s.getUserChatCustomPrompt(ctx, userID)
}
func (s *stubChatConfigStore) GetChatAdvisorConfig(ctx context.Context) (string, error) {
s.advisorConfigCalls.Add(1)
if s.getChatAdvisorConfig == nil {
panic("unexpected GetChatAdvisorConfig call")
}
return s.getChatAdvisorConfig(ctx)
}
func TestConfigCache_EnabledProviders_CacheHit(t *testing.T) {
t.Parallel()
@@ -976,3 +987,216 @@ func TestConfigCache_CallerCancellation(t *testing.T) {
}
})
}
func TestConfigCache_AdvisorConfig_CacheHit(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
clock := quartz.NewMock(t)
const raw = `{"enabled":true,"max_uses_per_run":3,"max_output_tokens":16384}`
store := &stubChatConfigStore{
getChatAdvisorConfig: func(context.Context) (string, error) {
return raw, nil
},
}
cache := newChatConfigCache(ctx, store, clock)
first, err := cache.AdvisorConfig(ctx)
require.NoError(t, err)
second, err := cache.AdvisorConfig(ctx)
require.NoError(t, err)
require.True(t, first.Enabled)
require.Equal(t, 3, first.MaxUsesPerRun)
require.Equal(t, int64(16384), first.MaxOutputTokens)
require.Equal(t, first, second)
require.Equal(t, int32(1), store.advisorConfigCalls.Load(),
"second lookup must be served from cache")
}
func TestConfigCache_AdvisorConfig_TTLExpiry(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
clock := quartz.NewMock(t)
store := &stubChatConfigStore{}
store.getChatAdvisorConfig = func(context.Context) (string, error) {
call := store.advisorConfigCalls.Load()
return fmt.Sprintf(`{"max_uses_per_run":%d}`, call), nil
}
cache := newChatConfigCache(ctx, store, clock)
first, err := cache.AdvisorConfig(ctx)
require.NoError(t, err)
clock.Advance(chatConfigAdvisorConfigTTL).MustWait(ctx)
second, err := cache.AdvisorConfig(ctx)
require.NoError(t, err)
require.NotEqual(t, first.MaxUsesPerRun, second.MaxUsesPerRun,
"TTL expiry must trigger a refetch")
require.Equal(t, int32(2), store.advisorConfigCalls.Load())
}
func TestConfigCache_AdvisorConfig_DBErrorNotCached(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
clock := quartz.NewMock(t)
expected := xerrors.New("boom")
store := &stubChatConfigStore{
getChatAdvisorConfig: func(context.Context) (string, error) {
return "", expected
},
}
cache := newChatConfigCache(ctx, store, clock)
_, err := cache.AdvisorConfig(ctx)
require.ErrorIs(t, err, expected)
_, err = cache.AdvisorConfig(ctx)
require.ErrorIs(t, err, expected)
require.Equal(t, int32(2), store.advisorConfigCalls.Load(),
"errors must not populate the cache; every call retries")
}
func TestConfigCache_AdvisorConfig_InvalidJSONNotCached(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
clock := quartz.NewMock(t)
store := &stubChatConfigStore{
getChatAdvisorConfig: func(context.Context) (string, error) {
return "not valid json", nil
},
}
cache := newChatConfigCache(ctx, store, clock)
_, err := cache.AdvisorConfig(ctx)
require.Error(t, err, "malformed JSON must surface as an error")
_, err = cache.AdvisorConfig(ctx)
require.Error(t, err)
require.Equal(t, int32(2), store.advisorConfigCalls.Load(),
"parse errors must not populate the cache; every call retries")
}
func TestConfigCache_AdvisorConfig_EmptyJSONYieldsZeroValue(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
clock := quartz.NewMock(t)
// GetChatAdvisorConfig returns "{}" when the site-config row is
// absent. That must unmarshal to a zero-value AdvisorConfig rather
// than a parse error.
store := &stubChatConfigStore{
getChatAdvisorConfig: func(context.Context) (string, error) {
return "{}", nil
},
}
cache := newChatConfigCache(ctx, store, clock)
cfg, err := cache.AdvisorConfig(ctx)
require.NoError(t, err)
require.Equal(t, codersdk.AdvisorConfig{}, cfg)
}
// Guards the pubsub-driven invalidation path. Without this, an admin
// writing PUT /api/experimental/chats/config/advisor could keep every
// replica serving stale enabled/model/limits for up to
// chatConfigAdvisorConfigTTL, which defeats the subscriber in chatd.go.
func TestConfigCache_InvalidateAdvisorConfig(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
clock := quartz.NewMock(t)
store := &stubChatConfigStore{}
store.getChatAdvisorConfig = func(context.Context) (string, error) {
call := store.advisorConfigCalls.Load()
return fmt.Sprintf(`{"max_uses_per_run":%d}`, call), nil
}
cache := newChatConfigCache(ctx, store, clock)
first, err := cache.AdvisorConfig(ctx)
require.NoError(t, err)
cache.InvalidateAdvisorConfig()
second, err := cache.AdvisorConfig(ctx)
require.NoError(t, err)
require.NotEqual(t, first.MaxUsesPerRun, second.MaxUsesPerRun,
"invalidation must force a refetch without waiting for TTL expiry")
require.Equal(t, int32(2), store.advisorConfigCalls.Load())
}
// Guards against the invalidation-during-singleflight race. A stale
// in-flight fill started before InvalidateAdvisorConfig must not
// re-cache its pre-update value, which would defeat the pubsub
// invalidation path for up to chatConfigAdvisorConfigTTL.
func TestConfigCache_InvalidateAdvisorConfig_BlocksStaleInFlight(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
clock := quartz.NewMock(t)
staleConfig := `{"max_uses_per_run":1}`
freshConfig := `{"max_uses_per_run":2}`
firstStarted := make(chan struct{})
secondStarted := make(chan struct{})
releaseFirst := make(chan struct{})
releaseSecond := make(chan struct{})
store := &stubChatConfigStore{}
store.getChatAdvisorConfig = func(context.Context) (string, error) {
switch call := store.advisorConfigCalls.Load(); call {
case 1:
close(firstStarted)
<-releaseFirst
return staleConfig, nil
case 2:
close(secondStarted)
<-releaseSecond
return freshConfig, nil
default:
return "", xerrors.Errorf("unexpected advisor config call %d", call)
}
}
cache := newChatConfigCache(ctx, store, clock)
type result struct {
config codersdk.AdvisorConfig
err error
}
firstResult := make(chan result, 1)
go func() {
config, err := cache.AdvisorConfig(ctx)
firstResult <- result{config: config, err: err}
}()
waitForSignal(t, firstStarted)
cache.InvalidateAdvisorConfig()
secondResult := make(chan result, 1)
go func() {
config, err := cache.AdvisorConfig(ctx)
secondResult <- result{config: config, err: err}
}()
waitForSignal(t, secondStarted)
close(releaseFirst)
first := <-firstResult
require.NoError(t, first.err)
require.EqualValues(t, 1, first.config.MaxUsesPerRun)
require.Nil(t, cache.advisorConfig,
"stale fill must not re-cache after invalidation")
close(releaseSecond)
second := <-secondResult
require.NoError(t, second.err)
require.EqualValues(t, 2, second.config.MaxUsesPerRun)
require.Equal(t, int32(2), store.advisorConfigCalls.Load())
third, err := cache.AdvisorConfig(ctx)
require.NoError(t, err)
require.EqualValues(t, 2, third.MaxUsesPerRun)
require.Equal(t, int32(2), store.advisorConfigCalls.Load())
}