feat(acp): add Grok CLI driver (#1745)

This commit is contained in:
zensi-dev
2026-07-18 22:07:07 +05:30
committed by GitHub
parent 69972b4f79
commit 4684390bc6
15 changed files with 1735 additions and 62 deletions
+16 -1
View File
@@ -35,7 +35,22 @@ Protocol adapters in `server/adapter/transport/` normalize different agent CLIs:
- `process.Manager` owns subprocess, wires stdio to adapter
- Factory pattern in `server/adapter/factory.go` selects adapter by agent type
The `acp` transport is split by concern across `adapter_*.go` files: `adapter.go` (core/lifecycle), `adapter_session.go` (initialize/new/load/resume), `adapter_prompt.go` (prompt/cancel), `adapter_updates.go` (`session/update` notification fan-out), `adapter_tools.go` (`convertToolCallUpdate` / `convertToolCallResultUpdate` → normalized payloads), `adapter_permissions.go`, and `adapter_helpers.go`. Tool-call conversion lives in `adapter_tools.go`, not `adapter.go`.
The `acp` transport is split by concern across `adapter_*.go` files: `adapter.go` (core/lifecycle), `adapter_session.go` (initialize/new/load/resume), `adapter_prompt.go` (prompt/cancel), `adapter_updates.go` (`session/update` notification fan-out), `adapter_tools.go` (`convertToolCallUpdate` / `convertToolCallResultUpdate` -> normalized payloads), `adapter_permissions.go`, and `adapter_helpers.go`. Agent-specific ACP extensions use the package-private `acpDialect` function table in `dialect.go`; keep observed wire translation in `dialect_<agent>.go`. Dialect hooks return normalized data or request descriptions and never receive `*Adapter` or execute RPCs. Shared capability normalization used by both live sessions and utility probes belongs in `internal/agentctl/acpcompat/`. Tool-call conversion lives in `adapter_tools.go`, not `adapter.go`. See ADR-0043.
### Grok ACP dialect (`dialect_grok.go`)
Grok exposes some model, config, and usage surfaces through implementation-specific ACP metadata. Its dialect normalizes them in the backend so shared ACP paths and the frontend stay provider-agnostic:
| Grok wire | Kandev shape |
|---|---|
| Legacy `models` catalog | model `ConfigOption` used by generic frontend selectors |
| Model `_meta.supportsReasoningEffort` / `reasoningEfforts` / `reasoningEffort` | gated `reasoning_effort` select with category `thought_level` |
| `SetConfigOption(model, …)` or `(reasoning_effort, …)` | Grok has **no** `session/set_config_option`. Rewrite both to `session/set_model` (effort keeps current modelId + `_meta.reasoningEffort`; model switch may carry prior effort in meta when still supported). Frontend always uses set_config_option when a model ConfigOption exists. |
| Mid-session `MODEL_SWITCH_INCOMPATIBLE_AGENT` | return Grok's actionable error; user starts a new session explicitly |
| Notification `_meta.totalTokens` + model `_meta.totalContextTokens` | `EventTypeContextWindow` (used/size/remaining). Compaction may decrease used; clear used on model switch. |
| Prompt `_meta.usage` | input/output/cache/total plus `reasoningTokens` as `PromptUsage.ThoughtTokens`; sibling flat fields are not whole-turn totals |
Grok ACP currently exposes neither per-turn cost nor subscription quota/reset values. Do not estimate them or treat the optional subscription tier label as usage data.
## ACP Protocol
@@ -0,0 +1,328 @@
// Package acpcompat normalizes observed agent-specific ACP extensions into
// provider-neutral Kandev session shapes.
package acpcompat
import (
"fmt"
"math"
"strings"
"github.com/kandev/kandev/internal/agentctl/types/streams"
)
const (
GrokAgentID = "grok-acp"
GrokReasoningEffortOptionID = "reasoning_effort"
GrokReasoningEffortMetaKey = "reasoningEffort"
grokReasoningCategory = "thought_level"
grokReasoningName = "Reasoning Effort"
grokSupportsReasoning = "supportsReasoningEffort"
grokReasoningEfforts = "reasoningEfforts"
grokTotalContextTokens = "totalContextTokens"
grokTotalTokens = "totalTokens"
modelOptionID = "model"
)
// Model is the provider-neutral subset of an ACP model entry needed by
// compatibility normalization.
type Model struct {
ID string
Name string
Meta map[string]any
}
// ContextWindow is a context-usage sample derived from agent metadata.
type ContextWindow struct {
Used int64
Size int64
}
// NormalizeSessionConfig merges observed Grok model metadata into canonical
// session options without discarding typed or _meta options already parsed by
// the caller. Other agents pass through unchanged.
func NormalizeSessionConfig(
agentID string,
base []streams.ConfigOption,
models []Model,
currentModelID string,
) []streams.ConfigOption {
if agentID != GrokAgentID {
return base
}
out := append([]streams.ConfigOption(nil), base...)
if currentModelID == "" {
currentModelID = currentModelFromConfig(out)
}
if currentModelID == "" && len(models) == 1 {
currentModelID = models[0].ID
}
if !hasModelOption(out) {
if modelOption := buildModelOption(models, currentModelID); modelOption != nil {
out = append(out, *modelOption)
}
}
if !hasOption(out, GrokReasoningEffortOptionID) {
if effort := buildReasoningOption(models, currentModelID, ""); effort != nil {
out = append(out, *effort)
}
}
return out
}
// ConfigAfterModelChange rebuilds model-dependent Grok options after a model
// switch. Other agents pass through unchanged.
func ConfigAfterModelChange(
agentID string,
config []streams.ConfigOption,
models []Model,
modelID string,
) []streams.ConfigOption {
if agentID != GrokAgentID {
return config
}
previousEffort := ""
out := make([]streams.ConfigOption, 0, len(config))
for _, option := range config {
if option.ID == GrokReasoningEffortOptionID {
previousEffort = option.CurrentValue
continue
}
out = append(out, option)
}
if effort := buildReasoningOption(models, modelID, previousEffort); effort != nil {
out = append(out, *effort)
}
return out
}
// ValidateGrokReasoningEffort resolves a UI selection to Grok's canonical
// wire value for the active model.
func ValidateGrokReasoningEffort(models []Model, currentModelID, effort string) (string, error) {
if currentModelID == "" {
return "", fmt.Errorf("no active model to apply reasoning effort")
}
info := findModel(models, currentModelID)
if info == nil {
return "", fmt.Errorf("unknown model %q; cannot set reasoning effort", currentModelID)
}
if !getBool(info.Meta, grokSupportsReasoning) {
return "", fmt.Errorf("model %q does not support reasoning effort", currentModelID)
}
options, _ := parseReasoningEfforts(info.Meta)
if len(options) == 0 {
return "", fmt.Errorf("model %q does not advertise reasoning effort values", currentModelID)
}
if value := matchEffort(effort, options); value != "" {
return value, nil
}
offered := make([]string, 0, len(options))
for _, option := range options {
offered = append(offered, option.Value)
}
return "", fmt.Errorf("unsupported reasoning effort %q for model %q; use one of: %s",
effort, currentModelID, strings.Join(offered, ", "))
}
// GrokContextWindow reads Grok's notification total and active model context
// size as one immutable sample.
func GrokContextWindow(
meta map[string]any,
models []Model,
config []streams.ConfigOption,
) (ContextWindow, bool) {
used, ok := nonNegativeInt64(meta, grokTotalTokens)
if !ok {
return ContextWindow{}, false
}
model := findModel(models, currentModelFromConfig(config))
if model == nil {
return ContextWindow{}, false
}
size, ok := positiveInt64(model.Meta, grokTotalContextTokens)
if !ok {
return ContextWindow{}, false
}
return ContextWindow{Used: used, Size: size}, true
}
// GrokReasoningTokens reads Grok's nested prompt usage extension.
func GrokReasoningTokens(meta map[string]any) int64 {
usage, _ := meta["usage"].(map[string]any)
value, _ := nonNegativeInt64(usage, "reasoningTokens")
return value
}
func buildModelOption(models []Model, currentModelID string) *streams.ConfigOption {
values := make([]streams.ConfigOptionValue, 0, len(models))
for _, model := range models {
name := model.Name
if name == "" {
name = model.ID
}
values = append(values, streams.ConfigOptionValue{Value: model.ID, Name: name})
}
if len(values) == 0 {
return nil
}
return &streams.ConfigOption{
Type: "select",
ID: modelOptionID,
Category: modelOptionID,
Name: "Model",
CurrentValue: currentModelID,
Options: values,
}
}
func buildReasoningOption(models []Model, modelID, selected string) *streams.ConfigOption {
model := findModel(models, modelID)
if model == nil || !getBool(model.Meta, grokSupportsReasoning) {
return nil
}
options, defaultEffort := parseReasoningEfforts(model.Meta)
if len(options) == 0 {
return nil
}
current := resolveEffort(selected, options, defaultEffort, model.Meta)
return &streams.ConfigOption{
Type: "select",
ID: GrokReasoningEffortOptionID,
Category: grokReasoningCategory,
Name: grokReasoningName,
CurrentValue: current,
Options: options,
}
}
func parseReasoningEfforts(meta map[string]any) ([]streams.ConfigOptionValue, string) {
items, ok := meta[grokReasoningEfforts].([]any)
if !ok {
return nil, ""
}
var out []streams.ConfigOptionValue
defaultEffort := ""
for _, item := range items {
switch value := item.(type) {
case string:
value = strings.TrimSpace(value)
if value != "" {
out = append(out, streams.ConfigOptionValue{Value: value, Name: humanize(value)})
}
case map[string]any:
wireValue := getString(value, "value")
if wireValue == "" {
wireValue = getString(value, "id")
}
if wireValue == "" {
continue
}
name := getString(value, "label")
if name == "" {
name = humanize(wireValue)
}
out = append(out, streams.ConfigOptionValue{Value: wireValue, Name: name})
if getBool(value, "default") {
defaultEffort = wireValue
}
}
}
return out, defaultEffort
}
func resolveEffort(selected string, options []streams.ConfigOptionValue, fallback string, meta map[string]any) string {
for _, candidate := range []string{selected, getString(meta, GrokReasoningEffortMetaKey), fallback, "medium"} {
if value := matchEffort(candidate, options); value != "" {
return value
}
}
return options[0].Value
}
func matchEffort(token string, options []streams.ConfigOptionValue) string {
for _, option := range options {
if option.Value == token || option.Name == token {
return option.Value
}
}
return ""
}
func currentModelFromConfig(options []streams.ConfigOption) string {
for _, option := range options {
if option.ID == modelOptionID || option.Category == modelOptionID {
return option.CurrentValue
}
}
return ""
}
func hasModelOption(options []streams.ConfigOption) bool {
for _, option := range options {
if option.ID == modelOptionID || option.Category == modelOptionID {
return true
}
}
return false
}
func hasOption(options []streams.ConfigOption, id string) bool {
for _, option := range options {
if option.ID == id {
return true
}
}
return false
}
func findModel(models []Model, id string) *Model {
for i := range models {
if models[i].ID == id {
return &models[i]
}
}
return nil
}
func humanize(value string) string {
if value == "" {
return ""
}
return strings.ToUpper(value[:1]) + value[1:]
}
func getString(meta map[string]any, key string) string {
value, _ := meta[key].(string)
return value
}
func getBool(meta map[string]any, key string) bool {
value, _ := meta[key].(bool)
return value
}
func positiveInt64(meta map[string]any, key string) (int64, bool) {
value, ok := nonNegativeInt64(meta, key)
return value, ok && value > 0
}
func nonNegativeInt64(meta map[string]any, key string) (int64, bool) {
if meta == nil {
return 0, false
}
switch value := meta[key].(type) {
case float64:
if value < 0 || math.IsNaN(value) || math.IsInf(value, 0) ||
value != math.Trunc(value) || value >= math.Exp2(63) {
return 0, false
}
return int64(value), true
case int64:
return value, value >= 0
case int:
return int64(value), value >= 0
default:
return 0, false
}
}
@@ -0,0 +1,33 @@
package acpcompat
import (
"math"
"testing"
)
func TestNonNegativeInt64RejectsInvalidFloatTokenCounts(t *testing.T) {
tests := []struct {
name string
value any
want int64
ok bool
}{
{name: "zero", value: float64(0), want: 0, ok: true},
{name: "whole number", value: float64(42), want: 42, ok: true},
{name: "largest representable below int64 limit", value: math.Nextafter(math.Exp2(63), 0), want: 9_223_372_036_854_774_784, ok: true},
{name: "negative", value: float64(-1)},
{name: "fractional", value: 1.5},
{name: "nan", value: math.NaN()},
{name: "positive infinity", value: math.Inf(1)},
{name: "int64 overflow", value: math.Exp2(63)},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, ok := nonNegativeInt64(map[string]any{"tokens": tt.value}, "tokens")
if ok != tt.ok || got != tt.want {
t.Fatalf("nonNegativeInt64() = (%d, %t), want (%d, %t)", got, ok, tt.want, tt.ok)
}
})
}
}
@@ -228,6 +228,15 @@ type Adapter struct {
// the options list when the model is changed.
availableConfigOptions []streams.ConfigOption
dialect acpDialect
// Session configuration changes are serialized across model and option
// RPCs. configGeneration is incremented when a change begins so an older
// completion cannot overwrite a newer selection.
configChangeMu sync.Mutex
configGeneration uint64
contextSamples map[string]contextWindowSample
// Synchronization
mu sync.RWMutex
closed bool
@@ -293,12 +302,14 @@ func NewAdapter(cfg *shared.Config, log *logger.Logger) *Adapter {
logger: l,
agentID: cfg.AgentID,
normalizer: NewNormalizer(cfg.AgentID),
dialect: newACPDialect(cfg.AgentID),
updatesCh: make(chan AgentEvent, 100),
notifQueue: make(chan notifWork, notifQueueCapacity),
activeToolCalls: make(map[string]*streams.NormalizedPayload),
activeMonitors: make(map[string]map[string]string),
pendingWakeups: make(map[string]*pendingWakeup),
usageBySession: make(map[string]*usageTracker),
contextSamples: make(map[string]contextWindowSample),
attachMgr: shared.NewAttachmentManager(cfg.WorkDir, l.Zap()),
promptGate: make(chan struct{}, 1),
asyncTurnFinalizers: make(map[string]*asyncTurnFinalizer),
@@ -473,20 +484,22 @@ func (a *Adapter) SetPermissionHandler(handler PermissionHandler) {
func (a *Adapter) sendUpdate(event AgentEvent) {
a.mu.RLock()
defer a.mu.RUnlock()
a.sendUpdateLocked(event)
if !a.sendUpdateLocked(event) && !a.closed {
a.logger.Warn("updates channel full, dropping event", zap.String("type", event.Type))
}
}
// sendUpdateLocked queues an event while the caller holds a.mu for reading or
// writing. Session-bound emitters use it to make their session check, cache
// mutation, and convergence event atomic with session replacement.
func (a *Adapter) sendUpdateLocked(event AgentEvent) {
// sendUpdateLocked enqueues an event without acquiring a.mu. Callers must
// hold a.mu for reading or writing.
func (a *Adapter) sendUpdateLocked(event AgentEvent) bool {
if a.closed {
return
return false
}
select {
case a.updatesCh <- event:
return true
default:
a.logger.Warn("updates channel full, dropping event", zap.String("type", event.Type))
return false
}
}
@@ -183,7 +183,7 @@ func (a *Adapter) sendPrompt(
zap.String("session_id", sessionID),
zap.String("stop_reason", stopReason))
a.cancelAsyncTurnComplete(sessionID)
usage := extractUsage(&resp)
usage := a.dialect.promptUsage(extractUsage(&resp), resp.Meta)
// codex-acp emits no per-turn usage frame, only cumulative
// usage_update.used. Fall back to the inferred delta so the office
// cost subscriber sees at least an approximate input count. The
@@ -55,6 +55,8 @@ func (a *Adapter) NewSession(ctx context.Context, mcpServers []types.McpServer)
a.mu.Lock()
a.sessionID = string(resp.SessionId)
sessionID := a.sessionID
a.configGeneration++
clear(a.contextSamples)
// Reset session-scoped model caches before computing the new session's
// state so a session without a model surface can't reuse the previous
// session's models / configOptions for validation in SetModel.
@@ -134,14 +136,6 @@ func hasModelConfigOption(options []streams.ConfigOption) bool {
return false
}
func sessionConfigOptions(meta map[string]any, acpConfigOptions []acp.SessionConfigOption) []streams.ConfigOption {
configOptions := convertACPConfigOptions(acpConfigOptions)
if len(configOptions) > 0 {
return configOptions
}
return extractConfigOptions(meta)
}
// effectiveMcpCapabilities applies the adapter's AssumeMcpSse/AssumeMcpHttp
// config overrides on top of the capabilities advertised by the agent during
// the ACP handshake. Some agents (e.g. Auggie) support SSE/HTTP MCP transports
@@ -320,6 +314,8 @@ func (a *Adapter) LoadSession(ctx context.Context, sessionID string, mcpServers
a.mu.Lock()
a.sessionID = sessionID
a.configGeneration++
clear(a.contextSamples)
// Reset session-scoped model caches so a load that lands on a session
// without a model surface can't reuse the previous session's data.
a.availableModels = nil
@@ -426,9 +422,9 @@ func (a *Adapter) emitInitialModeState(modes *acp.SessionModeState) {
// emitSessionModels emits a session_models event from the session response.
func (a *Adapter) emitSessionModels(sessionID string, models *sessionModelState, meta map[string]any, acpConfigOptions []acp.SessionConfigOption) {
currentModelID := models.CurrentModelId
// Prefer typed config options from the response; fall back to _meta
// extraction for older agents.
configOptions := sessionConfigOptions(meta, acpConfigOptions)
configOptions := a.dialect.sessionConfigOptions(
meta, acpConfigOptions, models.AvailableModels, currentModelID,
)
// Fallback: if the SDK didn't parse currentModelId (some agents omit it),
// take the model-shaped configOption's CurrentValue verbatim. We
@@ -473,7 +469,13 @@ func (a *Adapter) emitSessionModels(sessionID string, models *sessionModelState,
// — this prevents a downstream consumer that reads ConfigOptions[model]
// .CurrentValue (codex-style agents surface the current model there) from
// disagreeing with the CurrentModelID emitted on the same event.
func (a *Adapter) emitSetModelEvent(sessionID, modelID string, cachedModels []modelInfo, cachedConfig []streams.ConfigOption) {
func (a *Adapter) emitSetModelEvent(
sessionID string,
modelID string,
cachedModels []modelInfo,
cachedConfig []streams.ConfigOption,
expectedGeneration ...uint64,
) {
outConfig := cachedConfig
if len(cachedConfig) > 0 {
// Shallow copy: only CurrentValue (a string) is rewritten below, so
@@ -488,7 +490,24 @@ func (a *Adapter) emitSetModelEvent(sessionID, modelID string, cachedModels []mo
}
}
}
outConfig = a.dialect.modelConfigAfterChange(outConfig, cachedModels, modelID)
a.mu.Lock()
if a.sessionID != sessionID ||
(len(expectedGeneration) > 0 && a.configGeneration != expectedGeneration[0]) {
a.mu.Unlock()
return
}
if len(outConfig) > 0 {
// Refresh the cached config options so a subsequent SetConfigOption
// (e.g. user toggles reasoning effort after switching model) doesn't
// reuse the stale model CurrentValue from session/new and clobber the
// just-applied model in the convergence event.
a.availableConfigOptions = outConfig
}
if tracker := a.usageBySession[sessionID]; tracker != nil {
tracker.maxSize = 0
}
delete(a.contextSamples, sessionID)
event := AgentEvent{
Type: streams.EventTypeSessionModels,
SessionID: sessionID,
@@ -496,13 +515,17 @@ func (a *Adapter) emitSetModelEvent(sessionID, modelID string, cachedModels []mo
SessionModels: convertSessionModels(cachedModels),
ConfigOptions: outConfig,
}
if !a.emitConfigUpdateForSession(sessionID, outConfig, event) {
return
}
sent := a.sendUpdateLocked(event)
closed := a.closed
a.mu.Unlock()
a.logger.Info("emitting session_models convergence event after SetModel",
zap.String("session_id", sessionID),
zap.String("model_id", modelID),
)
if !sent && !closed {
a.logger.Warn("updates channel full, dropping event", zap.String("type", event.Type))
}
}
// currentModelFromConfig returns the CurrentValue of the model-shaped
@@ -567,6 +590,53 @@ func (a *Adapter) SetModel(ctx context.Context, modelID string) error {
if conn == nil {
return fmt.Errorf("adapter not initialized")
}
return a.setModelWithConn(ctx, conn, sessionID, modelID, available, cachedConfig)
}
func (a *Adapter) setModelWithConn(
ctx context.Context,
conn sessionmodel.SDKConn,
sessionID string,
modelID string,
available []modelInfo,
cachedConfig []streams.ConfigOption,
) error {
generation := a.beginConfigChange()
a.configChangeMu.Lock()
defer a.configChangeMu.Unlock()
if !a.isCurrentConfigChange(sessionID, generation) {
return nil
}
rpc, err := a.dialect.modelRequest(dialectConfigChange{
sessionID: sessionID,
value: modelID,
models: available,
config: cachedConfig,
})
if err != nil {
return err
}
if rpc != nil {
_, err = conn.UnstableSetSessionModel(ctx, rpc.request)
if !a.isCurrentConfigChange(sessionID, generation) {
return nil
}
if err != nil {
return rpc.formatError(err)
}
a.finalizeSetModel(
sessionmodel.MethodSetModel,
sessionID,
modelID,
available,
cachedConfig,
configOptionIDModel,
nil,
generation,
)
return nil
}
// Validate model exists in the agent's available models (if known).
if len(available) > 0 {
@@ -576,10 +646,13 @@ func (a *Adapter) SetModel(ctx context.Context, modelID string) error {
}
method, responseConfig, configID, err := applySessionModelWithConfigOptions(ctx, conn, sessionID, modelID, cachedConfig)
if !a.isCurrentConfigChange(sessionID, generation) {
return nil
}
if err != nil {
return fmt.Errorf("set session model failed via %s: %w", method, err)
}
a.finalizeSetModel(method, sessionID, modelID, available, cachedConfig, configID, responseConfig)
a.finalizeSetModel(method, sessionID, modelID, available, cachedConfig, configID, responseConfig, generation)
return nil
}
@@ -604,16 +677,33 @@ func (a *Adapter) finalizeSetModel(
cachedConfig []streams.ConfigOption,
configID string,
responseConfig []acp.SessionConfigOption,
expectedGeneration ...uint64,
) {
if method == sessionmodel.MethodNone {
return
}
a.resetContextWindowMaxSize(sessionID)
if len(responseConfig) > 0 {
a.emitAuthoritativeConfigOptions(sessionID, configID, responseConfig, available)
a.emitAuthoritativeConfigOptions(sessionID, configID, responseConfig, available, expectedGeneration...)
return
}
a.emitSetModelEvent(sessionID, modelID, available, cachedConfig)
a.emitSetModelEvent(sessionID, modelID, available, cachedConfig, expectedGeneration...)
}
func (a *Adapter) beginConfigChange() uint64 {
a.mu.Lock()
defer a.mu.Unlock()
a.configGeneration++
return a.configGeneration
}
func (a *Adapter) isCurrentConfigChange(sessionID string, generation uint64) bool {
a.mu.RLock()
defer a.mu.RUnlock()
return a.configChangeIsCurrentLocked(sessionID, generation)
}
func (a *Adapter) configChangeIsCurrentLocked(sessionID string, generation uint64) bool {
return a.sessionID == sessionID && a.configGeneration == generation
}
func applySessionModel(
@@ -713,6 +803,48 @@ func (a *Adapter) SetConfigOption(ctx context.Context, configID, value string) e
return fmt.Errorf("no active session: call NewSession before SetConfigOption")
}
generation := a.beginConfigChange()
a.configChangeMu.Lock()
defer a.configChangeMu.Unlock()
if !a.isCurrentConfigChange(sessionID, generation) {
return nil
}
rpc, err := a.dialect.configRequest(dialectConfigChange{
sessionID: sessionID,
configID: configID,
value: value,
models: cachedModels,
config: cachedConfig,
})
if err != nil {
return err
}
if rpc != nil {
_, err = conn.UnstableSetSessionModel(ctx, rpc.request)
if !a.isCurrentConfigChange(sessionID, generation) {
return nil
}
if err != nil {
return rpc.formatError(err)
}
if isModelConfigID(configID, cachedConfig) {
a.finalizeSetModel(
sessionmodel.MethodSetModel,
sessionID,
value,
cachedModels,
cachedConfig,
configID,
nil,
generation,
)
} else {
a.emitSetConfigOptionEvent(sessionID, configID, value, cachedModels, cachedConfig, generation)
}
return nil
}
resp, err := conn.SetSessionConfigOption(ctx, acp.SetSessionConfigOptionRequest{
ValueId: &acp.SetSessionConfigOptionValueId{
SessionId: acp.SessionId(sessionID),
@@ -720,6 +852,9 @@ func (a *Adapter) SetConfigOption(ctx context.Context, configID, value string) e
Value: acp.SessionConfigValueId(value),
},
})
if !a.isCurrentConfigChange(sessionID, generation) {
return nil
}
if err != nil {
return fmt.Errorf("set session config option failed: %w", err)
}
@@ -730,13 +865,13 @@ func (a *Adapter) SetConfigOption(ctx context.Context, configID, value string) e
return nil
}
if len(resp.ConfigOptions) > 0 {
a.emitAuthoritativeConfigOptions(sessionID, configID, resp.ConfigOptions, cachedModels)
a.emitAuthoritativeConfigOptions(sessionID, configID, resp.ConfigOptions, cachedModels, generation)
return nil
}
if isModelConfigID(configID, cachedConfig) {
a.emitSetModelEvent(sessionID, value, cachedModels, cachedConfig)
a.emitSetModelEvent(sessionID, value, cachedModels, cachedConfig, generation)
} else {
a.emitSetConfigOptionEvent(sessionID, configID, value, cachedModels, cachedConfig)
a.emitSetConfigOptionEvent(sessionID, configID, value, cachedModels, cachedConfig, generation)
}
return nil
}
@@ -746,9 +881,10 @@ func (a *Adapter) emitAuthoritativeConfigOptions(
configID string,
options []acp.SessionConfigOption,
cachedModels []modelInfo,
expectedGeneration ...uint64,
) {
configOptions := convertACPConfigOptions(options)
a.emitConfigUpdateForSession(sessionID, configOptions, AgentEvent{
event := AgentEvent{
Type: streams.EventTypeSessionModels,
SessionID: sessionID,
CurrentModelID: currentModelFromConfig(configOptions),
@@ -758,29 +894,43 @@ func (a *Adapter) emitAuthoritativeConfigOptions(
"config_options_source": "provider_response",
"config_options_config_id": configID,
},
})
}
func (a *Adapter) emitConfigUpdateForSession(
sessionID string,
configOptions []streams.ConfigOption,
event AgentEvent,
) bool {
}
a.mu.Lock()
defer a.mu.Unlock()
if len(expectedGeneration) > 0 && !a.configChangeIsCurrentLocked(sessionID, expectedGeneration[0]) {
a.mu.Unlock()
return
}
if a.sessionID != sessionID || a.closed {
return false
a.mu.Unlock()
return
}
a.availableConfigOptions = configOptions
a.sendUpdateLocked(event)
return true
if isModelConfigID(configID, configOptions) {
if tracker := a.usageBySession[sessionID]; tracker != nil {
tracker.maxSize = 0
}
delete(a.contextSamples, sessionID)
}
sent := a.sendUpdateLocked(event)
closed := a.closed
a.mu.Unlock()
if !sent && !closed {
a.logger.Warn("updates channel full, dropping event", zap.String("type", event.Type))
}
}
// emitSetConfigOptionEvent emits a session_models convergence event after a
// non-model SetConfigOption RPC succeeds. The frontend uses this to keep the
// option dropdowns in sync with the agent without waiting for an agent-driven
// ConfigOptionUpdate.
func (a *Adapter) emitSetConfigOptionEvent(sessionID, configID, value string, cachedModels []modelInfo, cachedConfig []streams.ConfigOption) {
func (a *Adapter) emitSetConfigOptionEvent(
sessionID string,
configID string,
value string,
cachedModels []modelInfo,
cachedConfig []streams.ConfigOption,
expectedGeneration ...uint64,
) {
if len(cachedConfig) == 0 {
// In normal operation session/new populates availableConfigOptions
// before the frontend can fire a SetConfigOption. Hitting this branch
@@ -815,6 +965,16 @@ func (a *Adapter) emitSetConfigOptionEvent(sessionID, configID, value string, ca
zap.String("config_id", configID),
)
}
// Refresh the cached config options so consecutive SetConfigOption
// calls (or a follow-up SetModel) read the latest CurrentValues
// instead of the stale session/new snapshot.
a.mu.Lock()
if a.sessionID != sessionID ||
(len(expectedGeneration) > 0 && a.configGeneration != expectedGeneration[0]) {
a.mu.Unlock()
return
}
a.availableConfigOptions = outConfig
event := AgentEvent{
Type: streams.EventTypeSessionModels,
SessionID: sessionID,
@@ -822,14 +982,18 @@ func (a *Adapter) emitSetConfigOptionEvent(sessionID, configID, value string, ca
SessionModels: convertSessionModels(cachedModels),
ConfigOptions: outConfig,
}
if !a.emitConfigUpdateForSession(sessionID, outConfig, event) {
return
}
sent := a.sendUpdateLocked(event)
closed := a.closed
a.mu.Unlock()
a.logger.Info("emitting session_models convergence event after SetConfigOption",
zap.String("session_id", sessionID),
zap.String("config_id", configID),
zap.String("value", value),
)
if !sent && !closed {
a.logger.Warn("updates channel full, dropping event", zap.String("type", event.Type))
}
}
// isModelConfigID reports whether configID identifies the model-shaped
@@ -150,8 +150,12 @@ func (a *Adapter) handleACPUpdate(n acp.SessionNotification) {
sessionID := string(n.SessionId)
event := a.convertNotification(n)
if event == nil {
suppressed := a.dialect.suppresses(n)
var event *AgentEvent
if !suppressed {
event = a.convertNotification(n)
}
if event == nil && !suppressed {
// Try untyped updates not yet supported by the ACP SDK.
event = a.tryConvertUntypedUpdate(rawData, sessionID)
}
@@ -161,11 +165,55 @@ func (a *Adapter) handleACPUpdate(n acp.SessionNotification) {
event.Type, rawData, event)
a.sendUpdate(*event)
a.maybeScheduleAsyncTurnComplete(*event)
} else if updateJSON, err := json.Marshal(n.Update); err == nil {
a.logger.Warn("unhandled ACP session notification",
zap.String("session_id", sessionID),
zap.String("update_json", string(updateJSON)))
} else if !suppressed {
if updateJSON, err := json.Marshal(n.Update); err == nil {
a.logger.Warn("unhandled ACP session notification",
zap.String("session_id", sessionID),
zap.String("update_json", string(updateJSON)))
}
}
if !isLoading {
if supplemental := a.emitDialectContextWindow(sessionID, n.Meta); supplemental != nil {
shared.LogNormalizedEvent(shared.ProtocolACP, a.agentID, sessionID, supplemental)
}
}
}
// emitDialectContextWindow derives and enqueues an agent-specific context
// sample while holding the same lock that protects the active session/model.
// This keeps a model switch from interleaving between size selection and event
// delivery.
func (a *Adapter) emitDialectContextWindow(sessionID string, meta map[string]any) *AgentEvent {
a.mu.Lock()
if a.closed || sessionID != a.sessionID {
a.mu.Unlock()
return nil
}
sample, ok := a.dialect.contextSample(meta, a.availableModels, a.availableConfigOptions)
if !ok || a.contextSamples[sessionID] == sample {
a.mu.Unlock()
return nil
}
remaining := max(sample.size-sample.used, 0)
event := AgentEvent{
Type: streams.EventTypeContextWindow,
SessionID: sessionID,
ContextWindowSize: sample.size,
ContextWindowUsed: sample.used,
ContextWindowRemaining: remaining,
ContextEfficiency: float64(sample.used) / float64(sample.size) * 100,
}
sent := a.sendUpdateLocked(event)
if sent {
a.contextSamples[sessionID] = sample
}
a.mu.Unlock()
if !sent {
a.logger.Warn("updates channel full, dropping event", zap.String("type", event.Type))
return nil
}
return &event
}
// convertNotification converts an ACP SessionNotification to an AgentEvent.
@@ -324,6 +372,7 @@ func (a *Adapter) resetContextWindowMaxSize(sessionID string) {
if tr := a.usageBySession[sessionID]; tr != nil {
tr.maxSize = 0
}
delete(a.contextSamples, sessionID)
}
// consumeUsageDelta returns the delta of cumulative `used` since the
@@ -0,0 +1,118 @@
package acp
import (
"github.com/coder/acp-go-sdk"
"github.com/kandev/kandev/internal/agentctl/types/streams"
)
// acpDialect contains pure compatibility decisions for an agent's observed
// ACP implementation. The Adapter remains responsible for RPC execution,
// session state, serialization, and event delivery.
type acpDialect struct {
normalizeSessionConfig func(
[]streams.ConfigOption,
[]modelInfo,
string,
) []streams.ConfigOption
configAfterModelChange func(
[]streams.ConfigOption,
[]modelInfo,
string,
) []streams.ConfigOption
modelChangeRequest func(dialectConfigChange) (*dialectRPC, error)
configChangeRequest func(dialectConfigChange) (*dialectRPC, error)
suppressNotification func(acp.SessionNotification) bool
contextWindow func(map[string]any, []modelInfo, []streams.ConfigOption) (contextWindowSample, bool)
normalizePromptUsage func(*streams.PromptUsage, map[string]any) *streams.PromptUsage
}
type dialectConfigChange struct {
sessionID string
configID string
value string
models []modelInfo
config []streams.ConfigOption
}
type dialectRPC struct {
request acp.UnstableSetSessionModelRequest
formatError func(error) error
}
type contextWindowSample struct {
used int64
size int64
}
func newACPDialect(agentID string) acpDialect {
if agentID == grokAgentID {
return newGrokACPDialect()
}
return acpDialect{}
}
func (d acpDialect) sessionConfigOptions(
meta map[string]any,
options []acp.SessionConfigOption,
models []modelInfo,
currentModelID string,
) []streams.ConfigOption {
base := convertACPConfigOptions(options)
if len(base) == 0 {
base = extractConfigOptions(meta)
}
if d.normalizeSessionConfig == nil {
return base
}
return d.normalizeSessionConfig(base, models, currentModelID)
}
func (d acpDialect) modelConfigAfterChange(
config []streams.ConfigOption,
models []modelInfo,
modelID string,
) []streams.ConfigOption {
if d.configAfterModelChange == nil {
return config
}
return d.configAfterModelChange(config, models, modelID)
}
func (d acpDialect) modelRequest(change dialectConfigChange) (*dialectRPC, error) {
if d.modelChangeRequest == nil {
return nil, nil
}
return d.modelChangeRequest(change)
}
func (d acpDialect) configRequest(change dialectConfigChange) (*dialectRPC, error) {
if d.configChangeRequest == nil {
return nil, nil
}
return d.configChangeRequest(change)
}
func (d acpDialect) suppresses(notification acp.SessionNotification) bool {
return d.suppressNotification != nil && d.suppressNotification(notification)
}
func (d acpDialect) contextSample(
meta map[string]any,
models []modelInfo,
config []streams.ConfigOption,
) (contextWindowSample, bool) {
if d.contextWindow == nil {
return contextWindowSample{}, false
}
return d.contextWindow(meta, models, config)
}
func (d acpDialect) promptUsage(
usage *streams.PromptUsage,
meta map[string]any,
) *streams.PromptUsage {
if d.normalizePromptUsage == nil {
return usage
}
return d.normalizePromptUsage(usage, meta)
}
@@ -0,0 +1,202 @@
package acp
import (
"errors"
"fmt"
"strings"
"github.com/coder/acp-go-sdk"
"github.com/kandev/kandev/internal/agentctl/acpcompat"
"github.com/kandev/kandev/internal/agentctl/types/streams"
)
const (
grokAgentID = acpcompat.GrokAgentID
grokReasoningEffortMetaKey = acpcompat.GrokReasoningEffortMetaKey
configOptionIDReasoningEffort = acpcompat.GrokReasoningEffortOptionID
configOptionCategoryThoughtLevel = "thought_level"
)
func newGrokACPDialect() acpDialect {
return acpDialect{
normalizeSessionConfig: grokNormalizeSessionConfig,
configAfterModelChange: grokConfigAfterModelChange,
modelChangeRequest: grokModelChangeRequest,
configChangeRequest: grokConfigChangeRequest,
suppressNotification: func(notification acp.SessionNotification) bool {
return notification.Update.UserMessageChunk != nil
},
contextWindow: grokContextWindow,
normalizePromptUsage: normalizeGrokPromptUsage,
}
}
func grokNormalizeSessionConfig(
base []streams.ConfigOption,
models []modelInfo,
currentModelID string,
) []streams.ConfigOption {
return acpcompat.NormalizeSessionConfig(
grokAgentID,
base,
toCompatModels(models),
currentModelID,
)
}
// grokSessionConfigOptions is retained as a focused parser entry point for
// captured-wire tests. Runtime selection goes through acpDialect.
func grokSessionConfigOptions(
typed []streams.ConfigOption,
meta map[string]any,
models []modelInfo,
currentModelID string,
) []streams.ConfigOption {
base := typed
if len(base) == 0 {
base = extractConfigOptions(meta)
}
return grokNormalizeSessionConfig(base, models, currentModelID)
}
func grokConfigAfterModelChange(
config []streams.ConfigOption,
models []modelInfo,
modelID string,
) []streams.ConfigOption {
return acpcompat.ConfigAfterModelChange(grokAgentID, config, toCompatModels(models), modelID)
}
func grokModelChangeRequest(change dialectConfigChange) (*dialectRPC, error) {
if len(change.models) > 0 && findModelInfo(change.models, change.value) == nil {
return nil, fmt.Errorf("model %q is not in the agent's available models", change.value)
}
req := acp.UnstableSetSessionModelRequest{
SessionId: acp.SessionId(change.sessionID),
ModelId: change.value,
}
if previous := currentGrokEffortFromConfig(change.config); previous != "" {
if effort, err := acpcompat.ValidateGrokReasoningEffort(
toCompatModels(change.models), change.value, previous,
); err == nil {
req.Meta = map[string]any{grokReasoningEffortMetaKey: effort}
}
}
return &dialectRPC{request: req, formatError: formatGrokSetModelError}, nil
}
func grokConfigChangeRequest(change dialectConfigChange) (*dialectRPC, error) {
if isModelConfigID(change.configID, change.config) {
return grokModelChangeRequest(change)
}
if change.configID != configOptionIDReasoningEffort {
return nil, nil
}
modelID := currentModelFromConfig(change.config)
effort, err := acpcompat.ValidateGrokReasoningEffort(
toCompatModels(change.models), modelID, change.value,
)
if err != nil {
return nil, err
}
return &dialectRPC{
request: acp.UnstableSetSessionModelRequest{
SessionId: acp.SessionId(change.sessionID),
ModelId: modelID,
Meta: map[string]any{grokReasoningEffortMetaKey: effort},
},
formatError: formatGrokReasoningError,
}, nil
}
func grokContextWindow(
meta map[string]any,
models []modelInfo,
config []streams.ConfigOption,
) (contextWindowSample, bool) {
sample, ok := acpcompat.GrokContextWindow(meta, toCompatModels(models), config)
return contextWindowSample{used: sample.Used, size: sample.Size}, ok
}
func normalizeGrokPromptUsage(
usage *streams.PromptUsage,
meta map[string]any,
) *streams.PromptUsage {
if usage != nil && usage.ThoughtTokens == 0 {
usage.ThoughtTokens = acpcompat.GrokReasoningTokens(meta)
}
return usage
}
func toCompatModels(models []modelInfo) []acpcompat.Model {
out := make([]acpcompat.Model, 0, len(models))
for _, model := range models {
out = append(out, acpcompat.Model{ID: model.ModelId, Name: model.Name, Meta: toStringMap(model.Meta)})
}
return out
}
func findModelInfo(models []modelInfo, modelID string) *modelInfo {
for i := range models {
if models[i].ModelId == modelID {
return &models[i]
}
}
return nil
}
func currentGrokEffortFromConfig(config []streams.ConfigOption) string {
for _, option := range config {
if option.ID == configOptionIDReasoningEffort {
return option.CurrentValue
}
}
return ""
}
func formatGrokReasoningError(err error) error {
return fmt.Errorf("set grok reasoning effort via session/set_model failed: %w", err)
}
func formatGrokSetModelError(err error) error {
if err == nil {
return nil
}
var reqErr *acp.RequestError
if errors.As(err, &reqErr) && reqErr.Message != "" {
if isGrokIncompatibleAgentSwitchError(err) {
message := reqErr.Message
if !strings.Contains(message, "Start a new session") {
message += ". Start a new session to use this model."
}
return fmt.Errorf("%s [MODEL_SWITCH_INCOMPATIBLE_AGENT]: %w", message, err)
}
return fmt.Errorf("set grok model via session/set_model failed: %s: %w", reqErr.Message, err)
}
return fmt.Errorf("set grok model via session/set_model failed: %w", err)
}
func isGrokIncompatibleAgentSwitchError(err error) bool {
if err == nil {
return false
}
var reqErr *acp.RequestError
if errors.As(err, &reqErr) && grokErrorDataCode(reqErr.Data) == "MODEL_SWITCH_INCOMPATIBLE_AGENT" {
return true
}
message := err.Error()
return strings.Contains(message, "MODEL_SWITCH_INCOMPATIBLE_AGENT") ||
(strings.Contains(message, "requires agent") && strings.Contains(message, "Start a new session"))
}
func grokErrorDataCode(data any) string {
switch value := data.(type) {
case map[string]any:
code, _ := value["code"].(string)
return code
case map[string]string:
return value["code"]
default:
return ""
}
}
@@ -0,0 +1,585 @@
package acp
import (
"context"
"errors"
"strings"
"sync"
"testing"
"time"
"github.com/coder/acp-go-sdk"
"github.com/kandev/kandev/internal/agentctl/types/streams"
)
func TestGrokSessionConfig_BecomesModelAndReasoningSelect(t *testing.T) {
models := []modelInfo{{
ModelId: "grok-4.5",
Name: "Grok 4.5",
Meta: map[string]any{
"supportsReasoningEffort": true,
"totalContextTokens": float64(500_000),
"reasoningEffort": "high",
"reasoningEfforts": []any{"high", "medium", "low"},
},
}}
// No typed ACP configOptions / no extractable meta options → build from catalog.
opts := grokSessionConfigOptions(nil, nil, models, "grok-4.5")
if len(opts) != 2 {
t.Fatalf("got %d options, want 2 (model + reasoning_effort)", len(opts))
}
var model, effort *streams.ConfigOption
for i := range opts {
switch opts[i].ID {
case configOptionIDModel:
model = &opts[i]
case configOptionIDReasoningEffort:
effort = &opts[i]
}
}
if model == nil || model.CurrentValue != "grok-4.5" {
t.Fatalf("model option = %#v", model)
}
if effort == nil {
t.Fatal("missing reasoning_effort option")
}
if effort.Category != configOptionCategoryThoughtLevel {
t.Fatalf("effort category = %q, want %q (frontend filters mode)", effort.Category, configOptionCategoryThoughtLevel)
}
if effort.CurrentValue != "high" {
t.Fatalf("effort current = %q, want high", effort.CurrentValue)
}
}
func TestGrokSessionConfig_UnsupportedModelHidesEffort(t *testing.T) {
models := []modelInfo{{
ModelId: "grok-build",
Name: "Grok Build",
Meta: map[string]any{"totalContextTokens": float64(500_000)},
}}
opts := grokSessionConfigOptions(nil, nil, models, "grok-build")
for _, o := range opts {
if o.ID == configOptionIDReasoningEffort {
t.Fatalf("reasoning_effort must be hidden for unsupported model; got %#v", o)
}
}
}
func TestGrokSessionConfig_MergesDerivedReasoningWithTypedOptions(t *testing.T) {
modelCategory := acp.SessionConfigOptionCategoryModel
typed := []streams.ConfigOption{{
Type: "select",
ID: configOptionIDModel,
Category: string(modelCategory),
Name: "Model",
CurrentValue: "grok-4.5",
}}
models := []modelInfo{{
ModelId: "grok-4.5",
Meta: map[string]any{
"supportsReasoningEffort": true,
"reasoningEffort": "high",
"reasoningEfforts": []any{"high", "medium", "low"},
},
}}
opts := grokSessionConfigOptions(typed, nil, models, "grok-4.5")
if len(opts) != 2 {
t.Fatalf("got %d options, want typed model + derived reasoning", len(opts))
}
if opts[1].ID != configOptionIDReasoningEffort {
t.Fatalf("second option ID = %q, want %q", opts[1].ID, configOptionIDReasoningEffort)
}
}
type fakeGrokSetModelConn struct {
reqs []acp.UnstableSetSessionModelRequest
configReqs []acp.SetSessionConfigOptionRequest
err error
}
type blockingGrokSetModelConn struct {
mu sync.Mutex
started chan string
releaseFirst chan struct{}
calls int
}
func (f *blockingGrokSetModelConn) SetSessionConfigOption(
context.Context,
acp.SetSessionConfigOptionRequest,
) (acp.SetSessionConfigOptionResponse, error) {
return acp.SetSessionConfigOptionResponse{}, nil
}
func (f *blockingGrokSetModelConn) UnstableSetSessionModel(
_ context.Context,
req acp.UnstableSetSessionModelRequest,
) (acp.UnstableSetSessionModelResponse, error) {
f.mu.Lock()
f.calls++
call := f.calls
f.mu.Unlock()
f.started <- req.ModelId
if call == 1 {
<-f.releaseFirst
}
return acp.UnstableSetSessionModelResponse{}, nil
}
func (f *fakeGrokSetModelConn) SetSessionConfigOption(
_ context.Context,
req acp.SetSessionConfigOptionRequest,
) (acp.SetSessionConfigOptionResponse, error) {
f.configReqs = append(f.configReqs, req)
return acp.SetSessionConfigOptionResponse{}, f.err
}
func (f *fakeGrokSetModelConn) UnstableSetSessionModel(_ context.Context, req acp.UnstableSetSessionModelRequest) (acp.UnstableSetSessionModelResponse, error) {
f.reqs = append(f.reqs, req)
return acp.UnstableSetSessionModelResponse{}, f.err
}
func grokAdapterWithModels(t *testing.T, models []modelInfo, config []streams.ConfigOption) *Adapter {
t.Helper()
a := newTestAdapter()
a.agentID = grokAgentID
a.dialect = newGrokACPDialect()
a.sessionID = "sess-grok"
a.availableModels = models
a.availableConfigOptions = config
return a
}
func TestSetGrokModel_UsesSessionSetModel(t *testing.T) {
models := []modelInfo{
{ModelId: "grok-build", Name: "Grok Build"},
{ModelId: "grok-4.5", Name: "Grok 4.5", Meta: map[string]any{
"supportsReasoningEffort": true,
"reasoningEfforts": []any{"high", "medium", "low"},
}},
}
config := []streams.ConfigOption{
{Type: "select", ID: "model", Category: "model", CurrentValue: "grok-build",
Options: []streams.ConfigOptionValue{
{Value: "grok-build", Name: "Grok Build"},
{Value: "grok-4.5", Name: "Grok 4.5"},
}},
{Type: "select", ID: configOptionIDReasoningEffort, Category: configOptionCategoryThoughtLevel,
CurrentValue: "high", Options: []streams.ConfigOptionValue{
{Value: "low", Name: "Low"}, {Value: "medium", Name: "Medium"}, {Value: "high", Name: "High"},
}},
}
a := grokAdapterWithModels(t, models, config)
conn := &fakeGrokSetModelConn{}
if err := a.setModelWithConn(context.Background(), conn, "sess-grok", "grok-4.5", models, config); err != nil {
t.Fatalf("setModel: %v", err)
}
if len(conn.reqs) != 1 {
t.Fatalf("RPC count = %d, want 1", len(conn.reqs))
}
if conn.reqs[0].ModelId != "grok-4.5" {
t.Fatalf("modelId = %q, want grok-4.5", conn.reqs[0].ModelId)
}
// Prior effort still valid for target model → carried in meta.
if conn.reqs[0].Meta[grokReasoningEffortMetaKey] != "high" {
t.Fatalf("meta.reasoningEffort = %v, want high", conn.reqs[0].Meta[grokReasoningEffortMetaKey])
}
}
func TestSetGrokReasoningEffort_UsesNormalizedModelConfig(t *testing.T) {
models := []modelInfo{{
ModelId: "grok-4.5",
Meta: map[string]any{
"supportsReasoningEffort": true,
"reasoningEfforts": []any{"high", "medium", "low"},
},
}}
config := []streams.ConfigOption{
{Type: "select", ID: "model", Category: "model", CurrentValue: "grok-4.5"},
{Type: "select", ID: configOptionIDReasoningEffort, Category: configOptionCategoryThoughtLevel,
CurrentValue: "medium", Options: []streams.ConfigOptionValue{
{Value: "low", Name: "Low"}, {Value: "medium", Name: "Medium"}, {Value: "high", Name: "High"},
}},
}
rpc, err := grokConfigChangeRequest(dialectConfigChange{
sessionID: "sess-grok",
configID: configOptionIDReasoningEffort,
value: "high",
models: models,
config: config,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if rpc == nil {
t.Fatal("expected Grok session/set_model request")
}
req := rpc.request
if req.ModelId != "grok-4.5" {
t.Fatalf("modelId = %q, want current model preserved", req.ModelId)
}
if req.Meta[grokReasoningEffortMetaKey] != "high" {
t.Fatalf("meta.reasoningEffort = %v, want high", req.Meta[grokReasoningEffortMetaKey])
}
}
func TestSetGrokModel_IncompatibleHarnessAsksForNewSession(t *testing.T) {
models := []modelInfo{
{ModelId: "grok-build", Meta: map[string]any{"agentType": "grok-build-plan"}},
{ModelId: "grok-composer-2.5-fast", Name: "Composer 2.5", Meta: map[string]any{"agentType": "cursor"}},
}
config := []streams.ConfigOption{
{Type: "select", ID: "model", Category: "model", CurrentValue: "grok-build"},
}
a := grokAdapterWithModels(t, models, config)
conn := &fakeGrokSetModelConn{
err: &acp.RequestError{
Code: -32600,
Message: "Cannot switch model harness after first turn",
Data: map[string]any{"code": "MODEL_SWITCH_INCOMPATIBLE_AGENT"},
},
}
err := a.setModelWithConn(
context.Background(), conn, "sess-grok", "grok-composer-2.5-fast", models, config,
)
if err == nil || !strings.Contains(err.Error(), "Start a new session") {
t.Fatalf("setModel error = %v, want actionable new-session instruction", err)
}
if len(conn.reqs) != 1 {
t.Fatalf("set_model RPC count = %d, want 1", len(conn.reqs))
}
if a.sessionID != "sess-grok" {
t.Fatalf("session ID = %q, want unchanged sess-grok", a.sessionID)
}
if currentModelFromConfig(a.availableConfigOptions) != "grok-build" {
t.Fatalf("current model config changed after failed switch: %#v", a.availableConfigOptions)
}
}
func TestSetModelWithConn_GrokDriverAsksForNewSession(t *testing.T) {
models := []modelInfo{
{ModelId: "grok-build", Meta: map[string]any{"agentType": "grok-build-plan"}},
{ModelId: "grok-composer-2.5-fast", Meta: map[string]any{"agentType": "cursor"}},
}
config := []streams.ConfigOption{{
Type: "select", ID: "model", Category: "model", CurrentValue: "grok-build",
}}
adapter := grokAdapterWithModels(t, models, config)
conn := &fakeGrokSetModelConn{
err: &acp.RequestError{
Code: -32600,
Message: "Cannot switch model harness after first turn",
Data: map[string]any{"code": "MODEL_SWITCH_INCOMPATIBLE_AGENT"},
},
}
err := adapter.setModelWithConn(
context.Background(),
conn,
"sess-grok",
"grok-composer-2.5-fast",
models,
config,
)
if err == nil || !strings.Contains(err.Error(), "Start a new session") {
t.Fatalf("SetModel error = %v, want actionable new-session instruction", err)
}
if len(conn.configReqs) != 0 {
t.Fatalf("session/set_config_option calls = %d, want 0", len(conn.configReqs))
}
if len(conn.reqs) != 1 {
t.Fatalf("session/set_model calls = %d, want 1", len(conn.reqs))
}
if adapter.sessionID != "sess-grok" {
t.Fatalf("session ID = %q, want unchanged sess-grok", adapter.sessionID)
}
if currentModelFromConfig(adapter.availableConfigOptions) != "grok-build" {
t.Fatalf("current model config changed after failed switch: %#v", adapter.availableConfigOptions)
}
}
func TestSetModelWithConn_SerializesConcurrentGrokChanges(t *testing.T) {
models := []modelInfo{{ModelId: "grok-old"}, {ModelId: "grok-first"}, {ModelId: "grok-latest"}}
config := []streams.ConfigOption{{
Type: "select", ID: configOptionIDModel, Category: configOptionIDModel, CurrentValue: "grok-old",
}}
adapter := grokAdapterWithModels(t, models, config)
conn := &blockingGrokSetModelConn{
started: make(chan string, 2),
releaseFirst: make(chan struct{}),
}
firstDone := make(chan error, 1)
go func() {
firstDone <- adapter.setModelWithConn(
context.Background(), conn, "sess-grok", "grok-first", models, config,
)
}()
if got := <-conn.started; got != "grok-first" {
t.Fatalf("first RPC model = %q, want grok-first", got)
}
secondDone := make(chan error, 1)
go func() {
secondDone <- adapter.setModelWithConn(
context.Background(), conn, "sess-grok", "grok-latest", models, config,
)
}()
select {
case got := <-conn.started:
t.Fatalf("second RPC %q started before the first completed", got)
case <-time.After(50 * time.Millisecond):
}
close(conn.releaseFirst)
if err := <-firstDone; err != nil {
t.Fatalf("first SetModel: %v", err)
}
if got := <-conn.started; got != "grok-latest" {
t.Fatalf("second RPC model = %q, want grok-latest", got)
}
if err := <-secondDone; err != nil {
t.Fatalf("second SetModel: %v", err)
}
if got := currentModelFromConfig(adapter.availableConfigOptions); got != "grok-latest" {
t.Fatalf("cached model = %q, want latest selection", got)
}
}
func TestSetModelWithConn_StaleGrokCompletionDoesNotMutateReplacementSession(t *testing.T) {
models := []modelInfo{{ModelId: "grok-old"}, {ModelId: "grok-stale"}, {ModelId: "grok-new"}}
oldConfig := []streams.ConfigOption{{
Type: "select", ID: configOptionIDModel, Category: configOptionIDModel, CurrentValue: "grok-old",
}}
adapter := grokAdapterWithModels(t, models, oldConfig)
conn := &blockingGrokSetModelConn{
started: make(chan string, 1),
releaseFirst: make(chan struct{}),
}
done := make(chan error, 1)
go func() {
done <- adapter.setModelWithConn(
context.Background(), conn, "sess-grok", "grok-stale", models, oldConfig,
)
}()
<-conn.started
newConfig := []streams.ConfigOption{{
Type: "select", ID: configOptionIDModel, Category: configOptionIDModel, CurrentValue: "grok-new",
}}
adapter.mu.Lock()
adapter.sessionID = "sess-new"
adapter.availableConfigOptions = newConfig
adapter.mu.Unlock()
close(conn.releaseFirst)
if err := <-done; err != nil {
t.Fatalf("stale SetModel: %v", err)
}
adapter.mu.RLock()
gotSession := adapter.sessionID
gotModel := currentModelFromConfig(adapter.availableConfigOptions)
adapter.mu.RUnlock()
if gotSession != "sess-new" || gotModel != "grok-new" {
t.Fatalf("replacement state = session %q model %q, want sess-new/grok-new", gotSession, gotModel)
}
}
func TestGrokContextFromNotificationMeta(t *testing.T) {
models := []modelInfo{{
ModelId: "grok-4.5",
Meta: map[string]any{"totalContextTokens": float64(500_000)},
}}
a := grokAdapterWithModels(t, models, []streams.ConfigOption{{
Type: "select", ID: configOptionIDModel, Category: configOptionIDModel, CurrentValue: "grok-4.5",
}})
a.handleACPUpdate(acp.SessionNotification{
SessionId: "sess-grok",
Meta: map[string]any{"totalTokens": float64(42_000)},
Update: acp.SessionUpdate{
AgentMessageChunk: &acp.SessionUpdateAgentMessageChunk{
Content: acp.TextBlock("hi"),
},
},
})
events := drainEvents(a)
var ctx *AgentEvent
for i := range events {
if events[i].Type == streams.EventTypeContextWindow {
ctx = &events[i]
}
}
if ctx == nil {
t.Fatalf("expected context_window event; got %#v", events)
}
if ctx.ContextWindowSize != 500_000 || ctx.ContextWindowUsed != 42_000 {
t.Fatalf("size/used = %d/%d, want 500000/42000", ctx.ContextWindowSize, ctx.ContextWindowUsed)
}
a.handleACPUpdate(acp.SessionNotification{
SessionId: "sess-grok",
Meta: map[string]any{"totalTokens": float64(42_000)},
Update: acp.SessionUpdate{
AgentMessageChunk: &acp.SessionUpdateAgentMessageChunk{Content: acp.TextBlock("again")},
},
})
for _, event := range drainEvents(a) {
if event.Type == streams.EventTypeContextWindow {
t.Fatal("unchanged totalTokens must not emit duplicate context_window")
}
}
// Prompt completion consumes the shared ACP usage tracker. Driver-owned
// context dedupe must remain intact across that unrelated reset.
a.consumeUsageDelta("sess-grok")
a.handleACPUpdate(acp.SessionNotification{
SessionId: "sess-grok",
Meta: map[string]any{"totalTokens": float64(42_000)},
Update: acp.SessionUpdate{
AgentMessageChunk: &acp.SessionUpdateAgentMessageChunk{Content: acp.TextBlock("next turn")},
},
})
for _, event := range drainEvents(a) {
if event.Type == streams.EventTypeContextWindow {
t.Fatal("usage tracker reset must not duplicate unchanged dialect context")
}
}
// Compaction legitimately lowers context usage and must still emit.
a.handleACPUpdate(acp.SessionNotification{
SessionId: "sess-grok",
Meta: map[string]any{"totalTokens": float64(40_000)},
Update: acp.SessionUpdate{
AgentMessageChunk: &acp.SessionUpdateAgentMessageChunk{Content: acp.TextBlock("compacted")},
},
})
compactedUsed := int64(0)
compactedFound := false
for _, event := range drainEvents(a) {
if event.Type == streams.EventTypeContextWindow {
compactedUsed = event.ContextWindowUsed
compactedFound = true
}
}
if !compactedFound || compactedUsed != 40_000 {
t.Fatalf("compaction context used = %d (found=%t), want 40000", compactedUsed, compactedFound)
}
// A delayed notification from a replaced session cannot update current UI.
a.handleACPUpdate(acp.SessionNotification{
SessionId: "stale-session",
Meta: map[string]any{"totalTokens": float64(99_000)},
Update: acp.SessionUpdate{
AgentMessageChunk: &acp.SessionUpdateAgentMessageChunk{Content: acp.TextBlock("stale")},
},
})
for _, event := range drainEvents(a) {
if event.Type == streams.EventTypeContextWindow {
t.Fatal("stale session must not emit dialect context")
}
}
}
func TestGrokUserMessageEchoIsSuppressedWithoutDroppingContext(t *testing.T) {
models := []modelInfo{{
ModelId: "grok-current",
Meta: map[string]any{"totalContextTokens": float64(500_000)},
}}
a := grokAdapterWithModels(t, models, []streams.ConfigOption{{
Type: "select", ID: configOptionIDModel, Category: configOptionIDModel, CurrentValue: "grok-current",
}})
a.handleACPUpdate(acp.SessionNotification{
SessionId: "sess-grok",
Meta: map[string]any{"totalTokens": float64(42_000)},
Update: acp.SessionUpdate{
UserMessageChunk: &acp.SessionUpdateUserMessageChunk{
Content: acp.TextBlock("echoed prompt"),
},
},
})
contextFound := false
for _, event := range drainEvents(a) {
if event.Type == streams.EventTypeMessageChunk {
t.Fatalf("Grok user echo must not become a message event: %#v", event)
}
if event.Type == streams.EventTypeContextWindow {
contextFound = true
}
}
if !contextFound {
t.Fatal("suppressed user echo must still contribute context metadata")
}
}
func TestGrokContextRetriesAfterFullUpdateChannel(t *testing.T) {
models := []modelInfo{{
ModelId: "grok-current",
Meta: map[string]any{"totalContextTokens": float64(500_000)},
}}
a := grokAdapterWithModels(t, models, []streams.ConfigOption{{
Type: "select", ID: configOptionIDModel, Category: configOptionIDModel, CurrentValue: "grok-current",
}})
a.updatesCh = make(chan AgentEvent, 1)
a.updatesCh <- AgentEvent{Type: streams.EventTypeMessageChunk}
meta := map[string]any{"totalTokens": float64(42_000)}
if event := a.emitDialectContextWindow("sess-grok", meta); event != nil {
t.Fatalf("emitDialectContextWindow() = %#v with full channel, want nil", event)
}
if _, cached := a.contextSamples["sess-grok"]; cached {
t.Fatal("dropped context sample must not be cached")
}
<-a.updatesCh
if event := a.emitDialectContextWindow("sess-grok", meta); event == nil {
t.Fatal("same context sample must be retried after channel capacity returns")
}
}
func TestGrokDialect_NormalizesPrivateReasoningTokens(t *testing.T) {
response := &acp.PromptResponse{Meta: map[string]any{
"usage": map[string]any{
"inputTokens": float64(5),
"outputTokens": float64(3),
"totalTokens": float64(8),
"reasoningTokens": float64(2),
},
}}
dialect := newGrokACPDialect()
usage := dialect.promptUsage(extractUsage(response), response.Meta)
if usage == nil {
t.Fatal("expected non-nil usage")
}
if usage.ThoughtTokens != 2 {
t.Fatalf("ThoughtTokens = %d, want 2", usage.ThoughtTokens)
}
}
func TestIsGrokIncompatibleAgentSwitchError(t *testing.T) {
reqErr := &acp.RequestError{
Code: -32600,
Message: "Cannot switch to model 'composer': it requires agent 'cursor'. Start a new session to use this model.",
Data: map[string]any{"code": "MODEL_SWITCH_INCOMPATIBLE_AGENT"},
}
if !isGrokIncompatibleAgentSwitchError(reqErr) {
t.Fatal("expected structured error to match")
}
wrapped := formatGrokSetModelError(reqErr)
if !isGrokIncompatibleAgentSwitchError(wrapped) {
t.Fatal("expected wrapped error to still match")
}
if !strings.Contains(wrapped.Error(), "MODEL_SWITCH_INCOMPATIBLE_AGENT") {
t.Fatalf("wrapped should retain code tag, got %q", wrapped.Error())
}
if isGrokIncompatibleAgentSwitchError(errors.New("method not found")) {
t.Fatal("unrelated error must not match")
}
}
@@ -156,6 +156,23 @@ func TestExtractUsage(t *testing.T) {
}
}
func TestExtractUsage_DoesNotInterpretGrokPrivateReasoningTokens(t *testing.T) {
usage := extractUsage(&acp.PromptResponse{Meta: map[string]any{
"usage": map[string]any{
"inputTokens": float64(5),
"outputTokens": float64(3),
"totalTokens": float64(8),
"reasoningTokens": float64(2),
},
}})
if usage == nil {
t.Fatal("expected non-nil usage")
}
if usage.ThoughtTokens != 0 {
t.Fatalf("ThoughtTokens = %d, want 0; Grok private fields belong to its ACP dialect", usage.ThoughtTokens)
}
}
// TestUsageTracker_CumulativeDelta asserts the codex-acp fallback path:
// usage_update updates the cumulative used counter via tryConvertUntypedUpdate;
// consumeUsageDelta returns the running total and resets to zero so the next
@@ -15,8 +15,10 @@ import (
"time"
acp "github.com/coder/acp-go-sdk"
"github.com/kandev/kandev/internal/agentctl/acpcompat"
acpclient "github.com/kandev/kandev/internal/agentctl/server/acp"
"github.com/kandev/kandev/internal/agentctl/sessionmodel"
"github.com/kandev/kandev/internal/agentctl/types/streams"
"go.uber.org/zap"
)
@@ -344,7 +346,7 @@ func (e *ACPInferenceExecutor) Probe(ctx context.Context, req *ProbeRequest) (*P
}
defer cleanupACPCommand(ctx, cmd, lifecycle, e.logger)
resp, err := e.probeACPSession(ctx, stdin, stdout, workDir)
resp, err := e.probeACPSession(ctx, stdin, stdout, workDir, req.AgentID)
if err != nil {
return &ProbeResponse{
Success: false,
@@ -460,6 +462,7 @@ func (e *ACPInferenceExecutor) probeACPSession(
stdin io.Writer,
stdout io.Reader,
workDir string,
agentID string,
) (*ProbeResponse, error) {
var mu sync.Mutex
var commands []ProbeCommand
@@ -518,7 +521,7 @@ func (e *ACPInferenceExecutor) probeACPSession(
}
out := buildInitProbeFields(initResp)
applySessionProbeFields(out, sessionResp)
applySessionProbeFields(out, sessionResp, agentID)
mu.Lock()
out.Commands = append([]ProbeCommand(nil), commands...)
mu.Unlock()
@@ -565,7 +568,7 @@ func buildInitProbeFields(initResp acp.InitializeResponse) *ProbeResponse {
// top-level `models` field via acp.LegacyModels for agents that haven't
// migrated yet (auggie 0.29.x). The legacy `modes` field is still present on
// the SDK type and we keep populating from it for older agents.
func applySessionProbeFields(out *ProbeResponse, sessionResp acp.NewSessionResponse) {
func applySessionProbeFields(out *ProbeResponse, sessionResp acp.NewSessionResponse, agentID string) {
out.ConfigOptions = probeConfigOptions(sessionResp.ConfigOptions)
if sessionResp.Modes != nil {
out.CurrentModeID = string(sessionResp.Modes.CurrentModeId)
@@ -585,6 +588,53 @@ func applySessionProbeFields(out *ProbeResponse, sessionResp acp.NewSessionRespo
if sessionResp.Modes == nil {
applyConfigOptionsAsModes(out, sessionResp.ConfigOptions)
}
applySessionCompatibility(out, agentID)
}
func applySessionCompatibility(out *ProbeResponse, agentID string) {
models := make([]acpcompat.Model, 0, len(out.Models))
for _, model := range out.Models {
models = append(models, acpcompat.Model{ID: model.ID, Name: model.Name, Meta: model.Meta})
}
base := make([]streams.ConfigOption, 0, len(out.ConfigOptions))
for _, option := range out.ConfigOptions {
converted := streams.ConfigOption{
Type: option.Type,
ID: option.ID,
Name: option.Name,
Description: option.Description,
CurrentValue: option.CurrentValue,
Category: option.Category,
}
for _, choice := range option.Options {
converted.Options = append(converted.Options, streams.ConfigOptionValue{
Value: choice.Value,
Name: choice.Name,
Description: choice.Description,
})
}
base = append(base, converted)
}
normalized := acpcompat.NormalizeSessionConfig(agentID, base, models, out.CurrentModelID)
out.ConfigOptions = make([]ProbeConfigOption, 0, len(normalized))
for _, option := range normalized {
converted := ProbeConfigOption{
Type: option.Type,
ID: option.ID,
Name: option.Name,
Description: option.Description,
CurrentValue: option.CurrentValue,
Category: option.Category,
}
for _, choice := range option.Options {
converted.Options = append(converted.Options, ProbeConfigOptionChoice{
Value: choice.Value,
Name: choice.Name,
Description: choice.Description,
})
}
out.ConfigOptions = append(out.ConfigOptions, converted)
}
}
// applyLegacyModelsFallback fills out.Models / out.CurrentModelID from the
@@ -50,6 +50,32 @@ func TestProbeConfigOptions_PreservesDescriptions(t *testing.T) {
}
}
func TestApplySessionCompatibility_PreservesDescriptions(t *testing.T) {
t.Parallel()
out := &ProbeResponse{ConfigOptions: []ProbeConfigOption{{
Type: "select",
ID: "reasoning_effort",
Name: "Reasoning effort",
Description: "Controls reasoning depth.",
CurrentValue: "high",
Options: []ProbeConfigOptionChoice{{
Value: "high",
Name: "High",
Description: "More thorough reasoning.",
}},
}}}
applySessionCompatibility(out, "mock-agent")
if got := out.ConfigOptions[0].Description; got != "Controls reasoning depth." {
t.Errorf("option description = %q, want provider description", got)
}
if got := out.ConfigOptions[0].Options[0].Description; got != "More thorough reasoning." {
t.Errorf("value description = %q, want provider description", got)
}
}
func TestResolveProbeCommand_AllowsEveryListedBinary(t *testing.T) {
t.Parallel()
@@ -265,7 +291,7 @@ func TestApplySessionProbeFields_FallsBackToConfigOptions(t *testing.T) {
}
out := &ProbeResponse{}
applySessionProbeFields(out, resp)
applySessionProbeFields(out, resp, "")
if got, want := out.CurrentModelID, "opus"; got != want {
t.Fatalf("CurrentModelID = %q, want %q", got, want)
@@ -309,7 +335,7 @@ func TestApplySessionProbeFields_FallsBackToLegacyModels(t *testing.T) {
}
out := &ProbeResponse{}
applySessionProbeFields(out, resp)
applySessionProbeFields(out, resp, "")
if got, want := out.CurrentModelID, "claude-opus-4-7"; got != want {
t.Fatalf("CurrentModelID = %q, want %q", got, want)
@@ -325,6 +351,35 @@ func TestApplySessionProbeFields_FallsBackToLegacyModels(t *testing.T) {
}
}
func TestApplySessionProbeFields_GrokLegacyModelsExposeReasoning(t *testing.T) {
t.Parallel()
resp := acp.NewSessionResponse{
LegacyModels: &acp.LegacyModels{
CurrentModelId: "grok-4.5",
AvailableModels: []acp.LegacyModelInfo{{
ModelId: "grok-4.5",
Name: "Grok 4.5",
Meta: map[string]any{
"supportsReasoningEffort": true,
"reasoningEffort": "high",
"reasoningEfforts": []any{"high", "medium", "low"},
},
}},
},
}
out := &ProbeResponse{}
applySessionProbeFields(out, resp, "grok-acp")
if got, want := len(out.ConfigOptions), 2; got != want {
t.Fatalf("len(ConfigOptions) = %d, want model + reasoning", got)
}
if got, want := out.ConfigOptions[1].ID, "reasoning_effort"; got != want {
t.Fatalf("ConfigOptions[1].ID = %q, want %q", got, want)
}
}
// When both surfaces are present the typed configOptions list wins — the
// legacy field is only a fallback for unmigrated agents.
func TestApplySessionProbeFields_TypedConfigOptionsBeatLegacyModels(t *testing.T) {
@@ -351,7 +406,7 @@ func TestApplySessionProbeFields_TypedConfigOptionsBeatLegacyModels(t *testing.T
}
out := &ProbeResponse{}
applySessionProbeFields(out, resp)
applySessionProbeFields(out, resp, "")
if got, want := out.CurrentModelID, "opus"; got != want {
t.Fatalf("CurrentModelID = %q, want %q (typed configOptions must win)", got, want)
@@ -389,7 +444,7 @@ func TestApplySessionProbeFields_FlattensGroupedConfigOptions(t *testing.T) {
}
out := &ProbeResponse{}
applySessionProbeFields(out, resp)
applySessionProbeFields(out, resp, "")
if got, want := len(out.Modes), 2; got != want {
t.Fatalf("len(Modes) = %d, want %d", got, want)
@@ -0,0 +1,43 @@
# 0044: ACP Agent Compatibility Dialects
**Status:** accepted
**Date:** 2026-07-16
**Area:** backend, protocol
## Context
ACP defines common transport and session methods, but individual agent CLIs can expose models, configuration, usage, and recovery through private metadata or non-standard method combinations. Grok, for example, combines the legacy `models` response with model metadata for reasoning controls and uses `session/set_model` for both model and reasoning changes. The live adapter and the sessionless utility probe must interpret those capabilities consistently.
## Decision
The ACP adapter selects a package-private `acpDialect` function table by agent ID.
- Dialect hooks are optional, pure compatibility functions that return normalized data or request descriptions. They never receive `*Adapter`, execute RPCs, mutate session state, or emit events.
- Shared adapter code owns JSON-RPC transport, session lifecycle, serialized configuration changes, generation checks, state convergence, and event delivery.
- Canonical ACP behavior is the zero-value dialect; there is no standard implementation containing no-op methods.
- Capability normalization shared by live sessions and utility probes lives in `internal/agentctl/acpcompat/`.
- Grok-specific model, reasoning, usage, notification, and incompatible-agent error translation lives in `dialect_grok.go` and the shared compatibility package.
- A dialect never replaces an active session to satisfy a model change. When an implementation requires another agent harness, Kandev returns its actionable error and the user starts a new session explicitly.
## Consequences
- New ACP CLI variants can add narrowly scoped functions without spreading agent checks through shared adapter files.
- Provider-specific parsing is reusable outside the live adapter when capability discovery needs the same interpretation.
- Concurrent model and configuration requests converge on the latest generation and stale session completions cannot overwrite replacement state.
- A small function table adds indirection to ACP session and update handling.
- No product spec covers internal ACP compatibility handling; this decision remains the durable record.
- Model changes cannot silently discard upstream agent context by replacing the ACP session.
## Alternatives Considered
### Use a stateful ACP driver interface
Rejected because the hooks crossed ownership boundaries by receiving the concrete adapter, executing RPCs, mutating shared state, and emitting events. It also required a standard implementation made mostly of no-op methods.
### Keep agent checks in shared ACP files
Rejected because each new non-standard implementation would increase branching in common session and notification paths. Small optional dialect functions keep selection centralized without creating provider objects.
### Create a complete adapter for every ACP CLI
Rejected because transport, lifecycle, permissions, tools, and event delivery are already shared correctly and would be duplicated.
+1
View File
@@ -50,3 +50,4 @@ Read individual ADRs for full context. Create new ones via `/record decision` or
| 0042 | [Project shell output and fetch it on demand](0042-project-shell-output-and-fetch-on-demand.md) | accepted | backend, frontend, protocol | 2026-07-16 |
| 0043 | [Plugins read/write kandev data via capability-gated Host gRPC RPCs](0043-plugin-host-data-api.md) | accepted | backend, protocol | 2026-07-17 |
| 2026-07-15-office-agent-execution-profile-routing | [Separate Office identity from routed execution profiles](2026-07-15-office-agent-execution-profile-routing.md) | proposed | backend, frontend | 2026-07-15 |
| 0044 | [ACP agent compatibility dialects](0044-acp-agent-compatibility-dialects.md) | accepted | backend, protocol | 2026-07-16 |