feat(plugin): auto-inject retrieves on the conversation, not the typed words alone (#4364)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / integration (push) Successful in 49s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / Python tests (push) Failing after 1m7s
CI & Build / Build & push image (push) Skipped
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / integration (push) Successful in 49s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / Python tests (push) Failing after 1m7s
CI & Build / Build & push image (push) Skipped
The prompt hook sent only the operator's message, so a mid-session
follow-up ("yes do that") named nothing a note, lesson or rule could
match. The hook now reads the tail of the last assistant reply from the
transcript and sends it as `ctx`; the notes and rule arms append it to a
short prompt (<= 280 chars), prompt first, capped at 600 chars. No model
tokens: it is embedding input, and the injected menu's budget is unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
"""The auto-inject query carries the conversation beside a thin prompt (#4364).
|
||||
|
||||
The notes arm used to retrieve against the operator's typed words alone, and a
|
||||
mid-session follow-up — "yes do that", "now fix the filter" — names nothing a
|
||||
record can match. The hook now reads the tail of the last assistant reply out
|
||||
of the transcript and sends it as `ctx`; the server appends it to a SHORT
|
||||
prompt only. Two halves, pinned separately: what the server builds, and what
|
||||
the hook extracts.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from scribe.services.plugin_context import (
|
||||
_AUTOINJECT_CONTEXT_MAX,
|
||||
_AUTOINJECT_CONTEXT_PROMPT_MAX,
|
||||
_autoinject_query,
|
||||
)
|
||||
from tests.helpers import need_tools
|
||||
|
||||
DEFS = Path(__file__).resolve().parents[1] / "plugin" / "hooks" / "scribe_defs.sh"
|
||||
|
||||
|
||||
# --- the query the server builds ---------------------------------------------
|
||||
|
||||
def test_a_short_prompt_is_followed_by_the_context():
|
||||
q = _autoinject_query("yes do that", "I'll move the library filters into the side column.")
|
||||
assert q.startswith("yes do that\n\n")
|
||||
assert "library filters" in q
|
||||
|
||||
|
||||
def test_a_prompt_that_says_what_it_is_about_is_left_alone():
|
||||
long_prompt = "x" * (_AUTOINJECT_CONTEXT_PROMPT_MAX + 1)
|
||||
assert _autoinject_query(long_prompt, "anything at all") == long_prompt
|
||||
|
||||
|
||||
def test_no_context_is_the_prompt_unchanged():
|
||||
assert _autoinject_query("yes do that", "") == "yes do that"
|
||||
assert _autoinject_query("yes do that", " \n ") == "yes do that"
|
||||
|
||||
|
||||
def test_context_is_cut_from_the_end_of_the_reply():
|
||||
# The reply's close is where it says what it did and what is next — the
|
||||
# part the operator's follow-up is answering.
|
||||
ctx = "HEAD " + "m" * 2000 + " TAIL"
|
||||
q = _autoinject_query("ok", ctx)
|
||||
tail = q.split("\n\n", 1)[1]
|
||||
assert len(tail) == _AUTOINJECT_CONTEXT_MAX
|
||||
assert tail.endswith("TAIL") and "HEAD" not in tail
|
||||
|
||||
|
||||
# --- what the hook extracts --------------------------------------------------
|
||||
|
||||
def _rec(role: str, blocks: list[dict], sidechain: bool = False) -> str:
|
||||
return json.dumps({
|
||||
"type": role, "isSidechain": sidechain,
|
||||
"message": {"role": role, "content": blocks},
|
||||
}, separators=(",", ":"))
|
||||
|
||||
|
||||
def recent(tmp_path: Path, lines: list[str]) -> str:
|
||||
need_tools("bash", "awk", "grep", "tail")
|
||||
t = tmp_path / "t.jsonl"
|
||||
t.write_text("\n".join(lines) + "\n")
|
||||
r = subprocess.run(
|
||||
["bash", "-c", f'set -uo pipefail\n. "{DEFS}"\nscribe_recent_context "{t}"'],
|
||||
capture_output=True,
|
||||
)
|
||||
assert r.returncode == 0, r.stderr.decode()
|
||||
return r.stdout.decode().strip()
|
||||
|
||||
|
||||
def test_the_hook_reads_the_last_assistant_text(tmp_path):
|
||||
out = recent(tmp_path, [
|
||||
_rec("assistant", [{"type": "text", "text": "an older reply"}]),
|
||||
_rec("assistant", [{"type": "tool_use", "id": "t1", "name": "Bash", "input": {}}]),
|
||||
_rec("assistant", [{"type": "text", "text": "Shall I file it as an issue?\nNext: the hook."}]),
|
||||
])
|
||||
assert out == "Shall I file it as an issue? Next: the hook."
|
||||
|
||||
|
||||
def test_a_subagent_reply_is_not_this_session(tmp_path):
|
||||
out = recent(tmp_path, [
|
||||
_rec("assistant", [{"type": "text", "text": "the session's reply"}]),
|
||||
_rec("assistant", [{"type": "text", "text": "a subagent's report"}], sidechain=True),
|
||||
])
|
||||
assert out == "the session's reply"
|
||||
|
||||
|
||||
def test_the_hook_caps_what_it_sends(tmp_path):
|
||||
out = recent(tmp_path, [_rec("assistant", [{"type": "text", "text": "a" * 5000 + "END"}])])
|
||||
assert len(out) == 600 and out.endswith("END")
|
||||
|
||||
|
||||
def test_no_transcript_is_silence_not_failure(tmp_path):
|
||||
assert recent(tmp_path, []) == ""
|
||||
Reference in New Issue
Block a user