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"