mirror of
https://github.com/vegu-ai/talemate.git
synced 2026-09-02 12:09:31 +02:00
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:
@@ -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."
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
161
tests/server/test_scene_message.py
Normal file
161
tests/server/test_scene_message.py
Normal 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"
|
||||
Reference in New Issue
Block a user