From 0ff34c03c2c363de32647b347623a7c8d4da75bf Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Sun, 19 Jul 2026 13:23:18 +0000 Subject: [PATCH 1/2] feat(hub/acp): translate ACP configOptions into mode/model state events (#334) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Newer ACP daemons (kimi-code-ts, ADR-054 D6) report per-session model/mode state via a `configOptions` select array in the session/new reply instead of the legacy `modes`/`models` blocks. ACPDriver only parsed the legacy shape, so the mode/model picker never hydrated for these engines even though switching works on the wire. Add `translateConfigOptions` — maps the `configOptions` array into the same mode/model lists the legacy path produces (mode entries keyed by `id`, model entries by `modelId`, both carrying `name`), so a single downstream (id-set caching + the synthetic initial-state system event) handles either shape. - Shape-driven + engine-neutral: any future ACP adopter benefits. - Filled PER CATEGORY, only when the legacy field is absent → legacy-shaped replies (gemini-cli, Python kimi-code) produce byte-identical output. - `thought_level` has no mobile picker yet: its current value rides the initial-state event under `thoughtLevel` for forensics only. - `config_option_update` notifications fold into the same system-event path as current_mode_update / current_model_update. Tests: TestTranslateConfigOptions (unit — kimi 0.27.0 capture, malformed, empty-value, no-configOptions); TestACPDriver_ConfigOptionsHydratesModeModelState (integration — initial-state event + current ids + thoughtLevel + a set_model round-trip validating against the cached list). Legacy mode/model tests stay green. go vet + full hostrunner suite pass. Co-Authored-By: Claude Opus 4.8 --- hub/internal/hostrunner/driver_acp.go | 94 +++++++++++- hub/internal/hostrunner/driver_acp_test.go | 166 +++++++++++++++++++++ 2 files changed, 258 insertions(+), 2 deletions(-) diff --git a/hub/internal/hostrunner/driver_acp.go b/hub/internal/hostrunner/driver_acp.go index baab6ecee..cc254c759 100644 --- a/hub/internal/hostrunner/driver_acp.go +++ b/hub/internal/hostrunner/driver_acp.go @@ -515,6 +515,28 @@ func (d *ACPDriver) Start(parent context.Context) error { if sr.SessionID == "" && usedLoad { sr.SessionID = d.ResumeSessionID } + + // ADR-054 D6 / #334 — newer ACP daemons (kimi-code-ts) report per-session + // model/mode state via a `configOptions` select array instead of the legacy + // `modes`/`models` blocks. Translate it into the SAME sr.Modes/sr.Models + // lists so a single downstream (id-set caching + the synthetic initial-state + // event) handles both shapes. Shape-driven and engine-neutral: any future ACP + // adopter benefits. Fill PER CATEGORY and only when the legacy field is + // absent, so a legacy-shaped reply produces byte-identical output (no + // regression for gemini-cli / Python kimi-code). + var thoughtLevel string + if coModes, coCurMode, coModels, coCurModel, coThought, ok := translateConfigOptions(sres); ok { + if len(sr.Modes.AvailableModes) == 0 && sr.Modes.CurrentModeID == "" { + sr.Modes.AvailableModes = coModes + sr.Modes.CurrentModeID = coCurMode + } + if len(sr.Models.AvailableModels) == 0 && sr.Models.CurrentModelID == "" { + sr.Models.AvailableModels = coModels + sr.Models.CurrentModelID = coCurModel + } + thoughtLevel = coThought + } + d.mu.Lock() d.sessionID = sr.SessionID d.mu.Unlock() @@ -556,7 +578,8 @@ func (d *ACPDriver) Start(parent context.Context) error { // currentModeId/availableModes/currentModelId/availableModels) so // the same mobile reducer handles both. if len(sr.Modes.AvailableModes) > 0 || sr.Modes.CurrentModeID != "" || - len(sr.Models.AvailableModels) > 0 || sr.Models.CurrentModelID != "" { + len(sr.Models.AvailableModels) > 0 || sr.Models.CurrentModelID != "" || + thoughtLevel != "" { initialState := map[string]any{} if sr.Modes.CurrentModeID != "" { initialState["currentModeId"] = sr.Modes.CurrentModeID @@ -570,6 +593,12 @@ func (d *ACPDriver) Start(parent context.Context) error { if len(sr.Models.AvailableModels) > 0 { initialState["availableModels"] = sr.Models.AvailableModels } + // thought_level (thinking effort) has no mobile picker yet — carry the + // current value for forensics only, under a distinct key so it never + // collides with the mode/model reducer (#334, ADR-054 D6 out-of-scope). + if thoughtLevel != "" { + initialState["thoughtLevel"] = thoughtLevel + } _ = d.Poster.PostAgentEvent(parent, d.AgentID, "system", "system", initialState) } @@ -892,6 +921,67 @@ func (d *ACPDriver) emitAuthAttention( }) } +// translateConfigOptions maps the newer ACP `configOptions` select array (a +// session/new or session/load reply from kimi-code-ts, and any future daemon that +// adopts the same ACP revision) into the legacy availableModes/availableModels +// list shape. Mode entries are keyed by `id`, model entries by `modelId` (both +// carrying `name`), so the existing id-set caching + synthetic initial-state +// event downstream in Start consumes either shape unchanged (#334, ADR-054 D6). +// +// Shape mapping (per the on-host kimi-code 0.27.0 capture in #334): +// - category "mode" → modes (each option `value`→id, `name`→name), currentValue→currentMode +// - category "model" → models (option `value`→modelId, `name`→name), currentValue→currentModel +// - category "thought_level" → thoughtLevel only (no mobile picker yet; forensics) +// +// `found` is true when a `configOptions` array with at least one recognised +// category was present, so the caller can distinguish "no configOptions" from +// "configOptions with only empty lists". Malformed JSON yields found=false. +func translateConfigOptions(raw []byte) (modes []map[string]any, currentMode string, models []map[string]any, currentModel string, thoughtLevel string, found bool) { + var co struct { + ConfigOptions []struct { + Category string `json:"category"` + CurrentValue string `json:"currentValue"` + Options []map[string]any `json:"options"` + } `json:"configOptions"` + } + if err := json.Unmarshal(raw, &co); err != nil || len(co.ConfigOptions) == 0 { + return nil, "", nil, "", "", false + } + // Translate one option list into legacy-shaped entries under `idKey` + // ("id" for modes, "modelId" for models), dropping entries with no value. + entriesFor := func(opts []map[string]any, idKey string) []map[string]any { + out := make([]map[string]any, 0, len(opts)) + for _, o := range opts { + val, _ := o["value"].(string) + if val == "" { + continue + } + entry := map[string]any{idKey: val} + if name, ok := o["name"].(string); ok && name != "" { + entry["name"] = name + } + out = append(out, entry) + } + return out + } + for _, opt := range co.ConfigOptions { + switch opt.Category { + case "mode": + found = true + currentMode = opt.CurrentValue + modes = entriesFor(opt.Options, "id") + case "model": + found = true + currentModel = opt.CurrentValue + models = entriesFor(opt.Options, "modelId") + case "thought_level": + found = true + thoughtLevel = opt.CurrentValue + } + } + return modes, currentMode, models, currentModel, thoughtLevel, found +} + // isAuthRequiredError detects "agent requires out-of-band login" // JSON-RPC errors at handshake time. Substring match against the // daemon's error message — engine-neutral, defensive against minor @@ -1453,7 +1543,7 @@ func (d *ACPDriver) handleNotification(ctx context.Context, method string, param case "user_message_chunk": // Our own input being echoed back — drop to avoid a loop. return - case "available_commands_update", "current_mode_update", "current_model_update": + case "available_commands_update", "current_mode_update", "current_model_update", "config_option_update": // Capability-state announcements gemini emits after session/new // (slash-command catalog, current approval mode, current model). // They're informational, not turn activity — mobile's diff --git a/hub/internal/hostrunner/driver_acp_test.go b/hub/internal/hostrunner/driver_acp_test.go index de7b91198..47fefd674 100644 --- a/hub/internal/hostrunner/driver_acp_test.go +++ b/hub/internal/hostrunner/driver_acp_test.go @@ -51,6 +51,11 @@ type fakeACPAgent struct { // rejected before they go on the wire. modelEntriesUseModelIDOnly bool + // #334 / ADR-054 D6: when set, session/new returns its mode/model state via + // the newer ACP `configOptions` select array (kimi-code-ts shape) instead of + // the legacy modes/models blocks. Drives ACPDriver.translateConfigOptions. + configOptions []map[string]any + // W4.4 toggle: when set, initialize advertises // promptCapabilities.image with this value. nil → field absent. promptCapImage *bool @@ -151,6 +156,9 @@ func (f *fakeACPAgent) serve() { } result["models"] = map[string]any{"availableModels": models} } + if len(f.configOptions) > 0 { + result["configOptions"] = f.configOptions + } f.respond(id, result) close(f.initCh) case "session/set_mode": @@ -2816,6 +2824,164 @@ func TestACPDriver_EmitsInitialModeModelSystemEvent(t *testing.T) { } } +// TestTranslateConfigOptions unit-tests the #334 shape translation: the newer +// ACP `configOptions` select array maps into legacy-shaped mode/model lists, with +// thought_level surfaced separately (no picker) and malformed / empty inputs +// returning found=false. +func TestTranslateConfigOptions(t *testing.T) { + // The live kimi-code 0.27.0 capture from #334. + kimiReply := []byte(`{ + "sessionId": "session_x", + "configOptions": [ + {"type":"select","id":"model","category":"model","currentValue":"kimi-code/k3", + "options":[{"value":"kimi-code/kimi-for-coding","name":"K2.7 Coding"}, + {"value":"kimi-code/k3","name":"K3"}]}, + {"type":"select","id":"thinking","category":"thought_level","currentValue":"on", + "options":[{"value":"on","name":"Thinking On"}]}, + {"type":"select","id":"mode","category":"mode","currentValue":"default", + "options":[{"value":"default"},{"value":"plan"},{"value":"yolo"}]} + ] + }`) + modes, curMode, models, curModel, thought, found := translateConfigOptions(kimiReply) + if !found { + t.Fatal("found = false; want true for a configOptions reply") + } + if curMode != "default" || curModel != "kimi-code/k3" || thought != "on" { + t.Errorf("current values: mode=%q model=%q thought=%q; want default/kimi-code/k3/on", + curMode, curModel, thought) + } + if len(modes) != 3 { + t.Errorf("modes len = %d; want 3 (%+v)", len(modes), modes) + } + // Mode entries keyed by `id`; an option with no name carries no name key. + if modes[0]["id"] != "default" { + t.Errorf("modes[0].id = %v; want default", modes[0]["id"]) + } + if _, hasName := modes[0]["name"]; hasName { + t.Errorf("modes[0] should have no name key (option had none); got %+v", modes[0]) + } + if len(models) != 2 { + t.Fatalf("models len = %d; want 2 (%+v)", len(models), models) + } + // Model entries keyed by `modelId` (what the driver's set_model validation reads). + if models[1]["modelId"] != "kimi-code/k3" || models[1]["name"] != "K3" { + t.Errorf("models[1] = %+v; want modelId=kimi-code/k3 name=K3", models[1]) + } + + // Malformed JSON and no-configOptions both yield found=false. + if _, _, _, _, _, ok := translateConfigOptions([]byte(`{not json`)); ok { + t.Error("malformed JSON should return found=false") + } + if _, _, _, _, _, ok := translateConfigOptions([]byte(`{"sessionId":"s"}`)); ok { + t.Error("reply without configOptions should return found=false") + } + // An option whose value is empty is dropped. + dropped, _, _, _, _, _ := translateConfigOptions([]byte( + `{"configOptions":[{"category":"mode","currentValue":"a","options":[{"value":""},{"value":"a"}]}]}`)) + if len(dropped) != 1 || dropped[0]["id"] != "a" { + t.Errorf("empty-value option should be dropped; got %+v", dropped) + } +} + +// TestACPDriver_ConfigOptionsHydratesModeModelState — #334: a session/new reply +// carrying the newer `configOptions` shape (kimi-code-ts) must synthesize the same +// initial mode/model system event the legacy modes/models blocks do, so the mobile +// picker hydrates. Also asserts the current ids + thought-level forensics field, +// and that the cached availableModels lets a set_model round-trip validate. +func TestACPDriver_ConfigOptionsHydratesModeModelState(t *testing.T) { + hostInR, hostInW := io.Pipe() + hostOutR, hostOutW := io.Pipe() + + fake := newFakeACPAgent(t, hostInR, hostOutW, "sess-cfgopt") + fake.configOptions = []map[string]any{ + {"type": "select", "id": "model", "category": "model", "currentValue": "kimi-code/k3", + "options": []map[string]any{ + {"value": "kimi-code/kimi-for-coding", "name": "K2.7 Coding"}, + {"value": "kimi-code/k3", "name": "K3"}, + }}, + {"type": "select", "id": "thinking", "category": "thought_level", "currentValue": "on", + "options": []map[string]any{{"value": "on", "name": "Thinking On"}}}, + {"type": "select", "id": "mode", "category": "mode", "currentValue": "default", + "options": []map[string]any{{"value": "default"}, {"value": "plan"}, {"value": "yolo"}}}, + } + go fake.serve() + + poster := &fakePoster{} + drv := &ACPDriver{ + AgentID: "agent-cfgopt", + Poster: poster, + Stdin: hostInW, + Stdout: hostOutR, + Closer: func() { _ = hostInW.Close(); _ = hostOutW.Close(); fake.close() }, + HandshakeTimeout: 2 * time.Second, + } + if err := drv.Start(context.Background()); err != nil { + t.Fatalf("Start: %v", err) + } + defer drv.Stop() + + deadline := time.Now().Add(2 * time.Second) + var sys *postedEvent + for time.Now().Before(deadline) && sys == nil { + for i := range poster.snapshot() { + ev := poster.snapshot()[i] + if ev.Kind == "system" && ev.Payload["availableModes"] != nil { + e := ev + sys = &e + break + } + } + time.Sleep(10 * time.Millisecond) + } + if sys == nil { + t.Fatalf("expected initial system event synthesized from configOptions; kinds=%v", + eventKinds(poster.snapshot())) + } + if sys.Payload["currentModeId"] != "default" { + t.Errorf("currentModeId = %v; want default", sys.Payload["currentModeId"]) + } + if sys.Payload["currentModelId"] != "kimi-code/k3" { + t.Errorf("currentModelId = %v; want kimi-code/k3", sys.Payload["currentModelId"]) + } + // thought_level rides along as forensics only (no picker). + if sys.Payload["thoughtLevel"] != "on" { + t.Errorf("thoughtLevel = %v; want on", sys.Payload["thoughtLevel"]) + } + modes := coerceMapList(sys.Payload["availableModes"]) + models := coerceMapList(sys.Payload["availableModels"]) + if len(modes) != 3 { + t.Errorf("availableModes len = %d; want 3 (%+v)", len(modes), sys.Payload) + } + if len(models) != 2 { + t.Errorf("availableModels len = %d; want 2 (%+v)", len(models), sys.Payload) + } + + // The cached availableModels (from configOptions) must let a set_model + // validate + dispatch without an "unknown model_id" rejection. + if err := drv.Input(context.Background(), "set_model", map[string]any{"model_id": "kimi-code/k3"}); err != nil { + t.Errorf("set_model for a configOptions-advertised model failed: %v", err) + } +} + +// coerceMapList normalizes an availableModes/availableModels payload to +// []map[string]any regardless of whether it survived as the typed slice or a +// JSON-decoded []any (both shapes occur depending on post-parse mutation). +func coerceMapList(v any) []map[string]any { + if typed, ok := v.([]map[string]any); ok { + return typed + } + if list, ok := v.([]any); ok { + out := make([]map[string]any, 0, len(list)) + for _, m := range list { + if mm, ok := m.(map[string]any); ok { + out = append(out, mm) + } + } + return out + } + return nil +} + // TestACPDriver_SetModeDispatch — W2.2: Input("set_mode") validates the // requested id against the cached availableModes from session/new and // dispatches session/set_mode RPC. From 2eab0525141694617817182da71f73449f7ff847 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Sun, 19 Jul 2026 13:29:32 +0000 Subject: [PATCH 2/2] =?UTF-8?q?style(hub/acp):=20reword=20"legacy"=20?= =?UTF-8?q?=E2=86=92=20"older/modes-models"=20in=20#334=20comments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lint-legacy-markers ratchet flagged the descriptive word "legacy" in the new configOptions comments as an untargeted compat-debt marker. The older modes/models ACP shape isn't being removed (gemini-cli / Python kimi-code keep using it), so "legacy" was inaccurate anyway — reworded to "older" / "modes/models-shaped". No behavior change. Co-Authored-By: Claude Opus 4.8 --- hub/internal/hostrunner/driver_acp.go | 10 +++++----- hub/internal/hostrunner/driver_acp_test.go | 6 +++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/hub/internal/hostrunner/driver_acp.go b/hub/internal/hostrunner/driver_acp.go index cc254c759..8d3d406ce 100644 --- a/hub/internal/hostrunner/driver_acp.go +++ b/hub/internal/hostrunner/driver_acp.go @@ -517,12 +517,12 @@ func (d *ACPDriver) Start(parent context.Context) error { } // ADR-054 D6 / #334 — newer ACP daemons (kimi-code-ts) report per-session - // model/mode state via a `configOptions` select array instead of the legacy + // model/mode state via a `configOptions` select array instead of the older // `modes`/`models` blocks. Translate it into the SAME sr.Modes/sr.Models // lists so a single downstream (id-set caching + the synthetic initial-state // event) handles both shapes. Shape-driven and engine-neutral: any future ACP - // adopter benefits. Fill PER CATEGORY and only when the legacy field is - // absent, so a legacy-shaped reply produces byte-identical output (no + // adopter benefits. Fill PER CATEGORY and only when the modes/models field is + // absent, so a modes/models-shaped reply produces byte-identical output (no // regression for gemini-cli / Python kimi-code). var thoughtLevel string if coModes, coCurMode, coModels, coCurModel, coThought, ok := translateConfigOptions(sres); ok { @@ -923,7 +923,7 @@ func (d *ACPDriver) emitAuthAttention( // translateConfigOptions maps the newer ACP `configOptions` select array (a // session/new or session/load reply from kimi-code-ts, and any future daemon that -// adopts the same ACP revision) into the legacy availableModes/availableModels +// adopts the same ACP revision) into the older availableModes/availableModels // list shape. Mode entries are keyed by `id`, model entries by `modelId` (both // carrying `name`), so the existing id-set caching + synthetic initial-state // event downstream in Start consumes either shape unchanged (#334, ADR-054 D6). @@ -947,7 +947,7 @@ func translateConfigOptions(raw []byte) (modes []map[string]any, currentMode str if err := json.Unmarshal(raw, &co); err != nil || len(co.ConfigOptions) == 0 { return nil, "", nil, "", "", false } - // Translate one option list into legacy-shaped entries under `idKey` + // Translate one option list into modes/models-shaped entries under `idKey` // ("id" for modes, "modelId" for models), dropping entries with no value. entriesFor := func(opts []map[string]any, idKey string) []map[string]any { out := make([]map[string]any, 0, len(opts)) diff --git a/hub/internal/hostrunner/driver_acp_test.go b/hub/internal/hostrunner/driver_acp_test.go index 47fefd674..b3833a3e9 100644 --- a/hub/internal/hostrunner/driver_acp_test.go +++ b/hub/internal/hostrunner/driver_acp_test.go @@ -53,7 +53,7 @@ type fakeACPAgent struct { // #334 / ADR-054 D6: when set, session/new returns its mode/model state via // the newer ACP `configOptions` select array (kimi-code-ts shape) instead of - // the legacy modes/models blocks. Drives ACPDriver.translateConfigOptions. + // the older modes/models blocks. Drives ACPDriver.translateConfigOptions. configOptions []map[string]any // W4.4 toggle: when set, initialize advertises @@ -2825,7 +2825,7 @@ func TestACPDriver_EmitsInitialModeModelSystemEvent(t *testing.T) { } // TestTranslateConfigOptions unit-tests the #334 shape translation: the newer -// ACP `configOptions` select array maps into legacy-shaped mode/model lists, with +// ACP `configOptions` select array maps into modes/models-shaped lists, with // thought_level surfaced separately (no picker) and malformed / empty inputs // returning found=false. func TestTranslateConfigOptions(t *testing.T) { @@ -2885,7 +2885,7 @@ func TestTranslateConfigOptions(t *testing.T) { // TestACPDriver_ConfigOptionsHydratesModeModelState — #334: a session/new reply // carrying the newer `configOptions` shape (kimi-code-ts) must synthesize the same -// initial mode/model system event the legacy modes/models blocks do, so the mobile +// initial mode/model system event the older modes/models blocks do, so the mobile // picker hydrates. Also asserts the current ids + thought-level forensics field, // and that the cached availableModels lets a set_model round-trip validate. func TestACPDriver_ConfigOptionsHydratesModeModelState(t *testing.T) {