From b41cf926f873544b234dd536c159e14def3ce05c Mon Sep 17 00:00:00 2001 From: Krzysztof Socha Date: Fri, 4 Sep 2026 06:31:23 +0200 Subject: [PATCH] Bound chat history, name untitled threads, and park send/stop on the gate. Talk was feeding the whole thread and parking a review-shaped turn. Bound the prompt, fill an empty title from the first user line, and answer ChatTurn through send/stop so stop ends the run without a message row. Co-authored-by: Cursor --- backend/druks/contrib/chat/models.py | 43 ++++++++ backend/druks/contrib/chat/templates/talk.md | 2 +- backend/druks/contrib/chat/workflows.py | 24 +++-- backend/tests/chat/test_models.py | 66 ++++++++++++ backend/tests/chat/test_pages.py | 7 +- backend/tests/chat/test_prompts.py | 20 ++++ backend/tests/chat/test_workflows.py | 106 ++++++++++++++++--- frontend/src/components/RunControls.test.tsx | 8 ++ frontend/src/components/RunControls.tsx | 2 + 9 files changed, 256 insertions(+), 22 deletions(-) create mode 100644 backend/tests/chat/test_prompts.py diff --git a/backend/druks/contrib/chat/models.py b/backend/druks/contrib/chat/models.py index 37056562..cd4f11bc 100644 --- a/backend/druks/contrib/chat/models.py +++ b/backend/druks/contrib/chat/models.py @@ -1,3 +1,4 @@ +from collections.abc import Sequence from datetime import datetime from sqlalchemy import ForeignKey, select @@ -7,6 +8,10 @@ from druks.contrib.chat.schemas import ConversationSummary from druks.db import Base, StoredSubject, db_session +_PROMPT_MESSAGES = 40 +_PROMPT_CHARS = 16_000 +_TITLE_CHARS = 80 + class Conversation(StoredSubject): __tablename__ = "chat_conversations" @@ -67,9 +72,31 @@ async def save_autonomy(self, autonomy: Autonomy) -> None: self.autonomy = autonomy await db_session().flush() + async def name_from_first_line(self) -> None: + """Fill an empty title from the first user line. A title the operator + already set, or a prior call, is left alone.""" + if self.title: + return + first = next( + (message for message in await self.list_messages() if message.role == Role.USER), + None, + ) + if not first: + return + line = next( + (" ".join(raw.split()) for raw in first.body.splitlines() if raw.strip()), + "", + ) + if line: + self.title = line[:_TITLE_CHARS] + await db_session().flush() + async def list_messages(self) -> list["Message"]: return await Message.list_for_conversation(self.id) + async def list_prompt_messages(self) -> list["Message"]: + return Message.bound_recent(await self.list_messages()) + def get_summary(self) -> ConversationSummary: return ConversationSummary.model_validate(self) @@ -115,3 +142,19 @@ async def list_for_conversation(cls, conversation_id: int) -> list["Message"]: .order_by(cls.created_at, cls.id) ) return list(await db_session().scalars(statement)) + + @classmethod + def bound_recent(cls, messages: Sequence["Message"]) -> list["Message"]: + """Newest lines that fit the prompt. Older lines drop first. One + oversize last line still goes in — truncating it would hide the turn + the operator just sent.""" + chosen: list[Message] = [] + chars = 0 + for message in reversed(messages): + size = len(message.body) + if chosen and (len(chosen) >= _PROMPT_MESSAGES or chars + size > _PROMPT_CHARS): + break + chosen.append(message) + chars += size + chosen.reverse() + return chosen diff --git a/backend/druks/contrib/chat/templates/talk.md b/backend/druks/contrib/chat/templates/talk.md index 4c6c240a..137ac825 100644 --- a/backend/druks/contrib/chat/templates/talk.md +++ b/backend/druks/contrib/chat/templates/talk.md @@ -1,4 +1,4 @@ -You are this operator. Act through the live Druks MCP catalog. +Act on behalf of the signed-in operator. Use the attached MCP tools to inspect and change this appliance. Discover work with list_open_subjects. Echo parkedAt from get_gate unchanged when you answer a gate. Do not invent run ids. diff --git a/backend/druks/contrib/chat/workflows.py b/backend/druks/contrib/chat/workflows.py index 734afe90..2e35fce3 100644 --- a/backend/druks/contrib/chat/workflows.py +++ b/backend/druks/contrib/chat/workflows.py @@ -27,8 +27,8 @@ class ChatTurn(Gate): would offer approve/request_changes on a chat turn.""" name = "chat_turn" - text: str - stop: bool = False + action: Literal["send", "stop"] + note: str = "" class ConfirmTool(Gate): @@ -91,12 +91,13 @@ class Talk(Workflow): async def run_multistep(self) -> None: while True: conversation = await self.subject - messages = await conversation.list_messages() + messages = await conversation.list_prompt_messages() result = await Chat.reply( autonomy=conversation.autonomy, messages=[{"role": message.role, "body": message.body} for message in messages], ) await self.record_message(Role.ASSISTANT, result.text) + await self.name_thread() deferred = await self.deferred_writes() if deferred: decision = await ConfirmTool.wait( @@ -104,18 +105,24 @@ async def run_multistep(self) -> None: "presentation": "in_app", "label": "Confirm the proposed action", "controls": ["approve", "reject"], + "questions": [], }, hold_sandbox=self.sandbox_hold, ) if decision.action == "approve": await self.apply_deferred_writes(deferred) reply = await ChatTurn.wait( - input_request={"presentation": "in_app", "label": "Chat turn"}, + input_request={ + "presentation": "in_app", + "label": "Message", + "controls": ["send", "stop"], + "questions": [], + }, hold_sandbox=self.sandbox_hold, ) - if reply.stop: + if reply.action == "stop": return - await self.record_message(Role.USER, reply.text) + await self.record_message(Role.USER, reply.note) async def get_workspace_kwargs(self, host: "Host") -> dict[str, Any]: conversation = await self.subject @@ -133,6 +140,11 @@ async def record_message(self, role: Role, body: str) -> None: conversation = await self.subject await conversation.add_message(role=role, body=body) + @step + async def name_thread(self) -> None: + conversation = await self.subject + await conversation.name_from_first_line() + @step async def deferred_writes(self) -> list[dict[str, str]]: return await OperatorToken.take_deferred(self.workflow_id) diff --git a/backend/tests/chat/test_models.py b/backend/tests/chat/test_models.py index a051e9a6..d528763e 100644 --- a/backend/tests/chat/test_models.py +++ b/backend/tests/chat/test_models.py @@ -75,3 +75,69 @@ async def test_get_for_account_misses_another_operators_thread(): assert (await Conversation.get_for_account(mine.id, owner.id)).id == mine.id assert await Conversation.get_for_account(theirs.id, owner.id) is None assert await Conversation.get_for_account(mine.id, None) is None + + +async def test_name_from_first_line_fills_an_empty_title(): + account = await Account.get_or_create("op@example.com") + conversation = await Conversation.create(account_id=account.id, title="") + await conversation.add_message(role=Role.USER, body=" Pump the tires \nmore detail") + await conversation.add_message(role=Role.ASSISTANT, body="ok") + + await conversation.name_from_first_line() + + assert conversation.title == "Pump the tires" + + +async def test_name_from_first_line_leaves_an_operator_title(): + account = await Account.get_or_create("op@example.com") + conversation = await Conversation.create(account_id=account.id, title="Pump") + await conversation.add_message(role=Role.USER, body="something else") + + await conversation.name_from_first_line() + + assert conversation.title == "Pump" + + +async def test_name_from_first_line_caps_a_long_first_line(): + account = await Account.get_or_create("op@example.com") + conversation = await Conversation.create(account_id=account.id, title="") + await conversation.add_message(role=Role.USER, body="x" * 100) + + await conversation.name_from_first_line() + + assert conversation.title == "x" * 80 + + +async def test_name_from_first_line_is_idempotent(): + account = await Account.get_or_create("op@example.com") + conversation = await Conversation.create(account_id=account.id, title="") + await conversation.add_message(role=Role.USER, body="first") + await conversation.name_from_first_line() + await conversation.add_message(role=Role.USER, body="later") + + await conversation.name_from_first_line() + + assert conversation.title == "first" + + +async def test_prompt_history_keeps_the_newest_lines(): + account = await Account.get_or_create("op@example.com") + conversation = await Conversation.create(account_id=account.id, title="t") + for index in range(41): + await conversation.add_message(role=Role.USER, body=f"m{index}") + + prompt = await conversation.list_prompt_messages() + + assert [message.body for message in prompt] == [f"m{index}" for index in range(1, 41)] + assert [message.body for message in await conversation.list_messages()][0] == "m0" + + +async def test_prompt_history_drops_older_lines_that_overflow_the_char_budget(): + account = await Account.get_or_create("op@example.com") + conversation = await Conversation.create(account_id=account.id, title="t") + await conversation.add_message(role=Role.USER, body="a" * 10_000) + await conversation.add_message(role=Role.ASSISTANT, body="b" * 10_000) + + prompt = await conversation.list_prompt_messages() + + assert [message.body[0] for message in prompt] == ["b"] diff --git a/backend/tests/chat/test_pages.py b/backend/tests/chat/test_pages.py index 97376166..75c33c73 100644 --- a/backend/tests/chat/test_pages.py +++ b/backend/tests/chat/test_pages.py @@ -96,7 +96,12 @@ async def test_a_parked_turn_puts_gate_controls_on_the_thread(druks_client, druk subject=conversation, state="parked", input_gate=ChatTurn.name, - input_request={"presentation": "in_app", "label": "Chat turn"}, + input_request={ + "presentation": "in_app", + "label": "Message", + "controls": ["send", "stop"], + "questions": [], + }, ) run.input_requested_at = datetime.now(UTC) await druks_db.flush() diff --git a/backend/tests/chat/test_prompts.py b/backend/tests/chat/test_prompts.py new file mode 100644 index 00000000..9cbf7856 --- /dev/null +++ b/backend/tests/chat/test_prompts.py @@ -0,0 +1,20 @@ +from druks.prompts import render_prompt + + +async def test_talk_prompt_carries_autonomy_bounded_history_and_mcp_discovery(): + rendered = await render_prompt( + "chat/talk.md", + autonomy="propose", + messages=[ + {"role": "user", "body": "hello"}, + {"role": "assistant", "body": "hi"}, + ], + ) + + assert "Act on behalf of the signed-in operator" in rendered + assert "list_open_subjects" in rendered + assert "parkedAt" in rendered + assert "Do not invent run ids" in rendered + assert "Autonomy: propose" in rendered + assert "user: hello" in rendered + assert "assistant: hi" in rendered diff --git a/backend/tests/chat/test_workflows.py b/backend/tests/chat/test_workflows.py index 98a866e0..98f05274 100644 --- a/backend/tests/chat/test_workflows.py +++ b/backend/tests/chat/test_workflows.py @@ -7,10 +7,28 @@ from druks.contrib.chat.enums import Role from druks.contrib.chat.models import Conversation from druks.contrib.chat.workflows import ChatTurn, ConfirmTool, Talk +from druks.notifications.services import validate_in_app_answer from druks.workflows import current_workflow +_CHAT_TURN_ASK = { + "presentation": "in_app", + "label": "Message", + "controls": ["send", "stop"], + "questions": [], +} -async def _run_talk(conversation: Conversation) -> None: + +def test_chat_turn_accepts_the_in_app_send_and_stop_payload(): + send = validate_in_app_answer(_CHAT_TURN_ASK, "send", {}, "and then?") + assert ChatTurn.model_validate(send).action == "send" + assert ChatTurn.model_validate(send).note == "and then?" + stop = validate_in_app_answer(_CHAT_TURN_ASK, "stop", {}, "") + assert ChatTurn.model_validate(stop).action == "stop" + + +async def _run_talk(conversation: Conversation, monkeypatch) -> None: + monkeypatch.setattr(Talk, "record_message", Talk.record_message.__wrapped__) + monkeypatch.setattr(Talk, "name_thread", Talk.name_thread.__wrapped__) flow = Talk() flow.subject = conversation flow.account_id = conversation.account_id @@ -43,20 +61,20 @@ async def test_talk_appends_the_assistant_line_and_stops(druks_db, monkeypatch): async def wait(cls, **kwargs): waits.append(kwargs) - return ChatTurn(text="", stop=True) + return ChatTurn(action="stop") monkeypatch.setattr(ChatTurn, "wait", classmethod(wait)) - monkeypatch.setattr(Talk, "record_message", Talk.record_message.__wrapped__) monkeypatch.setattr(Talk, "deferred_writes", mock.AsyncMock(return_value=[])) - await _run_talk(conversation) + await _run_talk(conversation, monkeypatch) reply.assert_awaited_once() assert waits[0]["hold_sandbox"] == timedelta(minutes=15) - assert waits[0]["input_request"] == {"presentation": "in_app", "label": "Chat turn"} + assert waits[0]["input_request"] == _CHAT_TURN_ASK messages = await conversation.list_messages() assert [message.body for message in messages] == ["hello", "hi"] assert [message.role for message in messages] == [Role.USER, Role.ASSISTANT] + assert conversation.title == "hello" async def test_talk_appends_the_operator_line_and_loops(druks_db, monkeypatch): @@ -72,16 +90,17 @@ async def reply(**kwargs): return next(outputs) monkeypatch.setattr(Chat, "reply", staticmethod(reply)) - answers = iter([ChatTurn(text="and then?", stop=False), ChatTurn(text="", stop=True)]) + answers = iter( + [ChatTurn(action="send", note="and then?"), ChatTurn(action="stop", note="leave unused")] + ) async def wait(cls, **kwargs): return next(answers) monkeypatch.setattr(ChatTurn, "wait", classmethod(wait)) - monkeypatch.setattr(Talk, "record_message", Talk.record_message.__wrapped__) monkeypatch.setattr(Talk, "deferred_writes", mock.AsyncMock(return_value=[])) - await _run_talk(conversation) + await _run_talk(conversation, monkeypatch) messages = await conversation.list_messages() assert [message.body for message in messages] == ["hello", "hi", "and then?", "ok"] @@ -93,6 +112,67 @@ async def wait(cls, **kwargs): ] assert turns[0]["autonomy"] == conversation.autonomy assert turns[1]["messages"][-1] == {"role": Role.USER, "body": "and then?"} + assert conversation.title == "hello" + + +async def test_stop_does_not_write_a_message(druks_db, monkeypatch): + account = await Account.get_or_create("op@example.com") + conversation = await Conversation.create(account_id=account.id, title="") + await conversation.add_message(role=Role.USER, body="hello") + monkeypatch.setattr(Chat, "reply", mock.AsyncMock(return_value=TurnOutput(text="hi"))) + monkeypatch.setattr(Talk, "deferred_writes", mock.AsyncMock(return_value=[])) + + async def wait(cls, **kwargs): + return ChatTurn(action="stop", note="goodnight") + + monkeypatch.setattr(ChatTurn, "wait", classmethod(wait)) + + await _run_talk(conversation, monkeypatch) + + assert [message.body for message in await conversation.list_messages()] == ["hello", "hi"] + + +async def test_talk_names_an_untitled_thread_once(druks_db, monkeypatch): + account = await Account.get_or_create("op@example.com") + conversation = await Conversation.create(account_id=account.id, title="Pump") + await conversation.add_message(role=Role.USER, body="hello") + monkeypatch.setattr(Chat, "reply", mock.AsyncMock(return_value=TurnOutput(text="hi"))) + monkeypatch.setattr(Talk, "deferred_writes", mock.AsyncMock(return_value=[])) + + async def wait(cls, **kwargs): + return ChatTurn(action="stop") + + monkeypatch.setattr(ChatTurn, "wait", classmethod(wait)) + + await _run_talk(conversation, monkeypatch) + + assert conversation.title == "Pump" + + +async def test_talk_prompts_with_bounded_history(druks_db, monkeypatch): + account = await Account.get_or_create("op@example.com") + conversation = await Conversation.create(account_id=account.id, title="t") + for index in range(41): + await conversation.add_message(role=Role.USER, body=f"m{index}") + turns: list[dict] = [] + + async def reply(**kwargs): + turns.append(kwargs) + return TurnOutput(text="ok") + + monkeypatch.setattr(Chat, "reply", staticmethod(reply)) + + async def wait(cls, **kwargs): + return ChatTurn(action="stop") + + monkeypatch.setattr(ChatTurn, "wait", classmethod(wait)) + monkeypatch.setattr(Talk, "deferred_writes", mock.AsyncMock(return_value=[])) + + await _run_talk(conversation, monkeypatch) + + assert [message["body"] for message in turns[0]["messages"]] == [ + f"m{index}" for index in range(1, 41) + ] async def test_talk_confirms_deferred_writes_before_the_next_line(druks_db, monkeypatch): @@ -101,7 +181,6 @@ async def test_talk_confirms_deferred_writes_before_the_next_line(druks_db, monk await conversation.add_message(role=Role.USER, body="hello") monkeypatch.setattr(Chat, "reply", mock.AsyncMock(return_value=TurnOutput(text="hi"))) - monkeypatch.setattr(Talk, "record_message", Talk.record_message.__wrapped__) proposed = [ { "method": "POST", @@ -124,12 +203,12 @@ async def confirm_wait(cls, **kwargs): return ConfirmTool(action="approve") async def turn_wait(cls, **kwargs): - return ChatTurn(text="", stop=True) + return ChatTurn(action="stop") monkeypatch.setattr(ConfirmTool, "wait", classmethod(confirm_wait)) monkeypatch.setattr(ChatTurn, "wait", classmethod(turn_wait)) - await _run_talk(conversation) + await _run_talk(conversation, monkeypatch) assert parked[0]["input_request"]["controls"] == ["approve", "reject"] assert applied == proposed @@ -141,7 +220,6 @@ async def test_talk_skips_deferred_writes_when_the_operator_rejects(druks_db, mo await conversation.add_message(role=Role.USER, body="hello") monkeypatch.setattr(Chat, "reply", mock.AsyncMock(return_value=TurnOutput(text="hi"))) - monkeypatch.setattr(Talk, "record_message", Talk.record_message.__wrapped__) monkeypatch.setattr( Talk, "deferred_writes", @@ -156,11 +234,11 @@ async def confirm_wait(cls, **kwargs): return ConfirmTool(action="reject") async def turn_wait(cls, **kwargs): - return ChatTurn(text="", stop=True) + return ChatTurn(action="stop") monkeypatch.setattr(ConfirmTool, "wait", classmethod(confirm_wait)) monkeypatch.setattr(ChatTurn, "wait", classmethod(turn_wait)) - await _run_talk(conversation) + await _run_talk(conversation, monkeypatch) apply.assert_not_awaited() diff --git a/frontend/src/components/RunControls.test.tsx b/frontend/src/components/RunControls.test.tsx index d6f0c811..d96e3daf 100644 --- a/frontend/src/components/RunControls.test.tsx +++ b/frontend/src/components/RunControls.test.tsx @@ -97,6 +97,14 @@ describe('InAppReview', () => { expect(screen.getByText('A note is sent to the agent as feedback.')).toBeTruthy() }) + + it('labels send and stop', () => { + stubFetch() + renderReview({ presentation: 'in_app', controls: ['send', 'stop'], questions: [] }) + + expect(screen.getByText('Send')).toBeTruthy() + expect(screen.getByText('Stop')).toBeTruthy() + }) }) describe('the lent run controls', () => { diff --git a/frontend/src/components/RunControls.tsx b/frontend/src/components/RunControls.tsx index 1ebdcd61..1fa07ea0 100644 --- a/frontend/src/components/RunControls.tsx +++ b/frontend/src/components/RunControls.tsx @@ -84,6 +84,8 @@ const CONTROL_LABEL: Record = { approve: 'Approve', request_changes: 'Request changes', revise_contract: 'Revise contract', + send: 'Send', + stop: 'Stop', } export function InAppReview({