From 118d6ea73ddc2d9033f25bb2ca03e041268c34ad Mon Sep 17 00:00:00 2001 From: vegu-ai-tools <152010387+vegu-ai-tools@users.noreply.github.com> Date: Fri, 15 May 2026 18:25:19 +0300 Subject: [PATCH] Enhance Message Revision Handling: Add support for 'continue' action in message edits, allowing users to append to the revision stack without replacing the active entry. Update relevant components and tests to reflect this new functionality. --- CHANGELOG.yaml | 1 + src/talemate/scene_message.py | 2 +- src/talemate/server/scene_message.py | 15 +- src/talemate/tale_mate.py | 6 +- .../src/components/CharacterMessage.vue | 18 +- .../src/components/RevisionNav.vue | 1 + .../src/components/SceneMessages.vue | 9 +- tests/server/test_scene_message.py | 161 ++++++++++++++++++ 8 files changed, 204 insertions(+), 9 deletions(-) create mode 100644 tests/server/test_scene_message.py diff --git a/CHANGELOG.yaml b/CHANGELOG.yaml index 4387a7ed..d963ed55 100644 --- a/CHANGELOG.yaml +++ b/CHANGELOG.yaml @@ -2,6 +2,7 @@ features: - "Message Revision History: Regenerated AI messages now show a paginator above the message body. Click the arrows to browse previous regenerations; the version you're viewing becomes the canonical one the AI continues from. Lives in the browser session only." improvements: + - "Message Revision History: Continuing a character message now creates a navigable revision entry tagged 'Continued', alongside the existing regenerate entries. The pre-continuation text is reachable via the paginator arrows." - "Pydantic Migration: Internal data models across the codebase converted to pydantic for stricter validation. No user-visible behavior changes." - "Character Sheet: Removed the read-only Character Sheet dialog and its button from the World State panel. The Manage character button (World State Manager) already covers viewing and editing character details." - "Message Toolbar: Consolidated the hover toolbar shared by character, narrator, and context-investigation messages into a single component. Action chips now use a filled (tonal) style for better visibility, and the revision chip is labeled with the editor agent's configured revision method — 'Dedupe', 'Unslop', or 'Targeted Rewrite' — instead of the generic 'Editor Revision' label." diff --git a/src/talemate/scene_message.py b/src/talemate/scene_message.py index 1c585c67..87df0100 100644 --- a/src/talemate/scene_message.py +++ b/src/talemate/scene_message.py @@ -23,7 +23,7 @@ __all__ = [ "DIRECTOR_INPUT_PREFIX_YIELD", ] -MutationSource = Literal["original", "revision", "regenerate"] +MutationSource = Literal["original", "revision", "regenerate", "continue"] # Prefixes the user can type in the main input box to route a message to the # director instead of having the player character speak/act. The yield variant diff --git a/src/talemate/server/scene_message.py b/src/talemate/server/scene_message.py index 6f1dfeed..3a77b89f 100644 --- a/src/talemate/server/scene_message.py +++ b/src/talemate/server/scene_message.py @@ -7,10 +7,13 @@ into `Scene.edit_message`, but the manual-edit path additionally runs the content through the editor agent's exposition cleanup when configured. """ +from typing import Literal + import pydantic import structlog import talemate.instance as instance +from talemate.scene_message import MutationSource from talemate.server.websocket_plugin import Plugin log = structlog.get_logger("talemate.server.scene_message") @@ -21,6 +24,11 @@ __all__ = ["SceneMessagePlugin"] class EditPayload(pydantic.BaseModel): id: int text: str + # When set, forwarded onto the message_edited emit so the frontend + # splices the new text onto the message's revision stack instead of + # replacing the active entry in place. + reason: Literal["revision", "regenerate", "continue"] | None = None + mutation_source: MutationSource | None = None class SwapRevisionPayload(pydantic.BaseModel): @@ -70,7 +78,12 @@ class SceneMessagePlugin(Plugin): strip_partial=not editor.allow_incomplete_sentences, ) - self.scene.edit_message(payload.id, new_text) + self.scene.edit_message( + payload.id, + new_text, + reason=payload.reason, + mutation_source=payload.mutation_source, + ) async def handle_swap_revision(self, data: dict): """ diff --git a/src/talemate/tale_mate.py b/src/talemate/tale_mate.py index 2b27f600..5cb4d892 100644 --- a/src/talemate/tale_mate.py +++ b/src/talemate/tale_mate.py @@ -989,7 +989,7 @@ class Scene(Emitter): self, message_id: int, message: str, - reason: Literal["revision", "regenerate"] | None = None, + reason: Literal["revision", "regenerate", "continue"] | None = None, mutations: list[MessageMutation] | None = None, mutation_source: MutationSource | None = None, ): @@ -1003,6 +1003,10 @@ class Scene(Emitter): - ``regenerate``: in-place regenerate; frontend appends the prior intermediate(s) (``mutations``) and the new canonical text to its stack. + - ``continue``: user-initiated continuation of a character + message; frontend appends the extended text as a new entry at + the end of the stack. The prior canonical is already in the + stack at the active index, so no mutations travel with it. Plain user edits omit the reason and ship no metadata. ``mutations`` are pre-canonical intermediate texts attached as diff --git a/talemate_frontend/src/components/CharacterMessage.vue b/talemate_frontend/src/components/CharacterMessage.vue index 17ea4e8d..a0d17bfd 100644 --- a/talemate_frontend/src/components/CharacterMessage.vue +++ b/talemate_frontend/src/components/CharacterMessage.vue @@ -321,7 +321,12 @@ export default { this.editing_text = this.text + completion; } - this.submitEdit(); + // Tag the commit so the echo lands as a new entry on the + // slot's revision stack instead of replacing the active one. + this.submitEdit({ + reason: 'continue', + mutation_source: 'continue', + }); }, this.$refs.textarea ) @@ -353,8 +358,15 @@ export default { this.$refs.textarea.focus(); }); }, - submitEdit() { - this.getWebsocket().send(JSON.stringify({ type: 'scene_message', action: 'edit', id: this.message_id, text: this.character+": "+this.editing_text })); + submitEdit(meta = null) { + const payload = { + ...(meta || {}), + type: 'scene_message', + action: 'edit', + id: this.message_id, + text: this.character + ": " + this.editing_text, + }; + this.getWebsocket().send(JSON.stringify(payload)); this.editing = false; }, deleteMessage() { diff --git a/talemate_frontend/src/components/RevisionNav.vue b/talemate_frontend/src/components/RevisionNav.vue index 5512f884..6b3acc7d 100644 --- a/talemate_frontend/src/components/RevisionNav.vue +++ b/talemate_frontend/src/components/RevisionNav.vue @@ -25,6 +25,7 @@ const SOURCE_TAGS = { original: { label: 'Original', icon: 'mdi-creation', color: 'muted' }, revision: { label: 'Revised', icon: 'mdi-typewriter', color: 'highlight4' }, regenerate: { label: 'Regenerated', icon: 'mdi-refresh', color: 'primary' }, + continue: { label: 'Continued', icon: 'mdi-fast-forward', color: 'primary' }, }; export default { diff --git a/talemate_frontend/src/components/SceneMessages.vue b/talemate_frontend/src/components/SceneMessages.vue index 4ea899f1..b9907da6 100644 --- a/talemate_frontend/src/components/SceneMessages.vue +++ b/talemate_frontend/src/components/SceneMessages.vue @@ -1739,9 +1739,10 @@ export default { // Manual editor revision slots the new entry // immediately after the current one (it's a // revision *of* that entry). In-place regenerate - // appends to the end of the stack — the new text - // is a fresh alternative, not a revision of the - // active entry, so prior entries should keep + // and Continue both append to the end of the + // stack — the new text is a fresh alternative + // (regenerate) or an extension of the prior + // canonical (continue), so prior entries keep // their positions. Plain edits / revision-swap // echoes replace the current entry's text in // place (preserving its source tag). @@ -1756,6 +1757,8 @@ export default { [...(data.mutations || []), canonicalEntry], ); this.messages[i].regenerating = false; + } else if (data.reason === 'continue') { + this.revisionAppendAtEnd(data.id, [canonicalEntry]); } else { this.revisionUpdateCurrentEntry(data.id, data.message); } diff --git a/tests/server/test_scene_message.py b/tests/server/test_scene_message.py new file mode 100644 index 00000000..7970cee4 --- /dev/null +++ b/tests/server/test_scene_message.py @@ -0,0 +1,161 @@ +""" +Unit tests for the scene_message websocket plugin. + +Focuses on the wire contract of ``SceneMessagePlugin.handle_edit``: the +optional ``reason`` / ``mutation_source`` metadata is forwarded to +``Scene.edit_message`` so the frontend's ``message_edited`` echo can +splice the new text onto the per-slot revision stack rather than +replacing the active entry in place. The editor agent's +``cleanup_character_message`` branch is bypassed by stubbing the agent +to ``enabled=False``; that branch is exercised in its own agent tests +and isn't the contract under test here. +""" + +import pytest + +from talemate.scene_message import CharacterMessage, reset_message_id +from talemate.server.scene_message import SceneMessagePlugin +from talemate.tale_mate import Scene + + +class _MockWebsocketHandler: + """Minimal handler stand-in: exposes ``scene`` and a queue_put list.""" + + def __init__(self, scene): + self._scene = scene + self.messages: list[dict] = [] + + @property + def scene(self): + return self._scene + + def queue_put(self, data): + self.messages.append(data) + + +class _DisabledEditor: + """Stub editor agent; the plugin checks ``enabled`` and short-circuits.""" + + enabled = False + + +@pytest.fixture(autouse=True) +def _reset_message_ids(): + reset_message_id() + yield + reset_message_id() + + +@pytest.fixture +def scene_with_message(): + """Real Scene carrying one CharacterMessage. Tests target its id.""" + scene = Scene() + msg = CharacterMessage(message="Alice: Hello.") + scene.history.append(msg) + return scene, msg + + +@pytest.fixture +def plugin(scene_with_message, monkeypatch): + """SceneMessagePlugin wired to a real Scene with editor cleanup disabled. + + ``edit_message`` is replaced by a spy so the test asserts on the + plugin's pass-through directly without running the emit bus. + """ + scene, _ = scene_with_message + handler = _MockWebsocketHandler(scene=scene) + plug = SceneMessagePlugin(handler) + + monkeypatch.setattr( + "talemate.server.scene_message.instance.get_agent", + lambda name: _DisabledEditor(), + ) + + calls: list[dict] = [] + + def _spy(message_id, message, *, reason=None, mutation_source=None): + calls.append( + { + "message_id": message_id, + "message": message, + "reason": reason, + "mutation_source": mutation_source, + } + ) + + monkeypatch.setattr(scene, "edit_message", _spy) + plug._spy_calls = calls + return plug + + +class TestSceneMessagePluginHandleEdit: + @pytest.mark.asyncio + async def test_plain_edit_forwards_no_metadata(self, plugin, scene_with_message): + """A bare edit payload must not invent metadata; reason and + mutation_source default to None so ``Scene.edit_message`` emits a + plain ``message_edited`` envelope (data=None).""" + _, msg = scene_with_message + await plugin.handle_edit({"id": msg.id, "text": "Alice: Hi."}) + + assert plugin._spy_calls == [ + { + "message_id": msg.id, + "message": "Alice: Hi.", + "reason": None, + "mutation_source": None, + } + ] + + @pytest.mark.asyncio + async def test_continue_edit_forwards_revision_metadata( + self, plugin, scene_with_message + ): + """The Continue action ships ``reason='continue'`` and + ``mutation_source='continue'``; both must reach + ``Scene.edit_message`` so the resulting emit carries them onto + the frontend's revision-stack splice.""" + _, msg = scene_with_message + await plugin.handle_edit( + { + "id": msg.id, + "text": "Alice: Hi. How are you?", + "reason": "continue", + "mutation_source": "continue", + } + ) + + assert plugin._spy_calls == [ + { + "message_id": msg.id, + "message": "Alice: Hi. How are you?", + "reason": "continue", + "mutation_source": "continue", + } + ] + + @pytest.mark.asyncio + async def test_client_supplied_mutations_field_is_dropped( + self, plugin, scene_with_message + ): + """``mutations`` is intentionally NOT part of ``EditPayload``; + clients cannot inject prior-state entries into the revision + stack via this wire path. Pydantic's default ``extra=ignore`` + drops the field, and ``Scene.edit_message`` is invoked without + ``mutations`` in its kwargs.""" + _, msg = scene_with_message + await plugin.handle_edit( + { + "id": msg.id, + "text": "Alice: Hi.", + "reason": "continue", + "mutation_source": "continue", + "mutations": [{"message": "injected", "source": "original"}], + } + ) + + # Only the supported metadata was forwarded; the rogue mutations + # field never reached Scene.edit_message. + call = plugin._spy_calls[0] + assert "mutations" not in call or call.get("mutations") is None + assert call["reason"] == "continue" + assert call["mutation_source"] == "continue"