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.

This commit is contained in:
vegu-ai-tools
2026-05-15 18:25:19 +03:00
parent 028ec4734b
commit 118d6ea73d
8 changed files with 204 additions and 9 deletions

View File

@@ -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."

View File

@@ -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

View File

@@ -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):
"""

View File

@@ -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

View File

@@ -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() {

View File

@@ -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 {

View File

@@ -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);
}

View File

@@ -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"