feat(office): add per-role tier defaults and explain why an agent's tier differs (#2853)

Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
nova28
2026-08-22 14:48:21 +00:00
committed by GitHub
parent 2b1c4c62de
commit 708d903b3a
42 changed files with 3497 additions and 111 deletions
+41 -2
View File
@@ -50,14 +50,53 @@ type UpdateAgentStatusRequest struct {
PauseReason string `json:"pause_reason"`
}
// agentResponseBody wraps a models.AgentInstance for API responses,
// shadowing the shared struct's "model" key so it can be omitted for
// Office identities (role != ""). Embedding rather than hand-mirroring
// the 43 JSON-tagged fields on the shared struct keeps this DTO in
// sync automatically as that struct grows; a *string shadow field
// (not string) is required because the embedded struct's Model field
// has no `omitempty` — a plain string would drop the "model" key
// entirely for role=="" rows too, which AC-13 requires unchanged.
type agentResponseBody struct {
*models.AgentInstance
Model *string `json:"model,omitempty"`
}
// newAgentResponseBody wraps agent for an API response. Model is set
// (and so emitted) only for role=="" rows (execution profiles); nil —
// omitted from JSON — for Office identities. A nil agent stays nil so
// callers serialise "agent": null exactly as before, never a
// zero-valued {} DTO.
func newAgentResponseBody(agent *models.AgentInstance) *agentResponseBody {
if agent == nil {
return nil
}
body := &agentResponseBody{AgentInstance: agent}
if agent.Role == "" {
m := agent.Model
body.Model = &m
}
return body
}
// newAgentResponseBodies wraps a slice of agents for a list response.
func newAgentResponseBodies(agents []*models.AgentInstance) []*agentResponseBody {
out := make([]*agentResponseBody, len(agents))
for i, a := range agents {
out[i] = newAgentResponseBody(a)
}
return out
}
// AgentResponse wraps a single agent instance.
type AgentResponse struct {
Agent *models.AgentInstance `json:"agent"`
Agent *agentResponseBody `json:"agent"`
}
// AgentListResponse wraps a list of agent instances.
type AgentListResponse struct {
Agents []*models.AgentInstance `json:"agents"`
Agents []*agentResponseBody `json:"agents"`
}
// UpsertMemoryRequest is the request body for creating/updating agent memory.
+66 -9
View File
@@ -1,7 +1,9 @@
package agents
import (
"encoding/json"
"errors"
"io"
"net/http"
"strings"
@@ -19,6 +21,12 @@ const (
ctxKeyAgentCaller = "agent_caller"
)
// maxUpdateAgentBodyBytes bounds the update-agent request body via
// http.MaxBytesReader, writing the 413 response below when exceeded.
// UpdateAgentRequest carries only short scalar fields plus a routing
// override blob, so this is generous relative to any legitimate payload.
const maxUpdateAgentBodyBytes = 64 * 1024
// Handler provides HTTP handlers for agent routes.
type Handler struct {
svc *AgentService
@@ -120,7 +128,7 @@ func (h *Handler) listAgents(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, AgentListResponse{Agents: agents})
c.JSON(http.StatusOK, AgentListResponse{Agents: newAgentResponseBodies(agents)})
}
func (h *Handler) createAgent(c *gin.Context) {
@@ -165,7 +173,7 @@ func (h *Handler) createAgent(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, AgentResponse{Agent: agent})
c.JSON(http.StatusCreated, AgentResponse{Agent: newAgentResponseBody(agent)})
}
func (h *Handler) getAgent(c *gin.Context) {
@@ -174,7 +182,7 @@ func (h *Handler) getAgent(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, AgentResponse{Agent: agent})
c.JSON(http.StatusOK, AgentResponse{Agent: newAgentResponseBody(agent)})
}
func (h *Handler) updateAgent(c *gin.Context) {
@@ -191,9 +199,8 @@ func (h *Handler) updateAgent(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
var req UpdateAgentRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
req, ok := decodeUpdateAgentRequest(c, agent.Role != "")
if !ok {
return
}
if req.Permissions != nil {
@@ -212,7 +219,7 @@ func (h *Handler) updateAgent(c *gin.Context) {
})
return
}
applyAgentUpdates(agent, &req)
applyAgentUpdates(agent, req)
if err := h.svc.UpdateAgentInstance(ctx, agent); err != nil {
if code := agentValidationErrorCode(err); code != "" {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": code})
@@ -221,7 +228,57 @@ func (h *Handler) updateAgent(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, AgentResponse{Agent: agent})
c.JSON(http.StatusOK, AgentResponse{Agent: newAgentResponseBody(agent)})
}
// decodeUpdateAgentRequest buffers the body once so the handler can inspect
// raw keys and then decode the typed request. Office identities reject the
// ignored model key before the request reaches the normal update path.
func decodeUpdateAgentRequest(c *gin.Context, officeAgent bool) (*UpdateAgentRequest, bool) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxUpdateAgentBodyBytes)
body, err := io.ReadAll(c.Request.Body)
if err != nil {
var maxBytesError *http.MaxBytesError
if errors.As(err, &maxBytesError) {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "request body too large"})
return nil, false
}
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return nil, false
}
if officeAgent {
hasModel, err := requestBodyHasKey(body, "model")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return nil, false
}
if hasModel {
respondRoutingValidation(c, &routing.ValidationError{
Field: "model",
Message: "model no longer selects a launched model for an Office agent; " +
"update the agent routing override or workspace tier profiles",
})
return nil, false
}
}
var req UpdateAgentRequest
if err := json.Unmarshal(body, &req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return nil, false
}
return &req, true
}
// requestBodyHasKey reports whether the top-level JSON object in body
// carries key, without caring about its value — {"model":"x"},
// {"model":""}, and {"model":null} must all be detected identically.
func requestBodyHasKey(body []byte, key string) (bool, error) {
raw := map[string]json.RawMessage{}
if err := json.Unmarshal(body, &raw); err != nil {
return false, err
}
_, ok := raw[key]
return ok, nil
}
// applyRoutingOverride validates the override blob and writes it onto
@@ -322,7 +379,7 @@ func (h *Handler) updateAgentStatus(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, AgentResponse{Agent: agent})
c.JSON(http.StatusOK, AgentResponse{Agent: newAgentResponseBody(agent)})
}
// -- Instruction handlers --
@@ -151,6 +151,357 @@ func asString(v interface{}) string {
return s
}
// newGetRecorder issues a GET against the agents router and returns the
// recorded response.
func newGetRecorder(t *testing.T, svc *AgentService, path string) *httptest.ResponseRecorder {
t.Helper()
gin.SetMode(gin.TestMode)
r := gin.New()
group := r.Group("/api/v1")
RegisterRoutes(group, svc, logger.Default())
req := httptest.NewRequest(http.MethodGet, path, nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
return rec
}
// decodeAgentMap decodes body's top-level "agent" key into a plain map, so
// tests can assert on key *presence* rather than on a typed zero value —
// AC-13c requires the "model" key to be absent, not merely empty.
func decodeAgentMap(t *testing.T, body []byte) map[string]interface{} {
t.Helper()
var resp map[string]json.RawMessage
if err := json.Unmarshal(body, &resp); err != nil {
t.Fatalf("decode response envelope: %v", err)
}
var agent map[string]interface{}
if err := json.Unmarshal(resp["agent"], &agent); err != nil {
t.Fatalf("decode agent: %v", err)
}
return agent
}
// AC-13/AC-13b/AC-13c: the "model" key is omitted entirely (not merely
// empty) for an Office agent (role != "") across every handler that
// serialises an agentResponseBody.
func TestGetAgent_OmitsModelFieldForOfficeAgent(t *testing.T) {
svc, _ := newTestAgentService(t)
ctx := context.Background()
agent := &models.AgentInstance{WorkspaceID: "ws-1", Name: "Worker", Role: models.AgentRoleWorker}
if err := svc.CreateAgentInstance(ctx, agent); err != nil {
t.Fatalf("create agent: %v", err)
}
rec := newGetRecorder(t, svc, "/api/v1/agents/"+agent.ID)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
got := decodeAgentMap(t, rec.Body.Bytes())
if _, ok := got["model"]; ok {
t.Errorf("model key present for Office agent: %+v", got)
}
}
// AC-13: a legacy execution-profile row (role == "") keeps the "model" key,
// even when the value itself is the empty string. Such rows predate the
// Office identity model and are constructed directly against the
// repository, bypassing CreateAgentInstance's role validation (empty role
// is not a creatable value through the service layer).
func TestGetAgent_IncludesModelFieldForExecutionProfileAgent(t *testing.T) {
svc, repo := newTestAgentService(t)
ctx := context.Background()
agent := &models.AgentInstance{
ID: "legacy-1", WorkspaceID: "ws-1", Name: "Legacy Profile", Model: "gpt-5",
}
if err := repo.CreateAgentInstance(ctx, agent); err != nil {
t.Fatalf("create legacy agent: %v", err)
}
rec := newGetRecorder(t, svc, "/api/v1/agents/"+agent.ID)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
got := decodeAgentMap(t, rec.Body.Bytes())
if v, ok := got["model"]; !ok || v != "gpt-5" {
t.Errorf("model key = %v (present=%v), want present with gpt-5", v, ok)
}
}
// AC-13a: the shadow field on agentResponseBody must be a *string, not a
// plain string. This is the regression the type choice guards against:
// a legacy execution-profile row (role == "") with an EMPTY model value
// must still emit the "model" key (present, empty string) — this only
// passes because the shadow field's omitempty is checked against a nil
// *pointer*, not the empty string it points to. A plain `string` field
// with omitempty would silently drop this key when the value is "",
// which is exactly the regression TestGetAgent_IncludesModelFieldForExecutionProfileAgent's
// non-empty "gpt-5" fixture cannot catch.
func TestGetAgent_IncludesEmptyModelKeyForExecutionProfileAgent(t *testing.T) {
svc, repo := newTestAgentService(t)
ctx := context.Background()
agent := &models.AgentInstance{
ID: "legacy-empty-model", WorkspaceID: "ws-1", Name: "Legacy No Model", Model: "",
}
if err := repo.CreateAgentInstance(ctx, agent); err != nil {
t.Fatalf("create legacy agent: %v", err)
}
rec := newGetRecorder(t, svc, "/api/v1/agents/"+agent.ID)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
got := decodeAgentMap(t, rec.Body.Bytes())
v, ok := got["model"]
if !ok {
t.Fatalf("model key absent for execution-profile agent with empty model: %+v", got)
}
if v != "" {
t.Errorf("model = %v, want empty string", v)
}
}
// AC-13b: the omission also applies to the list endpoint.
func TestListAgents_OmitsModelFieldForOfficeAgents(t *testing.T) {
svc, _ := newTestAgentService(t)
ctx := context.Background()
agent := &models.AgentInstance{WorkspaceID: "ws-1", Name: "Worker", Role: models.AgentRoleWorker}
if err := svc.CreateAgentInstance(ctx, agent); err != nil {
t.Fatalf("create agent: %v", err)
}
rec := newGetRecorder(t, svc, "/api/v1/workspaces/ws-1/agents")
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
var resp map[string]json.RawMessage
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode envelope: %v", err)
}
var list []map[string]interface{}
if err := json.Unmarshal(resp["agents"], &list); err != nil {
t.Fatalf("decode agents: %v", err)
}
if len(list) != 1 {
t.Fatalf("expected 1 agent, got %d", len(list))
}
if _, ok := list[0]["model"]; ok {
t.Errorf("model key present for Office agent in list: %+v", list[0])
}
}
// AC-13b: create returns the same shape as get/list.
func TestCreateAgent_OmitsModelFieldForOfficeAgent(t *testing.T) {
svc, _ := newTestAgentService(t)
gin.SetMode(gin.TestMode)
r := gin.New()
group := r.Group("/api/v1")
RegisterRoutes(group, svc, logger.Default())
body := `{"name":"Worker","role":"worker"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/workspaces/ws-1/agents", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
got := decodeAgentMap(t, rec.Body.Bytes())
if _, ok := got["model"]; ok {
t.Errorf("model key present for created Office agent: %+v", got)
}
}
// AC-13b: status transitions return the same shape too.
func TestUpdateAgentStatus_OmitsModelFieldForOfficeAgent(t *testing.T) {
svc, _ := newTestAgentService(t)
ctx := context.Background()
agent := &models.AgentInstance{WorkspaceID: "ws-1", Name: "Worker", Role: models.AgentRoleWorker}
if err := svc.CreateAgentInstance(ctx, agent); err != nil {
t.Fatalf("create agent: %v", err)
}
gin.SetMode(gin.TestMode)
r := gin.New()
group := r.Group("/api/v1")
RegisterRoutes(group, svc, logger.Default())
body := `{"status":"paused","pause_reason":"testing"}`
req := httptest.NewRequest(http.MethodPatch, "/api/v1/agents/"+agent.ID+"/status", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
got := decodeAgentMap(t, rec.Body.Bytes())
if _, ok := got["model"]; ok {
t.Errorf("model key present for Office agent after status update: %+v", got)
}
}
// AC-13b: a routine field-only PATCH also omits "model" from the response.
func TestUpdateAgent_OmitsModelFieldForOfficeAgent(t *testing.T) {
svc, _ := newTestAgentService(t)
ctx := context.Background()
agent := &models.AgentInstance{WorkspaceID: "ws-1", Name: "Worker", Role: models.AgentRoleWorker}
if err := svc.CreateAgentInstance(ctx, agent); err != nil {
t.Fatalf("create agent: %v", err)
}
rec := newPatchAgentRecorder(t, svc, agent.ID, `{"icon":"robot"}`)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
got := decodeAgentMap(t, rec.Body.Bytes())
if _, ok := got["model"]; ok {
t.Errorf("model key present for Office agent after PATCH: %+v", got)
}
}
// AC-14/AC-14a: PATCH rejects a "model" key for an Office agent with a
// structured 400 naming the field, regardless of the key's value shape —
// a string, an empty string, and a JSON null must all be detected
// identically since requestBodyHasKey only checks key presence.
func TestUpdateAgent_RejectsModelKeyForOfficeAgent(t *testing.T) {
svc, _ := newTestAgentService(t)
ctx := context.Background()
cases := []struct {
name string
body string
}{
{"string value", `{"model":"gpt-5"}`},
{"empty string value", `{"model":""}`},
{"null value", `{"model":null}`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
agent := &models.AgentInstance{WorkspaceID: "ws-1", Name: "Worker " + tc.name, Role: models.AgentRoleWorker}
if err := svc.CreateAgentInstance(ctx, agent); err != nil {
t.Fatalf("create agent: %v", err)
}
rec := newPatchAgentRecorder(t, svc, agent.ID, tc.body)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp["field"] != "model" {
t.Errorf("response field = %v, want model", resp["field"])
}
stored, err := svc.GetAgentFromConfig(ctx, agent.ID)
if err != nil {
t.Fatalf("get agent: %v", err)
}
if stored.Model != "" {
t.Errorf("model persisted despite 400: %q", stored.Model)
}
})
}
}
// TestUpdateAgent_RejectsOversizedBody pins the request-body size cap on
// PATCH /agents/:id: a body larger than maxUpdateAgentBodyBytes must be
// rejected with 413 before JSON decoding runs, not buffered unbounded into
// memory.
func TestUpdateAgent_RejectsOversizedBody(t *testing.T) {
svc, _ := newTestAgentService(t)
ctx := context.Background()
agent := &models.AgentInstance{WorkspaceID: "ws-1", Name: "Worker", Role: models.AgentRoleWorker}
if err := svc.CreateAgentInstance(ctx, agent); err != nil {
t.Fatalf("create agent: %v", err)
}
oversized := `{"name":"` + strings.Repeat("x", maxUpdateAgentBodyBytes+1) + `"}`
rec := newPatchAgentRecorder(t, svc, agent.ID, oversized)
if rec.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("status = %d, want 413; body=%s", rec.Code, rec.Body.String())
}
stored, err := svc.GetAgentFromConfig(ctx, agent.ID)
if err != nil {
t.Fatalf("get agent: %v", err)
}
if stored.Name != "Worker" {
t.Errorf("name changed despite 413: %q", stored.Name)
}
}
// AC-14c (AC-40 test #4): the model-key rejection uses the same structured
// ValidationError shape (field + details) as applyRoutingOverride, not the
// bare {"error": "..."} shape the pre-existing agent_profile_id rejection
// uses below in the same handler — proving these are genuinely distinct
// response contracts, not incidentally similar ones.
func TestUpdateAgent_ModelRejection_UsesStructuredShapeUnlikeAgentProfileIDRejection(t *testing.T) {
svc, _ := newTestAgentService(t)
ctx := context.Background()
modelAgent := &models.AgentInstance{WorkspaceID: "ws-1", Name: "Worker Model", Role: models.AgentRoleWorker}
if err := svc.CreateAgentInstance(ctx, modelAgent); err != nil {
t.Fatalf("create agent: %v", err)
}
profileAgent := &models.AgentInstance{WorkspaceID: "ws-1", Name: "Worker Profile", Role: models.AgentRoleWorker}
if err := svc.CreateAgentInstance(ctx, profileAgent); err != nil {
t.Fatalf("create agent: %v", err)
}
modelRec := newPatchAgentRecorder(t, svc, modelAgent.ID, `{"model":"gpt-5"}`)
profileRec := newPatchAgentRecorder(t, svc, profileAgent.ID, `{"agent_profile_id":"prof-1"}`)
var modelResp, profileResp map[string]interface{}
if err := json.Unmarshal(modelRec.Body.Bytes(), &modelResp); err != nil {
t.Fatalf("decode model response: %v", err)
}
if err := json.Unmarshal(profileRec.Body.Bytes(), &profileResp); err != nil {
t.Fatalf("decode profile response: %v", err)
}
if _, ok := modelResp["field"]; !ok {
t.Errorf("model rejection missing structured field key: %+v", modelResp)
}
if _, ok := profileResp["field"]; ok {
t.Errorf("agent_profile_id rejection unexpectedly carries a field key: %+v", profileResp)
}
}
// AC-14b: the model-key gate is scoped to Office agents (role != "") —
// requestBodyHasKey is only consulted when agent.Role != "". A legacy
// execution-profile row (role == "") never reaches this endpoint
// successfully at all (validateAgentUpdate rejects any empty role,
// independent of this feature), so the gate being scoped correctly is
// what keeps that pre-existing, unrelated 500 as the response instead of
// this feature's 400 "field":"model" shape shadowing it.
func TestUpdateAgent_ModelKeyGateSkipped_ForExecutionProfileAgent(t *testing.T) {
svc, repo := newTestAgentService(t)
ctx := context.Background()
agent := &models.AgentInstance{
ID: "legacy-2", WorkspaceID: "ws-1", Name: "Legacy Profile", Model: "gpt-5",
}
if err := repo.CreateAgentInstance(ctx, agent); err != nil {
t.Fatalf("create legacy agent: %v", err)
}
rec := newPatchAgentRecorder(t, svc, agent.ID, `{"model":"claude-opus"}`)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500 (pre-existing invalid-role rejection); body=%s",
rec.Code, rec.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp["field"] == "model" {
t.Errorf("model-key gate fired for role==\"\" agent, want it scoped to Office agents: %+v", resp)
}
}
// TestAgentAuthMiddleware_RejectsCrossWorkspaceToken ensures a token minted
// for workspace A cannot access endpoints scoped to workspace B via the
// :wsId path parameter. Without this check, an agent in one workspace could
@@ -251,7 +251,7 @@ func TestRouting_PreviewComposesAgentRows(t *testing.T) {
fake.preview = []routing.PreviewItem{
{
AgentID: "a1", AgentName: "Alice",
TierSource: "inherit", EffectiveTier: "balanced",
TierSource: routing.TierSourceWorkspace, EffectiveTier: "balanced",
PrimaryProviderID: "claude-acp", PrimaryModel: "sonnet",
FallbackChain: []routing.PreviewProviderModel{
{ProviderID: "codex-acp", Model: "gpt-5", Tier: "balanced"},
@@ -284,6 +284,87 @@ func TestRouting_PreviewComposesAgentRows(t *testing.T) {
}
}
// AC-18a/AC-20: the preview row's TierSource passes through unmodified,
// including the two new widened values ("role", "workspace") the
// dashboard layer never previously had to carry.
func TestRouting_PreviewRow_WidenedTierSourceValues(t *testing.T) {
cases := []string{
routing.TierSourceOverride,
routing.TierSourceRole,
routing.TierSourceWorkspace,
}
for _, source := range cases {
t.Run(source, func(t *testing.T) {
deps, fake := newRoutingTestDeps(t)
fake.preview = []routing.PreviewItem{
{AgentID: "a1", AgentName: "Alice", TierSource: source, EffectiveTier: "balanced"},
}
req := httptest.NewRequest(http.MethodGet, "/api/v1/office/workspaces/ws-1/routing/preview", nil)
rec := httptest.NewRecorder()
deps.router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String())
}
var resp dashboard.RoutingPreviewResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if len(resp.Agents) != 1 || resp.Agents[0].TierSource != source {
t.Fatalf("TierSource = %+v, want %q", resp.Agents, source)
}
})
}
}
// AC-20/AC-20a: RouteAttemptDTO.TierSource carries the persisted
// precedence level through the /runs/:id/attempts endpoint.
func TestRouting_AttemptsEndpoint_TierSourceRoundTrips(t *testing.T) {
deps, _ := newRoutingTestDeps(t)
lister := &fakeAttemptLister{byRun: map[string][]models.RouteAttempt{
"run-1": {
{RunID: "run-1", Seq: 1, ProviderID: "claude-acp", Model: "haiku",
Tier: "economy", TierSource: "role", Outcome: "launched",
StartedAt: time.Now().UTC()},
},
}}
deps.svc.SetRouteAttemptLister(lister)
req := httptest.NewRequest(http.MethodGet, "/api/v1/office/runs/run-1/attempts", nil)
rec := httptest.NewRecorder()
deps.router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String())
}
var resp dashboard.RouteAttemptsResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if len(resp.Attempts) != 1 || resp.Attempts[0].TierSource != "role" {
t.Fatalf("unexpected attempts: %+v", resp.Attempts)
}
}
// An attempt row with no recorded tier_source (pre-migration or a
// max-attempts-exceeded terminal row) omits the field entirely rather
// than emitting "tier_source":"" — the DTO field carries omitempty.
func TestRouting_AttemptsEndpoint_EmptyTierSourceOmittedFromJSON(t *testing.T) {
deps, _ := newRoutingTestDeps(t)
lister := &fakeAttemptLister{byRun: map[string][]models.RouteAttempt{
"run-1": {
{RunID: "run-1", Seq: 1, Outcome: "skipped_max_attempts", StartedAt: time.Now().UTC()},
},
}}
deps.svc.SetRouteAttemptLister(lister)
req := httptest.NewRequest(http.MethodGet, "/api/v1/office/runs/run-1/attempts", nil)
rec := httptest.NewRecorder()
deps.router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String())
}
if bytes.Contains(rec.Body.Bytes(), []byte(`"tier_source"`)) {
t.Fatalf("expected tier_source to be omitted, got %s", rec.Body.String())
}
}
func TestRouting_HealthEndpointReturnsRows(t *testing.T) {
deps, fake := newRoutingTestDeps(t)
fake.healthRows = []models.ProviderHealth{
@@ -31,8 +31,11 @@ type RoutingHealthResponse struct {
}
// AgentRoutePreview is one row in the workspace routing preview table.
// TierSource is "inherit" when the agent uses the workspace default and
// "override" when its override blob sets a tier explicitly.
// TierSource names the precedence level that supplied EffectiveTier:
// one of "wake_reason", "override", "role", "workspace" (see
// routing.PreviewItem.TierSource / effectiveTier). Always computed
// with an empty run reason, so this never reports "wake_reason" even
// when the agent's own wake-reason policy would shadow it at runtime.
//
// PrimaryProviderID/PrimaryModel reflect the configured intent (first
// entry in the effective provider order). CurrentProviderID/
@@ -72,21 +75,26 @@ type RoutingPreviewResponse struct {
// RouteAttemptDTO mirrors models.RouteAttempt with stable JSON tags for
// the dashboard run-detail UI. Timestamps are formatted as RFC3339.
type RouteAttemptDTO struct {
Seq int `json:"seq"`
ExecutionProfileID string `json:"execution_profile_id,omitempty"`
ProviderID string `json:"provider_id"`
Model string `json:"model,omitempty"`
Tier string `json:"tier"`
Outcome string `json:"outcome"`
ErrorCode string `json:"error_code,omitempty"`
ErrorConfidence string `json:"error_confidence,omitempty"`
AdapterPhase string `json:"adapter_phase,omitempty"`
ClassifierRule string `json:"classifier_rule,omitempty"`
ExitCode *int `json:"exit_code,omitempty"`
RawExcerpt string `json:"raw_excerpt,omitempty"`
ResetHint *string `json:"reset_hint,omitempty"`
StartedAt string `json:"started_at"`
FinishedAt *string `json:"finished_at,omitempty"`
Seq int `json:"seq"`
ExecutionProfileID string `json:"execution_profile_id,omitempty"`
ProviderID string `json:"provider_id"`
Model string `json:"model,omitempty"`
Tier string `json:"tier"`
// TierSource names the precedence level that supplied Tier for this
// attempt: one of "wake_reason", "override", "role", "workspace",
// or "" for attempts recorded before this column existed or that
// never resolved a tier (see models.RouteAttempt.TierSource).
TierSource string `json:"tier_source,omitempty"`
Outcome string `json:"outcome"`
ErrorCode string `json:"error_code,omitempty"`
ErrorConfidence string `json:"error_confidence,omitempty"`
AdapterPhase string `json:"adapter_phase,omitempty"`
ClassifierRule string `json:"classifier_rule,omitempty"`
ExitCode *int `json:"exit_code,omitempty"`
RawExcerpt string `json:"raw_excerpt,omitempty"`
ResetHint *string `json:"reset_hint,omitempty"`
StartedAt string `json:"started_at"`
FinishedAt *string `json:"finished_at,omitempty"`
}
// RunRouting is embedded on the run-detail response so the routing
@@ -130,6 +138,7 @@ func routeAttemptToDTO(a models.RouteAttempt) RouteAttemptDTO {
ProviderID: a.ProviderID,
Model: a.Model,
Tier: a.Tier,
TierSource: a.TierSource,
Outcome: string(a.Outcome),
ErrorCode: a.ErrorCode,
ErrorConfidence: string(a.ErrorConfidence),
+22 -16
View File
@@ -459,22 +459,28 @@ type Run struct {
// Persisted by the routing scheduler dispatcher in
// internal/office/scheduler/dispatch_routing.go.
type RouteAttempt struct {
RunID string `json:"run_id" db:"run_id"`
Seq int `json:"seq" db:"seq"`
ExecutionProfileID string `json:"execution_profile_id" db:"execution_profile_id"`
ProviderID string `json:"provider_id" db:"provider_id"`
Model string `json:"model" db:"model"`
Tier string `json:"tier" db:"tier"`
Outcome RouteAttemptOutcome `json:"outcome" db:"outcome"`
ErrorCode string `json:"error_code,omitempty" db:"error_code"`
ErrorConfidence ErrorConfidence `json:"error_confidence,omitempty" db:"error_confidence"`
AdapterPhase AdapterPhase `json:"adapter_phase,omitempty" db:"adapter_phase"`
ClassifierRule string `json:"classifier_rule,omitempty" db:"classifier_rule"`
ExitCode *int `json:"exit_code,omitempty" db:"exit_code"`
RawExcerpt string `json:"raw_excerpt,omitempty" db:"raw_excerpt"`
ResetHint *time.Time `json:"reset_hint,omitempty" db:"reset_hint"`
StartedAt time.Time `json:"started_at" db:"started_at"`
FinishedAt *time.Time `json:"finished_at,omitempty" db:"finished_at"`
RunID string `json:"run_id" db:"run_id"`
Seq int `json:"seq" db:"seq"`
ExecutionProfileID string `json:"execution_profile_id" db:"execution_profile_id"`
ProviderID string `json:"provider_id" db:"provider_id"`
Model string `json:"model" db:"model"`
Tier string `json:"tier" db:"tier"`
// TierSource names the precedence level that supplied Tier: one of
// "wake_reason", "override", "role", "workspace". Empty means "not
// recorded" — either a pre-migration row (Tier non-empty) or an
// attempt that never resolved a tier (both empty, e.g. a
// max-attempts-exceeded row) — never interpreted as "workspace".
TierSource string `json:"tier_source,omitempty" db:"tier_source"`
Outcome RouteAttemptOutcome `json:"outcome" db:"outcome"`
ErrorCode string `json:"error_code,omitempty" db:"error_code"`
ErrorConfidence ErrorConfidence `json:"error_confidence,omitempty" db:"error_confidence"`
AdapterPhase AdapterPhase `json:"adapter_phase,omitempty" db:"adapter_phase"`
ClassifierRule string `json:"classifier_rule,omitempty" db:"classifier_rule"`
ExitCode *int `json:"exit_code,omitempty" db:"exit_code"`
RawExcerpt string `json:"raw_excerpt,omitempty" db:"raw_excerpt"`
ResetHint *time.Time `json:"reset_hint,omitempty" db:"reset_hint"`
StartedAt time.Time `json:"started_at" db:"started_at"`
FinishedAt *time.Time `json:"finished_at,omitempty" db:"finished_at"`
}
// ProviderHealth records the health state of one (workspace, provider,
@@ -94,6 +94,7 @@ func (r *Repository) migrateProviderRouting() {
provider_order TEXT NOT NULL DEFAULT '[]',
provider_profiles TEXT NOT NULL DEFAULT '{}',
tier_per_reason TEXT NOT NULL DEFAULT '{}',
role_tiers TEXT NOT NULL DEFAULT '{}',
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
)`)
@@ -105,6 +106,7 @@ func (r *Repository) migrateProviderRouting() {
provider_id TEXT NOT NULL,
model TEXT NOT NULL,
tier TEXT NOT NULL,
tier_source TEXT NOT NULL DEFAULT '',
outcome TEXT NOT NULL,
error_code TEXT,
error_confidence TEXT,
@@ -123,6 +125,10 @@ func (r *Repository) migrateProviderRouting() {
`ALTER TABLE runs ADD COLUMN resolved_execution_profile_id TEXT`)
r.migrate.Apply("office_run_route_attempts.execution_profile_id",
`ALTER TABLE office_run_route_attempts ADD COLUMN execution_profile_id TEXT NOT NULL DEFAULT ''`)
r.migrate.Apply("office_workspace_routing.role_tiers",
`ALTER TABLE office_workspace_routing ADD COLUMN role_tiers TEXT NOT NULL DEFAULT '{}'`)
r.migrate.Apply("office_run_route_attempts.tier_source",
`ALTER TABLE office_run_route_attempts ADD COLUMN tier_source TEXT NOT NULL DEFAULT ''`)
_, _ = r.db.Exec(`
CREATE TABLE IF NOT EXISTS office_provider_health (
@@ -0,0 +1,110 @@
package sqlite_test
import (
"context"
"strings"
"testing"
"github.com/kandev/kandev/internal/office/routing"
)
// TestUpsertWorkspaceRouting_RoleTiersRoundTrip pins the JSON round-trip
// for the new role_tiers column, including AC-11's drop-empty-values
// behavior on write.
func TestUpsertWorkspaceRouting_RoleTiersRoundTrip(t *testing.T) {
repo := newTestRepo(t)
ctx := context.Background()
in := &routing.WorkspaceConfig{
Enabled: true,
DefaultTier: routing.TierBalanced,
ProviderOrder: []routing.ProviderID{"claude-acp"},
ProviderProfiles: map[routing.ProviderID]routing.ProviderProfile{
"claude-acp": {TierMap: routing.TierMap{Balanced: "sonnet", Economy: "haiku"}},
},
RoleTiers: routing.RoleTierMap{
"qa": routing.TierEconomy,
"devops": routing.TierEconomy,
"worker": "", // AC-11: dropped before persistence
},
}
if err := repo.UpsertWorkspaceRouting(ctx, "ws-1", in); err != nil {
t.Fatalf("upsert: %v", err)
}
got, err := repo.GetWorkspaceRouting(ctx, "ws-1")
if err != nil {
t.Fatalf("get: %v", err)
}
if got.RoleTiers["qa"] != routing.TierEconomy {
t.Errorf("qa tier = %q, want economy", got.RoleTiers["qa"])
}
if got.RoleTiers["devops"] != routing.TierEconomy {
t.Errorf("devops tier = %q, want economy", got.RoleTiers["devops"])
}
if _, ok := got.RoleTiers["worker"]; ok {
t.Errorf("empty-value worker entry survived persistence: %+v", got.RoleTiers)
}
}
// AC-40 test #1: a literal empty-string role_tiers column (the state a
// pre-migration row lands in via ADD COLUMN ... DEFAULT ”) decodes to an
// empty map with no error. This is the precedent-trap test proving
// decodeRoleTiers's raw=="" special case was necessary — the sibling
// tier_per_reason column only needs COALESCE(..., '{}') because SQL NULL
// is its only "missing" state, but role_tiers can also be a literal ”
// coming from the migration's own DEFAULT clause.
func TestGetWorkspaceRouting_EmptyStringRoleTiersColumn(t *testing.T) {
repo, _ := newTestRepoWithDB(t)
ctx := context.Background()
if _, err := repo.ExecRaw(ctx, `
INSERT INTO office_workspace_routing
(workspace_id, enabled, default_tier, provider_order, provider_profiles, role_tiers, updated_at)
VALUES (?, 1, 'balanced', '[]', '{}', '', datetime('now'))
`, "ws-empty-roles"); err != nil {
t.Fatalf("insert row with literal empty role_tiers: %v", err)
}
cfg, err := repo.GetWorkspaceRouting(ctx, "ws-empty-roles")
if err != nil {
t.Fatalf("GetWorkspaceRouting: %v", err)
}
if len(cfg.RoleTiers) != 0 {
t.Errorf("RoleTiers = %+v, want empty map", cfg.RoleTiers)
}
}
// AC-33: malformed (non-empty, non-JSON) role_tiers content is rejected
// rather than silently swallowed.
func TestGetWorkspaceRouting_MalformedRoleTiersColumn(t *testing.T) {
repo, _ := newTestRepoWithDB(t)
ctx := context.Background()
if _, err := repo.ExecRaw(ctx, `
INSERT INTO office_workspace_routing
(workspace_id, enabled, default_tier, provider_order, provider_profiles, role_tiers, updated_at)
VALUES (?, 1, 'balanced', '[]', '{}', 'not-json', datetime('now'))
`, "ws-bad-roles"); err != nil {
t.Fatalf("insert row with malformed role_tiers: %v", err)
}
_, err := repo.GetWorkspaceRouting(ctx, "ws-bad-roles")
if err == nil {
t.Fatal("expected error decoding malformed role_tiers")
}
if !strings.Contains(err.Error(), "role_tiers") {
t.Errorf("error should name role_tiers, got %v", err)
}
}
// defaultWorkspaceRouting (the never-configured-workspace path) also
// carries a non-nil, empty RoleTiers map.
func TestGetWorkspaceRouting_DefaultOnEmpty_RoleTiers(t *testing.T) {
repo := newTestRepo(t)
ctx := context.Background()
cfg, err := repo.GetWorkspaceRouting(ctx, "ws-never-configured")
if err != nil {
t.Fatalf("GetWorkspaceRouting: %v", err)
}
if len(cfg.RoleTiers) != 0 {
t.Errorf("RoleTiers = %+v, want empty map", cfg.RoleTiers)
}
}
@@ -22,11 +22,11 @@ func (r *Repository) AppendRouteAttempt(
}
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
INSERT INTO office_run_route_attempts (
run_id, seq, execution_profile_id, provider_id, model, tier, outcome,
run_id, seq, execution_profile_id, provider_id, model, tier, tier_source, outcome,
error_code, error_confidence, adapter_phase, classifier_rule,
exit_code, raw_excerpt, reset_hint, started_at, finished_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`), a.RunID, a.Seq, a.ExecutionProfileID, a.ProviderID, a.Model, a.Tier, a.Outcome,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`), a.RunID, a.Seq, a.ExecutionProfileID, a.ProviderID, a.Model, a.Tier, a.TierSource, a.Outcome,
nullableString(a.ErrorCode), nullableString(string(a.ErrorConfidence)),
nullableString(string(a.AdapterPhase)), nullableString(a.ClassifierRule),
a.ExitCode, nullableString(a.RawExcerpt), a.ResetHint,
@@ -45,7 +45,7 @@ func (r *Repository) ListRouteAttempts(
ctx context.Context, runID string,
) ([]models.RouteAttempt, error) {
rows, err := r.ro.QueryxContext(ctx, r.ro.Rebind(`
SELECT run_id, seq, execution_profile_id, provider_id, model, tier, outcome,
SELECT run_id, seq, execution_profile_id, provider_id, model, tier, tier_source, outcome,
COALESCE(error_code,'') AS error_code,
COALESCE(error_confidence,'') AS error_confidence,
COALESCE(adapter_phase,'') AS adapter_phase,
@@ -102,6 +102,47 @@ func TestAppendAndListRouteAttempts_RoundTrip(t *testing.T) {
}
}
// TestAppendAndListRouteAttempts_TierSourceRoundTrip pins the JSON-free
// round-trip for the new tier_source column, including the empty-string
// default (a max-attempts-exceeded row, or any attempt appended before
// this column existed, never resolved a tier).
func TestAppendAndListRouteAttempts_TierSourceRoundTrip(t *testing.T) {
repo := newTestRepo(t)
ctx := context.Background()
now := time.Now().UTC().Truncate(time.Second)
seedRun(t, repo, "run-1", "agent-1", now)
withSource := &models.RouteAttempt{
RunID: "run-1", Seq: 1, ProviderID: "claude-acp", Model: "haiku",
Tier: "economy", TierSource: "role", Outcome: "launched", StartedAt: now,
}
if err := repo.AppendRouteAttempt(ctx, withSource); err != nil {
t.Fatalf("append with tier_source: %v", err)
}
withoutSource := &models.RouteAttempt{
RunID: "run-1", Seq: 2, ProviderID: "claude-acp", Model: "sonnet",
Tier: "balanced", Outcome: "launched", StartedAt: now.Add(time.Minute),
}
if err := repo.AppendRouteAttempt(ctx, withoutSource); err != nil {
t.Fatalf("append without tier_source: %v", err)
}
attempts, err := repo.ListRouteAttempts(ctx, "run-1")
if err != nil {
t.Fatalf("list: %v", err)
}
if len(attempts) != 2 {
t.Fatalf("expected 2 attempts, got %d", len(attempts))
}
if attempts[0].TierSource != "role" {
t.Errorf("attempts[0].TierSource = %q, want role", attempts[0].TierSource)
}
if attempts[1].TierSource != "" {
t.Errorf("attempts[1].TierSource = %q, want empty", attempts[1].TierSource)
}
}
func TestListRouteAttempts_EmptyReturnsEmptySlice(t *testing.T) {
repo := newTestRepo(t)
ctx := context.Background()
@@ -33,14 +33,15 @@ func (r *Repository) GetWorkspaceRouting(
orderRaw string
profsRaw string
reasonsRaw string
rolesRaw string
updatedAt time.Time
)
err := r.ro.QueryRowxContext(ctx, r.ro.Rebind(`
SELECT enabled, default_tier, provider_order, provider_profiles,
COALESCE(tier_per_reason, '{}'), updated_at
COALESCE(tier_per_reason, '{}'), COALESCE(role_tiers, '{}'), updated_at
FROM office_workspace_routing
WHERE workspace_id = ?
`), workspaceID).Scan(&enabled, &tier, &orderRaw, &profsRaw, &reasonsRaw, &updatedAt)
`), workspaceID).Scan(&enabled, &tier, &orderRaw, &profsRaw, &reasonsRaw, &rolesRaw, &updatedAt)
if errors.Is(err, sql.ErrNoRows) {
return defaultWorkspaceRouting(), nil
}
@@ -60,12 +61,31 @@ func (r *Repository) GetWorkspaceRouting(
if err := json.Unmarshal([]byte(reasonsRaw), &cfg.TierPerReason); err != nil {
return nil, fmt.Errorf("routing: decode tier_per_reason: %w", err)
}
if err := decodeRoleTiers(rolesRaw, &cfg.RoleTiers); err != nil {
return nil, fmt.Errorf("routing: decode role_tiers: %w", err)
}
if cfg.ProviderProfiles == nil {
cfg.ProviderProfiles = map[routing.ProviderID]routing.ProviderProfile{}
}
return cfg, nil
}
// decodeRoleTiers decodes raw into *out, treating a literal empty
// string as "{}" (no role policy) before unmarshalling (AC-32). The
// adjacent tier_per_reason decode above does not need this: it reads
// COALESCE(tier_per_reason, '{}'), which only substitutes for SQL
// NULL, so a literal ” falls through to json.Unmarshal and errors —
// which happens to be correct there (AC-33 malformed-JSON rejection),
// but AC-32 explicitly names ” as a valid role_tiers value, so
// copying the sibling's COALESCE-only pattern would wrongly reject it.
func decodeRoleTiers(raw string, out *routing.RoleTierMap) error {
if raw == "" {
*out = routing.RoleTierMap{}
return nil
}
return json.Unmarshal([]byte(raw), out)
}
// UpsertWorkspaceRouting persists cfg for workspaceID, replacing any
// previously stored row. JSON marshaling errors are surfaced before
// touching the DB so a malformed config can never leave a half-written
@@ -88,6 +108,10 @@ func (r *Repository) UpsertWorkspaceRouting(
if err != nil {
return err
}
rolesRaw, err := marshalRoleTiers(cfg.RoleTiers)
if err != nil {
return err
}
enabled := 0
if cfg.Enabled {
enabled = 1
@@ -98,16 +122,17 @@ func (r *Repository) UpsertWorkspaceRouting(
}
_, err = r.db.ExecContext(ctx, r.db.Rebind(`
INSERT INTO office_workspace_routing
(workspace_id, enabled, default_tier, provider_order, provider_profiles, tier_per_reason, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
(workspace_id, enabled, default_tier, provider_order, provider_profiles, tier_per_reason, role_tiers, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(workspace_id) DO UPDATE SET
enabled = excluded.enabled,
default_tier = excluded.default_tier,
provider_order = excluded.provider_order,
provider_profiles = excluded.provider_profiles,
tier_per_reason = excluded.tier_per_reason,
role_tiers = excluded.role_tiers,
updated_at = excluded.updated_at
`), workspaceID, enabled, tier, orderRaw, profsRaw, reasonsRaw, time.Now().UTC())
`), workspaceID, enabled, tier, orderRaw, profsRaw, reasonsRaw, rolesRaw, time.Now().UTC())
if err != nil {
return fmt.Errorf("routing: upsert workspace %q: %w", workspaceID, err)
}
@@ -123,6 +148,7 @@ func defaultWorkspaceRouting() *routing.WorkspaceConfig {
ProviderOrder: []routing.ProviderID{},
ProviderProfiles: map[routing.ProviderID]routing.ProviderProfile{},
TierPerReason: routing.TierPerReason{},
RoleTiers: routing.RoleTierMap{},
}
}
@@ -215,6 +241,26 @@ func marshalTierPerReason(m routing.TierPerReason) (string, error) {
return string(b), nil
}
// marshalRoleTiers marshals the role tier policy, normalising nil to
// "{}" and dropping empty-string values (AC-11: an empty value means
// "absent, fall through to default_tier", not "explicitly set to a
// blank tier" — dropping it here keeps GetWorkspaceRouting's read path
// from ever having to special-case a persisted empty-string entry).
func marshalRoleTiers(m routing.RoleTierMap) (string, error) {
out := make(routing.RoleTierMap, len(m))
for role, tier := range m {
if tier == "" {
continue
}
out[role] = tier
}
b, err := json.Marshal(out)
if err != nil {
return "", fmt.Errorf("routing: marshal role_tiers: %w", err)
}
return string(b), nil
}
// marshalOrder marshals the provider order, normalising nil to "[]"
// so the column's NOT NULL default invariant is preserved.
func marshalOrder(order []routing.ProviderID) (string, error) {
@@ -22,8 +22,15 @@ import (
// eligible candidate); when not degraded these equal the primary.
// When every provider is skipped, both Current fields are empty.
type PreviewItem struct {
AgentID string
AgentName string
AgentID string
AgentName string
// TierSource names the precedence level supplying EffectiveTier:
// one of TierSourceWakeReason, TierSourceOverride, TierSourceRole,
// TierSourceWorkspace (see effectiveTier). Always computed with an
// empty reason (AC-18b), so this never reports TierSourceWakeReason
// even when the agent's own wake-reason policy would shadow it at
// runtime — the frontend surfaces that shadowing separately from
// the agent's TierPerReason data, not from this field.
TierSource string
EffectiveTier string
PrimaryProviderID string
@@ -166,14 +173,15 @@ func (p *Provider) ListExecutionProfiles(
// - enabled→disabled: parked runs would otherwise sit forever waiting
// on a provider that no longer matters.
// - enabled→enabled with a material change (provider order, default
// tier, or provider profiles): a user fixing a missing tier mapping
// should unblock blocked_provider_action_required runs immediately;
// without this, those parks persist until the user manually retries.
// tier, role tiers, or provider profiles): a user fixing a missing
// tier mapping should unblock blocked_provider_action_required runs
// immediately; without this, those parks persist until the user
// manually retries.
//
// "Material" is defined coarsely — any change to ProviderOrder,
// DefaultTier, or ProviderProfiles triggers a clear. False positives
// (clearing when the change couldn't affect any block reason) are
// harmless because runs simply re-dispatch and re-park with the
// DefaultTier, RoleTiers, or ProviderProfiles triggers a clear. False
// positives (clearing when the change couldn't affect any block reason)
// are harmless because runs simply re-dispatch and re-park with the
// latest verdict.
func (p *Provider) UpdateConfig(
ctx context.Context, workspaceID string, cfg WorkspaceConfig,
@@ -396,8 +404,8 @@ func shouldClearParked(prev *WorkspaceConfig, next WorkspaceConfig) bool {
// routingConfigEqual reports whether two enabled configs are
// behaviorally identical for routing decisions. Compares provider
// order, default tier, and per-provider profile maps. The Enabled
// field is intentionally not compared — callers check that.
// order, default tier, per-provider profile maps, and role tiers. The
// Enabled field is intentionally not compared — callers check that.
func routingConfigEqual(a, b WorkspaceConfig) bool {
if a.DefaultTier != b.DefaultTier {
return false
@@ -405,9 +413,24 @@ func routingConfigEqual(a, b WorkspaceConfig) bool {
if !providerOrderEqual(a.ProviderOrder, b.ProviderOrder) {
return false
}
if !roleTiersEqual(a.RoleTiers, b.RoleTiers) {
return false
}
return providerProfilesEqual(a.ProviderProfiles, b.ProviderProfiles)
}
func roleTiersEqual(a, b RoleTierMap) bool {
if len(a) != len(b) {
return false
}
for k, va := range a {
if vb, ok := b[k]; !ok || va != vb {
return false
}
}
return true
}
func providerOrderEqual(a, b []ProviderID) bool {
if len(a) != len(b) {
return false
@@ -567,11 +590,11 @@ func (p *Provider) previewForAgent(
ctx context.Context, workspaceID string,
agent models.AgentInstance, cfg *WorkspaceConfig,
) (PreviewItem, error) {
tierSource := tierSourceForAgent(agent, cfg)
res, err := p.resolver.Resolve(ctx, workspaceID, agent, ResolveOptions{})
if err != nil {
return PreviewItem{}, fmt.Errorf("routing: resolve %s: %w", agent.ID, err)
}
tierSource := previewTierSource(res, agent, cfg)
primaryProvider, primaryProfile, primaryModel := primaryProviderModel(res, cfg)
return PreviewItem{
AgentID: agent.ID,
@@ -607,17 +630,43 @@ func primaryProviderModel(res *Resolution, cfg *WorkspaceConfig) (string, string
return string(first), prof.ExecutionProfileID(res.RequestedTier), prof.TierMap.Model(res.RequestedTier)
}
// tierSourceForAgent returns "override" when the agent's settings flip
// TierSource explicitly; "inherit" otherwise.
func tierSourceForAgent(agent models.AgentInstance, _ *WorkspaceConfig) string {
ov, err := ReadAgentOverrides(agent.Settings)
if err != nil {
return "inherit"
// tierSourceForAgent delegates to effectiveTier — the resolve path's
// producer — rather than deciding independently, so preview and resolve
// never diverge on which precedence level supplied the tier (AC-20d).
// Always called with an empty reason (AC-18b): a preview never carries
// run context, so this can never report TierSourceWakeReason. A parse
// error on the agent's settings blob falls back to zero-value overrides
// rather than a fixed string, so the result still lands on one of the
// four widened values instead of the old error-only "inherit" literal.
func tierSourceForAgent(agent models.AgentInstance, cfg *WorkspaceConfig) string {
ov, _ := ReadAgentOverrides(agent.Settings)
if cfg == nil {
cfg = &WorkspaceConfig{}
}
if ov.TierSource == TierSourceOverride && ov.Tier != "" {
return "override"
_, source := effectiveTier(cfg, ov, string(agent.Role), "")
return source
}
// previewTierSource returns the source PreviewItem.TierSource must carry.
// AC-20d requires preview and resolve to share one producer AND never
// disagree — sharing the effectiveTier function alone is not enough,
// because previewForAgent's cfg snapshot and Resolve's own internal
// GetWorkspaceRouting read are two independent reads of the same row: a
// workspace routing write landing between them would otherwise let
// tierSourceForAgent(agent, cfg) answer from a config Resolve never saw.
// res.TierSource is therefore the primary source — it is exactly what
// Resolve computed against the config it actually used. The one
// exception is Resolve's disabled-empty-config early return
// (resolver.go, cfg == nil || (!cfg.Enabled && len(cfg.ProviderOrder) ==
// 0)), which returns a Resolution that never calls effectiveTier and so
// carries an empty TierSource; previewForAgent's own contract still
// requires a non-blank preview row in that case, so it falls back to the
// snapshot computation only then.
func previewTierSource(res *Resolution, agent models.AgentInstance, cfg *WorkspaceConfig) string {
if res != nil && res.TierSource != "" {
return res.TierSource
}
return "inherit"
return tierSourceForAgent(agent, cfg)
}
// effectivePreviewTier returns the tier the resolver consumed, falling
@@ -19,9 +19,22 @@ type fakeProviderRepo struct {
byID map[string]*models.AgentInstance
clearedParkedFor []string // workspace ids passed to ClearAllParked
clearedParkedErr error
// cfgSeq, when non-nil, makes GetWorkspaceRouting return a different
// config on each successive call (advancing cfgSeqIdx), simulating a
// workspace routing row changing between two reads within a single
// logical operation (AC-20d race coverage). Falls back to cfg once
// the sequence is exhausted.
cfgSeq []*WorkspaceConfig
cfgSeqIdx int
}
func (f *fakeProviderRepo) GetWorkspaceRouting(_ context.Context, _ string) (*WorkspaceConfig, error) {
if f.cfgSeqIdx < len(f.cfgSeq) {
cfg := f.cfgSeq[f.cfgSeqIdx]
f.cfgSeqIdx++
return cfg, nil
}
return f.cfg, nil
}
@@ -414,6 +427,61 @@ func TestProvider_PreviewAgentRoundTrips(t *testing.T) {
}
}
// AC-20d: PreviewItem.TierSource must equal what Resolve actually used,
// never a value computed from a separately-read, potentially stale copy
// of the workspace config. previewForAgent reads the workspace config
// once for tierSourceForAgent and Resolve reads it again internally;
// this simulates a routing config write landing between those two reads
// and asserts the preview reports the same source the live resolve did,
// not the stale snapshot's answer.
func TestProvider_PreviewAgentTierSourceMatchesResolveDespiteConfigRaceBetweenReads(t *testing.T) {
profiles := map[ProviderID]ProviderProfile{
"claude-acp": {
ExecutionProfileIDs: ExecutionProfileIDs{Frontier: "claude-opus", Balanced: "claude-sonnet", Economy: "claude-haiku"},
TierMap: TierMap{Frontier: "opus", Balanced: "sonnet", Economy: "haiku"},
},
}
stale := &WorkspaceConfig{
Enabled: true,
DefaultTier: TierBalanced,
ProviderOrder: []ProviderID{"claude-acp"},
ProviderProfiles: profiles,
RoleTiers: RoleTierMap{"qa": TierFrontier},
}
fresh := &WorkspaceConfig{
Enabled: true,
DefaultTier: TierBalanced,
ProviderOrder: []ProviderID{"claude-acp"},
ProviderProfiles: profiles,
RoleTiers: RoleTierMap{}, // role tier removed between the two reads
}
agent := &models.AgentInstance{ID: "a1", Name: "Alice", WorkspaceID: "ws-1", Role: settingsmodels.AgentRoleQA}
repo := &fakeProviderRepo{
cfgSeq: []*WorkspaceConfig{stale, fresh},
agents: []*models.AgentInstance{agent},
byID: map[string]*models.AgentInstance{"a1": agent},
}
resolver := NewResolver(&providerResolverAdapter{repo: repo}, nil)
p := NewProvider(repo, nil, resolver, &fakeRetry{})
item, err := p.PreviewAgent(context.Background(), "a1")
if err != nil {
t.Fatalf("PreviewAgent: %v", err)
}
if item == nil {
t.Fatal("PreviewAgent returned nil item")
return
}
if item.TierSource != TierSourceWorkspace {
t.Errorf("TierSource = %q, want %q — must match what Resolve actually computed against"+
" the fresh config, not the stale role_tiers entry read earlier",
item.TierSource, TierSourceWorkspace)
}
if item.EffectiveTier != string(TierBalanced) {
t.Errorf("EffectiveTier = %q, want %q", item.EffectiveTier, TierBalanced)
}
}
func TestProvider_UpdateRejectsInvalid(t *testing.T) {
cfg := &WorkspaceConfig{}
p, _ := newProviderTest(cfg, nil)
@@ -525,6 +593,37 @@ func TestProvider_UpdateClearsParkedWhenExecutionProfileChanges(t *testing.T) {
}
}
func TestProvider_UpdateClearsParkedOnRoleTiersOnlyChange(t *testing.T) {
prev := &WorkspaceConfig{
Enabled: true,
DefaultTier: TierBalanced,
ProviderOrder: []ProviderID{"claude-acp"},
ProviderProfiles: map[ProviderID]ProviderProfile{
"claude-acp": {
TierMap: TierMap{Frontier: "opus", Balanced: "sonnet"},
ExecutionProfileIDs: ExecutionProfileIDs{
Frontier: "claude-opus", Balanced: "claude-sonnet",
},
},
},
RoleTiers: RoleTierMap{"qa": TierFrontier},
}
p, repo := newProviderTest(prev, nil)
// Only RoleTiers changes; DefaultTier, ProviderOrder, and
// ProviderProfiles stay identical. A run parked because the "qa"
// role resolved to frontier with no usable candidate should unblock
// once that role is remapped to balanced.
next := *prev
next.RoleTiers = RoleTierMap{"qa": TierBalanced}
if err := p.UpdateConfig(context.Background(), "ws-1", next); err != nil {
t.Fatalf("update config: %v", err)
}
if len(repo.clearedParkedFor) != 1 || repo.clearedParkedFor[0] != "ws-1" {
t.Fatalf("role-tiers-only change did not clear parked runs: %v", repo.clearedParkedFor)
}
}
func TestProvider_UpdateDoesNotClearWhenConfigUnchanged(t *testing.T) {
prev := &WorkspaceConfig{
Enabled: true,
@@ -124,8 +124,14 @@ type BlockReason struct {
// fall through to the existing concrete-profile launch path and ignore
// the other fields.
type Resolution struct {
Enabled bool
RequestedTier Tier
Enabled bool
RequestedTier Tier
// TierSource names the precedence level that supplied
// RequestedTier: one of TierSourceWakeReason, TierSourceOverride,
// TierSourceRole, TierSourceWorkspace, or "" (only when
// RequestedTier is also "", see effectiveTier). This is the sole
// carrier of the resolved source — it is not recomputed downstream.
TierSource string
ProviderOrder []ProviderID
Candidates []Candidate
SkippedDegraded []SkippedCandidate
@@ -174,12 +180,17 @@ func (r *Resolver) Resolve(
if err != nil {
return nil, fmt.Errorf("routing: load agent overrides: %w", err)
}
tier := effectiveTier(cfg, ov, opts.Reason)
tier, tierSource := effectiveTier(cfg, ov, string(agent.Role), opts.Reason)
order := effectiveOrder(cfg, ov)
if len(order) == 0 {
return nil, ErrEmptyOrder
}
res := &Resolution{Enabled: cfg.Enabled, RequestedTier: tier, ProviderOrder: order}
res := &Resolution{
Enabled: cfg.Enabled,
RequestedTier: tier,
TierSource: tierSource,
ProviderOrder: order,
}
idx, err := r.loadHealthIndex(ctx, workspaceID)
if err != nil {
return nil, err
@@ -414,21 +425,47 @@ func aggregateBlock(skipped []SkippedCandidate) BlockReason {
return br
}
// effectiveTier resolves the tier for one run.
// effectiveTier resolves the tier for one run and the precedence level
// that supplied it (see Resolution.TierSource and PreviewItem.TierSource).
// Order: 1) wake-reason policy (agent override > workspace policy),
// 2) agent tier override, 3) workspace default. The reason argument
// may be empty when the caller has no run context — in that case the
// wake-reason step is skipped.
func effectiveTier(cfg *WorkspaceConfig, ov AgentOverrides, reason string) Tier {
// 2) agent tier override, 3) per-role tier (role's entry in
// cfg.RoleTiers), 4) workspace default. The reason argument may be
// empty when the caller has no run context — in that case the
// wake-reason step is skipped (this is how a preview computation,
// which never carries a reason, is guaranteed to never report
// TierSourceWakeReason — see AC-18b). role is the agent's role string;
// an empty role, or a role absent from (or empty-valued in)
// cfg.RoleTiers, simply never matches the map lookup and falls through.
// A non-empty role outside the seven-value enum is explicitly gated by
// agentRoleSet() (AC-34): role_tiers may persist a matching key for such
// a role (AC-34b permits it on the read path), so the lookup itself
// would otherwise match — the enum check exists specifically to stop
// that from applying. When cfg.DefaultTier is itself empty, the function
// returns ("", "") rather than (DefaultTier, TierSourceWorkspace): an
// empty source must never be interpreted as "workspace" (AC-20c). This
// cannot happen in practice (validateTier rejects an empty default_tier
// and the column default is non-empty), but the fallthrough is pinned
// defensively anyway.
func effectiveTier(cfg *WorkspaceConfig, ov AgentOverrides, role string, reason string) (Tier, string) {
if reason != "" {
if t := wakeReasonTier(cfg, ov, reason); t != "" {
return t
return t, TierSourceWakeReason
}
}
if ov.TierSource == TierSourceOverride && ov.Tier != "" {
return ov.Tier
return ov.Tier, TierSourceOverride
}
return cfg.DefaultTier
if role != "" {
if _, known := agentRoleSet()[role]; known {
if t, ok := cfg.RoleTiers[role]; ok && t != "" {
return t, TierSourceRole
}
}
}
if cfg.DefaultTier == "" {
return "", ""
}
return cfg.DefaultTier, TierSourceWorkspace
}
// wakeReasonTier returns the tier the wake-reason policy assigns for
@@ -0,0 +1,234 @@
package routing_test
import (
"context"
"testing"
settingsmodels "github.com/kandev/kandev/internal/agent/settings/models"
"github.com/kandev/kandev/internal/office/routing"
)
// agentWithRole builds a resolver-ready agent profile carrying role and an
// optional overrides blob, mirroring agentWithOverrides but adding the Role
// field the widened effectiveTier precedence needs.
func agentWithRole(t *testing.T, role settingsmodels.AgentRole, ov routing.AgentOverrides) settingsmodels.AgentProfile {
t.Helper()
agent := agentWithOverrides(t, ov)
agent.Role = role
return agent
}
// AC-3/AC-7 style: with no wake reason, no per-agent override, and no
// matching role entry, the workspace default_tier supplies the tier and
// TierSource reports "workspace".
func TestResolve_TierSource_WorkspaceDefault_NoRoleEntry(t *testing.T) {
cfg := twoProviderCfg()
repo := &fakeRepo{cfg: cfg}
r := newResolver(t, repo)
res, err := r.Resolve(context.Background(), wsID,
agentWithRole(t, settingsmodels.AgentRoleQA, routing.AgentOverrides{}),
routing.ResolveOptions{})
if err != nil {
t.Fatalf("resolve: %v", err)
}
if res.RequestedTier != cfg.DefaultTier {
t.Errorf("RequestedTier = %q, want workspace default %q", res.RequestedTier, cfg.DefaultTier)
}
if res.TierSource != routing.TierSourceWorkspace {
t.Errorf("TierSource = %q, want %q", res.TierSource, routing.TierSourceWorkspace)
}
}
// AC-11 fallthrough at the resolve layer: a role_tiers entry present but
// set to "" behaves identically to the role being entirely absent from
// the map — falls through to the workspace default.
func TestResolve_TierSource_EmptyRoleEntryFallsThroughToWorkspace(t *testing.T) {
cfg := twoProviderCfg()
cfg.RoleTiers = routing.RoleTierMap{"qa": ""}
repo := &fakeRepo{cfg: cfg}
r := newResolver(t, repo)
res, err := r.Resolve(context.Background(), wsID,
agentWithRole(t, settingsmodels.AgentRoleQA, routing.AgentOverrides{}),
routing.ResolveOptions{})
if err != nil {
t.Fatalf("resolve: %v", err)
}
if res.RequestedTier != cfg.DefaultTier || res.TierSource != routing.TierSourceWorkspace {
t.Errorf("got (%q,%q), want (%q,%q)",
res.RequestedTier, res.TierSource, cfg.DefaultTier, routing.TierSourceWorkspace)
}
}
// The new level 3: a role_tiers entry matching the agent's role supplies
// the tier, below any per-agent override and above the workspace default.
func TestResolve_TierSource_RoleTierApplies(t *testing.T) {
cfg := twoProviderCfg()
cfg.RoleTiers = routing.RoleTierMap{"qa": routing.TierEconomy}
repo := &fakeRepo{cfg: cfg}
r := newResolver(t, repo)
res, err := r.Resolve(context.Background(), wsID,
agentWithRole(t, settingsmodels.AgentRoleQA, routing.AgentOverrides{}),
routing.ResolveOptions{})
if err != nil {
t.Fatalf("resolve: %v", err)
}
if res.RequestedTier != routing.TierEconomy {
t.Errorf("RequestedTier = %q, want economy", res.RequestedTier)
}
if res.TierSource != routing.TierSourceRole {
t.Errorf("TierSource = %q, want %q", res.TierSource, routing.TierSourceRole)
}
}
// A role_tiers entry for a different role than the agent's own must not
// apply — it falls through to the workspace default exactly as if
// role_tiers were empty.
func TestResolve_TierSource_RoleTierForDifferentRoleDoesNotApply(t *testing.T) {
cfg := twoProviderCfg()
cfg.RoleTiers = routing.RoleTierMap{"devops": routing.TierEconomy}
repo := &fakeRepo{cfg: cfg}
r := newResolver(t, repo)
res, err := r.Resolve(context.Background(), wsID,
agentWithRole(t, settingsmodels.AgentRoleQA, routing.AgentOverrides{}),
routing.ResolveOptions{})
if err != nil {
t.Fatalf("resolve: %v", err)
}
if res.RequestedTier != cfg.DefaultTier || res.TierSource != routing.TierSourceWorkspace {
t.Errorf("got (%q,%q), want workspace default", res.RequestedTier, res.TierSource)
}
}
// Level 2 beats level 3: a per-agent tier override outranks the agent's
// role tier.
func TestResolve_TierSource_AgentOverrideBeatsRoleTier(t *testing.T) {
cfg := twoProviderCfg()
cfg.RoleTiers = routing.RoleTierMap{"qa": routing.TierEconomy}
repo := &fakeRepo{cfg: cfg}
r := newResolver(t, repo)
ov := routing.AgentOverrides{TierSource: routing.TierSourceOverride, Tier: routing.TierFrontier}
res, err := r.Resolve(context.Background(), wsID,
agentWithRole(t, settingsmodels.AgentRoleQA, ov),
routing.ResolveOptions{})
if err != nil {
t.Fatalf("resolve: %v", err)
}
if res.RequestedTier != routing.TierFrontier {
t.Errorf("RequestedTier = %q, want frontier (agent override)", res.RequestedTier)
}
if res.TierSource != routing.TierSourceOverride {
t.Errorf("TierSource = %q, want %q", res.TierSource, routing.TierSourceOverride)
}
}
// Level 1 beats everything: a matching wake-reason policy outranks both
// the per-agent override and the role tier.
func TestResolve_TierSource_WakeReasonBeatsOverrideAndRoleTier(t *testing.T) {
cfg := twoProviderCfg()
cfg.TierPerReason = routing.TierPerReason{routing.WakeReasonHeartbeat: routing.TierEconomy}
cfg.RoleTiers = routing.RoleTierMap{"qa": routing.TierFrontier}
repo := &fakeRepo{cfg: cfg}
r := newResolver(t, repo)
ov := routing.AgentOverrides{TierSource: routing.TierSourceOverride, Tier: routing.TierBalanced}
res, err := r.Resolve(context.Background(), wsID,
agentWithRole(t, settingsmodels.AgentRoleQA, ov),
routing.ResolveOptions{Reason: routing.WakeReasonHeartbeat})
if err != nil {
t.Fatalf("resolve: %v", err)
}
if res.RequestedTier != routing.TierEconomy {
t.Errorf("RequestedTier = %q, want economy (wake reason)", res.RequestedTier)
}
if res.TierSource != routing.TierSourceWakeReason {
t.Errorf("TierSource = %q, want %q", res.TierSource, routing.TierSourceWakeReason)
}
}
// AC-34: an agent whose role is outside the seven-value enum (a row
// written by an older build or by hand) must never consult role_tiers,
// even when the persisted map happens to hold a matching non-enum key
// (AC-34b permits such a key to exist on the read path). Resolution
// falls through to the workspace default exactly as if role_tiers were
// empty for this agent.
func TestResolve_TierSource_NonEnumRoleIgnoresMatchingRoleTiersKey(t *testing.T) {
cfg := twoProviderCfg()
cfg.RoleTiers = routing.RoleTierMap{"legacy-role": routing.TierFrontier}
repo := &fakeRepo{cfg: cfg}
r := newResolver(t, repo)
res, err := r.Resolve(context.Background(), wsID,
agentWithRole(t, settingsmodels.AgentRole("legacy-role"), routing.AgentOverrides{}),
routing.ResolveOptions{})
if err != nil {
t.Fatalf("resolve: %v", err)
}
if res.RequestedTier != cfg.DefaultTier {
t.Errorf("RequestedTier = %q, want workspace default %q (non-enum role must not match role_tiers)",
res.RequestedTier, cfg.DefaultTier)
}
if res.TierSource != routing.TierSourceWorkspace {
t.Errorf("TierSource = %q, want %q", res.TierSource, routing.TierSourceWorkspace)
}
}
// AC-6: an agent whose role is the empty string must never consult
// role_tiers at all, regardless of the map's contents. This is the
// strongest form of the guard: role_tiers holds an entry keyed by the
// empty string (unreachable via the write path, since AC-8 restricts
// keys to the seven-value enum, but a resolver read-path lookup does not
// itself know that) to prove the `role != ""` gate in effectiveTier, not
// merely the absence of an empty key in a realistic fixture.
func TestResolve_TierSource_EmptyRoleNeverConsultsRoleTiers(t *testing.T) {
cfg := twoProviderCfg()
cfg.RoleTiers = routing.RoleTierMap{"": routing.TierFrontier}
repo := &fakeRepo{cfg: cfg}
r := newResolver(t, repo)
res, err := r.Resolve(context.Background(), wsID,
agentWithRole(t, settingsmodels.AgentRole(""), routing.AgentOverrides{}),
routing.ResolveOptions{})
if err != nil {
t.Fatalf("resolve: %v", err)
}
if res.RequestedTier != cfg.DefaultTier {
t.Errorf("RequestedTier = %q, want workspace default %q (empty role must not match role_tiers)",
res.RequestedTier, cfg.DefaultTier)
}
if res.TierSource != routing.TierSourceWorkspace {
t.Errorf("TierSource = %q, want %q", res.TierSource, routing.TierSourceWorkspace)
}
}
// AC-12a: a disabled workspace with a non-empty provider order still
// resolves the role tier — Enabled only gates automatic fallback, not
// tier resolution. This is the branch that does NOT hit Resolve's
// "disabled and empty order" early return.
func TestResolve_TierSource_RoleTierAppliesWhenDisabled(t *testing.T) {
cfg := twoProviderCfg()
cfg.Enabled = false
cfg.RoleTiers = routing.RoleTierMap{"qa": routing.TierEconomy}
repo := &fakeRepo{cfg: cfg}
r := newResolver(t, repo)
res, err := r.Resolve(context.Background(), wsID,
agentWithRole(t, settingsmodels.AgentRoleQA, routing.AgentOverrides{}),
routing.ResolveOptions{})
if err != nil {
t.Fatalf("resolve: %v", err)
}
if res.Enabled {
t.Fatalf("expected Enabled=false to be preserved")
}
if res.RequestedTier != routing.TierEconomy {
t.Errorf("RequestedTier = %q, want economy even though disabled", res.RequestedTier)
}
if res.TierSource != routing.TierSourceRole {
t.Errorf("TierSource = %q, want %q", res.TierSource, routing.TierSourceRole)
}
}
@@ -0,0 +1,191 @@
package routing_test
import (
"errors"
"strings"
"testing"
"github.com/kandev/kandev/internal/office/routing"
)
// roleTiersCfg returns a minimal enabled config with one provider mapping
// all three tiers, used as the base for role_tiers validation cases.
func roleTiersCfg() routing.WorkspaceConfig {
return routing.WorkspaceConfig{
Enabled: true,
DefaultTier: routing.TierBalanced,
ProviderOrder: []routing.ProviderID{"claude-acp"},
ProviderProfiles: map[routing.ProviderID]routing.ProviderProfile{
"claude-acp": {
ExecutionProfileIDs: routing.ExecutionProfileIDs{
Frontier: "claude-opus-profile",
Balanced: "claude-sonnet-profile",
Economy: "claude-haiku-profile",
},
TierMap: routing.TierMap{
Frontier: "opus",
Balanced: "sonnet",
Economy: "haiku",
},
},
},
}
}
// AC-8: an unknown role key is rejected with a per-key ValidationDetail.
func TestValidateWorkspaceConfig_RoleTiers_UnknownRole(t *testing.T) {
cfg := roleTiersCfg()
cfg.RoleTiers = routing.RoleTierMap{"astronaut": routing.TierEconomy}
err := routing.ValidateWorkspaceConfig(cfg, testKnown)
var ve *routing.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *ValidationError, got %v", err)
}
if ve.Field != "role_tiers" {
t.Fatalf("Field = %q, want role_tiers", ve.Field)
}
if len(ve.Details) != 1 || ve.Details[0].Field != "astronaut" {
t.Fatalf("Details = %+v, want one entry for astronaut", ve.Details)
}
}
// AC-9: an invalid tier value is rejected. The message must not carry
// "default_tier" or the generic "routing config invalid:" prefix that
// validateTier's own Error() produces — those would misattribute the
// field to the workspace default-tier validator instead of role_tiers.
func TestValidateWorkspaceConfig_RoleTiers_InvalidTierValue(t *testing.T) {
cfg := roleTiersCfg()
cfg.RoleTiers = routing.RoleTierMap{"qa": routing.Tier("nonsense")}
err := routing.ValidateWorkspaceConfig(cfg, testKnown)
var ve *routing.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *ValidationError, got %v", err)
}
if ve.Field != "role_tiers" {
t.Fatalf("Field = %q, want role_tiers", ve.Field)
}
if len(ve.Details) != 1 || ve.Details[0].Field != "qa" {
t.Fatalf("Details = %+v, want one entry for qa", ve.Details)
}
if strings.Contains(ve.Details[0].Message, "default_tier") {
t.Fatalf("detail message leaked default_tier field: %q", ve.Details[0].Message)
}
full := ve.Error()
if strings.Contains(full, "routing config invalid: default_tier") {
t.Fatalf("rendered error misattributes to default_tier: %q", full)
}
}
// AC-10/AC-10a: a role tier that is valid but not mapped on any provider
// in the workspace order is rejected, accumulating per-role details the
// same way checkTierPerReasonMapped does (not the single-value shape
// checkTierMapped uses for the agent-override tier).
func TestValidateWorkspaceConfig_RoleTiers_UnmappedTier(t *testing.T) {
cfg := roleTiersCfg()
// claude-acp has no frontier mapping in this variant.
prof := cfg.ProviderProfiles["claude-acp"]
prof.ExecutionProfileIDs.Frontier = ""
prof.TierMap.Frontier = ""
cfg.ProviderProfiles["claude-acp"] = prof
cfg.RoleTiers = routing.RoleTierMap{"ceo": routing.TierFrontier}
err := routing.ValidateWorkspaceConfig(cfg, testKnown)
var ve *routing.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *ValidationError, got %v", err)
}
if ve.Field != "role_tiers" {
t.Fatalf("Field = %q, want role_tiers", ve.Field)
}
if len(ve.Details) != 1 || ve.Details[0].Field != "ceo" {
t.Fatalf("Details = %+v, want one entry for ceo", ve.Details)
}
}
// AC-11: an empty tier value means "absent" and is silently tolerated,
// both by the unknown-role/invalid-value check and the unmapped-tier
// check.
func TestValidateWorkspaceConfig_RoleTiers_EmptyValueIsAbsent(t *testing.T) {
cfg := roleTiersCfg()
cfg.RoleTiers = routing.RoleTierMap{"worker": ""}
if err := routing.ValidateWorkspaceConfig(cfg, testKnown); err != nil {
t.Fatalf("empty role tier value should validate cleanly, got %v", err)
}
}
// AC-12: role_tiers is validated identically regardless of cfg.Enabled —
// checkRoleTiersMapped is not gated behind the Enabled=true block, unlike
// the provider-order/default-tier checks in validateEnabledRules.
func TestValidateWorkspaceConfig_RoleTiers_ValidatedWhenDisabled(t *testing.T) {
cfg := roleTiersCfg()
cfg.Enabled = false
cfg.RoleTiers = routing.RoleTierMap{"astronaut": routing.TierEconomy}
err := routing.ValidateWorkspaceConfig(cfg, testKnown)
var ve *routing.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected role_tiers to be validated while disabled, got %v", err)
}
if ve.Field != "role_tiers" {
t.Fatalf("Field = %q, want role_tiers", ve.Field)
}
}
// AC-40 test #3: two bad role_tiers entries in a single write must
// produce two Details entries, not collapse into one flat error.
func TestValidateWorkspaceConfig_RoleTiers_TwoBadEntriesAccumulate(t *testing.T) {
cfg := roleTiersCfg()
cfg.RoleTiers = routing.RoleTierMap{
"astronaut": routing.TierEconomy, // unknown role
"qa": routing.Tier("nonsense"), // invalid tier value
}
err := routing.ValidateWorkspaceConfig(cfg, testKnown)
var ve *routing.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *ValidationError, got %v", err)
}
if len(ve.Details) != 2 {
t.Fatalf("Details = %+v, want 2 entries", ve.Details)
}
fields := map[string]bool{}
for _, d := range ve.Details {
fields[d.Field] = true
}
if !fields["astronaut"] || !fields["qa"] {
t.Fatalf("Details fields = %v, want astronaut and qa", fields)
}
}
// A fully valid role_tiers map (every AllAgentRoles key, every value
// mapped) must pass cleanly.
func TestValidateWorkspaceConfig_RoleTiers_AllRolesValid(t *testing.T) {
cfg := roleTiersCfg()
cfg.RoleTiers = routing.RoleTierMap{
"ceo": routing.TierFrontier,
"worker": routing.TierEconomy,
"specialist": routing.TierBalanced,
"assistant": routing.TierEconomy,
"security": routing.TierBalanced,
"qa": routing.TierEconomy,
"devops": routing.TierBalanced,
}
if err := routing.ValidateWorkspaceConfig(cfg, testKnown); err != nil {
t.Fatalf("all-known-roles config should validate cleanly, got %v", err)
}
}
// A nil/missing role_tiers map means "no role policy" and must never be
// rejected.
func TestValidateWorkspaceConfig_RoleTiers_NilMapIsValid(t *testing.T) {
cfg := roleTiersCfg()
cfg.RoleTiers = nil
if err := routing.ValidateWorkspaceConfig(cfg, testKnown); err != nil {
t.Fatalf("nil role_tiers should validate cleanly, got %v", err)
}
}
@@ -0,0 +1,118 @@
package routing
import (
"encoding/json"
"testing"
settingsmodels "github.com/kandev/kandev/internal/agent/settings/models"
)
// agentSettingsWithOverrides marshals ov under the "routing" settings key,
// mirroring the production agent_settings.go shape.
func agentSettingsWithOverrides(t *testing.T, ov AgentOverrides) string {
t.Helper()
if ov.IsZero() {
return ""
}
blob, err := json.Marshal(map[string]interface{}{"routing": ov})
if err != nil {
t.Fatalf("marshal overrides: %v", err)
}
return string(blob)
}
// AC-16/AC-20d: tierSourceForAgent delegates to the same effectiveTier
// producer the resolver uses, so a role_tiers entry reports "role" here
// exactly as it would on a live Resolve call.
func TestTierSourceForAgent_RoleTier(t *testing.T) {
cfg := &WorkspaceConfig{
DefaultTier: TierBalanced,
RoleTiers: RoleTierMap{"qa": TierEconomy},
}
agent := settingsmodels.AgentProfile{Role: settingsmodels.AgentRoleQA}
if got := tierSourceForAgent(agent, cfg); got != TierSourceRole {
t.Errorf("tierSourceForAgent = %q, want %q", got, TierSourceRole)
}
}
// A per-agent override outranks a matching role tier.
func TestTierSourceForAgent_OverrideBeatsRoleTier(t *testing.T) {
cfg := &WorkspaceConfig{
DefaultTier: TierBalanced,
RoleTiers: RoleTierMap{"qa": TierEconomy},
}
ov := AgentOverrides{TierSource: TierSourceOverride, Tier: TierFrontier}
agent := settingsmodels.AgentProfile{
Role: settingsmodels.AgentRoleQA,
Settings: agentSettingsWithOverrides(t, ov),
}
if got := tierSourceForAgent(agent, cfg); got != TierSourceOverride {
t.Errorf("tierSourceForAgent = %q, want %q", got, TierSourceOverride)
}
}
// No override, no matching role entry: falls through to the workspace
// default.
func TestTierSourceForAgent_WorkspaceDefault(t *testing.T) {
cfg := &WorkspaceConfig{DefaultTier: TierBalanced}
agent := settingsmodels.AgentProfile{Role: settingsmodels.AgentRoleQA}
if got := tierSourceForAgent(agent, cfg); got != TierSourceWorkspace {
t.Errorf("tierSourceForAgent = %q, want %q", got, TierSourceWorkspace)
}
}
// AC-18b: a preview computation never carries wake-reason context.
// tierSourceForAgent always calls effectiveTier with an empty reason, so
// even when the agent's own wake-reason override would shadow the tier
// at runtime, the preview must never report "wake_reason".
func TestTierSourceForAgent_NeverReportsWakeReason(t *testing.T) {
cfg := &WorkspaceConfig{
DefaultTier: TierBalanced,
TierPerReason: TierPerReason{WakeReasonHeartbeat: TierEconomy},
}
ov := AgentOverrides{
TierPerReasonSource: TierPerReasonSourceOverride,
TierPerReason: TierPerReason{WakeReasonHeartbeat: TierFrontier},
}
agent := settingsmodels.AgentProfile{
Role: settingsmodels.AgentRoleQA,
Settings: agentSettingsWithOverrides(t, ov),
}
got := tierSourceForAgent(agent, cfg)
if got == TierSourceWakeReason {
t.Fatalf("tierSourceForAgent reported wake_reason for a reason-less preview")
}
if got != TierSourceWorkspace {
t.Errorf("tierSourceForAgent = %q, want %q", got, TierSourceWorkspace)
}
}
// A nil cfg (defensive edge case — Provider always constructs a default
// before calling this, but the helper is exported to package-internal
// callers only) must not panic and falls through to the empty-source
// pinned-defensive case in effectiveTier.
func TestTierSourceForAgent_NilConfigDoesNotPanic(t *testing.T) {
agent := settingsmodels.AgentProfile{Role: settingsmodels.AgentRoleQA}
if got := tierSourceForAgent(agent, nil); got != "" {
t.Errorf("tierSourceForAgent(nil cfg) = %q, want empty", got)
}
}
// A malformed settings blob falls back to zero-value overrides rather
// than erroring the preview (SR-23): the tier source still resolves via
// the normal precedence chain instead of surfacing a fixed sentinel.
func TestTierSourceForAgent_MalformedSettingsFallsBackToZeroOverrides(t *testing.T) {
cfg := &WorkspaceConfig{DefaultTier: TierBalanced}
agent := settingsmodels.AgentProfile{
Role: settingsmodels.AgentRoleQA,
Settings: "{not valid json",
}
if got := tierSourceForAgent(agent, cfg); got != TierSourceWorkspace {
t.Errorf("tierSourceForAgent = %q, want %q (fell back to workspace default)", got, TierSourceWorkspace)
}
}
@@ -12,6 +12,8 @@ import (
"errors"
"fmt"
"strings"
settingsmodels "github.com/kandev/kandev/internal/agent/settings/models"
)
// Tier is a normalized model strength bucket. Workspace and agent
@@ -37,6 +39,19 @@ const ProviderOrderSourceOverride = "override"
// that the override blob's Tier replaces the workspace default.
const TierSourceOverride = "override"
// Tier-resolution level names for the widened, four-value tier_source
// contract computed by effectiveTier and carried on Resolution and
// RouteAttempt: wake_reason | override | role | workspace. This is a
// different contract from AgentOverrides.TierSource above (still
// inherit | override | "" — a persisted "did the user override"
// marker, not a resolution level) even though both are spelled
// "tier_source" and share the "override" string. See spec AC-18a.
const (
TierSourceWakeReason = "wake_reason"
TierSourceRole = "role"
TierSourceWorkspace = "workspace"
)
// TierPerReasonSourceOverride is the magic value AgentOverrides uses to
// signal that its TierPerReason map replaces the workspace policy
// entirely. Any other value (default "") inherits the workspace map.
@@ -67,6 +82,26 @@ var AllWakeReasons = []string{
// the agent overrides blob.
type TierPerReason map[string]Tier
// AllAgentRoles enumerates the seven role_tiers keys, in the
// settingsmodels.AgentRole constants' declaration order. This order is
// also what the frontend role list follows, so it lives here as the
// single source of truth rather than being duplicated as a literal list.
var AllAgentRoles = []settingsmodels.AgentRole{
settingsmodels.AgentRoleCEO,
settingsmodels.AgentRoleWorker,
settingsmodels.AgentRoleSpecialist,
settingsmodels.AgentRoleAssistant,
settingsmodels.AgentRoleSecurity,
settingsmodels.AgentRoleQA,
settingsmodels.AgentRoleDevOps,
}
// RoleTierMap maps an agent role to the tier that role should run at.
// Keys are restricted to AllAgentRoles; values to AllTiers. Empty /
// missing keys mean "no role policy — fall through to the workspace
// default_tier." Stored as JSON on the workspace routing row.
type RoleTierMap map[string]Tier
// ProviderID identifies a CLI provider (the agent type ID from the
// agent/registry package). First-class IDs in v1 are restricted by
// catalogue.KnownProviders.
@@ -224,6 +259,11 @@ type WorkspaceConfig struct {
// empty / missing map means "no special policy — every reason uses
// the agent's effective tier."
TierPerReason TierPerReason `json:"tier_per_reason,omitempty"`
// RoleTiers maps an agent role onto the tier that role should run
// at, applied below TierPerReason and the per-agent override but
// above DefaultTier. An empty / missing map means "no role policy"
// — the default for every workspace.
RoleTiers RoleTierMap `json:"role_tiers,omitempty"`
}
// AgentOverrides is the routing override blob stored on
@@ -296,6 +336,12 @@ func ValidateWorkspaceConfig(cfg WorkspaceConfig, known []ProviderID) error {
if err := validateTierPerReason(cfg.TierPerReason, "tier_per_reason"); err != nil {
return err
}
if err := validateRoleTiers(cfg.RoleTiers, "role_tiers"); err != nil {
return err
}
if err := checkRoleTiersMapped(cfg.RoleTiers, &cfg); err != nil {
return err
}
if !cfg.Enabled {
return nil
}
@@ -506,6 +552,109 @@ func wakeReasonSet() map[string]struct{} {
return out
}
// validateRoleTiers rejects role_tiers keys outside the seven
// AllAgentRoles values and values outside AllTiers. Unlike
// validateTierPerReason (fail-fast on the first bad entry),
// role_tiers accumulates one ValidationDetail per offending key —
// role_tiers is a map that may carry several bad entries in a single
// write, and a single flat error would collapse them into one message
// and hide which of the seven roles is broken. Messages are built
// directly here rather than through validateTier, whose Field is
// hardcoded to "default_tier" and whose Error() text is prefixed with
// "routing config invalid:" — either leaking into a role_tiers message
// would misattribute the offending field.
func validateRoleTiers(m RoleTierMap, field string) error {
if len(m) == 0 {
return nil
}
knownRoles := agentRoleSet()
var details []ValidationDetail
for role, tier := range m {
if _, ok := knownRoles[role]; !ok {
details = append(details, ValidationDetail{
Field: role,
Message: fmt.Sprintf("unknown role %q", role),
})
continue
}
if tier == "" {
// AC-11: empty value means "absent"; dropped before
// persistence, not rejected.
continue
}
if !isValidTierValue(tier) {
details = append(details, ValidationDetail{
Field: role,
Message: fmt.Sprintf("invalid tier %q", string(tier)),
})
}
}
if len(details) == 0 {
return nil
}
return &ValidationError{
Field: field,
Message: "role_tiers references unknown roles or invalid tiers",
Details: details,
}
}
// isValidTierValue reports whether t is one of AllTiers. Unlike
// validateTier, it carries no field/message opinion — callers that need
// a role_tiers-scoped error build it themselves (see validateRoleTiers).
func isValidTierValue(t Tier) bool {
for _, v := range AllTiers {
if v == t {
return true
}
}
return false
}
// agentRoleSet returns the lookup set used by validateRoleTiers. Built
// from AllAgentRoles so the source of truth stays single.
func agentRoleSet() map[string]struct{} {
out := make(map[string]struct{}, len(AllAgentRoles))
for _, r := range AllAgentRoles {
out[string(r)] = struct{}{}
}
return out
}
// checkRoleTiersMapped returns nil when every non-empty tier in
// role_tiers is mapped on at least one provider in cfg.ProviderOrder.
// Mirrors checkTierPerReasonMapped's accumulation shape rather than
// checkTierMapped's single-value shape, for the same reason
// validateRoleTiers does: role_tiers may hold several bad entries at
// once. Checked unconditionally (not gated on cfg.Enabled) because a
// role tier can supply the effective tier for a launch even while
// automatic fallback is disabled (AC-12/AC-12a).
func checkRoleTiersMapped(m RoleTierMap, cfg *WorkspaceConfig) error {
var details []ValidationDetail
for role, tier := range m {
if tier == "" {
continue
}
if tierMappedOnAnyProvider(tier, cfg.ProviderOrder, cfg.ProviderProfiles) {
continue
}
details = append(details, ValidationDetail{
Field: role,
Message: fmt.Sprintf(
"tier %q is not mapped on any provider in the workspace provider order",
string(tier)),
})
}
if len(details) == 0 {
return nil
}
return &ValidationError{
Field: "role_tiers",
Message: "role tier overrides reference unmapped tiers",
Details: details,
}
}
// validateTier returns an error when t is not in AllTiers. Empty tier
// is rejected because every config that reaches the validator should
// already have a default applied.
@@ -202,6 +202,7 @@ func (ss *SchedulerService) parkRunBlocked(
Seq: seq,
ProviderID: string(sk.ProviderID),
Tier: string(res.RequestedTier),
TierSource: res.TierSource,
Outcome: skipOutcome(sk.Reason),
ErrorCode: sk.ErrorCode,
RawExcerpt: sk.RawExcerpt,
@@ -252,7 +253,7 @@ func (ss *SchedulerService) tryCandidates(
candidateLaunch := continuationLaunchContext(
launch, prior, run.RouteCycleBaselineSeq, candidate,
)
seq, err := ss.recordAttemptStart(ctx, run, candidate, res.RequestedTier)
seq, err := ss.recordAttemptStart(ctx, run, candidate, res.RequestedTier, res.TierSource)
if err != nil {
return false, nil, err
}
@@ -352,10 +353,14 @@ func latestFailedExecutionProfile(
}
// recordAttemptStart increments the attempt sequence and appends the
// in-flight attempt row with outcome=launched.
// in-flight attempt row with outcome=launched. tierSource is the
// resolution's precedence level (routing.Resolution.TierSource) —
// threaded in from the caller, which has the *routing.Resolution in
// scope, rather than re-derived here (AC-20d names effectiveTier as
// the sole producer).
func (ss *SchedulerService) recordAttemptStart(
ctx context.Context, run *models.Run,
candidate routing.Candidate, tier routing.Tier,
candidate routing.Candidate, tier routing.Tier, tierSource string,
) (int, error) {
seq, err := ss.repo.IncrementRouteAttemptSeq(ctx, run.ID)
if err != nil {
@@ -369,6 +374,7 @@ func (ss *SchedulerService) recordAttemptStart(
ProviderID: string(candidate.ProviderID),
Model: candidate.Model,
Tier: string(tier),
TierSource: tierSource,
Outcome: RouteAttemptOutcomeLaunched,
StartedAt: time.Now().UTC(),
}
@@ -0,0 +1,295 @@
package scheduler_test
import (
"context"
"fmt"
"testing"
"time"
settingsmodels "github.com/kandev/kandev/internal/agent/settings/models"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/events"
"github.com/kandev/kandev/internal/events/bus"
officemodels "github.com/kandev/kandev/internal/office/models"
"github.com/kandev/kandev/internal/office/routing"
"github.com/kandev/kandev/internal/office/scheduler"
)
// makeAgentWithRole mirrors makeAgent but sets the organisational Role so
// role_tiers precedence tests can exercise the resolver's role level.
func makeAgentWithRole(role settingsmodels.AgentRole) *officemodels.AgentInstance {
agent := makeAgent()
agent.Role = role
return agent
}
// AC-20d/AC-20e: a launch that resolves at the workspace-default level
// persists tier_source="workspace" on the recorded attempt via
// recordAttemptStart.
func TestDispatch_TierSource_WorkspaceDefault(t *testing.T) {
repo := newTestRepoSched(t)
seedRoutingConfig(t, repo, []routing.ProviderID{"claude-acp", "codex-acp"})
ss := buildScheduler(t, repo, newFakeTaskStarter())
run := seedRun(t, repo, `{"task_id":"t-1"}`)
launched, _, err := ss.DispatchWithRouting(context.Background(), run, makeAgent(), scheduler.LaunchContext{})
if err != nil {
t.Fatalf("dispatch: %v", err)
}
if !launched {
t.Fatalf("expected launched=true")
}
attempts, err := repo.ListRouteAttempts(context.Background(), run.ID)
if err != nil || len(attempts) != 1 {
t.Fatalf("attempts = %v err=%v, want one attempt", attempts, err)
}
if attempts[0].TierSource != "workspace" {
t.Errorf("TierSource = %q, want workspace", attempts[0].TierSource)
}
}
// The new role level: an agent whose role has a role_tiers entry
// launches at that tier, and the attempt records tier_source="role".
func TestDispatch_TierSource_RoleTier(t *testing.T) {
repo := newTestRepoSched(t)
profiles := map[routing.ProviderID]routing.ProviderProfile{
"claude-acp": {
TierMap: routing.TierMap{Balanced: "sonnet", Economy: "haiku"},
ExecutionProfileIDs: routing.ExecutionProfileIDs{
Balanced: "claude-sonnet-profile",
Economy: "claude-haiku-profile",
},
Mode: "default",
},
}
cfg := &routing.WorkspaceConfig{
Enabled: true,
DefaultTier: routing.TierBalanced,
ProviderOrder: []routing.ProviderID{"claude-acp"},
ProviderProfiles: profiles,
RoleTiers: routing.RoleTierMap{"qa": routing.TierEconomy},
}
if err := repo.UpsertWorkspaceRouting(context.Background(), testWorkspaceID, cfg); err != nil {
t.Fatalf("upsert routing: %v", err)
}
starter := newFakeTaskStarter()
ss := buildScheduler(t, repo, starter)
run := seedRun(t, repo, `{"task_id":"t-1"}`)
launched, _, err := ss.DispatchWithRouting(
context.Background(), run, makeAgentWithRole(settingsmodels.AgentRoleQA), scheduler.LaunchContext{})
if err != nil {
t.Fatalf("dispatch: %v", err)
}
if !launched {
t.Fatalf("expected launched=true")
}
got := starter.lastCall().route
if got.Tier != string(routing.TierEconomy) || got.Model != "haiku" {
t.Errorf("route = %+v, want economy/haiku", got)
}
attempts, err := repo.ListRouteAttempts(context.Background(), run.ID)
if err != nil || len(attempts) != 1 {
t.Fatalf("attempts = %v err=%v, want one attempt", attempts, err)
}
if attempts[0].TierSource != "role" {
t.Errorf("TierSource = %q, want role", attempts[0].TierSource)
}
}
// The top level: a matching wake-reason policy resolves the tier and the
// attempt records tier_source="wake_reason", outranking a role entry.
func TestDispatch_TierSource_WakeReasonBeatsRole(t *testing.T) {
repo := newTestRepoSched(t)
profiles := map[routing.ProviderID]routing.ProviderProfile{
"claude-acp": {
TierMap: routing.TierMap{Frontier: "opus", Balanced: "sonnet", Economy: "haiku"},
ExecutionProfileIDs: routing.ExecutionProfileIDs{
Frontier: "claude-opus-profile",
Balanced: "claude-sonnet-profile",
Economy: "claude-haiku-profile",
},
Mode: "default",
},
}
cfg := &routing.WorkspaceConfig{
Enabled: true,
DefaultTier: routing.TierBalanced,
ProviderOrder: []routing.ProviderID{"claude-acp"},
ProviderProfiles: profiles,
RoleTiers: routing.RoleTierMap{"qa": routing.TierFrontier},
TierPerReason: routing.TierPerReason{
routing.WakeReasonHeartbeat: routing.TierEconomy,
},
}
if err := repo.UpsertWorkspaceRouting(context.Background(), testWorkspaceID, cfg); err != nil {
t.Fatalf("upsert routing: %v", err)
}
starter := newFakeTaskStarter()
ss := buildScheduler(t, repo, starter)
run := seedRun(t, repo, `{"task_id":"t-1"}`)
run.Reason = routing.WakeReasonHeartbeat
launched, _, err := ss.DispatchWithRouting(
context.Background(), run, makeAgentWithRole(settingsmodels.AgentRoleQA), scheduler.LaunchContext{})
if err != nil {
t.Fatalf("dispatch: %v", err)
}
if !launched {
t.Fatalf("expected launched=true")
}
got := starter.lastCall().route
if got.Tier != string(routing.TierEconomy) || got.Model != "haiku" {
t.Errorf("route = %+v, want economy/haiku (wake reason beats role)", got)
}
attempts, err := repo.ListRouteAttempts(context.Background(), run.ID)
if err != nil || len(attempts) != 1 {
t.Fatalf("attempts = %v err=%v, want one attempt", attempts, err)
}
if attempts[0].TierSource != "wake_reason" {
t.Errorf("TierSource = %q, want wake_reason", attempts[0].TierSource)
}
}
// parkRunBlocked's skip-attempt rows also carry tier_source, propagated
// from the resolution's block-reason path rather than a launch attempt.
func TestDispatch_TierSource_ParkedBlockedRecordsSource(t *testing.T) {
repo := newTestRepoSched(t)
seedRoutingConfig(t, repo, []routing.ProviderID{"claude-acp"})
starter := newFakeTaskStarter()
starter.failFor["claude-acp"] = fmt.Errorf("missing API key: not authenticated")
ss := buildScheduler(t, repo, starter)
run := seedRun(t, repo, `{"task_id":"t-1"}`)
launched, parked, err := ss.DispatchWithRouting(context.Background(), run, makeAgent(), scheduler.LaunchContext{})
if err != nil {
t.Fatalf("dispatch: %v", err)
}
if launched || !parked {
t.Fatalf("expected parked; got launched=%v parked=%v", launched, parked)
}
attempts, err := repo.ListRouteAttempts(context.Background(), run.ID)
if err != nil || len(attempts) != 1 {
t.Fatalf("attempts = %v err=%v, want one attempt", attempts, err)
}
if attempts[0].TierSource != "workspace" {
t.Errorf("TierSource = %q, want workspace", attempts[0].TierSource)
}
}
// AC-20f/AC-20g: the terminal skipped_max_attempts row parkRunMaxAttempts
// records carries no Tier today, and correspondingly no TierSource — the
// cap fires before the resolver ever runs for this dispatch call.
func TestDispatch_TierSource_MaxAttemptsRowLeavesSourceEmpty(t *testing.T) {
repo := newTestRepoSched(t)
seedRoutingConfig(t, repo, []routing.ProviderID{"claude-acp", "codex-acp"})
starter := newFakeTaskStarter()
ss := buildScheduler(t, repo, starter)
run := seedRun(t, repo, `{"task_id":"t-1"}`)
for i := 0; i < scheduler.MaxAttemptsPerRun; i++ {
seq, err := repo.IncrementRouteAttemptSeq(context.Background(), run.ID)
if err != nil {
t.Fatalf("seed seq bump: %v", err)
}
if err := repo.AppendRouteAttempt(context.Background(), &officemodels.RouteAttempt{
RunID: run.ID,
Seq: seq,
ProviderID: "claude-acp",
Model: "claude-acp-bal",
Tier: "balanced",
TierSource: "workspace",
Outcome: scheduler.RouteAttemptOutcomeFailedProviderUnavail,
StartedAt: time.Now().UTC(),
}); err != nil {
t.Fatalf("seed prior attempt: %v", err)
}
}
launched, parked, err := ss.DispatchWithRouting(context.Background(),
run, makeAgent(), scheduler.LaunchContext{})
if err != nil {
t.Fatalf("dispatch: %v", err)
}
if launched || !parked {
t.Fatalf("expected parked at cap; got launched=%v parked=%v", launched, parked)
}
attempts, err := repo.ListRouteAttempts(context.Background(), run.ID)
if err != nil {
t.Fatalf("list: %v", err)
}
last := attempts[len(attempts)-1]
if last.Outcome != scheduler.RouteAttemptOutcomeMaxAttempts {
t.Fatalf("last outcome = %q, want skipped_max_attempts", last.Outcome)
}
if last.TierSource != "" {
t.Errorf("TierSource = %q, want empty on the max-attempts terminal row", last.TierSource)
}
}
// The route_attempt_appended WS event carries the same TierSource the
// persisted attempt row does — the payload is the raw models.RouteAttempt,
// not a re-derived value, so this pins the wiring rather than the value.
func TestDispatch_TierSource_PublishedOnRouteAttemptAppended(t *testing.T) {
repo := newTestRepoSched(t)
profiles := map[routing.ProviderID]routing.ProviderProfile{
"claude-acp": {
TierMap: routing.TierMap{Balanced: "sonnet", Economy: "haiku"},
ExecutionProfileIDs: routing.ExecutionProfileIDs{
Balanced: "claude-sonnet-profile",
Economy: "claude-haiku-profile",
},
Mode: "default",
},
}
cfg := &routing.WorkspaceConfig{
Enabled: true,
DefaultTier: routing.TierBalanced,
ProviderOrder: []routing.ProviderID{"claude-acp"},
ProviderProfiles: profiles,
RoleTiers: routing.RoleTierMap{"qa": routing.TierEconomy},
}
if err := repo.UpsertWorkspaceRouting(context.Background(), testWorkspaceID, cfg); err != nil {
t.Fatalf("upsert routing: %v", err)
}
ss := buildScheduler(t, repo, newFakeTaskStarter())
log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "console"})
if err != nil {
t.Fatalf("logger: %v", err)
}
eb := bus.NewMemoryEventBus(log)
ss.SetEventBus(eb)
var captured *officemodels.RouteAttempt
if _, err := eb.Subscribe(events.OfficeRouteAttemptAppended, func(_ context.Context, ev *bus.Event) error {
data, ok := ev.Data.(map[string]interface{})
if !ok {
t.Fatalf("event data type = %T, want map[string]interface{}", ev.Data)
}
attempt, ok := data["attempt"].(officemodels.RouteAttempt)
if !ok {
t.Fatalf("attempt field type = %T, want officemodels.RouteAttempt", data["attempt"])
}
captured = &attempt
return nil
}); err != nil {
t.Fatalf("subscribe: %v", err)
}
run := seedRun(t, repo, `{"task_id":"t-1"}`)
launched, _, err := ss.DispatchWithRouting(
context.Background(), run, makeAgentWithRole(settingsmodels.AgentRoleQA), scheduler.LaunchContext{})
if err != nil {
t.Fatalf("dispatch: %v", err)
}
if !launched {
t.Fatalf("expected launched=true")
}
if captured == nil {
t.Fatal("route_attempt_appended was not published")
}
if captured.TierSource != "role" {
t.Errorf("published attempt TierSource = %q, want role", captured.TierSource)
}
}
@@ -7,6 +7,7 @@ import { useAppStore } from "@/components/state-provider";
import { useAgentRoute } from "@/hooks/domains/office/use-agent-route";
import { useWorkspaceRouting } from "@/hooks/domains/office/use-workspace-routing";
import { providerLabel } from "../../../workspace/routing/components/provider-order-editor";
import { TIER_SOURCE_LABEL_KEYS } from "../../../lib/label-keys";
import { useTranslation } from "react-i18next";
// Catalog keys, not copy — module scope freezes a `t()` at the boot locale.
@@ -76,9 +77,12 @@ function ConfiguredRow({ preview }: { preview: AgentRoutePreview }) {
</span>
))}
</span>
<Badge variant="secondary" className="capitalize ml-auto">
{preview.effective_tier}
</Badge>
<div className="flex items-center gap-1.5 ml-auto">
<Badge variant="secondary" className="capitalize">
{preview.effective_tier}
</Badge>
<span className={LABEL_CLASS}>{t(TIER_SOURCE_LABEL_KEYS[preview.tier_source])}</span>
</div>
</div>
);
}
@@ -11,12 +11,15 @@ import { useAgentRoute } from "@/hooks/domains/office/use-agent-route";
import { useAppStore } from "@/components/state-provider";
import { useWorkspaceRouting } from "@/hooks/domains/office/use-workspace-routing";
import type {
AgentRoutePreview,
AgentRoutingOverrides,
Tier,
TierPerReason,
WorkspaceRouting,
} from "@/lib/state/slices/office/types";
import { ProviderOrderEditor } from "../../../workspace/routing/components/provider-order-editor";
import { AgentWakeReasonOverrides } from "./agent-wake-reason-overrides";
import { WAKE_REASONS } from "../../../workspace/routing/components/wake-reason-info";
import type { TFunction } from "i18next";
import { Trans, useTranslation } from "react-i18next";
import { TIER_NAME_KEYS } from "../../../lib/label-keys";
@@ -90,6 +93,12 @@ export function AgentRoutingCard({ agentId, initial }: Props) {
workspaceConfig={workspace.config}
knownProviders={workspace.knownProviders}
saving={saving}
preview={route.data?.preview}
/>
<TierShadowingNotice
overrides={overrides}
workspaceConfig={workspace.config}
preview={route.data?.preview}
/>
<AgentWakeReasonOverrides
overrides={overrides}
@@ -163,6 +172,7 @@ type FieldsProps = {
workspaceConfig: WorkspaceRouting | undefined;
knownProviders: string[];
saving: boolean;
preview: AgentRoutePreview | undefined;
};
function RoutingFields({
@@ -171,6 +181,7 @@ function RoutingFields({
workspaceConfig,
knownProviders,
saving,
preview,
}: FieldsProps) {
const { t } = useTranslation();
const overrideTier = overrides.tier_source === "override";
@@ -204,7 +215,7 @@ function RoutingFields({
onChange={(t) => setOverrides({ ...overrides, tier: t })}
/>
) : (
<InheritedTierHint defaultTier={workspaceConfig?.default_tier} />
<InheritedTierHint preview={preview} defaultTier={workspaceConfig?.default_tier} />
)}
<InheritRow
label={t("office:overrideWorkspaceProviderOrder")}
@@ -240,17 +251,29 @@ function TierToggleGroup({ value, onChange }: { value: string; onChange: (t: Tie
);
}
function InheritedTierHint({ defaultTier }: { defaultTier?: Tier }) {
// AC-16: names both the tier in force and the level that supplied it. The
// preview's tier_source is authoritative (role vs. workspace); while it is
// still loading, fall back to the workspace default with "workspace"
// wording, since that is the only level we can name without it.
function InheritedTierHint({
preview,
defaultTier,
}: {
preview: AgentRoutePreview | undefined;
defaultTier?: Tier;
}) {
const { t } = useTranslation();
if (!defaultTier) return null;
const tier = preview?.effective_tier ?? defaultTier;
if (!tier) return null;
const i18nKey =
preview?.tier_source === "role"
? "office:inheritsTierFromRole"
: "office:inheritsTierFromWorkspace";
return (
// One sentence, one key: "Inherits" + a bare tier id + "from workspace"
// froze the English order and rendered the wire value as display copy.
<p className="text-xs text-muted-foreground">
<Trans
i18nKey="office:inheritsTierFromWorkspace"
values={{ tier: t(TIER_NAME_KEYS[defaultTier]) }}
>
<Trans i18nKey={i18nKey} values={{ tier: t(TIER_NAME_KEYS[tier]) }}>
Inherits
<Badge variant="secondary" className="capitalize">
tier
@@ -261,6 +284,52 @@ function InheritedTierHint({ defaultTier }: { defaultTier?: Tier }) {
);
}
// AC-17 / AC-17a / AC-17b: names the wake reasons whose policy shadows the
// tier displayed above, whichever level supplied it. The effective map is
// the agent's own tier_per_reason when it overrides the workspace policy
// (an override replaces the map entirely, per docs/specs/office/routing.md)
// and the workspace map otherwise; keys with an empty value do not shadow
// anything and are excluded.
function effectiveWakeReasonMap(
overrides: AgentRoutingOverrides,
workspaceConfig: WorkspaceRouting | undefined,
): TierPerReason {
if (overrides.tier_per_reason_source === "override") {
return overrides.tier_per_reason ?? {};
}
return workspaceConfig?.tier_per_reason ?? {};
}
function TierShadowingNotice({
overrides,
workspaceConfig,
preview,
}: {
overrides: AgentRoutingOverrides;
workspaceConfig: WorkspaceRouting | undefined;
preview: AgentRoutePreview | undefined;
}) {
const { t } = useTranslation();
const overriding = overrides.tier_source === "override";
// AC-18b: a preview never reports wake_reason, so the role/workspace case
// is only reachable once the preview has actually loaded.
const roleOrWorkspace = preview?.tier_source === "role" || preview?.tier_source === "workspace";
if (!overriding && !roleOrWorkspace) return null;
const map = effectiveWakeReasonMap(overrides, workspaceConfig);
const reasons = WAKE_REASONS.filter((r) => !!map[r.id]).map((r) => t(r.labelKey));
if (reasons.length === 0) return null;
const i18nKey = overriding
? "office:overrideShadowedByWakeReasons"
: "office:tierShadowedByWakeReasons";
return (
<p className="text-xs text-muted-foreground" role="note">
{t(i18nKey, { list: reasons.join(", ") })}
</p>
);
}
function computeNextOrder(
on: boolean,
current: string[] | undefined,
+13
View File
@@ -3,6 +3,7 @@ import type {
OfficeTaskStatus,
ProjectStatus,
Tier,
TierSource,
} from "@/lib/state/slices/office/types";
/**
@@ -58,6 +59,18 @@ export const TIER_NAME_KEYS: Record<Tier, string> = {
economy: "office:tierNameEconomy",
};
/**
* Names the precedence level that supplied an agent's effective tier: a
* matching wake-reason policy, a per-agent override, the agent's role entry,
* or the workspace default. Wire values are never shown raw (AC-16, AC-16a).
*/
export const TIER_SOURCE_LABEL_KEYS: Record<TierSource, string> = {
wake_reason: "office:tierSourceWakeReason",
override: "office:tierSourceOverride",
role: "office:tierSourceRole",
workspace: "office:tierSourceWorkspace",
};
/**
* Routine concurrency policies. The row and the expanded detail used to render
* the wire value with `policy.replace(/_/g, " ")`, which showed the identifier
@@ -5,6 +5,7 @@ import { Badge } from "@kandev/ui/badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@kandev/ui/table";
import type { AgentRoutePreview } from "@/lib/state/slices/office/types";
import { providerLabel } from "./provider-order-editor";
import { TIER_SOURCE_LABEL_KEYS } from "../../../lib/label-keys";
import { useTranslation } from "react-i18next";
type Props = {
@@ -67,7 +68,7 @@ function PreviewRow({ a }: { a: AgentRoutePreview }) {
{a.effective_tier}
</Badge>
<span className="text-[10px] text-muted-foreground uppercase tracking-wide">
{a.tier_source}
{t(TIER_SOURCE_LABEL_KEYS[a.tier_source])}
</span>
</div>
</TableCell>
@@ -4,4 +4,5 @@ export { ProviderOrderEditor, providerLabel } from "./provider-order-editor";
export { ProviderTierMapping } from "./provider-tier-mapping";
export { AgentPreviewTable } from "./agent-preview-table";
export { ProviderHealthBanner } from "./provider-health-banner";
export { WakeReasonTierCard } from "./wake-reason-tier-card";
export { WakeReasonTierCard, firstProviderWithTier } from "./wake-reason-tier-card";
export { RoleTierCard } from "./role-tier-card";
@@ -0,0 +1,195 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { Badge } from "@kandev/ui/badge";
import { IconInfoCircle, IconAlertTriangle } from "@tabler/icons-react";
import type {
AgentRole,
RoleMeta,
RoleTierMap,
Tier,
WorkspaceRouting,
} from "@/lib/state/slices/office/types";
import { providerLabel } from "./provider-order-editor";
import { TIER_NAME_KEYS } from "../../../lib/label-keys";
import { firstProviderWithTier } from "./wake-reason-tier-card";
import { useTranslation } from "react-i18next";
// USE_WORKSPACE_DEFAULT is the sentinel used in the per-role tier dropdown
// to mean "no role entry — use the workspace default tier." Persisting this
// removes the role's key from the workspace's role_tiers map.
const USE_WORKSPACE_DEFAULT = "__workspace_default__" as const;
type Props = {
roles: RoleMeta[];
config: WorkspaceRouting;
value: RoleTierMap;
onChange: (next: RoleTierMap) => void;
disabled?: boolean;
};
// RoleTierCard renders the per-role tier policy table on the workspace
// routing settings page: the third of four precedence levels, below a
// wake-reason policy and a per-agent override, above the workspace default.
// See docs/specs/office-agent-tier-routing/spec.md.
export function RoleTierCard({ roles, config, value, onChange, disabled }: Props) {
const { t } = useTranslation();
const handleRowChange = (role: AgentRole, tier: Tier | typeof USE_WORKSPACE_DEFAULT) => {
const next: RoleTierMap = { ...value };
if (tier === USE_WORKSPACE_DEFAULT) {
delete next[role];
} else {
next[role] = tier;
}
onChange(next);
};
if (roles.length === 0) return null;
return (
<Card>
<CardHeader className="space-y-1">
<CardTitle className="text-sm">{t("office:roleTierPolicy")}</CardTitle>
<p className="text-xs text-muted-foreground leading-relaxed">
{t("office:setADefaultTierForEveryAgentOfA")}
</p>
</CardHeader>
<CardContent className="divide-y divide-border">
{roles.map((role) => (
<RoleRow
key={role.id}
role={role}
tier={value[role.id as AgentRole]}
config={config}
disabled={disabled}
onChange={(tier) => handleRowChange(role.id as AgentRole, tier)}
/>
))}
</CardContent>
</Card>
);
}
type RowProps = {
role: RoleMeta;
tier: Tier | undefined;
config: WorkspaceRouting;
disabled?: boolean;
onChange: (tier: Tier | typeof USE_WORKSPACE_DEFAULT) => void;
};
function RoleRow({ role, tier, config, disabled, onChange }: RowProps) {
const labelId = `role-tier-label-${role.id}`;
return (
<div className="py-3 space-y-2 first:pt-0 last:pb-0">
<div className="flex items-center justify-between gap-3">
<RowLabel role={role} labelId={labelId} />
<TierSelect tier={tier} onChange={onChange} disabled={disabled} ariaLabelledBy={labelId} />
</div>
<ResolvedRow tier={tier} config={config} />
</div>
);
}
function RowLabel({ role, labelId }: { role: RoleMeta; labelId: string }) {
const { t } = useTranslation();
return (
<div className="flex items-center gap-1.5">
<span id={labelId} className="text-sm font-medium">
{role.label}
</span>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={t("office:moreInfoAbout", { label: role.label })}
className="inline-flex h-11 w-11 shrink-0 cursor-pointer items-center justify-center text-muted-foreground hover:text-foreground"
>
<IconInfoCircle className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="right" className="max-w-xs text-xs leading-relaxed">
{role.description || t("office:roleTierRowInfo")}
</TooltipContent>
</Tooltip>
</div>
);
}
// Catalog keys, not copy — module scope freezes a `t()` at the boot locale.
const TIER_OPTIONS: Array<{ value: Tier; labelKey: string }> = [
{ value: "frontier", labelKey: "office:tierFrontierQualified" },
{ value: "balanced", labelKey: "office:tierBalancedQualified" },
{ value: "economy", labelKey: "office:tierEconomyQualified" },
];
function TierSelect({
tier,
onChange,
disabled,
ariaLabelledBy,
}: {
tier: Tier | undefined;
onChange: (tier: Tier | typeof USE_WORKSPACE_DEFAULT) => void;
disabled?: boolean;
ariaLabelledBy: string;
}) {
const { t } = useTranslation();
const selected = tier ?? USE_WORKSPACE_DEFAULT;
return (
<Select
value={selected}
onValueChange={(v) => onChange(v as Tier | typeof USE_WORKSPACE_DEFAULT)}
disabled={disabled}
>
<SelectTrigger aria-labelledby={ariaLabelledBy} className="min-h-11 w-[220px] cursor-pointer">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={USE_WORKSPACE_DEFAULT} className="cursor-pointer">
{t("office:useWorkspaceDefaultTier")}
</SelectItem>
{TIER_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value} className="cursor-pointer">
{t(opt.labelKey)}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
// ResolvedRow shows "Currently maps to: <provider> / <model>" so users see
// the concrete (provider, model) their selection produces. When the
// selected tier is not mapped on any provider in the order we surface a
// warning chip directing them to the provider tier mapping section.
function ResolvedRow({ tier, config }: { tier: Tier | undefined; config: WorkspaceRouting }) {
const { t } = useTranslation();
if (!tier) return null;
const match = firstProviderWithTier(tier, config);
if (!match) {
return (
<p className="text-[11px] text-destructive flex items-center gap-1 pl-1">
<IconAlertTriangle className="h-3 w-3" />
{/* One key: the tier appeared twice in one sentence, so two fragments
would have frozen both the order and the repetition. */}
{t("office:noProviderHasTierMapped", { tier: t(TIER_NAME_KEYS[tier]) })}
</p>
);
}
return (
<p className="text-[11px] text-muted-foreground pl-1">
{t("office:currentlyMapsTo")}{" "}
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="secondary" className="ml-0.5 cursor-help">
{providerLabel(match.providerId)} / {match.model}
</Badge>
</TooltipTrigger>
<TooltipContent side="right" className="max-w-xs text-xs leading-relaxed">
{t("office:typicalMappingAtLaunchTimeThe")}
</TooltipContent>
</Tooltip>
</p>
);
}
@@ -188,7 +188,9 @@ function ResolvedRow({ tier, config }: { tier: Tier | undefined; config: Workspa
);
}
function firstProviderWithTier(
// Exported for reuse by RoleTierCard's own ResolvedRow (same "currently
// maps to" lookup, one level down the tier precedence chain).
export function firstProviderWithTier(
tier: Tier,
config: WorkspaceRouting,
): { providerId: string; model: string } | null {
@@ -12,6 +12,8 @@ import { useRoutingPreview } from "@/hooks/domains/office/use-routing-preview";
import type {
ProviderProfile,
ExecutionProfileSummary,
RoleMeta,
RoleTierMap,
Tier,
TierPerReason,
WorkspaceRouting,
@@ -22,6 +24,7 @@ import {
ProviderHealthBanner,
ProviderOrderEditor,
ProviderTierMapping,
RoleTierCard,
RoutingEnableCard,
WakeReasonTierCard,
} from "./components";
@@ -29,6 +32,13 @@ import { useTranslation } from "react-i18next";
const DEFAULT_PROFILE: ProviderProfile = { tier_map: {} };
// Stable fallback: a Zustand selector that returns a fresh `[]` on every call
// (when `office.meta` hasn't loaded yet) breaks useSyncExternalStore's
// reference-equality snapshot check and infinite-loops into React error #185
// ("Maximum update depth exceeded"), caught by the route error boundary. See
// EMPTY_PREVIEW / EMPTY_HEALTH in the sibling hooks for the same pattern.
const EMPTY_ROLES: RoleMeta[] = [];
function emptyConfig(): WorkspaceRouting {
return {
enabled: false,
@@ -42,6 +52,7 @@ function emptyConfig(): WorkspaceRouting {
export default function ProviderRoutingPage() {
const { t } = useTranslation();
const workspaceId = useAppStore((s) => s.workspaces.activeId);
const roles = useAppStore((s) => s.office.meta?.roles ?? EMPTY_ROLES);
const routing = useWorkspaceRouting(workspaceId);
const health = useProviderHealth(workspaceId);
const preview = useRoutingPreview(workspaceId);
@@ -85,6 +96,7 @@ export default function ProviderRoutingPage() {
<PageBody
draft={draft}
setDraft={setDraft}
roles={roles}
knownProviders={routing.knownProviders}
executionProfiles={routing.executionProfiles}
saving={saving}
@@ -102,6 +114,7 @@ export default function ProviderRoutingPage() {
type PageBodyProps = {
draft: WorkspaceRouting;
setDraft: (cfg: WorkspaceRouting) => void;
roles: RoleMeta[];
knownProviders: string[];
executionProfiles: ExecutionProfileSummary[];
saving: boolean;
@@ -117,6 +130,7 @@ type PageBodyProps = {
function PageBody({
draft,
setDraft,
roles,
knownProviders,
executionProfiles,
saving,
@@ -133,6 +147,7 @@ function PageBody({
// Named `tier`, not `t`: a parameter called `t` shadows the translate function.
const setTier = (tier: Tier) => setDraft({ ...draft, default_tier: tier });
const setTierPerReason = (m: TierPerReason) => setDraft({ ...draft, tier_per_reason: m });
const setRoleTiers = (m: RoleTierMap) => setDraft({ ...draft, role_tiers: m });
const setOrder = (next: string[]) => {
const profiles = { ...draft.provider_profiles };
for (const p of next) {
@@ -159,6 +174,13 @@ function PageBody({
onChange={setTier}
disabled={sectionsDisabled}
/>
<RoleTierCard
roles={roles}
config={draft}
value={draft.role_tiers ?? {}}
onChange={setRoleTiers}
disabled={sectionsDisabled}
/>
<WakeReasonTierCard
config={draft}
value={draft.tier_per_reason ?? {}}
@@ -275,6 +275,7 @@ export class OfficeApiClient {
execution_profile_ids?: { frontier?: string; balanced?: string; economy?: string };
}
>;
role_tiers?: Record<string, "frontier" | "balanced" | "economy">;
};
known_providers: string[];
execution_profiles: Array<{
@@ -306,6 +307,7 @@ export class OfficeApiClient {
execution_profile_ids?: { frontier?: string; balanced?: string; economy?: string };
}
>;
role_tiers?: Record<string, "frontier" | "balanced" | "economy">;
},
): Promise<Record<string, unknown>> {
return this.request("PUT", `/workspaces/${wsId}/routing`, cfg);
@@ -34,4 +34,46 @@ test.describe("Office execution profile routing on mobile", () => {
await expect(testPage.getByRole("option").first()).toBeVisible();
await assertNoDocumentHorizontalOverflow(testPage, "provider routing profile selector");
});
test("edits and saves a role tier on mobile", async ({
backend,
apiClient,
officeApi,
officeSeed,
testPage,
}) => {
await backend.restart({ KANDEV_MOCK_PROVIDERS: "claude-acp" });
const configured = await balancedExecutionProfileRouting(
apiClient,
officeApi,
officeSeed.workspaceId,
["claude-acp"],
);
await officeApi.updateRouting(officeSeed.workspaceId, { ...configured, enabled: false });
await testPage.goto("/office/workspace/routing");
const roleTier = testPage.getByRole("combobox", { name: "CEO", exact: true });
await expect(roleTier).toBeVisible();
const roleTierBox = await roleTier.boundingBox();
expect(roleTierBox?.width).toBeGreaterThanOrEqual(44);
expect(roleTierBox?.height).toBeGreaterThanOrEqual(44);
const infoButton = testPage.getByRole("button", { name: "More info about CEO", exact: true });
const infoButtonBox = await infoButton.boundingBox();
expect(infoButtonBox?.width).toBeGreaterThanOrEqual(44);
expect(infoButtonBox?.height).toBeGreaterThanOrEqual(44);
await roleTier.click();
await testPage.getByRole("option", { name: /Balanced/ }).click();
await testPage.getByRole("button", { name: "Save", exact: true }).click();
const saved = await officeApi.getRouting(officeSeed.workspaceId);
expect(saved.config.role_tiers?.ceo).toBe("balanced");
await testPage.reload();
await expect(testPage.getByRole("combobox", { name: "CEO", exact: true })).toContainText(
"Balanced",
);
await assertNoDocumentHorizontalOverflow(testPage, "role tier editor");
});
});
@@ -113,3 +113,103 @@ test.describe("Office provider routing — agent override", () => {
expect(attempts.every((a) => a.provider_id === "claude-acp")).toBe(true);
});
});
/**
* Per-role tier routing (AC-13/13b/14/14b/16a/18) and the removal of the
* `model` field from Office agent identity (AC-8a).
*
* The seeded office agent (`officeSeed.agentId`) is always created with
* Role: "ceo" (see internal/office/onboarding/service.go), so a
* `role_tiers: {ceo: ...}` entry targets it directly without a second
* agent.
*
* checkRoleTiersMapped (internal/office/routing/types.go) rejects a
* role_tiers entry whose tier has no provider mapping it via
* execution_profile_ids, so the "economy" tier below reuses the same
* execution profile the balanced-tier helper already created — nothing
* requires a distinct profile per tier, only a non-empty mapping.
*/
test.describe("Office provider routing — role tiers", () => {
test.beforeEach(async ({ apiClient, officeSeed }) => {
await apiClient.e2eReset(officeSeed.workspaceId, [officeSeed.workflowId]);
});
test.afterAll(async ({ backend }) => {
await backend.restart();
});
test("role tier overrides the workspace default and renders its translated source label", async ({
backend,
apiClient,
officeApi,
officeSeed,
testPage,
}) => {
await backend.restart({ KANDEV_MOCK_PROVIDERS: "claude-acp" });
const configured = await balancedExecutionProfileRouting(
apiClient,
officeApi,
officeSeed.workspaceId,
["claude-acp"],
);
const balancedProfile = configured.provider_profiles["claude-acp"];
await officeApi.updateRouting(officeSeed.workspaceId, {
...configured,
role_tiers: { ceo: "economy" },
provider_profiles: {
"claude-acp": {
tier_map: { ...balancedProfile.tier_map, economy: balancedProfile.tier_map.balanced },
execution_profile_ids: {
...balancedProfile.execution_profile_ids,
economy: balancedProfile.execution_profile_ids?.balanced,
},
},
},
});
// API: the seeded CEO agent has no per-agent override and no
// wake-reason policy applies, so it resolves the role tier, not the
// workspace default ("balanced").
const route = await officeApi.getAgentRoute(officeSeed.agentId);
expect(route.preview.tier_source).toBe("role");
expect(route.preview.effective_tier).toBe("economy");
// UI: the resolved-routes preview table renders the widened
// tier_source value as its translated label, not the raw wire value.
await testPage.goto("/office/workspace/routing");
const row = testPage.getByTestId(`preview-row-${officeSeed.agentId}`);
await expect(row).toBeVisible();
await expect(row).toContainText("economy");
await expect(row).toContainText("from role");
});
test("PATCH rejects a model key for an Office-identity agent with the structured shape", async ({
backend,
request,
officeSeed,
}) => {
await backend.restart({ KANDEV_MOCK_PROVIDERS: "claude-acp" });
const modelRes = await request.patch(
`${backend.baseUrl}/api/v1/office/agents/${officeSeed.agentId}`,
{ data: { model: "some-model" } },
);
expect(modelRes.status()).toBe(400);
const modelBody = await modelRes.json();
expect(modelBody.field).toBe("model");
expect(modelBody.error).toBeTruthy();
// Contrast: the agent_profile_id rejection uses the older bare
// {"error": ...} shape with no "field" key — the model rejection
// above is deliberately more structured (AC-8a).
const profileRes = await request.patch(
`${backend.baseUrl}/api/v1/office/agents/${officeSeed.agentId}`,
{ data: { agent_profile_id: "some-profile" } },
);
expect(profileRes.status()).toBe(400);
const profileBody = await profileRes.json();
expect(profileBody.field).toBeUndefined();
expect(profileBody.error).toBeTruthy();
});
});
@@ -98,7 +98,7 @@ describe("office routing store actions", () => {
{
agent_id: "a-1",
agent_name: "CEO",
tier_source: "inherit",
tier_source: "workspace",
effective_tier: "balanced",
fallback_chain: [],
missing: [],
@@ -1,9 +1,11 @@
// --- Provider routing types ---
//
// Split out of types.ts (which grows past the file-length lint limit easily
// given how many office domains it hosts) — provider routing is a
// self-contained concern with no dependencies on the rest of the office
// slice types, so it splits cleanly.
// Split out of `types.ts` to keep that file under the repo's 600-line cap
// (see apps/web/AGENTS.md "Code-quality limits"). Re-exported from
// `types.ts` so existing `@/lib/state/slices/office/types` imports keep
// working unchanged.
import type { AgentRole } from "@/lib/types/agent-profile";
export type Tier = "frontier" | "balanced" | "economy";
@@ -56,12 +58,23 @@ export type WakeReason = "heartbeat" | "routine_trigger" | "budget_alert";
export type TierPerReason = Partial<Record<WakeReason, Tier>>;
// Per-role tier policy: workspace-level default for every agent of a given
// role, overridable per-agent and shadowed by tier_per_reason. See
// docs/specs/office-agent-tier-routing/spec.md.
export type RoleTierMap = Partial<Record<AgentRole, Tier>>;
// The four precedence levels a resolved tier can come from, in
// highest-to-lowest priority order: a matching wake-reason policy, a
// per-agent override, the agent's role entry, or the workspace default.
export type TierSource = "wake_reason" | "override" | "role" | "workspace";
export type WorkspaceRouting = {
enabled: boolean;
provider_order: string[];
default_tier: Tier;
provider_profiles: Record<string, ProviderProfile>;
tier_per_reason?: TierPerReason;
role_tiers?: RoleTierMap;
};
export type AgentRoutingOverrides = {
@@ -106,6 +119,9 @@ export type RouteAttempt = {
provider_id: string;
model?: string;
tier: Tier | "";
// Empty for attempts recorded before this column existed, or that never
// resolved a tier (e.g. a max-attempts-exceeded row).
tier_source?: TierSource | "";
outcome: RouteAttemptOutcome;
error_code?: RoutingErrorCode;
error_confidence?: "high" | "medium" | "low";
@@ -128,7 +144,7 @@ export type ProviderModelPair = {
export type AgentRoutePreview = {
agent_id: string;
agent_name: string;
tier_source: "inherit" | "override";
tier_source: TierSource;
effective_tier: Tier;
// primary_* reflects configured intent — first entry in the
// effective provider order, even when that provider is currently
+7 -3
View File
@@ -459,9 +459,10 @@ export type OfficeMeta = {
};
// --- Provider routing types ---
// Extracted to routing-types.ts to keep this file under the file-length
// lint limit; re-exported here so existing `from ".../office/types"`
// imports keep working unchanged.
//
// Defined in `./routing-types` (kept out of this file to stay under the
// 600-line cap) and re-exported here so existing
// `@/lib/state/slices/office/types` imports keep working unchanged.
export type {
Tier,
@@ -471,6 +472,8 @@ export type {
ExecutionProfileSummary,
WakeReason,
TierPerReason,
RoleTierMap,
TierSource,
WorkspaceRouting,
AgentRoutingOverrides,
ProviderHealthState,
@@ -486,6 +489,7 @@ export type {
RunAttemptsState,
AgentRoutingSliceState,
} from "./routing-types";
import type {
AgentRouteData,
AgentRoutePreview,
+11
View File
@@ -355,6 +355,7 @@
"inherit": "Inherit",
"inheritFromProjectWorkspace": "Inherit from project/workspace",
"inheritFromWorkspace": "Inherit from workspace",
"inheritsTierFromRole": "Inherits <1>{{tier}}</1> from role.",
"inheritsTierFromWorkspace": "Inherits <1>{{tier}}</1> from workspace.",
"inheritsWorkspacePolicyList": "Inherits workspace policy: {{list}}.",
"inheritsWorkspacePolicyNoneSet": "Inherits workspace policy (no wake-reason tiers set yet).",
@@ -504,6 +505,7 @@
"outgoingDatabaseFilesystem": "Outgoing (Database → Filesystem)",
"output": "Output",
"outputTokens": "Output tokens",
"overrideShadowedByWakeReasons": "Your tier override is only used for runs that do not match a wake-reason policy. This agent's wake-reason tiers already cover: {{list}}.",
"overrideTheWorkspaceTierOrProvider": "Override the workspace tier or provider order for this agent.",
"overrideWakeReasonTiers": "Override wake-reason tiers",
"overrideWakeReasonTiersHelp": "This is rare. Override only if this agent specifically needs different behaviour for its background work; for example, a security-critical agent that must use Frontier even for routine checks. Most agents should inherit the workspace policy.",
@@ -630,6 +632,8 @@
"roleQa": "QA",
"roleSecurity": "Security",
"roleSpecialist": "Specialist",
"roleTierPolicy": "Role tier policy",
"roleTierRowInfo": "Sets the default model tier for every agent with this role, unless a wake-reason policy or a per-agent override applies first.",
"roleWorker": "Worker",
"routeAttempts": "Route attempts",
"routeBlocked": "Blocked",
@@ -719,6 +723,7 @@
"sessionId": "Session ID",
"sessionIdAfter": "Session ID (after)",
"sessionIdBefore": "Session ID (before)",
"setADefaultTierForEveryAgentOfA": "Set a default tier for every agent of a given role. Falls back to the workspace default tier when a role has no entry here; a wake-reason policy or a per-agent override still takes priority over this.",
"setSpendingLimitsPerAgentOr": "Set spending limits per agent or project. Agents pause when limits are exceeded.",
"setupTierAgentProfiles": "Setup tier agent profiles",
"setupTitle": "Setup",
@@ -834,6 +839,11 @@
"tierNameBalanced": "balanced",
"tierNameEconomy": "economy",
"tierNameFrontier": "frontier",
"tierShadowedByWakeReasons": "The tier shown above is only used for runs that do not match a wake-reason policy. This workspace's wake-reason tiers already cover: {{list}}.",
"tierSourceOverride": "from override",
"tierSourceRole": "from role",
"tierSourceWakeReason": "from wake reason",
"tierSourceWorkspace": "from workspace",
"timezone": "Timezone",
"title": "Title",
"titleForAutoCreatedTasksUse": "Title for auto-created tasks. Use {{name}} and {{date}} as placeholders.",
@@ -863,6 +873,7 @@
"useAgentSNormalTier": "Use agent's normal tier",
"usedBy": "USED BY",
"useQuery": "Use “{{query}}”",
"useWorkspaceDefaultTier": "Use workspace default tier",
"usingThisAgentsWakeReasonOverrides": "Using this agent's wake-reason overrides.",
"variables": "Variables",
"waiting": "Waiting…",
+11
View File
@@ -355,6 +355,7 @@
"inherit": "Ĩńĥēŕĩţ",
"inheritFromProjectWorkspace": "Ĩńĥēŕĩţ ƒŕōḿ ƥŕōĵēćţ/ŵōŕķśƥàćē",
"inheritFromWorkspace": "Ĩńĥēŕĩţ ƒŕōḿ ŵōŕķśƥàćē",
"inheritsTierFromRole": "Ĩńĥēŕĩţś <1>{{tier}}</1> ƒŕōḿ ŕōĺē.",
"inheritsTierFromWorkspace": "Ĩńĥēŕĩţś <1>{{tier}}</1> ƒŕōḿ ŵōŕķśƥàćē.",
"inheritsWorkspacePolicyList": "Ĩńĥēŕĩţś ŵōŕķśƥàćē ƥōĺĩćŷ: {{list}}.",
"inheritsWorkspacePolicyNoneSet": "Ĩńĥēŕĩţś ŵōŕķśƥàćē ƥōĺĩćŷ (ńō ŵàķē-ŕēàśōń ţĩēŕś śēţ ŷēţ).",
@@ -504,6 +505,7 @@
"outgoingDatabaseFilesystem": "Ōũţĝōĩńĝ (Ďàţàƀàśē → Ƒĩĺēśŷśţēḿ)",
"output": "Ōũţƥũţ",
"outputTokens": "Ōũţƥũţ ţōķēńś",
"overrideShadowedByWakeReasons": "Ŷōũŕ ţĩēŕ ōvēŕŕĩďē ĩś ōńĺŷ ũśēď ƒōŕ ŕũńś ţĥàţ ďō ńōţ ḿàţćĥ à ŵàķē-ŕēàśōń ƥōĺĩćŷ. Ţĥĩś àĝēńţ'ś ŵàķē-ŕēàśōń ţĩēŕś àĺŕēàďŷ ćōvēŕ: {{list}}.",
"overrideTheWorkspaceTierOrProvider": "Ōvēŕŕĩďē ţĥē ŵōŕķśƥàćē ţĩēŕ ōŕ ƥŕōvĩďēŕ ōŕďēŕ ƒōŕ ţĥĩś àĝēńţ.",
"overrideWakeReasonTiers": "Ōvēŕŕĩďē ŵàķē-ŕēàśōń ţĩēŕś",
"overrideWakeReasonTiersHelp": "Ţĥĩś ĩś ŕàŕē. Ōvēŕŕĩďē ōńĺŷ ĩƒ ţĥĩś àĝēńţ śƥēćĩƒĩćàĺĺŷ ńēēďś ďĩƒƒēŕēńţ ƀēĥàvĩōũŕ ƒōŕ ĩţś ƀàćķĝŕōũńď ŵōŕķ; ƒōŕ ēxàḿƥĺē, à śēćũŕĩţŷ-ćŕĩţĩćàĺ àĝēńţ ţĥàţ ḿũśţ ũśē Ƒŕōńţĩēŕ ēvēń ƒōŕ ŕōũţĩńē ćĥēćķś. Ḿōśţ àĝēńţś śĥōũĺď ĩńĥēŕĩţ ţĥē ŵōŕķśƥàćē ƥōĺĩćŷ.",
@@ -630,6 +632,8 @@
"roleQa": "QÀ",
"roleSecurity": "Śēćũŕĩţŷ",
"roleSpecialist": "Śƥēćĩàĺĩśţ",
"roleTierPolicy": "Ŕōĺē ţĩēŕ ƥōĺĩćŷ",
"roleTierRowInfo": "Śēţś ţĥē ďēƒàũĺţ ḿōďēĺ ţĩēŕ ƒōŕ ēvēŕŷ àĝēńţ ŵĩţĥ ţĥĩś ŕōĺē, ũńĺēśś à ŵàķē-ŕēàśōń ƥōĺĩćŷ ōŕ à ƥēŕ-àĝēńţ ōvēŕŕĩďē àƥƥĺĩēś ƒĩŕśţ.",
"roleWorker": "Ŵōŕķēŕ",
"routeAttempts": "Ŕōũţē àţţēḿƥţś",
"routeBlocked": "Ɓĺōćķēď",
@@ -719,6 +723,7 @@
"sessionId": "Śēśśĩōń ĨĎ",
"sessionIdAfter": "Śēśśĩōń ĨĎ (àƒţēŕ)",
"sessionIdBefore": "Śēśśĩōń ĨĎ (ƀēƒōŕē)",
"setADefaultTierForEveryAgentOfA": "Śēţ à ďēƒàũĺţ ţĩēŕ ƒōŕ ēvēŕŷ àĝēńţ ōƒ à ĝĩvēń ŕōĺē. Ƒàĺĺś ƀàćķ ţō ţĥē ŵōŕķśƥàćē ďēƒàũĺţ ţĩēŕ ŵĥēń à ŕōĺē ĥàś ńō ēńţŕŷ ĥēŕē; à ŵàķē-ŕēàśōń ƥōĺĩćŷ ōŕ à ƥēŕ-àĝēńţ ōvēŕŕĩďē śţĩĺĺ ţàķēś ƥŕĩōŕĩţŷ ōvēŕ ţĥĩś.",
"setSpendingLimitsPerAgentOr": "Śēţ śƥēńďĩńĝ ĺĩḿĩţś ƥēŕ àĝēńţ ōŕ ƥŕōĵēćţ. Àĝēńţś ƥàũśē ŵĥēń ĺĩḿĩţś àŕē ēxćēēďēď.",
"setupTierAgentProfiles": "Śēţũƥ ţĩēŕ àĝēńţ ƥŕōƒĩĺēś",
"setupTitle": "Śēţũƥ",
@@ -834,6 +839,11 @@
"tierNameBalanced": "ƀàĺàńćēď",
"tierNameEconomy": "ēćōńōḿŷ",
"tierNameFrontier": "ƒŕōńţĩēŕ",
"tierShadowedByWakeReasons": "Ţĥē ţĩēŕ śĥōŵń àƀōvē ĩś ōńĺŷ ũśēď ƒōŕ ŕũńś ţĥàţ ďō ńōţ ḿàţćĥ à ŵàķē-ŕēàśōń ƥōĺĩćŷ. Ţĥĩś ŵōŕķśƥàćē'ś ŵàķē-ŕēàśōń ţĩēŕś àĺŕēàďŷ ćōvēŕ: {{list}}.",
"tierSourceOverride": "ƒŕōḿ ōvēŕŕĩďē",
"tierSourceRole": "ƒŕōḿ ŕōĺē",
"tierSourceWakeReason": "ƒŕōḿ ŵàķē ŕēàśōń",
"tierSourceWorkspace": "ƒŕōḿ ŵōŕķśƥàćē",
"timezone": "Ţĩḿēźōńē",
"title": "Ţĩţĺē",
"titleForAutoCreatedTasksUse": "Ţĩţĺē ƒōŕ àũţō-ćŕēàţēď ţàśķś. Ũśē {{name}} àńď {{date}} àś ƥĺàćēĥōĺďēŕś.",
@@ -863,6 +873,7 @@
"useAgentSNormalTier": "Ũśē àĝēńţ'ś ńōŕḿàĺ ţĩēŕ",
"usedBy": "ŨŚĒĎ ƁŶ",
"useQuery": "Ũśē “{{query}}”",
"useWorkspaceDefaultTier": "Ũśē ŵōŕķśƥàćē ďēƒàũĺţ ţĩēŕ",
"usingThisAgentsWakeReasonOverrides": "Ũśĩńĝ ţĥĩś àĝēńţ'ś ŵàķē-ŕēàśōń ōvēŕŕĩďēś.",
"variables": "Vàŕĩàƀĺēś",
"waiting": "Ŵàĩţĩńĝ…",
+11
View File
@@ -355,6 +355,7 @@
"inherit": "Herdar",
"inheritFromProjectWorkspace": "Herdar do projeto ou da área de trabalho",
"inheritFromWorkspace": "Herdar da área de trabalho",
"inheritsTierFromRole": "Herda <1>{{tier}}</1> da função.",
"inheritsTierFromWorkspace": "Herda <1>{{tier}}</1> da área de trabalho.",
"inheritsWorkspacePolicyList": "Herda a política da área de trabalho: {{list}}.",
"inheritsWorkspacePolicyNoneSet": "Herda a política da área de trabalho (ainda não há escalões definidos por motivo de despertar).",
@@ -504,6 +505,7 @@
"outgoingDatabaseFilesystem": "A enviar (base de dados → sistema de ficheiros)",
"output": "Saída",
"outputTokens": "Tokens de saída",
"overrideShadowedByWakeReasons": "A sua substituição de escalão só é usada em execuções que não correspondam a uma política de motivo de despertar. Os escalões por motivo de despertar deste agente já cobrem: {{list}}.",
"overrideTheWorkspaceTierOrProvider": "Substitua o escalão ou a ordem de fornecedores da área de trabalho para este agente.",
"overrideWakeReasonTiers": "Substituir os escalões por motivo de despertar",
"overrideWakeReasonTiersHelp": "Isto é raro. Substitua apenas se este agente precisar mesmo de um comportamento diferente no trabalho em segundo plano; por exemplo, um agente crítico para a segurança que tenha de usar Frontier mesmo em verificações de rotina. A maioria dos agentes deve herdar a política da área de trabalho.",
@@ -630,6 +632,8 @@
"roleQa": "QA",
"roleSecurity": "Segurança",
"roleSpecialist": "Especialista",
"roleTierPolicy": "Política de escalão por função",
"roleTierRowInfo": "Define o escalão de modelo predefinido para todos os agentes com esta função, salvo se uma política de motivo de despertar ou uma substituição por agente se aplicar primeiro.",
"roleWorker": "Trabalhador",
"routeAttempts": "Tentativas de encaminhamento",
"routeBlocked": "Bloqueada",
@@ -719,6 +723,7 @@
"sessionId": "ID da sessão",
"sessionIdAfter": "ID da sessão (depois)",
"sessionIdBefore": "ID da sessão (antes)",
"setADefaultTierForEveryAgentOfA": "Defina um escalão predefinido para todos os agentes de uma determinada função. Recorre ao escalão predefinido da área de trabalho quando uma função não tem aqui nenhuma entrada; uma política de motivo de despertar ou uma substituição por agente continua a ter prioridade sobre isto.",
"setSpendingLimitsPerAgentOr": "Defina limites de despesa por agente ou por projeto. Os agentes são pausados quando os limites são excedidos.",
"setupTierAgentProfiles": "Configurar os perfis de agente por escalão",
"setupTitle": "Configuração",
@@ -834,6 +839,11 @@
"tierNameBalanced": "Balanced",
"tierNameEconomy": "Economy",
"tierNameFrontier": "Frontier",
"tierShadowedByWakeReasons": "O escalão apresentado acima só é usado em execuções que não correspondam a uma política de motivo de despertar. Os escalões por motivo de despertar desta área de trabalho já cobrem: {{list}}.",
"tierSourceOverride": "por substituição",
"tierSourceRole": "por função",
"tierSourceWakeReason": "por motivo de despertar",
"tierSourceWorkspace": "por área de trabalho",
"timezone": "Fuso horário",
"title": "Título",
"titleForAutoCreatedTasksUse": "Título das tarefas criadas automaticamente. Use {{name}} e {{date}} como marcadores.",
@@ -863,6 +873,7 @@
"useAgentSNormalTier": "Usar o escalão normal do agente",
"usedBy": "USADO POR",
"useQuery": "Usar “{{query}}”",
"useWorkspaceDefaultTier": "Usar o escalão predefinido da área de trabalho",
"usingThisAgentsWakeReasonOverrides": "A usar as substituições por motivo de despertar deste agente.",
"variables": "Variáveis",
"waiting": "À espera…",
+11
View File
@@ -355,6 +355,7 @@
"inherit": "继承",
"inheritFromProjectWorkspace": "从项目/工作区继承",
"inheritFromWorkspace": "从工作区继承",
"inheritsTierFromRole": "从角色继承 <1>{{tier}}</1>。",
"inheritsTierFromWorkspace": "从工作区继承 <1>{{tier}}</1>。",
"inheritsWorkspacePolicyList": "继承工作区策略:{{list}}。",
"inheritsWorkspacePolicyNoneSet": "继承工作区策略(尚未设置唤醒原因层级)。",
@@ -504,6 +505,7 @@
"outgoingDatabaseFilesystem": "传出(数据库 → 文件系统)",
"output": "输出",
"outputTokens": "输出令牌",
"overrideShadowedByWakeReasons": "你的层级覆盖仅用于不匹配任何唤醒原因策略的运行。此智能体的唤醒原因层级已覆盖:{{list}}。",
"overrideTheWorkspaceTierOrProvider": "覆盖此智能体的工作区层级或提供商顺序。",
"overrideWakeReasonTiers": "覆盖唤醒原因层级",
"overrideWakeReasonTiersHelp": "较少使用。仅当此智能体的后台工作需要不同行为时覆盖;例如安全关键智能体即使例行检查也必须使用 Frontier。多数智能体应继承工作区策略。",
@@ -630,6 +632,8 @@
"roleQa": "QA",
"roleSecurity": "安全",
"roleSpecialist": "专家",
"roleTierPolicy": "角色层级策略",
"roleTierRowInfo": "为具有此角色的每个智能体设置默认模型层级,除非唤醒原因策略或按智能体的覆盖优先生效。",
"roleWorker": "工作者",
"routeAttempts": "路由尝试",
"routeBlocked": "已阻塞",
@@ -719,6 +723,7 @@
"sessionId": "会话 ID",
"sessionIdAfter": "会话 ID(之后)",
"sessionIdBefore": "会话 ID(之前)",
"setADefaultTierForEveryAgentOfA": "为具有给定角色的每个智能体设置默认层级。当某角色在此处没有条目时,将回退到工作区默认层级;唤醒原因策略或按智能体的覆盖仍优先于此设置。",
"setSpendingLimitsPerAgentOr": "按智能体或项目设置支出限额。超出限额时智能体会暂停。",
"setupTierAgentProfiles": "设置层级智能体配置",
"setupTitle": "初始设置",
@@ -834,6 +839,11 @@
"tierNameBalanced": "balanced",
"tierNameEconomy": "economy",
"tierNameFrontier": "frontier",
"tierShadowedByWakeReasons": "上方显示的层级仅用于不匹配任何唤醒原因策略的运行。此工作区的唤醒原因层级已覆盖:{{list}}。",
"tierSourceOverride": "来自覆盖",
"tierSourceRole": "来自角色",
"tierSourceWakeReason": "来自唤醒原因",
"tierSourceWorkspace": "来自工作区",
"timezone": "时区",
"title": "标题",
"titleForAutoCreatedTasksUse": "自动创建任务的标题。可用 {{name}} 与 {{date}} 作为占位符。",
@@ -863,6 +873,7 @@
"useAgentSNormalTier": "使用智能体的常规层级",
"usedBy": "使用者",
"useQuery": "使用“{{query}}”",
"useWorkspaceDefaultTier": "使用工作区默认层级",
"usingThisAgentsWakeReasonOverrides": "正在使用此智能体的唤醒原因覆盖。",
"variables": "变量",
"waiting": "等待中…",
+11
View File
@@ -355,6 +355,7 @@
"inherit": "繼承",
"inheritFromProjectWorkspace": "從項目/工作區繼承",
"inheritFromWorkspace": "從工作區繼承",
"inheritsTierFromRole": "從角色繼承 <1>{{tier}}</1>。",
"inheritsTierFromWorkspace": "從工作區繼承 <1>{{tier}}</1>。",
"inheritsWorkspacePolicyList": "繼承工作區策略:{{list}}。",
"inheritsWorkspacePolicyNoneSet": "繼承工作區策略(尚未設定喚醒原因層級)。",
@@ -504,6 +505,7 @@
"outgoingDatabaseFilesystem": "傳出(數據庫 → 檔案系統)",
"output": "輸出",
"outputTokens": "輸出權杖",
"overrideShadowedByWakeReasons": "你的層級覆蓋僅用於不匹配任何喚醒原因策略的執行。此代理程式的喚醒原因層級已覆蓋:{{list}}。",
"overrideTheWorkspaceTierOrProvider": "覆蓋此代理程式的工作區層級或提供者順序。",
"overrideWakeReasonTiers": "覆蓋喚醒原因層級",
"overrideWakeReasonTiersHelp": "較少使用。僅當此代理程式的後台工作需要不同行為時覆蓋;例如安全關鍵代理程式即使例行檢查也必須使用 Frontier。多數代理程式應繼承工作區策略。",
@@ -630,6 +632,8 @@
"roleQa": "QA",
"roleSecurity": "安全",
"roleSpecialist": "專家",
"roleTierPolicy": "角色層級策略",
"roleTierRowInfo": "為具有此角色的每個代理程式設定預設模型層級,除非喚醒原因策略或按代理程式的覆蓋優先生效。",
"roleWorker": "工作者",
"routeAttempts": "路由嘗試",
"routeBlocked": "已阻塞",
@@ -719,6 +723,7 @@
"sessionId": "工作階段 ID",
"sessionIdAfter": "工作階段 ID(之後)",
"sessionIdBefore": "工作階段 ID(之前)",
"setADefaultTierForEveryAgentOfA": "為具有給定角色的每個代理程式設定預設層級。當某角色在此處沒有條目時,將後備到工作區預設層級;喚醒原因策略或按代理程式的覆蓋仍優先於此設定。",
"setSpendingLimitsPerAgentOr": "按代理程式或項目設定支出限額。超出限額時代理程式會暫停。",
"setupTierAgentProfiles": "設定層級代理程式設定檔",
"setupTitle": "初始設置",
@@ -834,6 +839,11 @@
"tierNameBalanced": "balanced",
"tierNameEconomy": "economy",
"tierNameFrontier": "frontier",
"tierShadowedByWakeReasons": "上方顯示的層級僅用於不匹配任何喚醒原因策略的執行。此工作區的喚醒原因層級已覆蓋:{{list}}。",
"tierSourceOverride": "來自覆蓋",
"tierSourceRole": "來自角色",
"tierSourceWakeReason": "來自喚醒原因",
"tierSourceWorkspace": "來自工作區",
"timezone": "時區",
"title": "標題",
"titleForAutoCreatedTasksUse": "自動建立任務的標題。可用 {{name}} 與 {{date}} 作為佔位符。",
@@ -863,6 +873,7 @@
"useAgentSNormalTier": "使用代理程式的常規層級",
"usedBy": "用戶",
"useQuery": "使用「{{query}}」",
"useWorkspaceDefaultTier": "使用工作區預設層級",
"usingThisAgentsWakeReasonOverrides": "正在使用此代理程式的喚醒原因覆蓋。",
"variables": "變量",
"waiting": "等待中…",
+11
View File
@@ -355,6 +355,7 @@
"inherit": "繼承",
"inheritFromProjectWorkspace": "從專案/工作區繼承",
"inheritFromWorkspace": "從工作區繼承",
"inheritsTierFromRole": "從角色繼承 <1>{{tier}}</1>。",
"inheritsTierFromWorkspace": "從工作區繼承 <1>{{tier}}</1>。",
"inheritsWorkspacePolicyList": "繼承工作區策略:{{list}}。",
"inheritsWorkspacePolicyNoneSet": "繼承工作區策略(尚未設定喚醒原因層級)。",
@@ -504,6 +505,7 @@
"outgoingDatabaseFilesystem": "傳出(資料庫 → 檔案系統)",
"output": "輸出",
"outputTokens": "輸出權杖",
"overrideShadowedByWakeReasons": "你的層級覆蓋僅用於不匹配任何喚醒原因策略的執行。此代理程式的喚醒原因層級已覆蓋:{{list}}。",
"overrideTheWorkspaceTierOrProvider": "覆蓋此代理程式的工作區層級或提供者順序。",
"overrideWakeReasonTiers": "覆蓋喚醒原因層級",
"overrideWakeReasonTiersHelp": "較少使用。僅當此代理程式的後台工作需要不同行為時覆蓋;例如安全關鍵代理程式即使例行檢查也必須使用 Frontier。多數代理程式應繼承工作區策略。",
@@ -630,6 +632,8 @@
"roleQa": "QA",
"roleSecurity": "安全",
"roleSpecialist": "專家",
"roleTierPolicy": "角色層級策略",
"roleTierRowInfo": "為具有此角色的每個代理程式設定預設模型層級,除非喚醒原因策略或按代理程式的覆蓋優先生效。",
"roleWorker": "工作者",
"routeAttempts": "路由嘗試",
"routeBlocked": "已阻塞",
@@ -719,6 +723,7 @@
"sessionId": "工作階段 ID",
"sessionIdAfter": "工作階段 ID(之後)",
"sessionIdBefore": "工作階段 ID(之前)",
"setADefaultTierForEveryAgentOfA": "為具有給定角色的每個代理程式設定預設層級。當某角色在此處沒有條目時,將備援到工作區預設層級;喚醒原因策略或按代理程式的覆蓋仍優先於此設定。",
"setSpendingLimitsPerAgentOr": "按代理程式或專案設定支出限額。超出限額時代理程式會暫停。",
"setupTierAgentProfiles": "設定層級代理程式設定檔",
"setupTitle": "初始設置",
@@ -834,6 +839,11 @@
"tierNameBalanced": "balanced",
"tierNameEconomy": "economy",
"tierNameFrontier": "frontier",
"tierShadowedByWakeReasons": "上方顯示的層級僅用於不匹配任何喚醒原因策略的執行。此工作區的喚醒原因層級已覆蓋:{{list}}。",
"tierSourceOverride": "來自覆蓋",
"tierSourceRole": "來自角色",
"tierSourceWakeReason": "來自喚醒原因",
"tierSourceWorkspace": "來自工作區",
"timezone": "時區",
"title": "標題",
"titleForAutoCreatedTasksUse": "自動建立任務的標題。可用 {{name}} 與 {{date}} 作為佔位符。",
@@ -863,6 +873,7 @@
"useAgentSNormalTier": "使用代理程式的常規層級",
"usedBy": "使用者",
"useQuery": "使用「{{query}}」",
"useWorkspaceDefaultTier": "使用工作區預設層級",
"usingThisAgentsWakeReasonOverrides": "正在使用此代理程式的喚醒原因覆蓋。",
"variables": "變數",
"waiting": "等待中…",
+1
View File
@@ -22,6 +22,7 @@ The office umbrella covers kandev's autonomous-agent product surface: workspaces
| [scheduler](office/scheduler.md) | draft |
| [runtime](office/runtime.md) | draft |
| [routing](office/routing.md) | archived |
| [per-agent + per-role tier selection](office-agent-tier-routing/spec.md) | draft |
| [costs](office/costs.md) | in-progress |
| [dashboard](office/dashboard.md) | draft |
| [live-updates](office/live-updates.md) | draft |
@@ -0,0 +1,924 @@
---
status: draft
created: 2026-08-20
owner: nova28
umbrella: office
extends: docs/specs/office/routing.md
---
# Office per-agent and per-role tier selection
Office agents in one workspace must be able to run on different model tiers. Today
they can — a per-agent tier override already exists end to end — but the capability is
undiscoverable, the org has no way to express a tier as a property of a role, and the
Office agent record still carries a `model` field that routing ignores. The result is
an operator who configures a Critic on `opus[1m]`, watches it run on `sonnet`, and has
nothing in the product that explains the difference.
This spec extends `docs/specs/office/routing.md`. That spec remains authoritative for
tiers, provider order, execution profiles, provider health, and wake-reason policy.
Nothing here changes those contracts except where explicitly named in
[Precedence](#precedence-contract).
## Verified current state
Verified 2026-08-20 against `~/.kandev/data/kandev.db` (workspace
`95542bf3-37e9-4fbc-9dbe-e2c774a5e7f6`) and the tree at `0b10edfa2`.
### The reported symptom reproduces
`office_run_route_attempts` holds ten attempts; every one resolved
`tier=balanced`, and the five most recent `claude-acp` launches used
`execution_profile_id=b139adcd-…` (profile "Sonnet", `model=sonnet`).
`office_workspace_routing` for that workspace:
```text
enabled = 1
default_tier = balanced
provider_order = ["claude-acp","codex-acp"]
tier_per_reason = {"budget_alert":"economy","heartbeat":"economy","routine_trigger":"economy"}
```
### Why it happened — the override exists and was never set
| Agent | `role` | `agent_profiles.model` | `settings` | Ran as |
|---|---|---|---|---|
| CEO | `ceo` | `opus[1m]` | `{"routing":{"provider_order_source":"inherit","tier_source":"inherit"}}` | sonnet |
| Critic | `specialist` | `opus[1m]` | `{}` | sonnet |
| Analyst | `specialist` | `sonnet` | `{}` | sonnet |
| Tech Lead | `specialist` | `sonnet` | `{}` | sonnet |
| Product Manager | `assistant` | `sonnet` | `{}` | sonnet |
No agent carried `tier_source: "override"`. Every run therefore fell through to the
workspace default, which is the documented and correct behaviour.
A per-agent tier override is already implemented on every layer:
- **Type + validation** — `routing.AgentOverrides{TierSource, Tier}` and
`ValidateAgentOverridesAgainstWorkspace` (`internal/office/routing/types.go`).
- **Resolution** — `effectiveTier()` (`internal/office/routing/resolver.go`) already
honours `ov.TierSource == "override"` above the workspace default.
- **Persistence** — `routing.WriteAgentOverrides` onto `agent_profiles.settings`.
- **API** — `PATCH /office/agents/:id` with a `routing` body key
(`internal/office/agents/handler.go` → `applyRoutingOverride`).
- **UI** — "Override workspace tier" toggle plus a tier toggle group in
`apps/web/app/office/agents/[id]/components/agent-routing-card.tsx`.
**The card's premise that no per-agent override exists is incorrect.** The defect is
discoverability, the absent role lever, and the misleading `model` field.
### A role → tier map cannot solve the reported problem
The card proposes a `role -> tier` map and calls it the better option. The verified
org makes that insufficient on its own:
| `role` | agents |
|---|---|
| `ceo` | CEO |
| `assistant` | Product Manager |
| `specialist` | **Analyst, Tech Lead, Critic** |
Analyst and Critic share `role = specialist`. The card's motivating case is exactly
"the Critic must not run the same model as the Analyst it checks", and a role → tier
map cannot express that. Roles are a fixed seven-value enum
(`ceo, worker, specialist, assistant, security, qa, devops`;
`internal/agent/settings/models/agent_attributes.go:16-24`) and are not user-extensible.
Per-role is therefore specified here as a **bulk default**, never as the mechanism that
satisfies the Critic case. Per-agent remains the precise lever.
### The `model` field really is ignored
`models.AgentInstance` is a type alias for `settingsmodels.AgentProfile`
(`internal/office/models/models.go:57`) — Office identities and execution profiles are
rows in the same `agent_profiles` table, separated by `role != ''` and
`workspace_id != ''`.
At launch the resolver takes the tier's execution-profile ID and reads **that** row's
`Model`. It explicitly refuses an Office identity in that position:
`resolveExecutionProfile` returns `profile %q is an Office agent identity, not an
execution profile` when `profile.Role != ""` (`resolver.go`). So an Office agent's own
`model` column can never reach a launch.
Scope of the misleading surface, verified:
- **Not shown in Settings → Agents.** `filterGlobalProfiles`
(`internal/agent/settings/controller/agent_crud.go:99`) drops rows with
`WorkspaceID != ""`, and all five Office identities are workspace-scoped.
- **Not shown on the Office agent page.** `agent-route-strip.tsx` and `agent-card.tsx`
render `preview.current_model` / `preview.primary_model` — the resolved model.
- **Is exposed by the API.** `AgentResponse` returns `*models.AgentInstance` verbatim,
and `AgentProfile.Model` carries `json:"model"`, so `GET /office/agents/:id`
advertises `"model": "opus[1m]"` for the Critic. `PATCH` already rejects
`agent_profile_id` with *"agent_profile_id no longer selects an Office runtime"*,
but says nothing about `model`.
The accurate defect statement is therefore: **the Office agent API advertises a `model`
field that Office routing ignores**, not "the UI displays it".
## Precedence contract
### Deviation from the card's stated acceptance — read this first
The card's acceptance asks for `per-agent > per-role > tier_per_reason > workspace`.
This spec deliberately specifies `tier_per_reason > per-agent > per-role > workspace`
instead. Reasons, in order of weight:
1. **The card's own goal does not need the reversal.** The Critic and Analyst both ran
with reason `task_assigned`, for which no `tier_per_reason` key exists. The frozen
spec already guarantees that case falls through to the agent's effective tier
(`docs/specs/office/routing.md`, final wake-reason AC). Per-agent already wins where
the card needs it to win.
2. **The reversal breaks a shipped, documented guarantee.**
`docs/specs/office/routing.md` states: *"the resolver picks the Economy tier model
regardless of the agent's default tier"* for `tier_per_reason.heartbeat = economy`.
Reversing precedence silently voids that line.
3. **It would regress cost control.** `tier_per_reason` exists to cheap-out predictable
background work. Under the card's order, one agent pinned to `frontier` runs
`opus[1m]` on every heartbeat forever — the exact blowup the feature prevents. The
verified workspace maps all three wake reasons to `economy`.
4. **The card's intent already has a supported expression.** An agent that must stay on
Frontier even for heartbeats sets its own `tier_per_reason` override
(`TierPerReasonSource = "override"`), which is shipped, validated, and documented.
This is a contract decision made under the board's "prefer a defensible decision"
rule rather than parking the card. **Spec Review: if this reasoning is rejected, the
correct disposition is NEEDS RETHINK back to Spec, not a Build-time edit.**
### The contract
For one run, the effective tier is the first of these that yields a non-empty tier:
1. **Wake-reason policy** — agent `tier_per_reason` override when
`tier_per_reason_source == "override"`, otherwise workspace `tier_per_reason`,
keyed by the run's reason. Skipped entirely when the run reason is empty or absent
from the map. *(unchanged)*
2. **Per-agent tier** — `routing.tier` when `tier_source == "override"` and `tier` is
non-empty. *(unchanged)*
3. **Per-role tier** — the workspace `role_tiers` entry for the agent's `role`. *(new)*
4. **Workspace `default_tier`.** *(unchanged)*
Only step 3 is added. Steps 1, 2 and 4 keep their current order and semantics.
## Data model
One new column on the existing `office_workspace_routing` row. No new table, and no
column on `agent_profiles` — keeping routing policy in one place, per the card's own
recommendation and consistent with how `tier_per_reason` is stored.
```text
office_workspace_routing
role_tiers TEXT NOT NULL DEFAULT '{}' -- JSON map: role -> tier
```
- Keys are restricted to the seven `AgentRole` values. Any other key is rejected.
- Values are restricted to `frontier | balanced | economy`.
- An entry whose value is the empty string is treated as absent and is dropped before
persistence.
- `{}` means "no role policy" and is the default for every existing and new workspace.
- The Go field is `WorkspaceConfig.RoleTiers` (`internal/office/routing/types.go`,
beside `TierPerReason`), tagged `json:"role_tiers,omitempty"`. The workspace routing
PUT binds directly into `WorkspaceConfig` — there is no separate request DTO — so the
wire contract and the in-memory contract are the same type.
Migration is additive with a default, so existing rows need no backfill and behaviour
is unchanged until an operator writes a map.
## Acceptance criteria
Written EARS-style. Each is observable through the API, the database, or the UI.
### Per-role resolution
- **AC-1** — GIVEN workspace `role_tiers = {"specialist":"frontier"}` and an agent with
`role = specialist` and no tier override, WHEN a run launches with a reason carrying
no wake-reason policy, THEN the resolved tier is `frontier` and
`office_run_route_attempts.tier` records `frontier`.
- **AC-2** — GIVEN the same config and an agent with `role = assistant` absent from
`role_tiers`, WHEN a run launches, THEN the resolved tier is the workspace
`default_tier`.
- **AC-3** — GIVEN `role_tiers = {"specialist":"economy"}` and a `specialist` agent
whose settings carry `tier_source = "override"`, `tier = "frontier"`, WHEN a run
launches with no wake-reason policy, THEN the resolved tier is `frontier` — the
per-agent override outranks the role entry.
- **AC-4** — GIVEN `role_tiers = {"specialist":"frontier"}`, workspace
`tier_per_reason = {"heartbeat":"economy"}`, and a `specialist` agent, WHEN a
heartbeat run launches, THEN the resolved tier is `economy` — the wake-reason policy
outranks the role entry.
- **AC-5** — GIVEN `role_tiers = {"specialist":"frontier"}` and a `specialist` agent
carrying `tier_source = "override"`, `tier = "balanced"`, and workspace
`tier_per_reason = {"heartbeat":"economy"}`, WHEN a heartbeat run launches, THEN the
resolved tier is `economy`, demonstrating the full four-level order in one case.
- **AC-6** — GIVEN an agent whose `role` is the empty string, WHEN a run launches,
THEN `role_tiers` is not consulted and resolution proceeds to `default_tier`.
- **AC-7** — GIVEN `role_tiers = {}`, WHEN any run launches, THEN the resolved tier is
identical to the tier resolved before this feature existed, for every agent in the
workspace.
### Validation
- **AC-8** — WHEN a workspace routing write carries a `role_tiers` key outside the
seven `AgentRole` values, THEN the write is rejected with HTTP 400 and a
`ValidationError` whose `Field` is `role_tiers` and whose `Details` carry one
`ValidationDetail` per offending key.
- **AC-8a** — The rejection's **wire shape** is the structured 400 that
`respondRoutingValidation` (`internal/office/agents/handler.go`) and the dashboard
routing handler already emit, and no other:
```json
{"error": "<ValidationError.Message>", "field": "<ValidationError.Field>", "details": [...]}
```
`ValidationDetail` is `{ProviderID, Field, Message}` (`internal/office/routing/types.go`)
— there is **no** role member. The offending role therefore goes in
`ValidationDetail.Field` and the reason in `ValidationDetail.Message`, with
`ProviderID` left empty for `role_tiers` entries. This is pinned because "`Details`
name the offending key" does not by itself say which member carries it, and a builder
cannot read that off the struct.
- **AC-9** — WHEN a write carries a `role_tiers` value outside
`frontier | balanced | economy`, THEN the write is rejected with HTTP 400 in the
AC-8a shape, and the offending **role** and **value** both appear in the response —
the role in `ValidationDetail.Field`, the value quoted in `ValidationDetail.Message`.
The message must contain neither the string `default_tier` nor the prefix
`routing config invalid:`; AC-10a explains why both are live risks rather than
stylistic notes.
- **AC-10** — WHEN a write carries a `role_tiers` entry whose tier is mapped by no
provider in the workspace `provider_order`, THEN the write is rejected with HTTP 400
rather than failing at launch. The structural precedent is
**`checkTierPerReasonMapped`**, *not* `checkTierMapped`: `role_tiers` is a **map** and
may hold several bad entries at once, so it needs the `[]ValidationDetail`
accumulation `checkTierPerReasonMapped` performs. `checkTierMapped` validates a
**single** value (`ov.Tier`) and returns `Field: "routing.tier"` with no `Details` at
all — copying it collapses N bad entries into one message and reports the wrong field.
Both are called from `ValidateAgentOverridesAgainstWorkspace`, so naming that function
alone does not pick the right one; this AC names the callee deliberately.
- **AC-10a** — `role_tiers` validation **requires a new field-parameterised validator**,
because the obvious existing one cannot satisfy AC-8. `validateTier(t Tier)`
(`internal/office/routing/types.go`) hardcodes `Field: "default_tier"` in the
`ValidationError` it returns and carries neither the role nor any `Details`. It is
already reused for the per-agent override (`validateTier(ov.Tier)`), so the
wrong-field pattern is **established in this repo** — reusing it for `role_tiers`
looks correct and is not. Two consequences:
1. The `role_tiers` value validator takes the field name as a **parameter**, exactly
as `validateTierPerReason(m TierPerReason, field string)` does, and emits
`Field: "role_tiers"`.
2. `validateTier`'s error must not be embedded in the message verbatim.
`ValidationError.Error()` renders as
`routing config invalid: default_tier: invalid tier "x"`, and
`validateTierPerReason` wraps exactly that string into its own `Message` — so even
the correct structural sibling leaks `default_tier` into user-facing text today. A
`role_tiers` message that inherits that wording violates AC-9.
- **AC-11** — WHEN a write carries a `role_tiers` entry with an empty-string value,
THEN the entry is dropped and the persisted map omits that key; the write succeeds.
- **AC-12** — GIVEN routing is disabled for the workspace (`enabled = 0`), WHEN a
`role_tiers` write arrives, THEN it is validated and persisted exactly as when
enabled; `enabled` gates automatic fallback, not tier selection.
- **AC-12a** — GIVEN `enabled = 0` and a non-empty `role_tiers`, WHEN a run launches,
THEN the role level still participates in tier resolution. `Resolve` computes the
effective tier before it branches on `Enabled`, and the single evaluated provider
(`provider_order[0]`) is looked up at the role-supplied tier.
### The ignored `model` field
- **AC-13** — WHEN `GET /office/agents/:id` returns an Office identity
(`role != ''`), THEN the `model` field is **omitted from the response body**. It is
not emitted as an empty string, and it is not emitted alongside a marker flag.
Omission is chosen over a marker so no consumer can read a value that has no effect.
For a row with `role == ''` (an execution profile) the field is unchanged.
- **AC-13a** — The omission is implemented as a **dedicated response DTO in
`internal/office/agents`** that **embeds** `models.AgentInstance` and **shadows** the
`model` key with its own field:
```go
type agentResponseBody struct {
*models.AgentInstance
Model *string `json:"model,omitempty"`
}
```
Go resolves the JSON name collision by depth: the outer `Model` (depth 0) wins over
the embedded `AgentProfile.Model` (depth 1), so the embedded field never reaches the
wire. Embedding a type alias compiles, and the embedded field is referred to by the
alias name as written — `agentResponseBody{AgentInstance: p}`.
The projection sets `Model` **only when `role == ''`** (pointing it at the row's
value) and leaves it **nil** for an Office identity, so `omitempty` drops the key.
This is what lets AC-13's two halves hold in one type, without the spec having to
assert which rows reach which handler.
**The shadow field is `*string`, not `string`, and this is load-bearing.** With a
plain `string`, an execution profile (`role == ''`) whose `model` is the empty string
would have its `model` key **dropped** by `omitempty` — a silent shape change on
exactly the rows AC-13 promises are unchanged, because the shared struct tags `Model`
as `json:"model"` with **no** `omitempty` and therefore emits `"model":""` today. A
`*string` distinguishes "absent" (nil) from "present and empty" (non-nil), so the DTO
is shape-preserving by construction whether or not an empty `model` is reachable in
practice. Verified against the real tag set: with `*string`, `role != ''` emits no
`model` key and `role == ''` emits `"model":""` for an empty value, matching the
unwrapped struct.
**Embedding is mandated over a field-by-field projection.**
`settingsmodels.AgentProfile` carries **43 JSON-tagged fields**, several computed or
`db:"-"`, with inconsistent `omitempty` usage. Hand-mirroring them is 43 chances to
drop a field or mistype a tag, and every such slip is a silent response-shape change
for execution-profile and kanban consumers. Embedding copies zero tags and so cannot
drift when a field is later added to the shared struct — which the
`RouteAttemptDTO` / `routeAttemptToDTO` pattern in
`internal/office/dashboard/routing_dto.go` does not give us here, because that DTO
mirrors a 17-field model this feature owns, not a 43-field struct shared with two
other subsystems.
Two alternatives remain **explicitly forbidden**: a custom `MarshalJSON` on
`settingsmodels.AgentProfile` (a type alias shared with execution profiles and kanban
— it would silently change their JSON too), and a `map[string]any` post-process (it
drops compile-time field checking). The shared struct and its `json:"model"` tag are
not edited.
**The existing wrappers change element type, not shape.** `AgentResponse.Agent`
becomes `*agentResponseBody` and `AgentListResponse.Agents` becomes
`[]*agentResponseBody` (`internal/office/agents/dto.go`). Their JSON keys stay `agent`
and `agents`; no new wrapper type is introduced and no envelope key is renamed. A nil
agent stays a nil **wrapper field**, serialising as `"agent": null` exactly as today —
do not substitute a zero-valued DTO, which would serialise as `{}` because a nil
embedded pointer contributes no promoted fields.
**Key ORDER changes and that is accepted.** Embedding emits the shadowing `Model`
where the outer field is declared, so for `role == ''` the `model` key moves to the
end of the object. The key SET and every value are identical. JSON object member
order is not semantically significant, no in-repo consumer depends on it, and this is
why AC-13c compares decoded maps rather than bytes. Earlier revisions of this AC said
"byte-identical"; that was never achievable by either mechanism and is corrected here.
- **AC-13c** — The shape-preservation property AC-13a asserts is **observable, not
merely asserted**. A Go test in `internal/office/agents` marshals the **same**
`models.AgentInstance` value twice — once directly, once through the response DTO —
decodes both into `map[string]any`, and asserts:
- for a row with `role != ''`: the DTO map equals the direct map **with the `model`
key deleted** (so no other key is added, dropped, or altered), and the DTO map
contains no `model` key;
- for a row with `role == ''`: the DTO map **equals the direct map exactly**,
including a `model` key whose value is `""` when the row's model is empty.
The comparison is over decoded maps, never raw bytes, per AC-13a's key-order note.
This AC exists because a mechanism whose whole job is "change one key and nothing
else" is worthless without a test that can see "nothing else"; it is the standing
guard for AC-13, AC-13a and AC-13b together.
- **AC-13b** — The omission applies to **every Office-identity agent payload the
`internal/office/agents` handler emits**, not only `GET /office/agents/:id`. Five
handlers emit the agent struct: `listAgents` (via `AgentListResponse`), `createAgent`
(201), `getAgent`, `updateAgent`, and `updateAgentStatus`. All five return the DTO.
A response from any of the five that still carries a `model` key for a row with
`role != ''` fails this AC.
- **AC-14** — WHEN `PATCH /office/agents/:id` carries a `model` field for an Office
identity, THEN the request is rejected with HTTP 400 and a message directing the
caller to workspace tier profiles or the agent routing override. The rejection is
*worded* like the existing `agent_profile_id` rejection, but is **not implemented the
same way** — see AC-14a for why the mechanisms necessarily differ, and AC-14c for the
response shape, which AC-14a does not cover.
- **AC-14c** — The rejection's **wire shape** is the AC-8a structured 400, produced by
`respondRoutingValidation` with
`&routing.ValidationError{Field: "model", Message: "<the AC-14 wording>"}` — and
**not** the bare `gin.H{"error": ...}` form the neighbouring `agent_profile_id`
rejection uses. Two rejections in one handler function therefore carry different
envelopes, and that is intended rather than an oversight: the `agent_profile_id` bare
form is the older shape, `respondRoutingValidation` is already called by
`applyRoutingOverride` a few lines away in the same file, and AC-8/AC-9/AC-10 commit
the rest of this feature to the structured form. A caller that receives this 400 can
then read `field == "model"` instead of pattern-matching prose.
**AC-14's "worded like" governs the sentence only, never the envelope.** The existing
`agent_profile_id` rejection is **not** changed to match: rewriting it is a separate
API change this feature is not authorised to make.
- **AC-14a** — `UpdateAgentRequest` has no `model` member, and Gin's `ShouldBindJSON`
uses stdlib `encoding/json`, which ignores unknown keys — so today a `model` key in a
PATCH body is silently discarded. Detection is therefore implemented by buffering the
request body **once** with `io.ReadAll(c.Request.Body)`, then decoding those same
bytes twice: once into `map[string]json.RawMessage` to test for **presence of the
`"model"` key specifically**, and once into `UpdateAgentRequest`. The handler must
therefore stop calling `c.ShouldBindJSON`, which consumes the body and would leave the
second decode empty; binding from the buffered bytes replaces it, and the existing
malformed-JSON 400 behaviour is preserved.
**On precedent — read this before going looking for one.** Three handlers already
buffer a Gin request body with `io.ReadAll(c.Request.Body)`:
`internal/office/routines/handler.go`, `internal/office/channels/handler.go` and
`internal/system/frontenderrors/handler.go`. They are precedent for **the buffering
step only**. **None of them does key-presence detection**, and copying any of them
wholesale produces the wrong thing:
- `routines/handler.go` buffers for HMAC verification, then unmarshals to
`map[string]interface{}` to build webhook variables — no typed struct, no presence
test.
- `channels/handler.go` buffers for signature verification and treats the body as raw
text; it never JSON-decodes it at all.
- `frontenderrors/handler.go` decodes into a typed struct with
**`DisallowUnknownFields()`**, then decodes a second time expecting `io.EOF` — a
trailing-garbage guard, and it uses the very technique forbidden two paragraphs
below. It is the closest-looking and the most misleading of the three.
The `map[string]json.RawMessage` presence check exists nowhere in this backend today.
**It is new code**, and the spec says so rather than sending a builder to find a
pattern that is not there.
`json.Decoder.DisallowUnknownFields` is **explicitly forbidden**: it would reject
every unknown key on this endpoint, a far broader breaking API change than this
feature is authorised to make. That `frontenderrors/handler.go` uses it is not a
licence — that endpoint accepts one closed payload shape; this one does not. The
existing `agent_profile_id` check stays a plain nil-check on the declared
`AgentProfileID *string` field; that path is unchanged.
- **AC-14b** — Presence, not value, triggers the rejection. `{"model": "opus[1m]"}`,
`{"model": ""}` and `{"model": null}` are **all rejected** with the same HTTP 400,
because each proves the caller believes the field is honoured. A body that omits the
`model` key entirely is accepted. For a row with `role == ''` the key is not rejected.
- **AC-15** — GIVEN an existing Office identity row with a non-empty `model` value,
WHEN this feature ships, THEN that stored value is left untouched in the database and
continues to have no effect on routing. No destructive migration runs.
### Discoverability
- **AC-16** — GIVEN an agent inherits its tier (no per-agent override, no role entry),
WHEN the operator views that agent's routing card, THEN the card names the tier in
force **and** the level that supplied it. The four wire values are exactly
`wake_reason | override | role | workspace` (AC-18); the card renders their
translated display labels. Wire values and display labels are distinct — the wire
value is never shown raw and is never translated.
- **AC-16a** — The same rule binds the **workspace routing preview table**
(`app/office/workspace/routing/components/agent-preview-table.tsx`), which today
renders `{a.tier_source}` raw in a user-facing cell. WHEN that table renders a row,
THEN it shows the translated display label for the row's `tier_source`, never the raw
wire value. Without this AC the widening ships the new literals `role` and
`workspace` untranslated into a shipped table, violating AC-16's own principle and
AC-19's five-locale requirement on a surface AC-16 does not reach.
- **AC-17** — GIVEN a per-agent tier override is shadowed by a wake-reason policy for
some reason, WHEN the operator views the agent's routing card, THEN the card states
that the override does not apply to those reasons and names them.
- **AC-17a** — The reasons named are the keys of the agent's **effective** wake-reason
map, which is the same map `wakeReasonTier` consults: WHEN
`tier_per_reason_source == "override"`, the effective map is the agent's own
`tier_per_reason` **and the workspace map is not consulted at all** (an override
replaces it entirely, per `docs/specs/office/routing.md`); OTHERWISE the effective map
is the workspace `tier_per_reason`. The card never shows the union of the two, and
never shows the workspace keys when an override is in force. Keys whose value is
empty are excluded, since they do not shadow anything. GIVEN the effective map is
empty, THEN the card shows no shadowing notice at all.
- **AC-17b** — The shadowing notice is **not** limited to a per-agent override. A
role-supplied tier is shadowed by wake-reason policy identically (AC-4), and AC-18b
guarantees a preview never reports `wake_reason`, so without this AC an agent on a
role tier would see a card asserting the role tier is in force while its heartbeat
runs actually take Economy, with nothing said. WHEN the agent's effective tier comes
from the **role** level or the **workspace default**, and the effective wake-reason
map is non-empty, THEN the card states that the named reasons do not use the
displayed tier, using the same notice and the same key set as AC-17a. No new data
path is required: `agent-routing-card.tsx` already holds the workspace map via
`useWorkspaceRouting` and the agent's own map via the persisted `overrides` blob.
- **AC-18** — WHEN the routing preview (`PreviewItem`) is produced for an agent, THEN
`TierSource` reports exactly one of `wake_reason | override | role | workspace`,
widening the current two-valued `override | inherit`.
- **AC-18a** — The value `override` keeps its current meaning and spelling, so an
existing consumer testing `tier_source === "override"` is unaffected. The value
`inherit` is **removed from the computed preview field only** and replaced by `role`
or `workspace`.
**The widening is scoped by TYPE, not by file.** Two unrelated fields are both spelled
`tier_source`, and exactly one of them changes. Several files contain both, so
"update file X" is not a safe instruction:
| | Type | Widens? |
|---|---|---|
| **Computed preview** — CHANGES | Go `routing.PreviewItem.TierSource` → Go `dashboard.AgentRoutePreview.TierSource` → TS `AgentRoutePreview.tier_source` | **YES** — becomes `wake_reason \| override \| role \| workspace` |
| **Persisted override** — UNCHANGED | Go `routing.AgentOverrides.TierSource` (`json:"tier_source,omitempty"`) → TS `AgentRoutingOverrides.tier_source` | **NO** — stays `inherit \| override \| ""` |
**Sites that MUST change** (the computed chain, in dataflow order):
1. `internal/office/routing/provider.go#tierSourceForAgent` — today the **only** site
that emits the literal `"inherit"`; it must instead yield `role` / `workspace`,
which requires the agent's role and the workspace `role_tiers` map. Per **AC-20d**
it does not decide this itself: it delegates to the widened `effectiveTier`, so
that preview and audit share one producer. "Sole producer" describes the situation
before this feature, not after it.
2. `internal/office/routing/provider.go` where the result is set on `PreviewItem`.
3. `internal/office/dashboard/handler_routing.go` where it is copied into
`AgentRoutePreview` (`previewItemsToDTOs`).
4. `internal/office/dashboard/routing_dto.go` — the doc comment on `AgentRoutePreview`
currently states the two-valued contract and becomes false.
5. TS `AgentRoutePreview.tier_source` in `lib/state/slices/office/types.ts`.
6. `agent-preview-table.tsx`, per AC-16a.
7. Tests fixturing the computed value — **both** of them, Go and TypeScript:
`internal/office/dashboard/handler_routing_test.go`, and
`apps/web/lib/state/slices/office/office-routing.test.ts`, whose
`setRoutingPreview` case fixtures a preview row with `tier_source: "inherit"`.
That TS fixture is the **computed** preview, not the persisted override, so it
stops compiling the moment item 5 lands. It is easy to mistake for a
MUST-NOT-CHANGE site because the same file name pattern appears in that list
below; it is not one.
This list is maintained by hand and has been wrong before. It is a starting point,
not a proof of completeness — **AC-18c is the proof**. Work the list, then run the
typecheck gate and treat whatever it reports as in scope.
**Sites that MUST NOT change** (persisted state — changing these is a regression):
- `routing.AgentOverrides.TierSource` and TS `AgentRoutingOverrides.tier_source`.
- `agent-routing-card.tsx` where it **writes** `tier_source: on ? "override" :
"inherit"` and reads `overrides.tier_source !== "override"` — that is the persisted
override blob being PATCHed, not the preview.
- `e2e/helpers/office-api-client.ts`, whose `tier_source` sits inside `overrides`.
- `internal/office/onboarding/service.go#writeAgentInheritMarkers`, which stamps
`TierSource: "inherit"` as the CEO's onboarding marker.
- `e2e/tests/office/office-routing-disabled.spec.ts`, which asserts
`route.overrides.tier_source === "inherit"`.
A stored settings blob containing `tier_source: "inherit"` keeps its persisted
spelling and continues to mean "not an override".
- **AC-18b** — GIVEN a preview is produced through `Provider.Preview` /
`Provider.PreviewAgent`, WHEN resolution runs, THEN it runs with an empty run reason,
so the wake-reason level cannot apply and `TierSource` never reports `wake_reason` in
a preview. AC-17 (override case) and AC-17b (role / workspace case) are what surface
wake-reason shadowing in that view; between them every level a preview can report
carries a shadowing notice.
- **AC-18c** — WHEN the `tier_source` widening has been applied, THEN
`pnpm --filter @kandev/web typecheck` passes with no error mentioning `tier_source`.
This AC exists because AC-18a's site list is maintained by hand and has been
incomplete in successive reviews — the union of "files that mention `tier_source`" is
not knowable by reading, and the type checker knows it exactly. Narrowing
`AgentRoutePreview.tier_source` from `"inherit" | "override"` to
`"wake_reason" | "override" | "role" | "workspace"` makes every stale `"inherit"`
fixture or comparison a compile error, so the gate is exhaustive by construction where
a list can only ever be a best effort.
Any site the gate reports is **in scope for this feature**, whether or not AC-18a
names it — with one exception that is a genuine failure, not a site to update: an
error on a MUST-NOT-CHANGE site from AC-18a means the **persisted** override type was
widened by mistake. Fix that by reverting the persisted type, never by widening the
fixture to match.
This mirrors the shape AC-19 already uses — delegate completeness to a checker that
can see everything, rather than to a list that cannot. AC-13c is the same idea for
the agent-payload half.
- **AC-19** — WHEN new user-facing copy is added for AC-16 through AC-18, THEN it is
routed through `t()` / `<Trans>` and present in all five locales
(`en`, `pt-pt`, `zh-cn`, `zh-hk`, `zh-tw`), with `pnpm run i18n:check` passing.
### Observability
- **AC-20** — WHEN a run resolves its tier, THEN the supplying level is persisted on
the route attempt in a new `office_run_route_attempts.tier_source` column
(`TEXT NOT NULL DEFAULT ''`), holding one of
`wake_reason | override | role | workspace`.
- **AC-20c** — `''` means **"the supplying level is not recorded"**, and it is never
interpreted as `workspace`. It arises from exactly two causes, which a consumer
separates by reading the sibling `tier` column:
| `tier` | `tier_source` | meaning |
|---|---|---|
| non-empty | `''` | row written before this column shipped |
| `''` | `''` | the attempt never resolved a tier |
| non-empty | non-empty | normal post-migration row |
The second case is real and is **not** a pre-migration artefact: the
`max_attempts_exceeded` attempt appended by
`internal/office/scheduler/dispatch_routing.go` sets no `Tier` today and will
likewise set no `TierSource`. The invariant this AC fixes is therefore
**`tier_source` is non-empty only when `tier` is non-empty**; the converse does not
hold, because legacy rows carry a tier and no source. A migration that back-fills
`tier_source` for legacy rows is forbidden — the level that produced those tiers was
never recorded and cannot be reconstructed.
**The widened `effectiveTier` preserves this invariant at its own fallthrough.** Its
last step returns `cfg.DefaultTier`; when that value is empty it returns an empty tier
and an **empty source**, never `workspace`. A source is reported only when a level
actually supplied a tier. The case is defensive rather than live — `validateTier`
rejects an empty tier and the column is declared
`default_tier TEXT NOT NULL DEFAULT 'balanced'`, so no supported write reaches it —
but AC-20c states its invariant without qualification, so the fallthrough is pinned
here rather than left for the builder to decide.
- **AC-20b** — The stored value is **surfaced, not write-only**. `tier_source` is added
to `dashboard.RouteAttemptDTO` as `json:"tier_source,omitempty"`, mapped in
`routeAttemptToDTO` beside the existing `Tier` field. It therefore reaches both
payloads that already carry `RouteAttemptDTO` — `GET /runs/:id/attempts`
(`RouteAttemptsResponse`) and the run-detail response (`RunRouting.Attempts`) — with
no new endpoint. The `omitempty` tag means a row whose level is unrecorded (`''`,
either cause in AC-20c) omits the key rather than reporting a false level.
The **TypeScript client contract widens with it**: `RouteAttempt` in
`apps/web/lib/state/slices/office/types.ts` gains
`tier_source?: "wake_reason" | "override" | "role" | "workspace"`. That one type backs
every consumer the field must reach — `RouteAttemptsResponse` for
`GET /runs/:id/attempts` and the run-detail `attempts` array (both in
`apps/web/lib/api/domains/office-runs-api.ts`), and the WS payload
(`apps/web/lib/ws/handlers/office.ts`). Without this the field is on the wire but
unreadable from TypeScript, and "surfaced, not write-only" is false in practice.
Note this is the **route-attempt** `tier_source`, a third type spelled the same way;
AC-18a's table governs the other two and is unaffected.
**The `route_attempt_appended` WS event gains the field too, and that is intended.**
`publishRouteAttemptAppended` (`internal/office/scheduler/routing_events.go`)
serialises `models.RouteAttempt` **whole** as `payload["attempt"]`, so the field
AC-20f adds to that struct appears on the event automatically, at all three publish
sites. This is additive and `omitempty`-guarded, so no existing consumer breaks. Build
must **not** suppress it: the two suppression routes available — dropping the JSON tag
AC-20f specifies verbatim, or introducing a separate WS-only DTO — are both forbidden
here, the first because it contradicts AC-20f and the second because it would create
the second producer AC-20d exists to prevent.
**Explicitly out of scope for this AC:** any new **UI rendering** of the value — no
control, no column, no badge. The field becomes available to the run-detail UI and to
WS consumers; presenting it is a later change.
- **AC-20d** — The value has **one producer, shared by the resolve path and the preview
path**, so an agent's audit record and its preview can never disagree about the level.
`effectiveTier` (`internal/office/routing/resolver.go`) is widened to return the tier
**and** its source — it already receives `cfg` and `ov`, and gains the agent's `role`
for the new level — and `tierSourceForAgent` (`provider.go`, today the preview-only
producer of the literal `"inherit"`) is reimplemented to delegate to it rather than
deciding independently. Two independent producers are **explicitly forbidden**: that
is the defect this AC exists to prevent.
- **AC-20e** — The source is carried out of resolution on
`routing.Resolution` as a new `TierSource string` field beside the existing
`RequestedTier` (`resolver.go`). `Resolution` is the only carrier; the source is not
threaded through `ResolveOptions`, not recomputed by the scheduler, and not re-derived
from the agent at write time.
- **AC-20f** — The persistence path is, in order:
1. `internal/office/models/models.go` — `RouteAttempt` gains a `TierSource string`
field beside `Tier`, carrying the JSON tag `tier_source,omitempty` and the db tag
`tier_source`:
```go
TierSource string `json:"tier_source,omitempty" db:"tier_source"`
```
2. `internal/office/repository/sqlite/base_migrations.go` — the additive column is
added with the replayable form already used one line above for this same table:
`r.migrate.Apply("office_run_route_attempts.tier_source", "ALTER TABLE
office_run_route_attempts ADD COLUMN tier_source TEXT NOT NULL DEFAULT ''")`.
**The migration for this column does not live in `workspace_routing.go`** — that
file holds the `office_workspace_routing` read/write SQL only, and the `role_tiers`
migration likewise belongs in `base_migrations.go`.
3. `internal/office/repository/sqlite/route_attempts.go` — `tier_source` is added to
the `AppendRouteAttempt` INSERT column list and its parameter list, and to the
`ListRouteAttempts` SELECT list so `StructScan` populates it.
4. `internal/office/scheduler/dispatch_routing.go` — the **three**
`AppendRouteAttempt` call sites set `TierSource` wherever they already set `Tier`
— and they do **not** divide evenly. Each of the three needs a different edit:
| site | `res` in scope? | sets `Tier` today? | edit |
|---|---|---|---|
| `parkRunMaxAttempts` | **no** | no | **none** — sets neither field, per AC-20c |
| `parkRunBlocked` | yes (`res *routing.Resolution` parameter) | yes, `Tier: string(res.RequestedTier)` | add `TierSource: res.TierSource` beside it |
| `recordAttemptStart` | **no** — receives a bare `tier routing.Tier` | yes, from that parameter | **thread** a source parameter, supplied at the call site |
So the split is **one no-op, one direct read, one threaded parameter** — there is
no second direct-read site, and a builder who goes looking for one is looking for
something that does not exist. `parkRunMaxAttempts` in particular takes no
`*routing.Resolution` at all; threading one into it to "complete the pattern" is
forbidden by this AC and by AC-20g.
For the threaded site: `recordAttemptStart`'s signature today ends in a bare
`tier routing.Tier` parameter and gains a source parameter alongside it, supplied
from `res.TierSource` at its call site, where `res` **is** already in scope.
Deriving the source inside `recordAttemptStart` instead is forbidden by AC-20d.
- **AC-20g** — Two nearby sites are **explicitly NOT write sites** and must not be
changed: the `prior = append(prior, models.RouteAttempt{…})` in
`dispatch_routing.go` is an in-memory mirror used for fallback bookkeeping by
`latestFailedExecutionProfile`, not a persistence call; and the attempt-finalisation
path updates outcome columns on an existing row and does not write `tier_source`.
- **AC-20a** — `office_run_route_attempts` carries no `agent_id` column; attribution is
via `run_id` to the owning run. This feature adds no agent column, so a
"which tier did agent X get" query remains a join through `runs`.
## Forced-to-invent pass
Decisions a builder would otherwise have to guess. Each is a contract.
### Ordering and tiebreak
- **AC-21** — The four precedence levels are evaluated in the fixed order in
[The contract](#the-contract). The order is not configurable.
- **AC-22** — `role_tiers` is a map keyed by a unique role, so no two entries can
apply to one agent and **no tiebreak is required**. An agent has exactly one `role`
(a single non-null column), so at most one entry matches.
- **AC-23** — WHEN `role_tiers` is serialised to JSON for persistence or API response,
THEN keys are emitted sorted ascending by the role string using byte order, so a
round-trip is byte-stable and diffs are reviewable. `map` iteration order must not
reach the wire.
- **AC-24** — WHEN the routing settings UI lists roles, THEN they are ordered by the
declaration order of the `AgentRole` constants
(`ceo, worker, specialist, assistant, security, qa, devops`), not alphabetically and
not by map order.
### Idempotency and retry
- **AC-25** — WHEN the same `role_tiers` payload is written twice, THEN the second
write succeeds and leaves the persisted value byte-identical. `updated_at` still
advances; it records the write, not a value change.
- **AC-26** — A `role_tiers` write is a whole-map replacement, not a merge. WHEN a
payload omits a role that is currently mapped, THEN that role's entry is deleted.
This matches how `tier_per_reason` and `provider_order` already behave.
- **AC-26a** — An **absent `role_tiers` key** on a routing write means the same as
`{}`: the stored map is cleared. It does not mean "preserve the current value". This
follows the endpoint's existing whole-config semantics — `TierPerReason` is likewise
`omitempty`, unmarshals to nil when the key is absent, and is written unconditionally
by the `office_workspace_routing` upsert — and making one field alone
absent-means-preserve would be a new and surprising rule on a PUT where every other
field is replaced. The consequence is that a client which does not yet know about
`role_tiers` erases it, so both in-repo writers are updated to round-trip the field:
the workspace routing page, and the `cfg` parameter type of the routing-config PUT
helper in `apps/web/e2e/helpers/office-api-client.ts`. (This is distinct from that
file's `tier_source`, which sits inside `overrides` and stays untouched per AC-18a.)
- **AC-27** — WHEN a routing write fails validation, THEN no part of it is persisted;
`role_tiers` and every other field are written in one transaction.
- **AC-28** — Tier resolution is a pure read. Retrying a failed launch re-resolves from
current config; a tier is never cached on the run and never frozen at enqueue time.
A config change between attempt N and attempt N+1 therefore takes effect on N+1, and
the two attempts may legitimately record different tiers.
### Concurrency
- **AC-29** — GIVEN two callers write `role_tiers` for the same workspace
concurrently, WHEN both succeed, THEN the last committed write wins in full and the
row is never left holding a mixture of the two maps. `office_workspace_routing` is
keyed by `workspace_id`, so this is a single-row update.
- **AC-30** — GIVEN a `role_tiers` write commits while a run is mid-resolution, WHEN
that resolution completes, THEN it uses whichever value its own read observed and is
never retried on that basis, and the run is not aborted. Both halves are observable:
the resolved tier is recorded on the route attempt, and a retry would appear as a
second attempt row.
*Design note, not an acceptance criterion:* no lock is taken across the launch. That
is a statement about the implementation, not an outcome any test, API response, or DB
query can witness — this section's preamble promises every AC is observable, and as an
AC clause it would not have been.
- **AC-31** — GIVEN an agent's `role` changes while it has queued runs, WHEN those runs
launch, THEN each resolves against the role in force at its own resolution time. Role
changes are not applied retroactively to already-resolved runs.
### Nil, empty and error behaviour
- **AC-32** — WHEN the `role_tiers` column holds `''`, `'{}'`, or SQL `NULL`, THEN it
decodes to an empty map and resolution proceeds to `default_tier`. None of the three
is an error.
- **AC-32a** — **AC-32 outranks AC-33 on the empty string.** `''` is simultaneously
"empty" under AC-32 and "JSON that fails to decode" under AC-33, because
`json.Unmarshal([]byte(""), ...)` returns *unexpected end of JSON input*. AC-32 wins
because it names `''` explicitly; AC-33 governs only **non-empty** bytes that fail to
parse (`{`, `not json`, a truncated object).
**The adjacent `tier_per_reason` decode does NOT behave this way, and copying it is
the failure mode.** `loadWorkspaceRouting`
(`internal/office/repository/sqlite/workspace_routing.go`) selects
`COALESCE(tier_per_reason, '{}')` and unmarshals the result. `COALESCE` substitutes
for SQL `NULL` **only** — a literal `''` passes through untouched and then errors in
`json.Unmarshal`. The pattern sitting one line from where `role_tiers` will be read
therefore implements **AC-33's behaviour on AC-32's input**. The `role_tiers` decode
needs an explicit empty-string short-circuit the precedent lacks: treat a zero-length
raw value as `{}` **before** unmarshalling, then unmarshal.
This is a defect in the precedent as a template, not in `tier_per_reason` as shipped —
that column is also `NOT NULL DEFAULT '{}'` and no supported write puts `''` in it.
`role_tiers` inherits the same protection, so this too is defensive. It is specified
because AC-32 states its contract unconditionally, and a builder extending the
neighbouring line would break it without ever seeing a failure.
- **AC-33** — WHEN the `role_tiers` column holds JSON that fails to decode, THEN
loading the workspace routing config returns an error and the launch is refused with
the existing blocked-route path. It does **not** silently fall back to
`default_tier`: a corrupt policy must be visible, not quietly ignored.
- **AC-34** — WHEN an agent's `role` holds a value not in the seven-value enum (a row
written by an older build or by hand), THEN `role_tiers` is not consulted for it,
resolution proceeds to `default_tier`, and the launch is not refused.
- **AC-34a** — WHEN a persisted `role_tiers` map holds an entry with an empty-string
tier (bypassing AC-11, e.g. written by hand), THEN that entry is treated as absent at
resolution time and the agent falls through to `default_tier`. It is not an error and
does not refuse the launch; AC-33's refusal is reserved for JSON that fails to decode.
- **AC-34b** — WHEN a persisted `role_tiers` map holds a key outside the seven-value
enum, THEN that entry is ignored at resolution time rather than refusing the launch.
AC-8 keeps such keys out on the write path; the read path stays permissive so a role
removed from the enum in a future build cannot brick every launch in the workspace.
- **AC-35** — WHEN the role-supplied tier is mapped by no provider in the effective
order at launch time, THEN the existing `missing_model_mapping` skip path applies
unchanged; this feature adds no new blocked-route status.
### Defaults and boundaries
- **AC-36** — The default value of `role_tiers` is `{}` for every workspace, existing
and new. Onboarding does not seed it.
- **AC-37** — `role_tiers` holds at most seven entries, bounded by the enum. A payload
with more keys than the enum has values is rejected by AC-8 before size matters, so
no separate length limit is specified.
- **AC-38** — Writing `role_tiers` for a role that currently has no agents is valid and
persists; the map is a policy, not a join, and it applies when such an agent is later
created.
- **AC-39** — WHEN an agent's per-agent tier override is cleared, THEN its effective
tier falls to the role entry if one exists, and to `default_tier` otherwise, with no
further operator action.
## Precedent citations
This spec cites in-repo code as precedent about a dozen times. Three review rounds have
now each found at least one citation where **copying the named precedent produces
behaviour that violates an AC in this same spec**: AC-14a's three body-buffering
handlers (round 3), then AC-32a's `tier_per_reason` decode and AC-10a's `validateTier`
(round 4). Those instances are fixed above. This section exists to close the class.
**The standing rule.** Every precedent named in this spec is cited for a **named step**,
never wholesale, and each citation states what copying it produces. A citation reading
only "matching X" or "as Y already does" is incomplete — treat it as a defect in this
spec to be routed back, not as licence to copy X or Y.
Why prose alone is not enough: all three rounds' traps were already *adjacent to* text
telling the builder to be careful, and a warning is only read by a builder who suspects
there is something to look for. The precedents in question look correct, compile, and
pass their own existing tests. So the guarantee is delegated to checks that go red.
- **AC-40** — For every AC whose behaviour a cited precedent would violate, there is a
test **that the precedent fails**. This feature ships at minimum these four:
1. a decode test feeding the `role_tiers` column a literal `''` and asserting an empty
map and **no error** (AC-32/AC-32a) — fails if the `COALESCE`-only pattern is
copied;
2. a validation test asserting the rejection for a bad `role_tiers` **value** carries
`field == "role_tiers"` and a message containing neither `default_tier` nor
`routing config invalid:` (AC-9/AC-10a) — fails if `validateTier`'s error is
returned or wrapped verbatim;
3. a validation test asserting that **two** bad `role_tiers` entries produce **two**
`Details` entries (AC-8/AC-10) — fails if `checkTierMapped`'s single-value shape is
copied;
4. a handler test asserting the PATCH `model` rejection body carries
`field == "model"` (AC-14c) — fails if the bare `gin.H{"error": ...}` form is copied
from the `agent_profile_id` rejection.
Four is a floor, not a ceiling: any further precedent the builder chooses to follow
earns the same treatment. AC-40 is the same delegation AC-18c and AC-13c already make
— move the guarantee off a list a human maintains and onto a check a machine runs.
## Out of scope
Named exclusions. Each is a deliberate contract, not an omission.
- **Reversing the wake-reason / per-agent precedence.** Justified in
[Deviation](#deviation-from-the-cards-stated-acceptance--read-this-first). If it is
wanted, it is a separate change to `docs/specs/office/routing.md`.
- **User-defined roles.** `AgentRole` stays a fixed seven-value enum. Making roles
extensible would give the Critic case a role-based answer, but it is a much larger
change to Office identity and is not attempted here.
- **A `tier` column on `agent_profiles`.** The card offers it as an alternative;
rejected so routing policy stays in `office_workspace_routing`, matching
`tier_per_reason`.
- **Removing or backfilling the `model` column on Office identity rows.** AC-15 keeps
the stored value. Dropping the column touches the shared `agent_profiles` table used
by execution profiles and kanban, and is not in this scope.
- **Per-agent or per-role provider *order* defaults.** Only tier selection gains a role
level. `provider_order` keeps its existing two levels.
- **Per-project, per-task, or per-skill tier selection.**
- **Automatic tier suggestions**, or any heuristic that picks a tier for a role without
the operator saying so.
- **Changing which model IDs the tiers map to.** `provider_profiles` is untouched.
- **Cost reporting or budget enforcement changes** arising from role-differentiated
tiers.
## Surfaces touched (E2E decision input)
- **Backend** — `internal/office/routing/{types,resolver,provider}.go` (incl.
`provider.go#tierSourceForAgent`, which after AC-20d delegates to the widened
`effectiveTier` rather than deciding the source itself),
`internal/office/repository/sqlite/workspace_routing.go` (`role_tiers` read/write SQL),
`internal/office/repository/sqlite/base_migrations.go` (**both** additive migrations —
`office_workspace_routing.role_tiers` and `office_run_route_attempts.tier_source`;
this is where the replayable `ALTER` precedent for these tables lives),
`internal/office/models/models.go` (`RouteAttempt.TierSource`, AC-20f),
`internal/office/repository/sqlite/route_attempts.go` (INSERT + SELECT, AC-20f),
`internal/office/scheduler/dispatch_routing.go` (three `AppendRouteAttempt` sites,
which need **three different edits** — see AC-20f's table),
`internal/office/dashboard/handler_routing.go`,
the AC-40 precedent-trap tests (decode in
`internal/office/repository/sqlite`, validation in `internal/office/routing`, the
PATCH rejection in `internal/office/agents`),
`internal/office/dashboard/routing_dto.go` (`AgentRoutePreview` doc comment +
`RouteAttemptDTO` per AC-20b), `internal/office/agents/handler.go` and a new
embedding response DTO in `internal/office/agents` (AC-13a) with its shape-preservation
test (AC-13c). **Not edited but affected:**
`internal/office/scheduler/routing_events.go` needs no change yet publishes the new
field automatically, because it serialises `models.RouteAttempt` whole (AC-20b).
- **API** — workspace routing GET/PUT gains `role_tiers`; all five Office-identity agent
payloads drop `model` (AC-13b); `PATCH /office/agents/:id` rejects a `model` key
(AC-14); `GET /runs/:id/attempts` and the run-detail payload gain `tier_source`
(AC-20b); the `route_attempt_appended` WS event gains it additively as a consequence,
which AC-20b accepts rather than suppresses.
- **Frontend** — `app/office/workspace/routing/` gains a role-tier card;
`agent-preview-table.tsx` renders a translated source label (AC-16a);
`app/office/agents/[id]/components/agent-routing-card.tsx` and `agent-route-strip.tsx`
gain the four-level source label; TS `AgentRoutePreview` in
`lib/state/slices/office/types.ts` widens and TS `RouteAttempt` in the same file gains
`tier_source?` (AC-20b) — TS `AgentRoutingOverrides` does **not** (AC-18a);
`lib/state/slices/office/office-routing.test.ts` updates its `setRoutingPreview`
fixture (AC-18a item 7); TS `WorkspaceRouting` gains `role_tiers` and the
routing-config PUT helper in `e2e/helpers/office-api-client.ts` gains it too (AC-26a).
This list is indicative; **AC-18c's typecheck gate is authoritative** for the
`tier_source` widening.
- **Explicitly NOT touched** (persisted-state sites, per AC-18a) —
`internal/office/onboarding/service.go#writeAgentInheritMarkers`; the `tier_source`
**inside the `overrides` blob** in `e2e/helpers/office-api-client.ts`; and the
persisted-override reads/writes in `agent-routing-card.tsx`. Note the same e2e helper
*is* edited elsewhere, for an unrelated field: its routing-config PUT `cfg` type gains
`role_tiers` per AC-26a. The untouched thing is the override `tier_source`, not the
file.
- **i18n** — new copy in five locales (AC-19), including the four source labels and the
AC-16a table label.
User-visible UI changes in the Office routing settings and the agent routing card mean
**E2E coverage is warranted**, scoped to the existing Office routing specs
(`apps/web/e2e/tests/office/office-routing-*.spec.ts`) rather than a new suite.