Skip to content

Commit f81edda

Browse files
author
Herik Webb
committed
fix(claude): preserve attachment context when the prompt is a slash command
The CLI query assembly kept only the last user line whenever it started with '/', silently discarding the context lines NBI had just appended for the same turn (attachment @-mentions, cell pointers, output context) — the footgun previously documented in extension.py's on_message. Extract the join into assemble_client_query: control-only commands (/clear, /cost, ...) still drop the context since it is meaningless to them, while any other command is moved to the front of the query (the CLI only recognizes a command at the start) with the context lines preserved after it, so custom skill and plugin commands receive the user's attachments. Promote claude_sessions' control-command set to a public name (adding /context) and reuse it as the single source of truth.
1 parent d9f76a5 commit f81edda

3 files changed

Lines changed: 92 additions & 6 deletions

File tree

notebook_intelligence/claude.py

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from anyio.abc import Process
1717
from notebook_intelligence.api import AskUserQuestionData, BackendMessageType, CancelToken, ChatCommand, ChatModel, ChatRequest, ChatResponse, ClaudeToolType, CompletionContext, ConfirmationData, Host, InlineCompletionModel, MarkdownData, ProgressData, SignalImpl, ToolCallData
1818
from notebook_intelligence.base_chat_participant import BaseChatParticipant
19+
from notebook_intelligence.claude_sessions import CONTROL_SLASH_COMMANDS
1920
from notebook_intelligence._version import __version__ as NBI_VERSION
2021
import base64
2122
import logging
@@ -34,6 +35,32 @@ def _extract_text_from_content(content) -> str:
3435
return "\n".join(block["text"] for block in content if isinstance(block, dict) and block.get("type") == "text")
3536
return content
3637

38+
39+
def assemble_client_query(query_lines: list[str]) -> str:
40+
"""Join this turn's user-role lines into the query sent to the CLI.
41+
42+
The CLI only recognizes a slash command when the query *starts* with
43+
it, but NBI appends this turn's context lines (attachment @-mentions,
44+
cell pointers, output context) *before* the user's prompt. The old
45+
behavior kept only the command line, silently discarding whatever the
46+
user had just attached (the footgun documented in extension.py's
47+
on_message). Instead:
48+
49+
- control-only commands (``/clear``, ``/cost``, ...) still drop the
50+
context — it is meaningless to them and could break their parsing;
51+
- every other command is moved to the front so the CLI still
52+
recognizes it, with the context lines preserved after it (a custom
53+
skill/plugin command receives them as part of its arguments).
54+
"""
55+
if query_lines and query_lines[-1].startswith('/'):
56+
command_line = query_lines[-1]
57+
command_token = command_line.split(None, 1)[0].lower()
58+
if command_token in CONTROL_SLASH_COMMANDS or len(query_lines) == 1:
59+
query_lines = [command_line]
60+
else:
61+
query_lines = [command_line, *query_lines[:-1]]
62+
return "\n".join(line.strip() for line in query_lines)
63+
3764
CLAUDE_CODE_ICON_SVG = '<svg width="1200" height="1200" viewBox="0 0 1200 1200" xmlns="http://www.w3.org/2000/svg"><g id="g314"><path id="path147" fill="#d97757" stroke="#d97757" d="M 233.959793 800.214905 L 468.644287 668.536987 L 472.590637 657.100647 L 468.644287 650.738403 L 457.208069 650.738403 L 417.986633 648.322144 L 283.892639 644.69812 L 167.597321 639.865845 L 54.926208 633.825623 L 26.577238 627.785339 L 3.3e-05 592.751709 L 2.73832 575.27533 L 26.577238 559.248352 L 60.724873 562.228149 L 136.187973 567.382629 L 249.422867 575.194763 L 331.570496 580.026978 L 453.261841 592.671082 L 472.590637 592.671082 L 475.328857 584.859009 L 468.724915 580.026978 L 463.570557 575.194763 L 346.389313 495.785217 L 219.543671 411.865906 L 153.100723 363.543762 L 117.181267 339.060425 L 99.060455 316.107361 L 91.248367 266.01355 L 123.865784 230.093994 L 167.677887 233.073853 L 178.872513 236.053772 L 223.248367 270.201477 L 318.040283 343.570496 L 441.825592 434.738342 L 459.946411 449.798706 L 467.194672 444.64447 L 468.080597 441.020203 L 459.946411 427.409485 L 392.617493 305.718323 L 320.778564 181.932983 L 288.80542 130.630859 L 280.348999 99.865845 C 277.369171 87.221436 275.194641 76.590698 275.194641 63.624268 L 312.322174 13.20813 L 332.8591 6.604126 L 382.389313 13.20813 L 403.248352 31.328979 L 434.013519 101.71814 L 483.865753 212.537048 L 561.181274 363.221497 L 583.812134 407.919434 L 595.892639 449.315491 L 600.40271 461.959839 L 608.214783 461.959839 L 608.214783 454.711609 L 614.577271 369.825623 L 626.335632 265.61084 L 637.771851 131.516846 L 641.718201 93.745117 L 660.402832 48.483276 L 697.530334 24.000122 L 726.52356 37.852417 L 750.362549 72 L 747.060486 94.067139 L 732.886047 186.201416 L 705.100708 330.52356 L 686.979919 427.167847 L 697.530334 427.167847 L 709.61084 415.087341 L 758.496704 350.174561 L 840.644348 247.490051 L 876.885925 206.738342 L 919.167847 161.71814 L 946.308838 140.29541 L 997.61084 140.29541 L 1035.38269 196.429626 L 1018.469849 254.416199 L 965.637634 321.422852 L 921.825562 378.201538 L 859.006714 462.765259 L 819.785278 530.41626 L 823.409424 535.812073 L 832.75177 534.92627 L 974.657776 504.724915 L 1051.328979 490.872559 L 1142.818848 475.167786 L 1184.214844 494.496582 L 1188.724854 514.147644 L 1172.456421 554.335693 L 1074.604126 578.496765 L 959.838989 601.449829 L 788.939636 641.879272 L 786.845764 643.409485 L 789.261841 646.389343 L 866.255127 653.637634 L 899.194702 655.409424 L 979.812134 655.409424 L 1129.932861 666.604187 L 1169.154419 692.537109 L 1192.671265 724.268677 L 1188.724854 748.429688 L 1128.322144 779.194641 L 1046.818848 759.865845 L 856.590759 714.604126 L 791.355774 698.335754 L 782.335693 698.335754 L 782.335693 703.731567 L 836.69812 756.885986 L 936.322205 846.845581 L 1061.073975 962.81897 L 1067.436279 991.490112 L 1051.409424 1014.120911 L 1034.496704 1011.704712 L 924.885986 929.234924 L 882.604126 892.107544 L 786.845764 811.48999 L 780.483276 811.48999 L 780.483276 819.946289 L 802.550415 852.241699 L 919.087341 1027.409424 L 925.127625 1081.127686 L 916.671204 1098.604126 L 886.469849 1109.154419 L 853.288696 1103.114136 L 785.073914 1007.355835 L 714.684631 899.516785 L 657.906067 802.872498 L 650.979858 806.81897 L 617.476624 1167.704834 L 601.771851 1186.147705 L 565.530212 1200 L 535.328857 1177.046997 L 519.302124 1139.919556 L 535.328857 1066.550537 L 554.657776 970.792053 L 570.362488 894.68457 L 584.536926 800.134277 L 592.993347 768.724976 L 592.429626 766.630859 L 585.503479 767.516968 L 514.22821 865.369263 L 405.825531 1011.865906 L 320.053711 1103.677979 L 299.516815 1111.812256 L 263.919525 1093.369263 L 267.221497 1060.429688 L 287.114136 1031.114136 L 405.825531 880.107361 L 477.422913 786.52356 L 523.651062 732.483276 L 523.328918 724.671265 L 520.590698 724.671265 L 205.288605 929.395935 L 149.154434 936.644409 L 124.993355 914.01355 L 127.973183 876.885986 L 139.409409 864.80542 L 234.201385 799.570435 L 233.879227 799.8927 Z"/></g></svg>'
3865
CLAUDE_CODE_ICON_URL = f"data:image/svg+xml;base64,{base64.b64encode(CLAUDE_CODE_ICON_SVG.encode('utf-8')).decode('utf-8')}"
3966
CLAUDE_DEFAULT_CHAT_MODEL = "claude-sonnet-4-5"
@@ -955,10 +982,7 @@ async def _client_thread_func(self):
955982
for msg in messages:
956983
if msg["role"] == "user":
957984
query_lines.append(_extract_text_from_content(msg["content"]))
958-
# if a command is present, remove other lines
959-
if len(query_lines) > 0 and query_lines[-1].startswith('/'):
960-
query_lines = query_lines[-1:]
961-
client_query = "\n".join([line.strip() for line in query_lines])
985+
client_query = assemble_client_query(query_lines)
962986

963987
already_handled = False
964988

notebook_intelligence/claude_sessions.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,10 +62,13 @@
6262
# Control-only slash commands the user typed to manage the session itself
6363
# rather than ask Claude something. A regex like ^/[A-Za-z]+$ would also
6464
# match "/tmp" or "/etc" — common file paths someone might paste — so we
65-
# enumerate the known set instead.
66-
_CONTROL_SLASH_COMMANDS = frozenset({
65+
# enumerate the known set instead. Public because claude.py also consults
66+
# it when assembling the CLI query: attachment context is meaningless to
67+
# these commands, but must be preserved for every other command.
68+
CONTROL_SLASH_COMMANDS = frozenset({
6769
"/clear",
6870
"/compact",
71+
"/context",
6972
"/cost",
7073
"/exit",
7174
"/help",
@@ -77,6 +80,7 @@
7780
"/reset",
7881
"/status",
7982
})
83+
_CONTROL_SLASH_COMMANDS = CONTROL_SLASH_COMMANDS
8084

8185

8286
@dataclass

tests/test_claude_client.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1282,3 +1282,61 @@ def test_fallback_when_server_info_missing(self):
12821282
participant._client.server_info = None
12831283
names = [cmd.name for cmd in participant.commands]
12841284
assert names == ["compact", "context", "cost", "clear"]
1285+
1286+
1287+
class TestAssembleClientQuery:
1288+
"""Slash-command queries must not silently discard this turn's context
1289+
lines (attachment @-mentions, cell pointers, output context).
1290+
1291+
Only control-only commands (/clear, /cost, ...) drop the context —
1292+
it is meaningless to them. Any other command is moved to the front
1293+
(the CLI only recognizes a command at the start of the query) with
1294+
the context preserved after it.
1295+
"""
1296+
1297+
def test_plain_prompt_joins_all_lines(self):
1298+
from notebook_intelligence.claude import assemble_client_query
1299+
query = assemble_client_query([
1300+
"The user attached @data.csv. Read it if relevant to the request.",
1301+
"Summarize this file",
1302+
])
1303+
assert query == (
1304+
"The user attached @data.csv. Read it if relevant to the request."
1305+
"\nSummarize this file"
1306+
)
1307+
1308+
def test_control_command_drops_context(self):
1309+
from notebook_intelligence.claude import assemble_client_query
1310+
query = assemble_client_query([
1311+
"The user attached @data.csv. Read it if relevant to the request.",
1312+
"/clear",
1313+
])
1314+
assert query == "/clear"
1315+
1316+
def test_control_command_matching_is_case_insensitive_and_token_based(self):
1317+
from notebook_intelligence.claude import assemble_client_query
1318+
assert assemble_client_query(["context line", "/Cost"]) == "/Cost"
1319+
1320+
def test_custom_command_keeps_context_after_command(self):
1321+
from notebook_intelligence.claude import assemble_client_query
1322+
query = assemble_client_query([
1323+
"The user attached @analysis.ipynb. Read it if relevant to the request.",
1324+
"/review-notebook focus on performance",
1325+
])
1326+
assert query.splitlines()[0] == "/review-notebook focus on performance"
1327+
assert "@analysis.ipynb" in query
1328+
1329+
def test_custom_command_without_context_is_unchanged(self):
1330+
from notebook_intelligence.claude import assemble_client_query
1331+
assert assemble_client_query(["/review-notebook"]) == "/review-notebook"
1332+
1333+
def test_empty_lines_list(self):
1334+
from notebook_intelligence.claude import assemble_client_query
1335+
assert assemble_client_query([]) == ""
1336+
1337+
def test_hidden_plan_mode_alias_still_leads_query(self):
1338+
# The plan-mode aliases are intercepted via client_query.startswith,
1339+
# so the command line must stay first even with context attached.
1340+
from notebook_intelligence.claude import assemble_client_query
1341+
query = assemble_client_query(["context line", "/enter-plan-mode"])
1342+
assert query.startswith("/enter-plan-mode")

0 commit comments

Comments
 (0)