From 9ae217d384d50de4f4c87b8ad60af24f9fc0a8c5 Mon Sep 17 00:00:00 2001 From: agentfleet Date: Thu, 30 Jul 2026 08:58:01 -0700 Subject: [PATCH] =?UTF-8?q?feat(hub):=20map=20kimi=200.31=20wire=20events?= =?UTF-8?q?=20=E2=80=94=20cancel,=20compaction,=20plan/swarm/permission=20?= =?UTF-8?q?modes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit kimi-code 0.31.0 keeps wire protocol 1.5 (gate unchanged) but adds 10 event types that fell into the mapper's default arm as unknown_type noise. Map them: - turn.cancel → turn.result {status:cancelled, stop_reason:cancelled} (main only, same guard as mapStepEnd). Fixes a cancelled turn leaving the session stuck busy — kimi writes no end_turn for the interrupted turn, and mobile's busy-walker had no terminal to find. Payload mirrors the ACP driver's eager-cancel vocabulary verbatim. - turn.steer (all origins) + turn.prompt origin=task → explicit, documented drops (engine-internal notices / input.text duplication). - context.apply_compaction → one system{subtype:compaction} card with bounded summary + token delta; begin/complete dropped as bookends. - plan_mode.*, permission.set_mode, swarm_mode.* → producer=system cards (no dedicated mode kind exists hub-side; ACP forwards current_mode_update the same way). - mcp.tools_discovered → drop (catalog noise). default: unknown_type drift policy unchanged. Pinned by a redacted 0.31.0 fixture replay (testdata/wire_main_0_31.jsonl); wire_main.jsonl stays the 0.28.1 pin. --- .../local_log_tail/kimi_code/mapper.go | 213 +++++++++++++- .../local_log_tail/kimi_code/mapper_test.go | 265 ++++++++++++++++++ .../kimi_code/testdata/wire_main_0_31.jsonl | 21 ++ 3 files changed, 490 insertions(+), 9 deletions(-) create mode 100644 hub/internal/drivers/local_log_tail/kimi_code/testdata/wire_main_0_31.jsonl diff --git a/hub/internal/drivers/local_log_tail/kimi_code/mapper.go b/hub/internal/drivers/local_log_tail/kimi_code/mapper.go index 238522adc..e32e784e8 100644 --- a/hub/internal/drivers/local_log_tail/kimi_code/mapper.go +++ b/hub/internal/drivers/local_log_tail/kimi_code/mapper.go @@ -12,9 +12,10 @@ // triples the claude-code / antigravity adapters emit so the existing // clients render them unchanged. // -// Shapes in this package are pinned against kimi-code 0.28.1 captures -// (see testdata/ and the plan's Appendix B). Field names were read off -// real wire.jsonl files, not guessed. +// Shapes in this package are pinned against kimi-code 0.28.1 and +// 0.31.0 captures (see testdata/ — wire_main.jsonl is the 0.28.1 pin, +// wire_main_0_31.jsonl the 0.31.0 pin — and the plan's Appendix B). +// Field names were read off real wire.jsonl files, not guessed. package kimi_code import ( @@ -55,9 +56,11 @@ func (e *ProtocolError) Unwrap() error { return ErrUnsupportedProtocol } // protocol_version is one this mapper was verified against. The // captured corpus (kimi-code 0.28.1, 2026-07) contains "1.4" (the // version the plan's Appendix B pins) and "1.5" (newer writes on the -// same build) with byte-identical event shapes for every type we map, -// so the gate is on the MAJOR version: any 1.x is accepted, anything -// else (missing, "2.0", "9") is rejected → PaneDriver fallback. +// same build) with byte-identical event shapes for every type we map; +// kimi-code 0.31.0 still writes "1.5" — its growth is purely new event +// TYPES (handled in MapLine below), not shape changes — so the gate is +// on the MAJOR version: any 1.x is accepted, anything else (missing, +// "2.0", "9") is rejected → PaneDriver fallback. func SupportedProtocolVersion(v string) bool { major, _, _ := strings.Cut(v, ".") return major == "1" @@ -96,12 +99,18 @@ func NewMapper(agentID, parentAgentID, engine string) *Mapper { // protocol version; known shapes with unexpected content degrade // gracefully (drop the block, surface schema drift as a system event). // -// Mapping table (verified against kimi-code 0.28.1 wire captures): +// Mapping table (verified against kimi-code 0.28.1 + 0.31.0 wire +// captures; the 0.31 rows are marked): // // metadata → drop; gate protocol_version (ErrUnsupportedProtocol) // config.update → drop (session metadata; model arrives via usage.record) // tools.set_active_tools → drop (tool catalog, no user signal) -// turn.prompt → drop (hub already posted input.text; arm the per-turn plan chain) +// turn.prompt (any origin) → drop (hub already posted input.text; arm the per-turn plan chain). +// 0.31 origin.kind=task (goal/cron engine tasks) drops the same way +// and STILL arms — a task prompt opens a real engine turn +// turn.cancel → turn.result {status:cancelled, stop_reason:cancelled} (0.31; main only) +// turn.steer (any origin) → drop (0.31: background_task = engine-internal task notice; +// user = input.text duplication, same rationale as turn.prompt) // context.append_message → drop (same input.text duplication rationale as claude's mapUserString) // context.append_loop_event: // step.begin → drop (internal marker) @@ -111,6 +120,13 @@ func NewMapper(agentID, parentAgentID, engine string) *Mapper { // tool.result → tool_result {tool_use_id, content, is_error, truncated?} // step.end finishReason=end_turn → turn.result {reason:end_of_turn, status:success} // step.end (other reasons) → drop (per-step bookkeeping; usage.record carries the numbers) +// context.apply_compaction → system {subtype:compaction, summary(≤500 chars), tokens_before, +// tokens_after, compacted_count, text} (0.31) +// full_compaction.begin/complete → drop (0.31: the apply event between them carries the signal) +// plan_mode.enter/cancel/exit → system {subtype:plan_mode, phase, id?, text} (0.31) +// permission.set_mode → system {subtype:permission_mode, mode, text} (0.31) +// swarm_mode.enter/exit → system {subtype:swarm_mode, phase, trigger?, text} (0.31) +// mcp.tools_discovered → drop (0.31: tool catalog, same class as tools.set_active_tools) // llm.tools_snapshot / llm.request → drop (request telemetry; model rides the usage event) // tools.update_store key=todo → plan {entries, message_id, partial:true} (full snapshot per update) // tools.update_store (other keys) → drop (engine-internal stores) @@ -133,7 +149,13 @@ func (m *Mapper) MapLine(raw []byte) ([]MappedEvent, error) { case "metadata": return nil, m.mapMetadata(raw) case "config.update", "tools.set_active_tools", "context.append_message", - "llm.tools_snapshot", "llm.request": + "llm.tools_snapshot", "llm.request", + // 0.31 drops: the compaction begin/complete pair brackets the + // apply event that carries the actual signal (one card per + // compaction, not three); mcp.tools_discovered is the same tool + // catalog noise class as tools.set_active_tools. + "full_compaction.begin", "full_compaction.complete", + "mcp.tools_discovered": return nil, nil case "turn.prompt": // New user turn: arm a fresh per-turn plan chain so the todo @@ -143,11 +165,44 @@ func (m *Mapper) MapLine(raw []byte) ([]MappedEvent, error) { // submits it, and kimi's echo carries the full envelope, so // re-emitting would duplicate the user bubble (the exact // rationale behind claude's mapUserString drop, v1.0.663). + // + // 0.31: origin.kind="task" prompts (goal-mode / cron engine + // tasks, plus background-task completion notices) get the same + // treatment — they open a REAL engine turn, so the plan chain + // still re-arms, and the body is an engine-generated + // notification envelope, not user prose worth surfacing. m.turnSeq++ m.planMsgID = "" return nil, nil + case "turn.steer": + // 0.31: mid-turn input injection. Dropped for EVERY origin, + // explicitly (the value over falling into default: is no + // unknown_type noise + this stated decision): + // background_task (129 of 133 steers in the 0.31 capture) is + // the engine's own task-notification envelope — CLI-internal + // noise with no rendering obligation. + // user — same duplication rationale as turn.prompt: + // hub-originated steers arrive via tmux send-keys and were + // already posted as input.text by the hub, so mapping would + // double the bubble. Pane-typed steers are the accepted + // loss, same as pane-typed prompts. + // unknown future origins — drop quietly rather than guess. + // No plan-chain side effects: a steer redirects the in-flight + // turn, it doesn't open a new one (verified: steers land + // mid-turn between loop events, turnId unchanged). + return nil, nil + case "turn.cancel": + return m.mapTurnCancel() case "context.append_loop_event": return m.mapLoopEvent(raw) + case "context.apply_compaction": + return m.mapApplyCompaction(raw) + case "plan_mode.enter", "plan_mode.cancel", "plan_mode.exit": + return m.mapPlanMode(raw, strings.TrimPrefix(top.Type, "plan_mode.")) + case "permission.set_mode": + return m.mapPermissionMode(raw) + case "swarm_mode.enter", "swarm_mode.exit": + return m.mapSwarmMode(raw, strings.TrimPrefix(top.Type, "swarm_mode.")) case "tools.update_store": return m.mapUpdateStore(raw) case "usage.record": @@ -377,6 +432,146 @@ func (m *Mapper) mapStepEnd(raw json.RawMessage) ([]MappedEvent, error) { })}, nil } +// mapTurnCancel emits the turn terminal for a cancelled turn (0.31). +// kimi writes turn.cancel the moment the abort lands and NO step.end +// end_turn follows for the interrupted turn (verified on the 0.31 +// capture: cancel → next line is the next turn's turn.prompt), so +// without this mapping the session stayed busy forever — mobile's +// busy-walker scans tail-first for turn.result/completion and never +// found one (the cancel-button-stuck bug class, cf. claude v1.0.667). +// +// The payload is EXACTLY the ACP driver's eager-cancel vocabulary +// (driver_acp.go Input("cancel"): {status:"cancelled", +// stop_reason:"cancelled"}): the walker terminates on the kind alone, +// the hub digest reads stop_reason ("cancelled" ∈ normalTurnStopReasons +// — an intentional end, not an integrity finding), and the status +// matches what every ACP engine already posts on a user cancel, so +// digest + mobile failure accounting treat both engines identically. +// +// Main-agent only, same guard as mapStepEnd (#374): a subagent's +// cancel describes its inner loop, not the session's turn. +func (m *Mapper) mapTurnCancel() ([]MappedEvent, error) { + if m.isSubagent() { + return nil, nil + } + return []MappedEvent{m.event("turn.result", "agent", map[string]any{ + "status": "cancelled", + "stop_reason": "cancelled", + })}, nil +} + +// --- 0.31 session-mode + compaction events --- + +// compactionSummaryBound caps the summary carried on the compaction +// system event. kimi's summaries are full working briefs (kilobytes); +// the card needs the gist, and the bound keeps one event from +// dominating the transcript + the event store. +const compactionSummaryBound = 500 + +// mapApplyCompaction surfaces kimi's context compaction as ONE system +// event. The wire brackets it with full_compaction.begin/complete +// (dropped in MapLine — the apply carries the signal: what was +// compacted and the token delta). Clients render it as a system card +// today (the text line lands on the default renderer); the payload +// fields keep enough structure for a future fold marker. Mirrors the +// claude mapper's compact_boundary arm, which is likewise a single +// producer=system notice kept off the busy-inference path. +func (m *Mapper) mapApplyCompaction(raw []byte) ([]MappedEvent, error) { + var c struct { + Summary string `json:"summary"` + CompactedCount int `json:"compactedCount"` + TokensBefore int `json:"tokensBefore"` + TokensAfter int `json:"tokensAfter"` + } + if err := json.Unmarshal(raw, &c); err != nil { + return nil, mapErr("apply_compaction parse", err) + } + payload := map[string]any{ + "subtype": "compaction", + "compacted_count": c.CompactedCount, + "tokens_before": c.TokensBefore, + "tokens_after": c.TokensAfter, + } + if c.Summary != "" { + summary := c.Summary + if r := []rune(summary); len(r) > compactionSummaryBound { + summary = string(r[:compactionSummaryBound]) + "…" + } + payload["summary"] = summary + } + payload["text"] = fmt.Sprintf("Context compacted: %d messages, %d → %d tokens", + c.CompactedCount, c.TokensBefore, c.TokensAfter) + return []MappedEvent{m.event("system", "system", payload)}, nil +} + +// mapPlanMode surfaces plan-mode transitions (0.31) as a system card +// carrying the phase (+ the plan id on enter). No dedicated mode event +// kind exists hub-side — the ACP driver forwards current_mode_update as +// a verbatim producer=system frame (driver_acp.go) — so a system +// subtype is the reuse-consistent shape, and producer=system keeps the +// card off mobile's busy-inference path either way. +func (m *Mapper) mapPlanMode(raw []byte, phase string) ([]MappedEvent, error) { + var p struct { + ID string `json:"id"` + } + if err := json.Unmarshal(raw, &p); err != nil { + return nil, mapErr("plan_mode parse", err) + } + payload := map[string]any{ + "subtype": "plan_mode", + "phase": phase, + } + text := "Plan mode: " + phase + if p.ID != "" { + payload["id"] = p.ID + text += " · " + p.ID + } + payload["text"] = text + return []MappedEvent{m.event("system", "system", payload)}, nil +} + +// mapPermissionMode surfaces approval-mode switches (0.31: yolo / +// auto / manual observed in the capture). session.init already carries +// the LAUNCH-time mode (adapter buildLaunchTimeSessionInit); this is +// the mid-session change, same system-card treatment as plan_mode. +func (m *Mapper) mapPermissionMode(raw []byte) ([]MappedEvent, error) { + var p struct { + Mode string `json:"mode"` + } + if err := json.Unmarshal(raw, &p); err != nil { + return nil, mapErr("permission.set_mode parse", err) + } + payload := map[string]any{ + "subtype": "permission_mode", + "mode": p.Mode, + "text": "Permission mode: " + p.Mode, + } + return []MappedEvent{m.event("system", "system", payload)}, nil +} + +// mapSwarmMode surfaces swarm-mode transitions (0.31), carrying the +// trigger on enter ("tool" in the capture — kimi escalates when a +// swarm-capable tool call lands). +func (m *Mapper) mapSwarmMode(raw []byte, phase string) ([]MappedEvent, error) { + var s struct { + Trigger string `json:"trigger"` + } + if err := json.Unmarshal(raw, &s); err != nil { + return nil, mapErr("swarm_mode parse", err) + } + payload := map[string]any{ + "subtype": "swarm_mode", + "phase": phase, + } + text := "Swarm mode: " + phase + if s.Trigger != "" { + payload["trigger"] = s.Trigger + text += " (trigger: " + s.Trigger + ")" + } + payload["text"] = text + return []MappedEvent{m.event("system", "system", payload)}, nil +} + // --- tools.update_store --- // mapUpdateStore maps the authoritative todo store onto a `plan` diff --git a/hub/internal/drivers/local_log_tail/kimi_code/mapper_test.go b/hub/internal/drivers/local_log_tail/kimi_code/mapper_test.go index 8be15d9a2..f3691b653 100644 --- a/hub/internal/drivers/local_log_tail/kimi_code/mapper_test.go +++ b/hub/internal/drivers/local_log_tail/kimi_code/mapper_test.go @@ -2,6 +2,7 @@ package kimi_code import ( "errors" + "fmt" "os" "strings" "testing" @@ -314,6 +315,11 @@ func TestMapper_DropsKnownNoise(t *testing.T) { `{"type":"llm.tools_snapshot","hash":"abc","tools":[]}`, `{"type":"llm.request","kind":"loop","model":"k3","time":1}`, `{"type":"context.append_loop_event","event":{"type":"step.begin","uuid":"s","turnId":"0","step":1},"time":1}`, + // 0.31: the compaction bookends bracket the apply event that + // carries the signal; mcp.tools_discovered is tool-catalog noise. + `{"type":"full_compaction.begin","source":"manual","time":1}`, + `{"type":"full_compaction.complete","time":2}`, + `{"type":"mcp.tools_discovered","serverName":"s","enabledNames":["a"],"hash":"h","tools":[],"time":3}`, } { if evs := mustMap(t, m, line); len(evs) != 0 { t.Errorf("noise line produced events: %s → %+v", line, evs) @@ -396,3 +402,262 @@ func TestMapper_RealFixtureReplay(t *testing.T) { plans[0]["message_id"], plans[1]["message_id"], plans[2]["message_id"]) } } + +// --- kimi-code 0.31.0 wire vocabulary --- + +// turn.cancel → turn.result with EXACTLY the ACP driver's eager-cancel +// vocabulary (driver_acp.go Input("cancel")): the busy-walker +// terminates on the kind, the digest reads stop_reason ("cancelled" is +// a normal stop — an intentional end, not a finding), and status +// matches what ACP engines post on a user cancel. This is THE 0.31 +// correctness fix: kimi writes no step.end end_turn for the +// interrupted turn, so without it the session stayed busy forever. +func TestMapper_TurnCancelMapsToCancelledTurnResult(t *testing.T) { + m := NewMapper("main", "", "") + evs := mustMap(t, m, `{"type":"turn.cancel","time":1784620213235}`) + if len(evs) != 1 || evs[0].Kind != "turn.result" { + t.Fatalf("want one turn.result; got %+v", evs) + } + ev := evs[0] + if ev.Producer != "agent" { + t.Fatalf("producer = %q, want agent", ev.Producer) + } + if ev.Payload["status"] != "cancelled" || ev.Payload["stop_reason"] != "cancelled" { + t.Fatalf("cancel vocabulary wrong: %+v", ev.Payload) + } + // No reason:end_of_turn — that spelling means a clean finish. + if _, has := ev.Payload["reason"]; has { + t.Fatalf("cancelled turn.result must not carry the end_of_turn reason key: %+v", ev.Payload) + } +} + +// A subagent's turn.cancel describes its inner loop, not the session's +// turn — same drop guard as mapStepEnd (#374). +func TestMapper_TurnCancel_SubagentDrops(t *testing.T) { + m := NewMapper("agent-9", "main", "") + if evs := mustMap(t, m, `{"type":"turn.cancel","time":1}`); len(evs) != 0 { + t.Fatalf("subagent turn.cancel should drop; got %+v", evs) + } +} + +// turn.steer drops EVERY origin explicitly (no unknown_type drift +// noise): background_task is the engine's own task-notification +// envelope (129/133 steers in the 0.31 capture), user steers duplicate +// the hub's input.text (same rationale as turn.prompt), unknown +// origins drop quietly rather than guess. +func TestMapper_TurnSteerDropsAllOrigins(t *testing.T) { + m := NewMapper("main", "", "") + for _, line := range []string{ + `{"type":"turn.steer","input":[{"type":"text","text":"x"}],"origin":{"kind":"background_task","taskId":"bash-0576ywug","status":"completed","notificationId":"task:bash-0576ywug:completed"},"time":1}`, + `{"type":"turn.steer","input":[{"type":"text","text":"x"}],"origin":{"kind":"user"},"time":2}`, + `{"type":"turn.steer","input":[{"type":"text","text":"x"}],"origin":{"kind":"some_future_origin"},"time":3}`, + `{"type":"turn.steer","input":[{"type":"text","text":"x"}],"time":4}`, + } { + if evs := mustMap(t, m, line); len(evs) != 0 { + t.Errorf("steer should drop silently (no events, no unknown_type): %s → %+v", line, evs) + } + } +} + +// turn.prompt origin.kind=task (0.31 goal/cron engine tasks + +// background-task notices) drops like any prompt but STILL re-arms the +// per-turn plan chain — a task prompt opens a real engine turn. +func TestMapper_TurnPromptTaskOriginDropsAndRearms(t *testing.T) { + m := NewMapper("main", "", "") + mustMap(t, m, `{"type":"turn.prompt","input":[{"type":"text","text":"do things"}],"origin":{"kind":"user"},"time":1}`) + first := mustMap(t, m, `{"type":"tools.update_store","key":"todo","value":[{"title":"a","status":"pending"}],"time":2}`) + idA := first[0].Payload["message_id"] + + taskPrompt := `{"type":"turn.prompt","input":[{"type":"text","text":""}],"origin":{"kind":"task","taskId":"bash-kl8rz8bn","status":"completed","notificationId":"task:bash-kl8rz8bn:completed"},"time":3}` + if evs := mustMap(t, m, taskPrompt); len(evs) != 0 { + t.Fatalf("task-origin prompt should drop; got %+v", evs) + } + second := mustMap(t, m, `{"type":"tools.update_store","key":"todo","value":[{"title":"b","status":"pending"}],"time":4}`) + if second[0].Payload["message_id"] == idA { + t.Fatalf("task prompt should re-arm the plan chain; still %q", idA) + } +} + +// context.apply_compaction → ONE system event (subtype compaction) +// with the summary (bounded), the token delta and the compacted count; +// a text line feeds the default card renderer. begin/complete and +// mcp.tools_discovered are dropped in TestMapper_DropsKnownNoise. +func TestMapper_ApplyCompactionMapsToSystemEvent(t *testing.T) { + m := NewMapper("main", "", "") + evs := mustMap(t, m, `{"type":"context.apply_compaction","summary":"working brief","contextSummary":"boilerplate","compactedCount":135,"tokensBefore":150972,"tokensAfter":1585,"keptUserMessageCount":7,"time":1}`) + if len(evs) != 1 || evs[0].Kind != "system" || evs[0].Producer != "system" { + t.Fatalf("want one system event; got %+v", evs) + } + p := evs[0].Payload + if p["subtype"] != "compaction" { + t.Fatalf("subtype = %v", p["subtype"]) + } + if p["summary"] != "working brief" { + t.Fatalf("summary = %v", p["summary"]) + } + if p["compacted_count"] != 135 || p["tokens_before"] != 150972 || p["tokens_after"] != 1585 { + t.Fatalf("counts wrong: %+v", p) + } + text, _ := p["text"].(string) + if !strings.Contains(text, "135") || !strings.Contains(text, "150972") || !strings.Contains(text, "1585") { + t.Fatalf("text summary = %q", text) + } + // contextSummary is engine-facing boilerplate, not carried. + if _, has := p["contextSummary"]; has { + t.Fatalf("contextSummary should not be forwarded: %+v", p) + } + + // The summary is bounded so one event can't dominate the transcript. + long := strings.Repeat("a", 600) + evs2 := mustMap(t, m, `{"type":"context.apply_compaction","summary":"`+long+`","compactedCount":1,"tokensBefore":2,"tokensAfter":1,"time":2}`) + got, _ := evs2[0].Payload["summary"].(string) + if r := []rune(got); len(r) != 501 || !strings.HasSuffix(got, "…") { + t.Fatalf("summary not truncated to the bound: %d runes", len(r)) + } +} + +// plan_mode / permission.set_mode / swarm_mode (0.31) → producer=system +// cards carrying the phase/mode/trigger. producer=system keeps them off +// mobile's busy-inference path (same treatment as the ACP driver's +// current_mode_update forward). +func TestMapper_ModeEvents(t *testing.T) { + m := NewMapper("main", "", "") + + enter := mustMap(t, m, `{"type":"plan_mode.enter","id":"lockjaw-iron-fist-adam-warlock","time":1}`) + p := enter[0].Payload + if enter[0].Kind != "system" || enter[0].Producer != "system" || + p["subtype"] != "plan_mode" || p["phase"] != "enter" || p["id"] != "lockjaw-iron-fist-adam-warlock" { + t.Fatalf("plan_mode.enter payload = %+v", p) + } + if text, _ := p["text"].(string); !strings.Contains(text, "enter") || !strings.Contains(text, "lockjaw") { + t.Fatalf("plan_mode.enter text = %q", text) + } + + for _, line := range []string{ + `{"type":"plan_mode.cancel","time":2}`, + `{"type":"plan_mode.exit","time":3}`, + } { + evs := mustMap(t, m, line) + if len(evs) != 1 || evs[0].Payload["subtype"] != "plan_mode" { + t.Fatalf("plan_mode arm produced %+v", evs) + } + if _, has := evs[0].Payload["id"]; has { + t.Fatalf("non-enter phases carry no id: %+v", evs[0].Payload) + } + } + + perm := mustMap(t, m, `{"type":"permission.set_mode","mode":"yolo","time":4}`) + if perm[0].Payload["subtype"] != "permission_mode" || perm[0].Payload["mode"] != "yolo" { + t.Fatalf("permission.set_mode payload = %+v", perm[0].Payload) + } + + swarmIn := mustMap(t, m, `{"type":"swarm_mode.enter","trigger":"tool","time":5}`) + if swarmIn[0].Payload["subtype"] != "swarm_mode" || swarmIn[0].Payload["phase"] != "enter" || + swarmIn[0].Payload["trigger"] != "tool" { + t.Fatalf("swarm_mode.enter payload = %+v", swarmIn[0].Payload) + } + swarmOut := mustMap(t, m, `{"type":"swarm_mode.exit","time":6}`) + if swarmOut[0].Payload["phase"] != "exit" { + t.Fatalf("swarm_mode.exit payload = %+v", swarmOut[0].Payload) + } + if _, has := swarmOut[0].Payload["trigger"]; has { + t.Fatalf("exit carries no trigger: %+v", swarmOut[0].Payload) + } +} + +// The 0.31 event types must not disturb the per-turn plan chain +// (turnSeq / planMsgID): steers, mode events, compaction and +// turn.cancel all leave the chain intact; only turn.prompt re-arms. +func TestMapper_PlanChainUnaffectedBy031Events(t *testing.T) { + m := NewMapper("main", "", "") + todo := `{"type":"tools.update_store","key":"todo","value":[{"title":"x","status":"pending"}],"time":%d}` + + mustMap(t, m, `{"type":"turn.prompt","input":[{"type":"text","text":"go"}],"origin":{"kind":"user"},"time":1}`) + first := mustMap(t, m, fmt.Sprintf(todo, 2)) + idA := first[0].Payload["message_id"] + + for i, line := range []string{ + `{"type":"turn.steer","input":[{"type":"text","text":"x"}],"origin":{"kind":"user"},"time":3}`, + `{"type":"plan_mode.enter","id":"p","time":4}`, + `{"type":"plan_mode.cancel","time":5}`, + `{"type":"permission.set_mode","mode":"yolo","time":6}`, + `{"type":"swarm_mode.enter","trigger":"tool","time":7}`, + `{"type":"swarm_mode.exit","time":8}`, + `{"type":"context.apply_compaction","summary":"s","compactedCount":1,"tokensBefore":9,"tokensAfter":2,"time":9}`, + `{"type":"turn.cancel","time":10}`, + } { + mustMap(t, m, line) + again := mustMap(t, m, fmt.Sprintf(todo, 20+i)) + if again[0].Payload["message_id"] != idA { + t.Fatalf("after %s the chain moved: %q → %q", line, idA, again[0].Payload["message_id"]) + } + } + + // And turn.prompt still re-arms after all of the above. + mustMap(t, m, `{"type":"turn.prompt","input":[{"type":"text","text":"next"}],"origin":{"kind":"user"},"time":100}`) + third := mustMap(t, m, fmt.Sprintf(todo, 101)) + if third[0].Payload["message_id"] == idA { + t.Fatalf("turn.prompt should re-arm; still %q", idA) + } +} + +// Fixture replay: the sanitized real 0.31.0 capture maps end-to-end — +// every new event type exercises its arm, explicit drops emit nothing, +// and the kind histogram + plan-chain rotation + cancel vocabulary are +// pinned (the drift alarm for 0.31 shapes, alongside the 0.28.1 pin in +// TestMapper_RealFixtureReplay). +func TestMapper_RealFixtureReplay_0_31(t *testing.T) { + data, err := os.ReadFile("testdata/wire_main_0_31.jsonl") + if err != nil { + t.Fatalf("read fixture: %v", err) + } + m := NewMapper("main", "", "kimi-code-ts") + counts := map[string]int{} + var plans []map[string]any + var turnResults []map[string]any + for i, line := range strings.Split(strings.TrimSpace(string(data)), "\n") { + evs, err := m.MapLine([]byte(line)) + if err != nil { + t.Fatalf("fixture line %d (%s): %v", i, line[:60], err) + } + for _, ev := range evs { + counts[ev.Kind]++ + switch ev.Kind { + case "plan": + plans = append(plans, ev.Payload) + case "turn.result": + turnResults = append(turnResults, ev.Payload) + case "system": + if ev.Payload["subtype"] == "unknown_type" { + t.Errorf("line %d fell to unknown_type — a 0.31 type lost its arm: %s", i, line[:80]) + } + } + } + } + // The fixture carries: 2 todo updates (one per turn), 1 text part, + // 1 usage, 1 turn.cancel, and the mode/compaction system events + // (2 plan_mode + 1 permission_mode + 2 swarm_mode + 1 compaction). + want := map[string]int{ + "plan": 2, + "system": 6, + "text": 1, + "usage": 1, + "turn.result": 1, + } + for kind, n := range want { + if counts[kind] != n { + t.Errorf("kind %s = %d, want %d (all: %v)", kind, counts[kind], n, counts) + } + } + // The task-origin turn.prompt between the two todo updates re-armed + // the chain. + if len(plans) == 2 && plans[0]["message_id"] == plans[1]["message_id"] { + t.Errorf("plan chain should rotate across turns; both %q", plans[0]["message_id"]) + } + // The single turn.result is the cancel terminal, in the ACP + // eager-cancel vocabulary. + if len(turnResults) == 1 && + (turnResults[0]["status"] != "cancelled" || turnResults[0]["stop_reason"] != "cancelled") { + t.Errorf("turn.result payload = %+v, want cancelled/cancelled", turnResults[0]) + } +} diff --git a/hub/internal/drivers/local_log_tail/kimi_code/testdata/wire_main_0_31.jsonl b/hub/internal/drivers/local_log_tail/kimi_code/testdata/wire_main_0_31.jsonl new file mode 100644 index 000000000..f15510867 --- /dev/null +++ b/hub/internal/drivers/local_log_tail/kimi_code/testdata/wire_main_0_31.jsonl @@ -0,0 +1,21 @@ +{"type":"metadata","protocol_version":"1.5","created_at":1784272902692} +{"type":"mcp.tools_discovered","serverName":"termipod-desktop","enabledNames":["browser_list_tabs","browser_snapshot"],"hash":"eb61a4cc762b90ac7aa2c61f8f8f6b3fc35efdb08bab8d9d47a414887b2dccff","tools":[{"name":"browser_list_tabs","description":"","inputSchema":{"type":"object","properties":{},"additionalProperties":false}}],"time":1784272903000} +{"type":"turn.prompt","input":[{"type":"text","text":""}],"origin":{"kind":"user"},"time":1784272903100} +{"type":"context.append_loop_event","event":{"type":"step.begin","uuid":"095ef45f-95e6-4a47-9699-0e74fa5979b3","turnId":"20","step":1},"time":1784272903101} +{"type":"tools.update_store","key":"todo","value":[{"title":"first task","status":"in_progress"}],"time":1784272903200} +{"type":"turn.steer","input":[{"type":"text","text":""}],"origin":{"kind":"background_task","taskId":"bash-0576ywug","status":"completed","notificationId":"task:bash-0576ywug:completed"},"time":1784272904000} +{"type":"turn.steer","input":[{"type":"text","text":""}],"origin":{"kind":"user"},"time":1784272905000} +{"type":"plan_mode.enter","id":"lockjaw-iron-fist-adam-warlock","time":1784272906000} +{"type":"plan_mode.cancel","time":1784272906231} +{"type":"permission.set_mode","mode":"yolo","time":1784272911012} +{"type":"swarm_mode.enter","trigger":"tool","time":1784272912000} +{"type":"swarm_mode.exit","time":1784272913000} +{"type":"full_compaction.begin","source":"manual","time":1784272914000} +{"type":"context.apply_compaction","summary":"","contextSummary":"","compactedCount":135,"tokensBefore":150972,"tokensAfter":1585,"keptUserMessageCount":7,"time":1784272914484} +{"type":"full_compaction.complete","time":1784272914506} +{"type":"turn.prompt","input":[{"type":"text","text":""}],"origin":{"kind":"task","taskId":"bash-kl8rz8bn","status":"completed","notificationId":"task:bash-kl8rz8bn:completed"},"time":1784272920000} +{"type":"tools.update_store","key":"todo","value":[{"title":"second task","status":"pending"}],"time":1784272921000} +{"type":"context.append_loop_event","event":{"type":"content.part","uuid":"85e2fdfb-b758-40e6-966a-0b6581ef04b4","turnId":"21","step":1,"part":{"type":"text","text":""}},"time":1784272922000} +{"type":"context.append_loop_event","event":{"type":"step.end","uuid":"06574e1c-856d-4bf2-af05-21b1d2697fab","turnId":"21","step":1,"usage":{"inputOther":791,"output":583,"inputCacheRead":43008,"inputCacheCreation":0},"finishReason":"tool_use"},"time":1784272923000} +{"type":"turn.cancel","time":1784272923235} +{"type":"usage.record","model":"kimi-code/k3","usage":{"inputOther":791,"output":583,"inputCacheRead":43008,"inputCacheCreation":0},"usageScope":"turn","time":1784272924000}