diff --git a/solstone/apps/chat/copy.py b/solstone/apps/chat/copy.py index 82c1e0afe..ac96f8a4a 100644 --- a/solstone/apps/chat/copy.py +++ b/solstone/apps/chat/copy.py @@ -48,6 +48,10 @@ CHAT_THINKING_SETTING_HELP = "sol does some thinking before replying. Choose how # Brand rule: "solstone support", never "sol pbc". CHAT_OFFER_SUPPORT_PROMPT = "Sounds like something's not working — want me to bring in solstone support?" CHAT_OFFER_SUPPORT_DECLINE = "Okay — I'll keep this local. Tell me if you'd like me to bring in solstone support after all." +# Deterministic support-draft-ready marker (backend-emitted; rides the sol_message +# text on a clean support talent_finished with a pending draft). Brand: "solstone +# support". Backend-only — no chat_copy.js twin (no card renders this lode). +CHAT_SUPPORT_DRAFT_READY = "Here's the support request I put together — look it over before anything goes to solstone support." # fmt: on from typing import Literal diff --git a/solstone/apps/support/call.py b/solstone/apps/support/call.py index 5bad7514a..5231e46e9 100644 --- a/solstone/apps/support/call.py +++ b/solstone/apps/support/call.py @@ -105,6 +105,36 @@ def _print_dry_run_preview( typer.echo("--- End dry run ---") +def _capture_draft( + client: ConveyClient, + *, + verb: str, + payload: dict, + diagnostics_snapshot: dict | None, +) -> None: + """POST the exact submit-path payload to the dormant draft-capture endpoint. + + Non-fatal: a capture failure prints a notice and returns; it never raises, so a + dry-run / no-network reply stays infallible. ConveyUnreachableError is a + ConveyClientError subclass, so the single except covers unreachable too. + """ + try: + client.request( + "POST", + "/app/support/api/draft", + json={ + "verb": verb, + "payload": payload, + "diagnostics_snapshot": diagnostics_snapshot, + }, + ) + except ConveyClientError: + typer.echo( + "(Draft not captured — solstone wasn't reachable to save it for review.)", + err=True, + ) + + def _local_build_identity() -> dict: try: ver = _pkg_version("solstone") @@ -233,6 +263,21 @@ def create( diagnostics=diagnostics, portal_url=config["portal_url"], ) + _capture_draft( + client, + verb="create", + payload={ + "subject": subject, + "description": description, + "product": product, + "severity": severity, + "category": category, + "user_context": diagnostics, + "auto_context": False, + "anonymous": anonymous, + }, + diagnostics_snapshot=diagnostics, + ) return # Step 1: KB-first — search before filing @@ -368,12 +413,35 @@ def show( def reply( ticket_id: int = typer.Argument(..., help="Ticket ID."), body: str = typer.Option(..., "--body", "-b", help="Reply content."), + submit: bool = typer.Option( + True, + "--submit/--no-submit", + help=( + "Send the reply to solstone support (default). Pass --no-submit to " + "capture a draft for review without contacting the portal." + ), + ), yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation."), ) -> None: """Reply to a ticket.""" client = get_client() _check_enabled(client) + if not submit: + # No-network capture: stash the exact submit body as a draft. The portal is + # not contacted at all; nothing is sent. + typer.echo( + "DRY RUN — nothing was sent. Re-run with --submit to actually send this." + ) + typer.echo(f"Reply to ticket #{ticket_id}:\n{body}") + _capture_draft( + client, + verb="reply", + payload={"ticket_id": ticket_id, "content": body}, + diagnostics_snapshot=None, + ) + return + if not yes: typer.echo(f"Reply to ticket #{ticket_id}:\n{body}\n") if not typer.confirm("Send this reply?"): @@ -468,6 +536,12 @@ def feedback( diagnostics=diagnostics, portal_url=config["portal_url"], ) + _capture_draft( + client, + verb="feedback", + payload={"body": body, "product": product, "anonymous": anonymous}, + diagnostics_snapshot=diagnostics, + ) return if not yes: diff --git a/solstone/apps/support/routes.py b/solstone/apps/support/routes.py index 4d5377bcd..3c4519266 100644 --- a/solstone/apps/support/routes.py +++ b/solstone/apps/support/routes.py @@ -9,10 +9,13 @@ Provides API endpoints consumed by workspace.html and the background service. from __future__ import annotations import logging +import time +import uuid from typing import Any from flask import Blueprint, jsonify, request +from solstone.convey.chat_stream import append_chat_event, day_for_ts from solstone.convey.reasons import ( FEATURE_UNAVAILABLE, INVALID_REQUEST_VALUE, @@ -58,6 +61,46 @@ def config() -> Any: ) +@support_bp.route("/api/draft", methods=["POST"]) +def capture_draft() -> Any: + """Capture a structured support draft into the chat stream — no portal I/O. + + Dormant cutover seam: the support CLI POSTs the exact submit-path payload here + on a dry-run / no-network capture. Emits a backend-only ``support_draft`` chat + event and returns its ``draft_id``. Nothing is sent to solstone support. + """ + if not _enabled(): + return error_response(FEATURE_UNAVAILABLE, detail="Support is disabled") + + payload = request.get_json(force=True) + verb = payload.get("verb") + draft_payload = payload.get("payload") + if verb is None or draft_payload is None: + return error_response( + MISSING_REQUIRED_FIELD, detail="verb and payload are required" + ) + if verb not in {"create", "feedback", "reply"} or not isinstance( + draft_payload, dict + ): + return error_response( + INVALID_REQUEST_VALUE, + detail="verb must be create|feedback|reply and payload must be an object", + ) + + ts = int(time.time() * 1000) + draft_id = uuid.uuid4().hex + append_chat_event( + "support_draft", + ts=ts, + draft_id=draft_id, + captured_day=day_for_ts(ts), + verb=verb, + payload=draft_payload, + diagnostics_snapshot=payload.get("diagnostics_snapshot"), + ) + return jsonify({"draft_id": draft_id}) + + @support_bp.route("/api/register", methods=["POST"]) def register() -> Any: """Register with the support portal and return the handle.""" diff --git a/solstone/convey/chat.py b/solstone/convey/chat.py index 59ff45b51..f61cd5885 100644 --- a/solstone/convey/chat.py +++ b/solstone/convey/chat.py @@ -30,6 +30,7 @@ from solstone.apps.chat.copy import ( CHAT_CLOSER_TALENT_ERRORED_GENERIC, CHAT_OFFER_SUPPORT_DECLINE, CHAT_OFFER_SUPPORT_PROMPT, + CHAT_SUPPORT_DRAFT_READY, ) from solstone.convey.chat_stream import ( append_chat_event, @@ -479,6 +480,23 @@ def _on_cortex_finish(message: dict[str, Any]) -> None: ) requested_target = None requested_task = None + draft: dict[str, Any] | None = None + if ( + trigger_type == "talent_finished" + and trigger.get("name") in OUTBOUND_TALENTS + and _support_draft_state(_today_day()) == "pending" + ): + latest_draft = _latest_support_draft(_today_day()) + if latest_draft is not None: + message_text = CHAT_SUPPORT_DRAFT_READY + draft = { + "draft_id": latest_draft.get("draft_id"), + "verb": latest_draft.get("verb"), + "payload": latest_draft.get("payload"), + "diagnostics_snapshot": latest_draft.get( + "diagnostics_snapshot" + ), + } if requested_target in OUTBOUND_TALENTS: consent = _support_consent_state(_today_day()) if consent == "none": @@ -502,6 +520,8 @@ def _on_cortex_finish(message: dict[str, Any]) -> None: sol_message_fields["thinking"] = thinking if offer is not None: sol_message_fields["offer"] = offer + if draft is not None: + sol_message_fields["draft"] = draft append_chat_event( "sol_message", **sol_message_fields, @@ -1774,3 +1794,41 @@ def _support_consent_state(day: str) -> str: }: return "pending" return "none" + + +def _support_draft_state(day: str) -> str: + """Deterministic, day-scoped support-draft state for the conversation. + + Mirrors _support_consent_state. Walks history tracking the LATEST support_draft. + Returns: + "submitted" — a `result` event back-references the latest draft's draft_id + (forward seam; no `result` writer exists yet, so this is + present-but-inert today — the next lode adds only that writer). + "pending" — a support_draft exists and is not yet submitted. + "none" — no support_draft. + Precedence: submitted, then pending, else none. + """ + latest_draft_id: str | None = None + result_draft_ids: set[str] = set() + for event in read_chat_events(day): + kind = event.get("kind") + if kind == "support_draft": + latest_draft_id = str(event.get("draft_id") or "") + elif kind == "result": + result_draft_id = str(event.get("draft_id") or "") + if result_draft_id: + result_draft_ids.add(result_draft_id) + if latest_draft_id and latest_draft_id in result_draft_ids: + return "submitted" + if latest_draft_id: + return "pending" + return "none" + + +def _latest_support_draft(day: str) -> dict[str, Any] | None: + """Return the most recent support_draft event for ``day``, or None.""" + latest: dict[str, Any] | None = None + for event in read_chat_events(day): + if event.get("kind") == "support_draft": + latest = event + return latest diff --git a/solstone/convey/chat_stream.py b/solstone/convey/chat_stream.py index 00767c89d..3bfd1ba66 100644 --- a/solstone/convey/chat_stream.py +++ b/solstone/convey/chat_stream.py @@ -27,7 +27,7 @@ _CHAT_STREAM = "chat" _SEGMENT_WINDOW_MS = 300_000 _APPENDED_CHAT_PATHS: dict[int, Path] = {} # owner_message may carry optional `source`; extras flow through unchanged. -# sol_message may carry optional `thinking` and `offer`; talent_finished may +# sol_message may carry optional `thinking`, `offer`, and `draft`; talent_finished may # carry optional `thinking`; extras flow through unchanged and are not part of # the required-field tuples below. _VALID_KINDS = { @@ -60,6 +60,13 @@ _VALID_KINDS = { "sol_chat_request_superseded": ("request_id", "replaced_by"), "owner_chat_open": ("request_id", "surface"), "owner_chat_dismissed": ("request_id", "surface", "reason"), + "support_draft": ( + "draft_id", + "captured_day", + "verb", + "payload", + "diagnostics_snapshot", + ), } _TRIGGER_KINDS = { "owner_message", @@ -231,6 +238,7 @@ def reduce_chat_state(day: str) -> dict[str, Any]: "requested_target": event["requested_target"], "requested_task": event["requested_task"], "offer": event.get("offer"), + "draft": event.get("draft"), } continue @@ -347,6 +355,11 @@ def _day_for_ts(ts_ms: int) -> str: return _ts_to_local_datetime(ts_ms).strftime("%Y%m%d") +def day_for_ts(ts: int) -> str: + """Return the chat-stream day bucket for an event timestamp (ms).""" + return _day_for_ts(ts) + + def _current_segment_key(day: str, ts_ms: int) -> str: event_dt = _ts_to_local_datetime(ts_ms) existing = _chat_segments(day) diff --git a/tests/test_app_support.py b/tests/test_app_support.py index 806fbab8b..c809eb38a 100644 --- a/tests/test_app_support.py +++ b/tests/test_app_support.py @@ -6,18 +6,20 @@ import json import os import re -from datetime import datetime, timedelta +from datetime import date, datetime, timedelta import pytest import requests from typer.testing import CliRunner -from solstone.apps.support.call import app +from solstone.apps.support.call import app as support_cli_app from solstone.apps.support.diagnostics import collect_recent_errors +from solstone.convey.chat_stream import read_chat_events DRY_RUN_BANNER = ( "DRY RUN — nothing was sent. Re-run with --submit to actually file this." ) +STUB_DIAGNOSTICS = {"version": "9.9.9", "revision": "abc1234", "sample": "value"} _LEAK_NEEDLES = ("private_key", "keypair", "access_token") @@ -99,7 +101,7 @@ def _enable_support_cli(monkeypatch): def _stub_dry_run_context(monkeypatch): monkeypatch.setattr( "solstone.apps.support.diagnostics.collect_all", - lambda: {"version": "9.9.9", "revision": "abc1234", "sample": "value"}, + lambda: dict(STUB_DIAGNOSTICS), ) monkeypatch.setattr( "solstone.apps.support.portal._get_portal_url_from_settings", @@ -107,6 +109,14 @@ def _stub_dry_run_context(monkeypatch): ) +def _today_support_drafts() -> list[dict]: + return [ + event + for event in read_chat_events(date.today().strftime("%Y%m%d")) + if event["kind"] == "support_draft" + ] + + def test_config_route_reports_enabled_and_portal_url(support_client, monkeypatch): monkeypatch.setattr("solstone.apps.support.portal.is_enabled", lambda: True) monkeypatch.setattr( @@ -428,8 +438,8 @@ def test_cli_dry_run_never_constructs_portal_client(cli, monkeypatch): monkeypatch.setattr("solstone.apps.support.portal.get_client", blocked_get_client) # Convey localhost only: no external support.solstone.app, no account mint. - feedback_result = cli.invoke(app, ["feedback", "-b", "x"]) - create_result = cli.invoke(app, ["create", "-s", "s", "-d", "d"]) + feedback_result = cli.invoke(support_cli_app, ["feedback", "-b", "x"]) + create_result = cli.invoke(support_cli_app, ["create", "-s", "s", "-d", "d"]) assert feedback_result.exit_code == 0 assert create_result.exit_code == 0 @@ -440,7 +450,7 @@ def test_cli_feedback_dry_run_preview_content(cli, monkeypatch): _enable_support_cli(monkeypatch) _stub_dry_run_context(monkeypatch) - result = cli.invoke(app, ["feedback", "-b", "owner feedback"]) + result = cli.invoke(support_cli_app, ["feedback", "-b", "owner feedback"]) assert result.exit_code == 0 assert result.stdout.splitlines()[0] == DRY_RUN_BANNER @@ -454,7 +464,7 @@ def test_cli_create_dry_run_preview_content(cli, monkeypatch): _enable_support_cli(monkeypatch) _stub_dry_run_context(monkeypatch) - result = cli.invoke(app, ["create", "-s", "subj", "-d", "desc"]) + result = cli.invoke(support_cli_app, ["create", "-s", "subj", "-d", "desc"]) assert result.exit_code == 0 assert result.stdout.splitlines()[0] == DRY_RUN_BANNER @@ -462,11 +472,128 @@ def test_cli_create_dry_run_preview_content(cli, monkeypatch): assert "Subject: subj" in result.stdout +def test_cli_create_dry_run_captures_exact_submit_body(cli, monkeypatch): + _enable_support_cli(monkeypatch) + _stub_dry_run_context(monkeypatch) + + result = cli.invoke( + support_cli_app, + ["create", "-s", "Subj", "-d", "Desc", "--anonymous"], + ) + + assert result.exit_code == 0 + assert "DRY RUN" in result.stdout + drafts = _today_support_drafts() + assert len(drafts) == 1 + draft = drafts[0] + assert draft["verb"] == "create" + assert draft["diagnostics_snapshot"] == STUB_DIAGNOSTICS + assert set(draft["payload"]) == { + "subject", + "description", + "product", + "severity", + "category", + "user_context", + "auto_context", + "anonymous", + } + assert draft["payload"]["subject"] == "Subj" + assert draft["payload"]["description"] == "Desc" + assert draft["payload"]["product"] == "solstone" + assert draft["payload"]["severity"] == "medium" + assert draft["payload"]["category"] is None + assert draft["payload"]["user_context"] == STUB_DIAGNOSTICS + assert draft["payload"]["auto_context"] is False + assert draft["payload"]["anonymous"] is True + + +def test_cli_feedback_dry_run_captures_submit_body(cli, monkeypatch): + _enable_support_cli(monkeypatch) + _stub_dry_run_context(monkeypatch) + + result = cli.invoke(support_cli_app, ["feedback", "-b", "Nice", "--anonymous"]) + + assert result.exit_code == 0 + assert "DRY RUN" in result.stdout + drafts = _today_support_drafts() + assert len(drafts) == 1 + draft = drafts[0] + assert draft["verb"] == "feedback" + assert draft["payload"] == { + "body": "Nice", + "product": "solstone", + "anonymous": True, + } + assert draft["diagnostics_snapshot"] == STUB_DIAGNOSTICS + + +def test_cli_reply_no_submit_captures_draft_without_submit(cli, monkeypatch): + _enable_support_cli(monkeypatch) + + result = cli.invoke( + support_cli_app, + ["reply", "42", "-b", "more info", "--no-submit"], + ) + + assert result.exit_code == 0 + assert "DRY RUN" in result.stdout + drafts = _today_support_drafts() + assert len(drafts) == 1 + draft = drafts[0] + assert draft["verb"] == "reply" + assert draft["payload"] == {"ticket_id": 42, "content": "more info"} + assert draft["diagnostics_snapshot"] is None + + +def test_cli_reply_default_still_submits(cli, monkeypatch): + replies: list[tuple[int, str]] = [] + + class ReplyClient: + def reply_to_ticket(self, ticket_id: int, content: str) -> dict: + replies.append((ticket_id, content)) + return {"id": 1} + + _enable_support_cli(monkeypatch) + monkeypatch.setattr( + "solstone.apps.support.routes._get_client", + lambda: ReplyClient(), + ) + + result = cli.invoke(support_cli_app, ["reply", "42", "-b", "hi", "--yes"]) + + assert result.exit_code == 0 + assert "Reply sent to ticket #42." in result.stdout + assert replies == [(42, "hi")] + assert _today_support_drafts() == [] + + +def test_cli_draft_capture_failure_is_nonfatal_and_visible(cli, monkeypatch): + from solstone.think.convey_client import ConveyClient, ConveyClientError + + original_request = ConveyClient.request + + def fail_draft(self, method, path, **kwargs): + if path == "/app/support/api/draft": + raise ConveyClientError("draft failed") + return original_request(self, method, path, **kwargs) + + _enable_support_cli(monkeypatch) + _stub_dry_run_context(monkeypatch) + monkeypatch.setattr(ConveyClient, "request", fail_draft) + + result = cli.invoke(support_cli_app, ["create", "-s", "Subj", "-d", "Desc"]) + + assert result.exit_code == 0 + assert "DRY RUN" in result.stdout + assert "(Draft not captured" in result.stderr + + def test_cli_feedback_yes_without_submit_is_still_dry_run(cli, monkeypatch): _enable_support_cli(monkeypatch) _stub_dry_run_context(monkeypatch) - result = cli.invoke(app, ["feedback", "-b", "x", "-y"]) + result = cli.invoke(support_cli_app, ["feedback", "-b", "x", "-y"]) assert result.exit_code == 0 assert result.stdout.splitlines()[0] == DRY_RUN_BANNER @@ -482,7 +609,7 @@ def test_cli_feedback_submit_calls_tool(cli, monkeypatch): _enable_support_cli(monkeypatch) monkeypatch.setattr("solstone.apps.support.tools.support_feedback", recorder) - result = cli.invoke(app, ["feedback", "-b", "hello", "--submit", "-y"]) + result = cli.invoke(support_cli_app, ["feedback", "-b", "hello", "--submit", "-y"]) assert result.exit_code == 0 assert len(captured) == 1 @@ -505,7 +632,8 @@ def test_cli_create_submit_calls_tool(cli, monkeypatch): monkeypatch.setattr("solstone.apps.support.tools.support_create", recorder) result = cli.invoke( - app, ["create", "-s", "S", "-d", "D", "--skip-kb", "--submit", "-y"] + support_cli_app, + ["create", "-s", "S", "-d", "D", "--skip-kb", "--submit", "-y"], ) assert result.exit_code == 0 @@ -531,7 +659,7 @@ def test_cli_create_submit_confirm_negative_does_not_call_tool(cli, monkeypatch) monkeypatch.setattr("solstone.apps.support.tools.support_create", recorder) result = cli.invoke( - app, + support_cli_app, ["create", "-s", "S", "-d", "D", "--skip-kb", "--submit"], input="n\n", ) @@ -544,7 +672,7 @@ def test_cli_create_submit_confirm_negative_does_not_call_tool(cli, monkeypatch) def test_search_disabled_prints_byte_locked_message(cli, monkeypatch): monkeypatch.setattr("solstone.apps.support.portal.is_enabled", lambda: False) - result = cli.invoke(app, ["search", "foo"]) + result = cli.invoke(support_cli_app, ["search", "foo"]) assert result.exit_code == 1 assert result.stdout == "" @@ -554,7 +682,7 @@ def test_search_disabled_prints_byte_locked_message(cli, monkeypatch): def test_diagnose_is_ungated_when_disabled(cli, monkeypatch): monkeypatch.setattr("solstone.apps.support.portal.is_enabled", lambda: False) - result = cli.invoke(app, ["diagnose"]) + result = cli.invoke(support_cli_app, ["diagnose"]) assert result.exit_code == 0 assert "# Local Diagnostics" in result.stdout @@ -571,7 +699,7 @@ def test_search_convey_down_prints_notice(monkeypatch): ) monkeypatch.setattr("solstone.apps.support.call.get_client", lambda: client) - result = CliRunner().invoke(app, ["search", "foo"]) + result = CliRunner().invoke(support_cli_app, ["search", "foo"]) assert result.exit_code == 1 assert result.stdout == "" @@ -591,7 +719,7 @@ def test_diagnose_convey_down_prints_build_identity_then_notice(monkeypatch): ) monkeypatch.setattr("solstone.apps.support.call.get_client", lambda: client) - result = CliRunner().invoke(app, ["diagnose"]) + result = CliRunner().invoke(support_cli_app, ["diagnose"]) assert result.exit_code == 1 assert result.stdout.startswith("# Local Diagnostics") @@ -614,7 +742,7 @@ def test_article_portal_error_is_reason_message(cli, monkeypatch): lambda: ArticleClient(), ) - result = cli.invoke(app, ["article", "intro"]) + result = cli.invoke(support_cli_app, ["article", "intro"]) assert result.exit_code == 1 assert result.stdout == "" @@ -632,7 +760,7 @@ def test_list_success_renders_rows(cli, monkeypatch): lambda: ListClient(), ) - result = cli.invoke(app, ["list"]) + result = cli.invoke(support_cli_app, ["list"]) assert result.exit_code == 0 assert " # 7 [open ] Hi" in result.stdout @@ -662,7 +790,7 @@ def test_attach_success_and_skip_via_fake_client(monkeypatch, tmp_path): fake = FakeClient() monkeypatch.setattr("solstone.apps.support.call.get_client", lambda: fake) - result = CliRunner().invoke(app, ["attach", "42", str(a), str(b), "-y"]) + result = CliRunner().invoke(support_cli_app, ["attach", "42", str(a), str(b), "-y"]) assert result.exit_code == 0 assert "Attached: a.png (id: 1)" in result.stdout @@ -683,7 +811,9 @@ def test_attach_success_and_skip_via_fake_client(monkeypatch, tmp_path): skipping_fake = SkippingFakeClient() monkeypatch.setattr("solstone.apps.support.call.get_client", lambda: skipping_fake) - skipped = CliRunner().invoke(app, ["attach", "42", str(a), str(b), "-y"]) + skipped = CliRunner().invoke( + support_cli_app, ["attach", "42", str(a), str(b), "-y"] + ) assert skipped.exit_code == 0 assert "Attached: b.png (id: 2)" in skipped.stdout @@ -992,7 +1122,7 @@ def test_window_excludes_old_and_cli_empty_state(cli, tmp_path, monkeypatch): assert collect_recent_errors() == [] - result = cli.invoke(app, ["diagnose"]) + result = cli.invoke(support_cli_app, ["diagnose"]) assert result.exit_code == 0 assert "No recent errors." in result.stdout @@ -1026,7 +1156,7 @@ def test_cli_count_matches_printed_rows(cli, tmp_path, monkeypatch): ] _write_log(health_dir, "count.log", lines) - result = cli.invoke(app, ["diagnose"]) + result = cli.invoke(support_cli_app, ["diagnose"]) assert result.exit_code == 0 assert f"Recent errors ({count}):" in result.stdout diff --git a/tests/test_chat_copy.py b/tests/test_chat_copy.py index ae88e2cf0..40973dcd2 100644 --- a/tests/test_chat_copy.py +++ b/tests/test_chat_copy.py @@ -177,6 +177,15 @@ def test_closer_constants_byte_parity(): assert "try again" in chat_copy.CHAT_CLOSER_SUPPORT_SEND_FAILED.lower() +def test_support_draft_ready_copy_bytes(): + assert chat_copy.CHAT_SUPPORT_DRAFT_READY == ( + "Here's the support request I put together — look it over before anything " + "goes to solstone support." + ) + assert "solstone support" in chat_copy.CHAT_SUPPORT_DRAFT_READY + assert "sol pbc" not in chat_copy.CHAT_SUPPORT_DRAFT_READY + + def test_chat_placeholder_css_present(): css = Path("solstone/convey/static/app.css").read_text(encoding="utf-8") diff --git a/tests/test_chat_schema.py b/tests/test_chat_schema.py index 32f5efe44..00a4eb532 100644 --- a/tests/test_chat_schema.py +++ b/tests/test_chat_schema.py @@ -19,3 +19,10 @@ def test_chat_schema_has_no_offer_affordance() -> None: assert "offer" not in schema["properties"] assert "offer" not in schema["required"] + + +def test_chat_schema_has_no_draft_affordance() -> None: + schema = _load_chat_schema() + + assert "draft" not in schema["properties"] + assert "draft" not in schema["required"] diff --git a/tests/test_chat_stream.py b/tests/test_chat_stream.py index d4eed9ad8..ffbd70657 100644 --- a/tests/test_chat_stream.py +++ b/tests/test_chat_stream.py @@ -168,6 +168,26 @@ def test_chat_queue_depth_event_validates_depth(tmp_path, monkeypatch): append_chat_event("chat_queue_depth", ts=ts + 2, depth="3") +def test_support_draft_event_round_trips(tmp_path, monkeypatch): + _setup_journal(tmp_path, monkeypatch) + ts = _ms(2026, 4, 20, 12, 0, 0) + + event = append_chat_event( + "support_draft", + ts=ts, + draft_id="draft-1", + captured_day="20260420", + verb="create", + payload={"subject": "help"}, + diagnostics_snapshot=None, + ) + + events = read_chat_events("20260420") + assert events == [event] + assert events[0]["kind"] == "support_draft" + assert events[0]["draft_id"] == "draft-1" + + def test_chat_error_preserves_optional_provider(tmp_path, monkeypatch): _setup_journal(tmp_path, monkeypatch) ts = _ms(2026, 4, 20, 12, 0, 0) @@ -486,6 +506,7 @@ def test_reduce_chat_state_extracts_latest_sol_and_active_talents( "requested_target": "exec", "requested_task": "compare drafts", "offer": None, + "draft": None, } assert reduced["active_talents"] == [ { @@ -541,6 +562,46 @@ def test_reduce_chat_state_includes_offer_and_clears_on_later_sol_message( assert reduced["latest_sol_message"]["offer"] is None +def test_reduce_chat_state_includes_draft_and_clears_on_later_sol_message( + tmp_path, monkeypatch +): + _setup_journal(tmp_path, monkeypatch) + start = _ms(2026, 4, 20, 12, 0, 0) + draft = { + "draft_id": "draft-1", + "verb": "create", + "payload": {"subject": "help"}, + "diagnostics_snapshot": {"version": "9.9.9"}, + } + + append_chat_event( + "sol_message", + ts=start, + use_id="chat-draft", + text="draft ready", + notes="support draft", + requested_target=None, + requested_task=None, + draft=draft, + ) + + reduced = reduce_chat_state("20260420") + assert reduced["latest_sol_message"]["draft"] == draft + + append_chat_event( + "sol_message", + ts=start + 1_000, + use_id="chat-answer", + text="done", + notes="answered", + requested_target=None, + requested_task=None, + ) + + reduced = reduce_chat_state("20260420") + assert reduced["latest_sol_message"]["draft"] is None + + def test_reduce_chat_state_returns_last_queue_depth(tmp_path, monkeypatch): _setup_journal(tmp_path, monkeypatch) start = _ms(2026, 4, 20, 12, 0, 0) diff --git a/tests/test_convey_chat.py b/tests/test_convey_chat.py index e2d7f8e98..83d8831c8 100644 --- a/tests/test_convey_chat.py +++ b/tests/test_convey_chat.py @@ -11,8 +11,10 @@ import pytest from flask import Flask from solstone.apps.chat.copy import ( + CHAT_CLOSER_SUPPORT_SEND_FAILED, CHAT_OFFER_SUPPORT_DECLINE, CHAT_OFFER_SUPPORT_PROMPT, + CHAT_SUPPORT_DRAFT_READY, ) from solstone.convey.chat import ChatSpawnResult, chat_bp from solstone.convey.chat_stream import append_chat_event, read_chat_events @@ -82,6 +84,12 @@ def _set_current_chat(chat_module, logical_use_id: str, raw_use_id: str | None) } +def _set_current_chat_trigger(chat_module, trigger: dict) -> None: + _set_current_chat(chat_module, "logical-chat", "raw-chat") + with chat_module._state_lock: + chat_module._current_chat_state["trigger"] = trigger + + def _talent_route_result(target: str, task: str = "file a ticket") -> dict: return { "message": "let me file that", @@ -353,6 +361,246 @@ def test_non_outbound_routes_are_not_gated(chat_client, monkeypatch, target): assert "offer" not in sol_message +def test_clean_support_finish_with_pending_draft_emits_marker(chat_client, monkeypatch): + import solstone.convey.chat as chat + + monkeypatch.setattr("solstone.convey.chat._run_next_action", lambda _action: None) + monkeypatch.setattr("solstone.convey.chat._emit_finish", lambda *_args: None) + monkeypatch.setattr("solstone.convey.chat._emit_error", lambda *_args: None) + draft_payload = {"subject": "Subj", "description": "Desc"} + diagnostics = {"version": "9.9.9", "revision": "abc1234"} + append_chat_event( + "support_draft", + draft_id="draft-1", + captured_day=date.today().strftime("%Y%m%d"), + verb="create", + payload=draft_payload, + diagnostics_snapshot=diagnostics, + ) + _set_current_chat_trigger( + chat, + {"type": "talent_finished", "name": "support", "summary": "drafted"}, + ) + + chat._on_cortex_finish( + { + "use_id": "raw-chat", + "result": json.dumps( + { + "message": "I drafted a request.", + "notes": "done", + "talent_request": None, + } + ), + } + ) + + sol_message = next( + event + for event in read_chat_events(chat._today_day()) + if event["kind"] == "sol_message" + ) + assert sol_message["text"] == CHAT_SUPPORT_DRAFT_READY + assert sol_message["draft"] == { + "draft_id": "draft-1", + "verb": "create", + "payload": draft_payload, + "diagnostics_snapshot": diagnostics, + } + assert "offer" not in sol_message + + +def test_errored_support_finish_with_pending_draft_keeps_send_failed_closer( + chat_client, monkeypatch +): + import solstone.convey.chat as chat + + monkeypatch.setattr("solstone.convey.chat._run_next_action", lambda _action: None) + monkeypatch.setattr("solstone.convey.chat._emit_finish", lambda *_args: None) + monkeypatch.setattr("solstone.convey.chat._emit_error", lambda *_args: None) + append_chat_event( + "support_draft", + draft_id="draft-1", + captured_day=date.today().strftime("%Y%m%d"), + verb="create", + payload={"subject": "Subj"}, + diagnostics_snapshot={"version": "9.9.9"}, + ) + _set_current_chat_trigger( + chat, + { + "type": "talent_errored", + "name": "support", + "reason": "Traceback", + "reason_code": "wall_clock_exceeded", + }, + ) + + chat._on_cortex_finish( + { + "use_id": "raw-chat", + "result": json.dumps( + { + "message": "I drafted a request.", + "notes": "blocked", + "talent_request": None, + } + ), + } + ) + + sol_message = next( + event + for event in read_chat_events(chat._today_day()) + if event["kind"] == "sol_message" + ) + assert sol_message["text"] == CHAT_CLOSER_SUPPORT_SEND_FAILED + assert "draft" not in sol_message + + +def test_clean_support_finish_without_pending_draft_does_not_emit_marker( + chat_client, monkeypatch +): + import solstone.convey.chat as chat + + monkeypatch.setattr("solstone.convey.chat._run_next_action", lambda _action: None) + monkeypatch.setattr("solstone.convey.chat._emit_finish", lambda *_args: None) + monkeypatch.setattr("solstone.convey.chat._emit_error", lambda *_args: None) + _set_current_chat_trigger( + chat, + {"type": "talent_finished", "name": "support", "summary": "done"}, + ) + + chat._on_cortex_finish( + { + "use_id": "raw-chat", + "result": json.dumps( + { + "message": "Done.", + "notes": "done", + "talent_request": None, + } + ), + } + ) + + sol_message = next( + event + for event in read_chat_events(chat._today_day()) + if event["kind"] == "sol_message" + ) + assert "draft" not in sol_message + assert sol_message["text"] != CHAT_SUPPORT_DRAFT_READY + + +def test_non_support_finish_with_pending_draft_does_not_emit_marker( + chat_client, monkeypatch +): + import solstone.convey.chat as chat + + monkeypatch.setattr("solstone.convey.chat._run_next_action", lambda _action: None) + monkeypatch.setattr("solstone.convey.chat._emit_finish", lambda *_args: None) + monkeypatch.setattr("solstone.convey.chat._emit_error", lambda *_args: None) + append_chat_event( + "support_draft", + draft_id="draft-1", + captured_day=date.today().strftime("%Y%m%d"), + verb="create", + payload={"subject": "Subj"}, + diagnostics_snapshot={"version": "9.9.9"}, + ) + _set_current_chat_trigger( + chat, + {"type": "talent_finished", "name": "read", "summary": "done"}, + ) + + chat._on_cortex_finish( + { + "use_id": "raw-chat", + "result": json.dumps( + { + "message": "Done.", + "notes": "done", + "talent_request": None, + } + ), + } + ) + + sol_message = next( + event + for event in read_chat_events(chat._today_day()) + if event["kind"] == "sol_message" + ) + assert "draft" not in sol_message + + +def test_support_draft_state_result_seam_uses_latest_draft(monkeypatch): + import solstone.convey.chat as chat + + monkeypatch.setattr( + chat, + "read_chat_events", + lambda _day: [ + {"kind": "support_draft", "draft_id": "d1"}, + {"kind": "result", "draft_id": "d1"}, + ], + ) + assert chat._support_draft_state("20260420") == "submitted" + + monkeypatch.setattr( + chat, + "read_chat_events", + lambda _day: [ + {"kind": "support_draft", "draft_id": "d1"}, + {"kind": "support_draft", "draft_id": "d2"}, + {"kind": "result", "draft_id": "d1"}, + ], + ) + assert chat._support_draft_state("20260420") == "pending" + + +def test_support_finish_draft_marker_does_not_emit_offer(chat_client, monkeypatch): + import solstone.convey.chat as chat + + monkeypatch.setattr("solstone.convey.chat._run_next_action", lambda _action: None) + monkeypatch.setattr("solstone.convey.chat._emit_finish", lambda *_args: None) + monkeypatch.setattr("solstone.convey.chat._emit_error", lambda *_args: None) + append_chat_event( + "support_draft", + draft_id="draft-1", + captured_day=date.today().strftime("%Y%m%d"), + verb="create", + payload={"subject": "Subj"}, + diagnostics_snapshot=None, + ) + _set_current_chat_trigger( + chat, + {"type": "talent_finished", "name": "support", "summary": "drafted"}, + ) + + chat._on_cortex_finish( + { + "use_id": "raw-chat", + "result": json.dumps( + { + "message": "I drafted a request.", + "notes": "done", + "talent_request": None, + } + ), + } + ) + + sol_message = next( + event + for event in read_chat_events(chat._today_day()) + if event["kind"] == "sol_message" + ) + assert "draft" in sol_message + assert "offer" not in sol_message + + def test_decline_offer_endpoint_appends_local_sol_message(chat_client, monkeypatch): monkeypatch.setattr( "solstone.convey.chat._emit_cortex_event", lambda *_args, **_kwargs: None