Skip to content

Commit eda5ec3

Browse files
fix(adapters): Kimi Code and Junie dropped MultiEdit/NotebookEdit content (#36)
Both docstrings claim Claude Code's wire protocol exactly, but parse() in each read only content/new_string, dropping MultiEdit's edits[].new_string and NotebookEdit's new_source. Mirrors claude_code.parse's fallback chain.
1 parent 290836c commit eda5ec3

5 files changed

Lines changed: 61 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77
## [Unreleased]
88

99
### Fixed
10+
- Kimi Code and Junie both claim Claude Code's wire protocol exactly (their own docstrings
11+
say so), but `parse()` in each read only `content`/`new_string` -- so MultiEdit's
12+
`edits[].new_string` and NotebookEdit's `new_source`/`notebook_path` were dropped, and a
13+
content policy that already works on claude_code's MultiEdit/NotebookEdit went blind on
14+
these two. Mirrors the fallback chain claude_code.parse already uses.
1015
- `install` could **destroy a user's entire config**. `_load` returned `{}` on any parse
1116
failure, so the fragment was merged into an empty object and written back, discarding
1217
everything the file held. For Junie, whose `config.json` is the whole CLI configuration

src/agentseam/adapters/junie.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,13 +84,20 @@ def claims(raw):
8484
def parse(raw):
8585
ti = raw.get("tool_input")
8686
ti = ti if isinstance(ti, dict) else {}
87+
# The docstring stakes everything on Junie's field names following Claude Code's wire
88+
# protocol exactly, so MultiEdit's edits[].new_string and NotebookEdit's new_source get
89+
# the same fallback chain claude_code.parse uses -- not a guess, a claim we already made.
90+
content = ti.get("content") or ti.get("new_string") or ti.get("new_source") or None
91+
if content is None and isinstance(ti.get("edits"), list):
92+
joined = "\n".join(str(e.get("new_string", "")) for e in ti["edits"])
93+
content = joined or None
8794
return Event(
8895
AGENT,
8996
EVENT_MAP.get(raw.get("hook_event_name"), UNKNOWN),
9097
tool=raw.get("tool_name"),
9198
command=ti.get("command"),
92-
path=ti.get("file_path") or ti.get("path"),
93-
content=ti.get("content") or ti.get("new_string"),
99+
path=ti.get("file_path") or ti.get("path") or ti.get("notebook_path"),
100+
content=content,
94101
output=raw.get("last_assistant_message"),
95102
prompt=raw.get("prompt"),
96103
session_id=raw.get("session_id"),

src/agentseam/adapters/kimi_code.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,13 @@ def claims(raw):
101101
def parse(raw):
102102
ti = raw.get("tool_input")
103103
ti = ti if isinstance(ti, dict) else {}
104-
content = ti.get("content") or ti.get("new_string") or None
104+
# The docstring stakes everything on this envelope being Claude Code's exactly (same
105+
# tool_input), so MultiEdit's edits[].new_string and NotebookEdit's new_source get the
106+
# same fallback chain claude_code.parse uses -- not a guess, a claim we already made.
107+
content = ti.get("content") or ti.get("new_string") or ti.get("new_source") or None
108+
if content is None and isinstance(ti.get("edits"), list):
109+
joined = "\n".join(str(e.get("new_string", "")) for e in ti["edits"])
110+
content = joined or None
105111
out = raw.get("tool_output")
106112
if isinstance(out, (dict, list)):
107113
out = _json.dumps(out)
@@ -113,7 +119,7 @@ def parse(raw):
113119
EVENT_MAP.get(raw.get("hook_event_name"), UNKNOWN),
114120
tool=raw.get("tool_name"),
115121
command=ti.get("command"),
116-
path=ti.get("file_path") or ti.get("path"),
122+
path=ti.get("file_path") or ti.get("path") or ti.get("notebook_path"),
117123
content=content,
118124
output=out,
119125
prompt=raw.get("prompt"),

tests/test_adapter_junie.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,25 @@ def test_project_path_separates_junie_from_claude_code():
2727
assert A.adapters.detect(without) == "claude_code"
2828

2929

30+
def test_multiedit_and_notebookedit_content_reach_a_content_policy():
31+
"""The docstring stakes everything on Junie's field names following Claude Code's wire
32+
protocol -- so a policy that already works on claude_code's MultiEdit/NotebookEdit must
33+
not go blind here just because project_path marks it as Junie."""
34+
multiedit = _pre(
35+
tool_name="MultiEdit",
36+
tool_input={
37+
"file_path": "AGENTS.md",
38+
"edits": [{"old_string": "x", "new_string": "AWS_SECRET_ACCESS_KEY=akia"}],
39+
},
40+
)
41+
_, _, event, _ = A.handle(multiedit, lambda e: Decision.deny("x"), agent="junie")
42+
assert "AWS_SECRET_ACCESS_KEY" in event.content
43+
44+
notebook = _pre(tool_name="NotebookEdit", tool_input={"notebook_path": "nb.ipynb", "new_source": "SECRET=akia"})
45+
_, _, event, _ = A.handle(notebook, lambda e: Decision.deny("x"), agent="junie")
46+
assert event.path == "nb.ipynb" and "SECRET" in event.content
47+
48+
3049
@pytest.mark.parametrize(
3150
"decision,expected",
3251
[

tests/test_adapter_kimi_code.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,26 @@ def test_observation_only_events_stay_silent():
4444
assert (text, code) == ("", 0)
4545

4646

47+
def test_multiedit_and_notebookedit_content_reach_a_content_policy():
48+
"""The docstring stakes everything on this envelope being Claude Code's exactly (same
49+
tool_input) -- so a policy that already works on claude_code's MultiEdit/NotebookEdit
50+
must not go blind here just because client_type marks it as Kimi."""
51+
multiedit = dict(KM_WRITE)
52+
multiedit["tool_name"] = "MultiEdit"
53+
multiedit["tool_input"] = {
54+
"file_path": "AGENTS.md",
55+
"edits": [{"old_string": "x", "new_string": "AWS_SECRET_ACCESS_KEY=akia"}],
56+
}
57+
_, _, event, _ = A.handle(multiedit, deny_all)
58+
assert "AWS_SECRET_ACCESS_KEY" in event.content
59+
60+
notebook = dict(KM_WRITE)
61+
notebook["tool_name"] = "NotebookEdit"
62+
notebook["tool_input"] = {"notebook_path": "nb.ipynb", "new_source": "SECRET=akia"}
63+
_, _, event, _ = A.handle(notebook, deny_all)
64+
assert event.path == "nb.ipynb" and "SECRET" in event.content
65+
66+
4767
def test_a_degraded_rewrite_names_the_rewrite_not_a_confirmation():
4868
text, _, _, _ = A.handle(KM_SHELL, lambda e: Decision.rewrite({"command": "true"}, "redact it"))
4969
reason = json.loads(text)["hookSpecificOutput"]["permissionDecisionReason"]

0 commit comments

Comments
 (0)