diff --git a/backend/druks/chat/channels/github/channel.py b/backend/druks/chat/channels/github/channel.py index 73d765060..e9d9543b7 100644 --- a/backend/druks/chat/channels/github/channel.py +++ b/backend/druks/chat/channels/github/channel.py @@ -103,7 +103,7 @@ async def send_reply( Druks wrote.""" asked = await session.get(Message, reply.reply_to) source_id = await conversation.get_answered_source_id(session, asked) - reply.source_id = await cls.post(conversation.thread_id, reply.body, source_id=source_id) + reply.source_id = await cls.post(conversation.thread_id, reply.answer, source_id=source_id) await session.commit() @classmethod diff --git a/backend/druks/chat/channels/slack/channel.py b/backend/druks/chat/channels/slack/channel.py index 5255b6ec9..493639392 100644 --- a/backend/druks/chat/channels/slack/channel.py +++ b/backend/druks/chat/channels/slack/channel.py @@ -158,10 +158,10 @@ async def send_reply( posted = [ await client.post_markdown( room or conversation.user_id, - reply.body[start : start + REPLY_PIECE_CHARACTERS], + reply.answer[start : start + REPLY_PIECE_CHARACTERS], thread_ts=thread_ts, ) - for start in range(0, len(reply.body), REPLY_PIECE_CHARACTERS) + for start in range(0, len(reply.answer), REPLY_PIECE_CHARACTERS) ] reply.source_id = f"{posted[0]['channel']}:{posted[0]['ts']}" await session.commit() diff --git a/backend/druks/chat/channels/whatsapp/channel.py b/backend/druks/chat/channels/whatsapp/channel.py index 9b24e6fc7..02d141855 100644 --- a/backend/druks/chat/channels/whatsapp/channel.py +++ b/backend/druks/chat/channels/whatsapp/channel.py @@ -18,7 +18,7 @@ async def send_reply( """Send a reply to the person who wrote. Druks records the reply's WhatsApp id before the send, so the copy that WAHA reports back is known as Druks's own.""" is_self_chat = conversation.user_id == conversation.connection.identity["user_id"] - text = f"[Druks] {reply.body}" if is_self_chat else reply.body + text = f"[Druks] {reply.answer}" if is_self_chat else reply.answer client = await Waha.get_client(session, conversation.connection) reply.source_id = await client.new_message_id() await session.commit() diff --git a/backend/druks/chat/models.py b/backend/druks/chat/models.py index a40521ef9..0d523e3e1 100644 --- a/backend/druks/chat/models.py +++ b/backend/druks/chat/models.py @@ -16,6 +16,7 @@ from druks.models import Base from druks.secrets.models import VaultSecret +from .constants import DELIVERY_FAILED_MESSAGE from .enums import ConversationSource, MessageRole, MessageState if TYPE_CHECKING: @@ -46,6 +47,13 @@ class Message(Base, Uuid7Pk): created_at: Mapped[datetime] = mapped_column(default=Base.utc_now) delivered_at: Mapped[datetime | None] + @property + def answer(self) -> str: + """The text after the agent's last tool call. The text before it is the agent's + notes.""" + start = max((call["textOffset"] for call in self.tool_calls), default=0) + return self.body[start:] or DELIVERY_FAILED_MESSAGE + @classmethod async def get_for_source_id(cls, session: AsyncSession, source_id: str) -> "Message | None": return await session.scalar(select(cls).where(cls.source_id == source_id)) diff --git a/backend/druks/chat/service.py b/backend/druks/chat/service.py index 370e71552..691e2ea29 100644 --- a/backend/druks/chat/service.py +++ b/backend/druks/chat/service.py @@ -588,7 +588,7 @@ async def finish_turn( await reset_live_stream(conversation.id) if conversation.connection: # A person who took the chat over during the turn answers it instead. - if state == MessageState.REPLIED and body and not await conversation.is_held(session): + if state == MessageState.REPLIED and not await conversation.is_held(session): await conversation.channel.send_reply(session, conversation, reply) elif not conversation.title: harness = get_harness(status["harness"]) diff --git a/backend/tests/test_chat.py b/backend/tests/test_chat.py index 36a1640ab..9b7348083 100644 --- a/backend/tests/test_chat.py +++ b/backend/tests/test_chat.py @@ -13,7 +13,7 @@ from druks.accounts.models import Account, PersonalAccessToken from druks.chat import service, sockets from druks.chat.bridge import Bridge -from druks.chat.constants import CHAT_KEY_NAME, CONVERSATION_HEADER +from druks.chat.constants import CHAT_KEY_NAME, CONVERSATION_HEADER, DELIVERY_FAILED_MESSAGE from druks.chat.enums import ConversationSource, MessageRole, MessageState from druks.chat.exceptions import ( ChatBridgeError, @@ -403,6 +403,18 @@ async def download(**values): assert host.upload_file.await_count == 0 +@pytest.mark.parametrize( + ("offsets", "answer"), [([14, 28], "Done."), ([14, 28, 33], DELIVERY_FAILED_MESSAGE)] +) +def test_a_reply_answers_with_the_text_after_its_last_tool_call(offsets, answer): + reply = Message( + body="Still running.Still running.Done.", + tool_calls=[{"textOffset": offset} for offset in offsets], + ) + + assert reply.answer == answer + + def channel(answer: bytes) -> tuple[asyncio.StreamReader, MagicMock]: """A loopback channel that sends one answer and ends.""" reader = asyncio.StreamReader() diff --git a/docs/chat.md b/docs/chat.md index 99596a9cf..bb5423deb 100644 --- a/docs/chat.md +++ b/docs/chat.md @@ -98,7 +98,11 @@ again and reads the saved messages and available live events. Agent text streams into the conversation. A tool row shows its ACP title and status. Select the row to read its input and output. Tool rows have no timestamps. Saved replies retain their tool details and -their position between text segments. +their position between text segments. Slack, GitHub, and WhatsApp get only the +text after the agent's last tool call. The text before it holds the agent's +notes. Only the Chat page shows them. When the agent writes nothing after its +last tool call, the channel gets "The reply is unavailable. Send the message +again to retry." An agent plan shows the latest complete plan. A plan update replaces the previous list. Tool updates instead change only the fields that the adapter