From 2388d1325a73038063fbf53d3ed101d5b2db8bae Mon Sep 17 00:00:00 2001 From: max Date: Fri, 31 Jul 2026 01:41:15 +0500 Subject: [PATCH] feat(share): conversations, owner-authored replies, local-CLI composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A question is rarely answered in one shot. Threads let the asker follow up without restating everything and let the owner ask back before answering — and, most valuably, let the owner answer in their own words when the real answer was never in the index. That path needs no approval: typing it IS the authorization, so only machine-drafted text still needs /ok. What crosses the wire is the answer, never the transcript: sessions ids and project names stay home (the asker cannot read them anyway and they are exactly the metadata worth not leaking), and the composer is told to cite artifacts that exist outside this machine — PRs, commits, file paths, commands — because those are what make an answer usable. Only a source count travels, so a grounded answer is still distinguishable from a guess. The composer now defaults to the locally installed `claude` CLI: no API key, no extra cost, and stripped to text-in/text-out via --tools "" and --strict-mcp-config, argv rather than a shell, in an empty working directory. Note in the module: print mode writes a transcript that session-recall indexes, so CLAUDE_CONFIG_DIR must point elsewhere before real use. The scanner now also reads the source fragments, flagging a candidate whose sources sat next to a secret even when the composed answer looks clean. Co-Authored-By: Claude Fable 5 --- src/session_recall/share/approval.py | 25 ++++- src/session_recall/share/cli.py | 40 +++++++- src/session_recall/share/compose.py | 93 ++++++++++++++--- src/session_recall/share/envelope.py | 17 +++- src/session_recall/share/notify.py | 20 +++- src/session_recall/share/thread.py | 108 ++++++++++++++++++++ src/session_recall/share/worker.py | 33 +++++- tests/test_share_envelope.py | 4 +- tests/test_share_threads.py | 144 +++++++++++++++++++++++++++ tests/test_share_worker.py | 6 +- 10 files changed, 460 insertions(+), 30 deletions(-) create mode 100644 src/session_recall/share/thread.py create mode 100644 tests/test_share_threads.py diff --git a/src/session_recall/share/approval.py b/src/session_recall/share/approval.py index 21fffd6..32d8701 100644 --- a/src/session_recall/share/approval.py +++ b/src/session_recall/share/approval.py @@ -119,6 +119,27 @@ def preview(cand: Candidate, redact: bool | None = None, return "\n".join(out) if plain else "\n\n".join(out) +def own_message(identity: Identity, trust: TrustStore, share_dir: Path, + transport, thread_id: str, text: str) -> bool: + """The owner answering in their own words. No approval step: they authored + it, which IS the authorization — a second confirmation on your own typing + is ritual, not a gate. Everything else (revoked peer, closed thread) still + fails closed.""" + from . import thread as thread_mod + convo = thread_mod.load(share_dir, thread_id) + if convo is None or convo.closed or not text.strip(): + return False + peer = trust.get_by_address(convo.peer_address) + if peer is None: + return False + transport.post_mail(peer.address, + make_response(identity, peer, text.strip(), + in_reply_to="", thread=thread_id)) + convo.append("owner", text.strip()) + thread_mod.save(share_dir, convo) + return True + + def approve(share_dir: Path, cand_id: str, version: str) -> Candidate | None: """None unless `version` matches the stored candidate exactly.""" cand = load_candidate(share_dir, cand_id) @@ -169,7 +190,9 @@ def dispatch(identity: Identity, trust: TrustStore, share_dir: Path, continue transport.post_mail(peer.address, make_response(identity, peer, cand.text, - in_reply_to=cand.reply_nonce)) + in_reply_to=cand.reply_nonce, + thread=cand.thread, + sources=len(cand.chunks))) # keep the audit trail honest: a delivered decline is not an answer set_status(share_dir, cand.id, "sent" if cand.status == "approved" else "declined-sent") diff --git a/src/session_recall/share/cli.py b/src/session_recall/share/cli.py index 2a7ca70..a2801bd 100644 --- a/src/session_recall/share/cli.py +++ b/src/session_recall/share/cli.py @@ -60,6 +60,10 @@ def add_parser(sub) -> None: akp.add_argument("--doing", required=True, help="what you are working on") akp.add_argument("--problem", required=True, help="what broke, with symptoms") akp.add_argument("--want", required=True, help="what you want to know") + rpp = ssub.add_parser("reply", help="continue a conversation you started") + rpp.add_argument("thread") + rpp.add_argument("text", nargs="+") + ssub.add_parser("threads", help="list conversations") ssub.add_parser("fetch", help="collect answers peers have sent you") okp = ssub.add_parser("approve", help="approve a candidate locally (no TG)") okp.add_argument("id") @@ -187,13 +191,47 @@ def run(args: argparse.Namespace) -> int: print(f"\n--- candidate answer (v{cand.version}) ---\n{cand.text}") return 0 - if cmd in ("ask", "fetch"): + if cmd == "threads": + from . import thread as thread_mod + convos = thread_mod.listing(sdir) + if not convos: + print("no conversations yet") + return 0 + for t in convos: + mark = "closed " if t.closed else "" + last = t.turns[-1]["text"][:60] if t.turns else "(empty)" + print(f"{mark}{t.id} {t.peer_name} {len(t.turns)} turns {last}") + return 0 + + if cmd in ("ask", "reply", "fetch"): + from . import thread as thread_mod from .ask import AskTooThin, validate from .envelope import ShareState, make_request, open_incoming transport = from_env(os.environ, identity=ident) if transport is None: print(_TRANSPORT_HINT) return 1 + + if cmd == "reply": + convo = thread_mod.load(sdir, args.thread) + if convo is None: + print(f"no conversation {args.thread!r} — see: session-recall share threads") + return 1 + if convo.closed or convo.should_close(): + print(f"conversation {convo.id} is closed — start a new ask") + return 1 + peer = trust.get_by_address(convo.peer_address) + if peer is None: + print(f"{convo.peer_name} is no longer trusted") + return 1 + text = " ".join(args.text).strip() + transport.post_mail(peer.address, make_request( + ident, peer, text, thread=convo.id)) + convo.append("owner", text) + thread_mod.save(sdir, convo) + print(f"sent into {convo.id}; they approve before anything comes back") + return 0 + if cmd == "ask": peer = (trust.get_by_address(args.peer) or next((p for p in trust.peers() if p.name == args.peer), None)) diff --git a/src/session_recall/share/compose.py b/src/session_recall/share/compose.py index 760b4b5..9c0d9ec 100644 --- a/src/session_recall/share/compose.py +++ b/src/session_recall/share/compose.py @@ -16,10 +16,13 @@ """ import os +import subprocess +import tempfile from typing import Callable MODEL = "claude-opus-5" MAX_TOKENS = 4000 +CLI_TIMEOUT_S = 180 Composer = Callable[[dict, list], str | None] @@ -31,11 +34,16 @@ Structure the answer as: 1. A direct answer to what they want to know, in a few sentences. -2. "Где смотреть" / "Where to look": the specific fragments carrying the \ -detail — name the project and session id shown on each fragment, and any file, \ -PR, command or error string they contain. +2. Anything in the fragments the asker can act on or open for themselves: pull \ +request or issue numbers, commit hashes, file paths, package versions, exact \ +commands, error strings. Quote them precisely — these are what make an answer \ +usable instead of merely reassuring. 3. What the fragments do NOT answer, if anything, stated plainly. +Never cite session ids, transcript locations, or dates of the owner's own \ +work — the asker has no access to those and they reveal nothing useful. Cite \ +artifacts that exist outside the owner's machine. + Rules: - Ground every claim in the fragments. Never invent file names, commands, \ versions, or outcomes that are not there. @@ -59,9 +67,18 @@ def _fragments(chunks: list) -> str: return "\n".join(parts) -def _prompt(req: dict, chunks: list) -> str: +def _history(turns: list) -> str: + if not turns: + return "" + lines = [f"{t['text']}" for t in turns] + return ("\n" + "\n".join(lines) + + "\n\n\n") + + +def _prompt(req: dict, chunks: list, turns: list | None = None) -> str: return ( "A colleague is asking about work recorded in these fragments.\n\n" + + _history(turns or []) + "\n" f"What they are doing: {req.get('task', '(not stated)')}\n" f"Problem and symptoms: {req.get('problem', '(not stated)')}\n" @@ -71,13 +88,51 @@ def _prompt(req: dict, chunks: list) -> str: "Write the answer.") -def make_composer(env: dict | None = None, client=None) -> Composer | None: - """None means "no composer configured" — the caller keeps the deterministic - digest. `client` is injectable so tests never touch the network.""" - env = os.environ if env is None else env - if (env.get("SESSION_RECALL_COMPOSE") or "none").strip().lower() != "claude": - return None +def _cli_composer(runner=None) -> Composer: + """Compose through the locally installed `claude` CLI. + Costs nothing beyond the existing subscription and needs no API key, but the + CLI is a full agent by default, so the invocation strips it to text-in / + text-out: `--tools ""` removes every built-in tool and `--strict-mcp-config` + ignores every configured MCP server. The prompt goes on argv, never through + a shell — no shell means no quoting bug can turn retrieved text into a + command. The working directory is an empty temp dir so no project's + CLAUDE.md is discovered. + + Known consequence, deliberately left to the operator: print mode writes a + session transcript under CLAUDE_CONFIG_DIR, which session-recall indexes by + default. Point CLAUDE_CONFIG_DIR at a scratch directory (or exclude it from + indexing) before real use, or a peer's question re-enters your own index and + later surfaces as if it were your own past work. + """ + def run(args, cwd): + return subprocess.run(args, cwd=cwd, capture_output=True, text=True, + timeout=CLI_TIMEOUT_S) + + runner = runner or run + + def compose(req: dict, chunks: list, turns: list | None = None) -> str | None: + if not chunks: + return None + with tempfile.TemporaryDirectory() as empty: + try: + done = runner([ + "claude", "-p", + "--tools", "", + "--strict-mcp-config", + "--system-prompt", _SYSTEM, + _prompt(req, chunks, turns), + ], empty) + except (OSError, subprocess.SubprocessError): + return None + if getattr(done, "returncode", 1) != 0: + return None + return (done.stdout or "").strip() or None + + return compose + + +def _api_composer(client=None) -> Composer | None: if client is None: try: import anthropic @@ -85,7 +140,7 @@ def make_composer(env: dict | None = None, client=None) -> Composer | None: return None client = anthropic.Anthropic() - def compose(req: dict, chunks: list) -> str | None: + def compose(req: dict, chunks: list, turns: list | None = None) -> str | None: if not chunks: return None try: @@ -95,7 +150,8 @@ def compose(req: dict, chunks: list) -> str | None: betas=["server-side-fallback-2026-07-01"], fallbacks="default", system=_SYSTEM, - messages=[{"role": "user", "content": _prompt(req, chunks)}], + messages=[{"role": "user", + "content": _prompt(req, chunks, turns)}], ) except Exception: return None # provider down → deterministic digest, never a gap @@ -105,3 +161,16 @@ def compose(req: dict, chunks: list) -> str | None: return text.strip() or None return compose + + +def make_composer(env: dict | None = None, client=None, runner=None) -> Composer | None: + """None means "no composer configured" — the caller keeps the deterministic + digest. `client`/`runner` are injectable so tests never touch the network or + spawn a process.""" + env = os.environ if env is None else env + engine = (env.get("SESSION_RECALL_COMPOSE") or "none").strip().lower() + if engine in ("claude-cli", "cli"): + return _cli_composer(runner=runner) + if engine in ("claude", "api", "claude-api"): + return _api_composer(client=client) + return None diff --git a/src/session_recall/share/envelope.py b/src/session_recall/share/envelope.py index a982685..c085fb4 100644 --- a/src/session_recall/share/envelope.py +++ b/src/session_recall/share/envelope.py @@ -98,17 +98,26 @@ def _sealed(identity: Identity, peer_box_pk: str, kind: str, body: dict, def make_request(identity: Identity, peer: Peer | dict, question: str, - task: str = "", problem: str = "") -> bytes: + task: str = "", problem: str = "", thread: str = "") -> bytes: + """`thread` rides inside the encrypted body on purpose: the relay must not + learn which envelopes belong to the same conversation.""" box_pk = peer.box_pk if isinstance(peer, Peer) else peer["box_pk"] address = peer.address if isinstance(peer, Peer) else peer["address"] return _sealed(identity, box_pk, "req", - {"question": question, "task": task, "problem": problem}, + {"question": question, "task": task, "problem": problem, + "thread": thread}, address, None) def make_response(identity: Identity, peer: Peer, text: str, - in_reply_to: str) -> bytes: - return _sealed(identity, peer.box_pk, "resp", {"text": text}, + in_reply_to: str, thread: str = "", sources: int = 0) -> bytes: + """Raw transcript never crosses. What goes out is the answer — the same + thing the owner would have said after reading their own history — plus how + many fragments it rests on, so the asker can tell a grounded answer from a + guess. Session ids and project names stay home: the asker could not read + them anyway, and they are exactly the metadata worth not leaking.""" + return _sealed(identity, peer.box_pk, "resp", + {"text": text, "thread": thread, "sources": sources}, peer.address, in_reply_to) diff --git a/src/session_recall/share/notify.py b/src/session_recall/share/notify.py index 6ba8cd2..41fbece 100644 --- a/src/session_recall/share/notify.py +++ b/src/session_recall/share/notify.py @@ -25,7 +25,8 @@ from .worker import Searcher, poll_once _REF_RE = re.compile(r"^\[([0-9a-f]{8}) v([0-9a-f]{8})\]") -_USAGE = "usage: reply to a preview with `/ok ` or `/no `" +_USAGE = ("usage: reply to a preview with `/ok `, `/no `, " + "or just write your own answer and it goes as-is") def _cand_ref(message: dict) -> tuple[str, str] | None: @@ -60,13 +61,26 @@ def _say_preview(self, cand) -> None: def _handle(self, message: dict) -> None: text = (message.get("text") or "").strip() mid = message.get("message_id") - if not (text.startswith("/ok") or text.startswith("/no")): + if not text: return ref = _cand_ref(message) if ref is None: - return self._say(_USAGE, reply_to=mid) + # a command needs a preview to point at; anything else is noise + return self._say(_USAGE, reply_to=mid) if text.startswith("/") else None cand_id, _preview_version = ref # the reply names the candidate… + if not text.startswith("/"): + # plain text replied into a thread is the owner answering in their + # own words — authorship is the authorization, so it just goes + from .worker import load_candidate + cand = load_candidate(self.share_dir, cand_id) + if cand is None or not cand.thread: + return self._say(f"nothing to reply to under {cand_id}", reply_to=mid) + ok = approval.own_message(self.identity, self.trust, self.share_dir, + self.transport, cand.thread, text) + return self._say(f"sent to {cand.peer_name}" if ok else + "not sent: thread closed or peer revoked", reply_to=mid) + if text.startswith("/ok"): parts = text.split() if len(parts) != 2: diff --git a/src/session_recall/share/thread.py b/src/session_recall/share/thread.py new file mode 100644 index 0000000..0912c25 --- /dev/null +++ b/src/session_recall/share/thread.py @@ -0,0 +1,108 @@ +"""Conversations: a question and everything that followed it. + +One-shot Q&A forces the asker to state the perfect question first time and gives +the owner no way to say "which OS?" or to answer from memory when the index has +nothing. A thread fixes both — and the owner writing a reply themselves is the +most valuable path of all, because often the real answer was never in the index. + +The thread id lives inside the *encrypted* body, not the signed envelope, so the +relay learns nothing about conversation structure — only that some address got +mail. Each side keeps its own log; there is no shared server-side state. + +Approval rule, and the reason the log records who authored each turn: +a machine-drafted answer needs /ok, while a message the owner typed is already +authorized by the act of typing it. Authorship is the gate. +""" + +import json +import os +import secrets +import stat +import time +from dataclasses import dataclass, field, asdict +from pathlib import Path + +THREADS_DIR = "threads" +MAX_TURNS = 40 # a thread this long has become a channel, not a question +IDLE_CLOSE_S = 14 * 86400 + + +@dataclass +class Turn: + author: str # "peer" | "owner" | "worker" + text: str + ts: float + kind: str = "message" # message | question | answer | decline + + +@dataclass +class Thread: + id: str + peer_address: str + peer_name: str + created_at: float + turns: list = field(default_factory=list) + closed: bool = False + + def append(self, author: str, text: str, kind: str = "message") -> None: + self.turns.append(asdict(Turn(author=author, text=text, ts=time.time(), + kind=kind))) + + def context(self, limit: int = 6) -> list: + """Recent turns for the composer. Untrusted every round: an injection + planted in turn 1 is still sitting here on turn 9, so callers must fence + this the same way they fence a fresh request.""" + return self.turns[-limit:] + + def should_close(self, now: float | None = None) -> bool: + now = time.time() if now is None else now + if len(self.turns) >= MAX_TURNS: + return True + last = self.turns[-1]["ts"] if self.turns else self.created_at + return now - last > IDLE_CLOSE_S + + +def new_id() -> str: + return secrets.token_hex(6) + + +def _path(share_dir: Path, thread_id: str) -> Path: + return share_dir / THREADS_DIR / f"{thread_id}.json" + + +def save(share_dir: Path, thread: Thread) -> None: + d = share_dir / THREADS_DIR + d.mkdir(parents=True, exist_ok=True) + d.chmod(0o700) + path = _path(share_dir, thread.id) + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, + stat.S_IRUSR | stat.S_IWUSR) + with os.fdopen(fd, "w") as f: + json.dump(asdict(thread), f, indent=2, ensure_ascii=False) + + +def load(share_dir: Path, thread_id: str) -> Thread | None: + path = _path(share_dir, thread_id) + if not path.exists(): + return None + return Thread(**json.loads(path.read_text())) + + +def open_or_create(share_dir: Path, thread_id: str, peer_address: str, + peer_name: str) -> Thread: + """Threads are created by whichever side speaks first; the other side + materialises the same id on receipt.""" + existing = load(share_dir, thread_id) + if existing is not None: + return existing + return Thread(id=thread_id, peer_address=peer_address, peer_name=peer_name, + created_at=time.time()) + + +def listing(share_dir: Path) -> list[Thread]: + d = share_dir / THREADS_DIR + if not d.is_dir(): + return [] + threads = [Thread(**json.loads(p.read_text())) for p in d.glob("*.json")] + return sorted(threads, key=lambda t: t.turns[-1]["ts"] if t.turns + else t.created_at, reverse=True) diff --git a/src/session_recall/share/worker.py b/src/session_recall/share/worker.py index 6505759..7454fbb 100644 --- a/src/session_recall/share/worker.py +++ b/src/session_recall/share/worker.py @@ -30,6 +30,7 @@ from nacl.encoding import RawEncoder from nacl import hash as nacl_hash +from . import thread as thread_mod from .ask import retrieval_query from .crypto import canonical from .envelope import Incoming, ShareState, open_incoming @@ -62,6 +63,7 @@ class Candidate: version: str = "" # /ok must quote this — approval of an exact blob problem: str = "" # symptoms the asker reported; last for compatibility composed: bool = False # True when an LLM wrote `text` from the fragments + thread: str = "" # conversation this answer belongs to def compute_version(self) -> str: digest = nacl_hash.blake2b( @@ -95,7 +97,8 @@ def _digest(chunks: list) -> str: def build_candidate(incoming: Incoming, searcher: Searcher, - allowed_projects: list[str], composer=None) -> Candidate: + allowed_projects: list[str], composer=None, + turns: list | None = None) -> Candidate: body = {"question": str(incoming.body.get("question", ""))[:2000], "task": str(incoming.body.get("task", ""))[:500], "problem": str(incoming.body.get("problem", ""))[:1000]} @@ -111,15 +114,27 @@ def build_candidate(incoming: Incoming, searcher: Searcher, text = ("(nothing found within shareable scope — " "see `session-recall share allow`)") else: - written = composer(body, chunks) if composer else None + written = composer(body, chunks, turns) if composer else None text, composed = (written, True) if written else (_digest(chunks), False) + # the scanner runs on the sources too: a composed answer can look clean + # while the model was reading secret-adjacent material, and the owner + # should know to open that one locally + findings = [asdict(f) for f in scan(text)] + seen = {(f["kind"], f["excerpt"]) for f in findings} + for c in chunks: + for f in scan(c["snippet"]): + if (f.kind, f.excerpt) not in seen: + seen.add((f.kind, f.excerpt)) + findings.append({**asdict(f), "in": "source"}) + cand = Candidate( id=secrets.token_hex(4), peer_name=incoming.peer.name, peer_address=incoming.peer.address, question=body["question"], task=body["task"], problem=body["problem"], reply_nonce=incoming.nonce, created_at=time.time(), text=text, - chunks=chunks, findings=[asdict(f) for f in scan(text)], composed=composed) + chunks=chunks, findings=findings, composed=composed, + thread=str(incoming.body.get("thread", ""))[:32]) cand.version = cand.compute_version() return cand @@ -134,8 +149,18 @@ def poll_once(identity: Identity, trust: TrustStore, state: ShareState, incoming = open_incoming(identity, trust, state, raw) if incoming is None or incoming.kind != "req": continue + thread_id = str(incoming.body.get("thread", ""))[:32] or thread_mod.new_id() + convo = thread_mod.open_or_create(share_dir, thread_id, + incoming.peer.address, incoming.peer.name) + if convo.closed or convo.should_close(): + convo.closed = True # a thread this old is a standing channel + thread_mod.save(share_dir, convo) + continue cand = build_candidate(incoming, searcher, trust.allowed_projects(), - composer=composer) + composer=composer, turns=convo.context()) + cand.thread = thread_id + convo.append("peer", cand.question, kind="question") + thread_mod.save(share_dir, convo) _write_candidate(share_dir, cand) out.append(cand) return out diff --git a/tests/test_share_envelope.py b/tests/test_share_envelope.py index 804567b..f3b5615 100644 --- a/tests/test_share_envelope.py +++ b/tests/test_share_envelope.py @@ -34,7 +34,7 @@ def test_request_roundtrip(world): assert got is not None assert got.kind == "req" assert got.body == {"question": "how did you fix the CI?", "task": "debug", - "problem": ""} + "problem": "", "thread": ""} assert got.peer.name == "egor" @@ -46,7 +46,7 @@ def test_response_roundtrip(world): egor_state = ShareState(maxim_trust.path.parent / "egor-state.json") got_resp = open_incoming(egor, egor_trust, egor_state, resp) assert got_resp.kind == "resp" - assert got_resp.body == {"text": "the answer"} + assert got_resp.body == {"text": "the answer", "thread": "", "sources": 0} assert got_resp.in_reply_to == got.nonce diff --git a/tests/test_share_threads.py b/tests/test_share_threads.py new file mode 100644 index 0000000..9b80372 --- /dev/null +++ b/tests/test_share_threads.py @@ -0,0 +1,144 @@ +"""Conversations: follow-ups without re-asking, and the owner answering in +their own words — which is the whole point, because the real answer is often +not in the index at all.""" + +from dataclasses import dataclass + +import pytest + +from session_recall.share import approval, compose, thread as thread_mod +from session_recall.share import identity as identity_mod +from session_recall.share.envelope import ShareState, make_request, open_incoming +from session_recall.share.transport import InMemoryTransport +from session_recall.share.trust import Peer, TrustStore +from session_recall.share.worker import load_candidate, poll_once + + +@dataclass +class FakeAnchor: + session_id: str = "sess-1234567890" + uuid: str = "u1" + role: str = "assistant" + snippet: str = "запинили mcp<2 в PR #13" + score: float = 0.9 + project: str = "session-recall" + when: int = 1785000000 + source: str = "claude" + + +@pytest.fixture +def world(tmp_path): + maxim = identity_mod.create(tmp_path / "maxim", "maxim") + egor = identity_mod.create(tmp_path / "egor", "egor") + mt = TrustStore(tmp_path / "maxim" / "trust.json") + b = egor.public_bundle() + mt.add(Peer(name=b["name"], address=b["address"], + sign_pk=b["sign_pk"], box_pk=b["box_pk"])) + mt.allow_project("session-recall") + et = TrustStore(tmp_path / "egor" / "trust.json") + mb = maxim.public_bundle() + et.add(Peer(name=mb["name"], address=mb["address"], + sign_pk=mb["sign_pk"], box_pk=mb["box_pk"])) + return {"maxim": maxim, "egor": egor, "mt": mt, "et": et, + "state": ShareState(tmp_path / "maxim" / "state.json"), + "transport": InMemoryTransport(), + "mdir": tmp_path / "maxim", "edir": tmp_path / "egor"} + + +def _ask(w, question="как чинили CI?", thread=""): + w["transport"].post_mail(w["maxim"].address, make_request( + w["egor"], w["maxim"].public_bundle(), question, + task="поднимаю relay", problem="ModuleNotFoundError", thread=thread)) + return poll_once(w["maxim"], w["mt"], w["state"], w["transport"], + lambda q, k: [FakeAnchor()], w["mdir"]) + + +def test_first_ask_opens_a_thread(world): + cand = _ask(world)[0] + assert cand.thread + convo = thread_mod.load(world["mdir"], cand.thread) + assert convo.peer_name == "egor" + assert convo.turns[0]["author"] == "peer" + + +def test_follow_up_joins_the_same_thread(world): + first = _ask(world)[0] + second = _ask(world, "а пин или миграция?", thread=first.thread)[0] + assert second.thread == first.thread + convo = thread_mod.load(world["mdir"], first.thread) + assert len(convo.turns) == 2 + + +def test_history_reaches_the_composer(world): + first = _ask(world)[0] + seen = {} + world["transport"].post_mail(world["maxim"].address, make_request( + world["egor"], world["maxim"].public_bundle(), "а точнее?", + thread=first.thread)) + + def composer(req, chunks, turns=None): + seen["turns"] = turns + return "ответ с учётом предыдущего" + + poll_once(world["maxim"], world["mt"], world["state"], world["transport"], + lambda q, k: [FakeAnchor()], world["mdir"], composer=composer) + assert seen["turns"], "the composer must see what was already asked" + assert "как чинили CI?" in seen["turns"][0]["text"] + + +def test_owner_can_answer_in_their_own_words(world): + """No approval step — typing it IS the authorization.""" + cand = _ask(world)[0] + ok = approval.own_message(world["maxim"], world["mt"], world["mdir"], + world["transport"], cand.thread, + "там дело было не в пине, а в версии ноды") + assert ok + inbox = world["transport"].fetch_mail(world["egor"].address) + got = open_incoming(world["egor"], world["et"], + ShareState(world["edir"] / "state.json"), inbox[0]) + assert got.body["text"].startswith("там дело было") + assert got.body["thread"] == cand.thread + + +def test_owner_message_fails_closed_on_revoked_peer(world): + cand = _ask(world)[0] + world["mt"].revoke("egor") + assert approval.own_message(world["maxim"], world["mt"], world["mdir"], + world["transport"], cand.thread, "ответ") is False + assert world["transport"].fetch_mail(world["egor"].address) == [] + + +def test_closed_thread_refuses_new_turns(world): + cand = _ask(world)[0] + convo = thread_mod.load(world["mdir"], cand.thread) + convo.closed = True + thread_mod.save(world["mdir"], convo) + assert _ask(world, "ещё вопрос", thread=cand.thread) == [] + assert approval.own_message(world["maxim"], world["mt"], world["mdir"], + world["transport"], cand.thread, "ответ") is False + + +def test_thread_closes_after_too_many_turns(world): + convo = thread_mod.Thread(id="abc", peer_address="x", peer_name="egor", + created_at=0.0) + for i in range(thread_mod.MAX_TURNS): + convo.append("peer", f"q{i}") + assert convo.should_close() + + +# -- what actually crosses the wire ------------------------------------------ +def test_response_carries_no_transcript_or_session_ids(world): + cand = _ask(world)[0] + approval.approve(world["mdir"], cand.id, cand.version) + approval.dispatch(world["maxim"], world["mt"], world["mdir"], + world["transport"]) + inbox = world["transport"].fetch_mail(world["egor"].address) + got = open_incoming(world["egor"], world["et"], + ShareState(world["edir"] / "state.json"), inbox[0]) + assert got.body["sources"] == 1 # grounded-vs-guess signal survives + assert "session_id" not in got.body and "refs" not in got.body + assert "sess-1234567890" not in str(got.body) + + +def test_composer_is_told_not_to_cite_sessions(): + assert "Never cite session ids" in compose._SYSTEM diff --git a/tests/test_share_worker.py b/tests/test_share_worker.py index 508f5e0..260fbe4 100644 --- a/tests/test_share_worker.py +++ b/tests/test_share_worker.py @@ -159,7 +159,7 @@ def test_composer_writes_the_answer(world): task="поднимаю relay", problem="ModuleNotFoundError fastmcp")) seen = {} - def composer(req, chunks): + def composer(req, chunks, turns=None): seen.update(req) return "запинили mcp<2, смотри session-recall · 07876709" @@ -177,7 +177,7 @@ def test_composer_failure_falls_back_to_digest(world): _ask(egor, maxim, transport) cand = poll_once(maxim, trust, state, transport, lambda q, k: [_anchor("session-recall")], sdir, - composer=lambda req, chunks: None)[0] + composer=lambda req, chunks, turns=None: None)[0] assert cand.composed is False assert "how we fixed it" in cand.text @@ -200,7 +200,7 @@ def test_secret_in_composed_answer_still_flagged(world): _ask(egor, maxim, transport) cand = poll_once(maxim, trust, state, transport, lambda q, k: [_anchor("session-recall")], sdir, - composer=lambda req, chunks: "ключ был AKIAIOSFODNN7EXAMPLE")[0] + composer=lambda req, chunks, turns=None: "ключ был AKIAIOSFODNN7EXAMPLE")[0] assert any(f["kind"] == "aws-access-key" for f in cand.findings)