mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
feat(operator-context): deliver structured per-kind meta to the UI
Operator-context system turns (watch results, output-guard findings, idle children, user interjections) carried their kind (_source) and a flattened text content, but the structured per-kind fields were dropped at every persist/deliver boundary — so the UI rendered every kind as one generic operator bubble and the structured watch-result card was lost. Wire the structured meta through as the single source of truth: - Storage: new conversations.meta JSON column (migration 060); threaded through save_message/save_messages_bulk (facade + protocol + both backends) and rehydrated in reconstruct_turns onto Turn.meta.extra["source_meta"]. - Canonical: make_system_turn carries meta as one _source_meta dict; turn_from_dict/turn_to_dict bridge it to/from Turn.meta.extra. - Live + history: widen on_system_turn(content, source, meta) across all impls + the SSE payload; surface _source_meta -> meta in the /history projection. SDK HistoryEvent docs note the field. - Producers derive both the model-facing content text AND the card from one meta dict, so they cannot drift: render_output_guard_text, build_watch_ reminder carrying output, idle_children and user_interjection metadata. - Frontend: addSystemContext / renderSystemTurn dispatch by source to the watch-result, guard-finding, idle-children, and queued-message cards in both the interactive and coordinator panes; every untrusted field renders via textContent. The meta is a leading-underscore key, stripped before the wire (sanitize_ messages and the native mid-conversation path copy only role+content), so the per-provider wire payloads stay byte-identical. Additive column, no backfill: operator turns predating it reload as plain text bubbles.
This commit is contained in:
@@ -22,6 +22,10 @@ export interface HistoryEvent {
|
||||
* - `source`: the operator-context kind on a `system` turn (`output_guard` /
|
||||
* `user_interjection` / `tool_error` / ...), or `system_nudge` on a
|
||||
* wake-driven empty user turn
|
||||
* - `meta`: structured per-kind fields on an operator-context `system` turn
|
||||
* (e.g. `watch_triggered`'s `{watch_name, command, poll_count, max_polls,
|
||||
* is_final}`) so the renderer can rebuild per-kind UI (the watch-result
|
||||
* card); absent for kinds with no structured data
|
||||
* - `attachments`: per-attachment metadata `{kind, filename, mime_type, size_bytes}`
|
||||
* - `reasoning`: concatenated reasoning text for assistant turns that
|
||||
* round-tripped a thinking-block lane (Anthropic-with-thinking today;
|
||||
|
||||
@@ -270,7 +270,10 @@ def test_replay_renders_system_turn_via_add_system_context() -> None:
|
||||
assert 'msg.role === "system"' in fn, (
|
||||
"replayHistory must have a system-role branch for first-class operator-context turns."
|
||||
)
|
||||
assert "addSystemContext(msg.content" in fn, (
|
||||
# Whitespace-tolerant: the call carries a 3rd ``meta`` arg now, so the
|
||||
# formatter wraps it across lines — match the call + first arg, not a
|
||||
# brittle contiguous substring.
|
||||
assert re.search(r"addSystemContext\(\s*msg\.content", fn), (
|
||||
"the system-role branch must route the turn through addSystemContext "
|
||||
"so it renders as an operator bubble."
|
||||
)
|
||||
|
||||
@@ -169,6 +169,34 @@ class TestEnqueueOnIdle:
|
||||
assert "child-a" in text
|
||||
assert "child-b" in text
|
||||
|
||||
def test_idle_children_carries_structured_meta(self, coord_setup):
|
||||
# The nudge rides the structured child list as ``metadata`` so the FE
|
||||
# rebuilds the idle-children card; the same list ``format_idle_children
|
||||
# _nudge`` rendered into ``text`` (one source, no drift).
|
||||
mgr, storage, ws = coord_setup
|
||||
_add_active_child(storage, ws_id="child-a", name="research", state="running")
|
||||
_add_active_child(storage, ws_id="child-b", name="deploy", state="thinking")
|
||||
ws.session.messages = turns_from_dicts(
|
||||
[
|
||||
{"role": "user", "content": "go"},
|
||||
{"role": "assistant", "content": "ok"},
|
||||
]
|
||||
)
|
||||
|
||||
observer = CoordinatorIdleObserver(mgr, storage)
|
||||
observer.start()
|
||||
mgr.fire_state(ws.id, WorkstreamState.IDLE)
|
||||
|
||||
snap = ws.session._nudge_queue.pending_with_metadata(channel="any")
|
||||
assert len(snap) == 1
|
||||
meta = snap[0][2]
|
||||
assert meta == {
|
||||
"children": [
|
||||
{"ws_id": "child-a", "name": "research", "state": "running"},
|
||||
{"ws_id": "child-b", "name": "deploy", "state": "thinking"},
|
||||
]
|
||||
}
|
||||
|
||||
def test_idle_with_no_active_children_no_enqueue(self, coord_setup):
|
||||
mgr, storage, ws = coord_setup
|
||||
# storage.children is empty
|
||||
|
||||
@@ -56,6 +56,28 @@ class TestSystemTurnProjection:
|
||||
assert history[0]["source"] == "user_interjection"
|
||||
assert history[0]["content"] == "check the logs"
|
||||
|
||||
def test_system_turn_source_meta_projects(self) -> None:
|
||||
# ``_source_meta`` → ``meta`` so a reconnecting tab rebuilds the same
|
||||
# per-kind card (the watch-result card etc.) the live SSE event drives.
|
||||
history = project_history_messages(
|
||||
[
|
||||
{
|
||||
"role": "system",
|
||||
"_source": "watch_triggered",
|
||||
"content": "ci failed",
|
||||
"_source_meta": {"watch_name": "ci", "poll_count": 3},
|
||||
}
|
||||
]
|
||||
)
|
||||
assert history[0]["source"] == "watch_triggered"
|
||||
assert history[0]["meta"] == {"watch_name": "ci", "poll_count": 3}
|
||||
|
||||
def test_system_turn_without_meta_omits_meta_field(self) -> None:
|
||||
history = project_history_messages(
|
||||
[{"role": "system", "_source": "correction", "content": "watch out"}]
|
||||
)
|
||||
assert "meta" not in history[0]
|
||||
|
||||
def test_legacy_reminders_column_not_projected(self) -> None:
|
||||
"""A pre-migration row that still carries ``_reminders`` must NOT
|
||||
surface a ``reminders`` field — the projection dropped that lane."""
|
||||
|
||||
@@ -70,7 +70,7 @@ class _FakeUI:
|
||||
def on_state_change(self, state: str) -> None:
|
||||
self.events.append(("state", state))
|
||||
|
||||
def on_system_turn(self, content: str, source: str) -> None:
|
||||
def on_system_turn(self, content: str, source: str, meta: dict | None = None) -> None:
|
||||
self.events.append(("system_turn", content, source))
|
||||
|
||||
def on_error(self, message: str) -> None:
|
||||
|
||||
@@ -372,6 +372,61 @@ class TestSystemTurns:
|
||||
assert msgs[2]["role"] == "system"
|
||||
assert "_source" not in msgs[2]
|
||||
|
||||
def test_system_row_with_meta_column_reconstructed(self):
|
||||
# A system row carrying the JSON ``meta`` column (migration 060) rehydrates
|
||||
# the structured operator meta onto the dict as ``_source_meta`` — the
|
||||
# source the FE watch-result card derives from. Full 11-tuple:
|
||||
# (id, role, content, tool_name, tc_id, pdata, tool_calls, source,
|
||||
# event_id, is_error, meta).
|
||||
meta_json = json.dumps({"watch_name": "ci", "command": "make test", "poll_count": 3})
|
||||
row = (
|
||||
1,
|
||||
"system",
|
||||
"ci failed",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
"watch_triggered",
|
||||
None,
|
||||
False,
|
||||
meta_json,
|
||||
)
|
||||
msgs = reconstruct_messages([row], "ws1")
|
||||
assert msgs[0] == {
|
||||
"role": "system",
|
||||
"content": "ci failed",
|
||||
"_source": "watch_triggered",
|
||||
"_source_meta": {"watch_name": "ci", "command": "make test", "poll_count": 3},
|
||||
}
|
||||
|
||||
def test_legacy_short_row_without_meta_column_valid(self):
|
||||
# A pre-meta 8-tuple row reconstructs fine (defensive length check) and
|
||||
# carries no ``_source_meta`` key.
|
||||
rows = [_row("system", "old note", source="output_guard")]
|
||||
msgs = reconstruct_messages(rows, "ws1")
|
||||
assert msgs[0]["_source"] == "output_guard"
|
||||
assert "_source_meta" not in msgs[0]
|
||||
|
||||
def test_malformed_meta_column_dropped_not_crashed(self):
|
||||
# A non-JSON / non-object meta column is dropped (the human-readable body
|
||||
# still rides ``content``), never raised.
|
||||
row = (
|
||||
1,
|
||||
"system",
|
||||
"note",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
"output_guard",
|
||||
None,
|
||||
False,
|
||||
"{bad json",
|
||||
)
|
||||
msgs = reconstruct_messages([row], "ws1")
|
||||
assert "_source_meta" not in msgs[0]
|
||||
|
||||
def test_trailing_system_after_incomplete_assistant_strips_both(self):
|
||||
"""A nudge appended after an interrupted tool-call turn must not leave
|
||||
the orphaned assistant — the strip walks through the trailing system
|
||||
|
||||
+33
-5
@@ -61,7 +61,7 @@ class NullUI:
|
||||
def on_error(self, message):
|
||||
pass
|
||||
|
||||
def on_system_turn(self, content, source):
|
||||
def on_system_turn(self, content, source, meta=None):
|
||||
pass
|
||||
|
||||
def on_state_change(self, state):
|
||||
@@ -2942,7 +2942,15 @@ class TestMetacognitiveBuffers:
|
||||
assert "HIGH" in content
|
||||
assert "API key detected" in content
|
||||
assert "redacted" in content.lower()
|
||||
assert meta == {}
|
||||
# The structured finding rides as meta (the source the FE card and the
|
||||
# rendered ``content`` both derive from); ``redacted`` is the boolean
|
||||
# projection of ``sanitized is not None``.
|
||||
assert meta == {
|
||||
"flags": ["credential_leak"],
|
||||
"risk_level": "high",
|
||||
"annotations": ["API key detected"],
|
||||
"redacted": True,
|
||||
}
|
||||
|
||||
def test_collect_advisories_drains_queued_messages_on_last_result(self, tmp_db):
|
||||
"""Queued user messages drain into a ``user_interjection`` spec on
|
||||
@@ -2958,7 +2966,10 @@ class TestMetacognitiveBuffers:
|
||||
assert len(specs) == 1
|
||||
source, content, meta = specs[0]
|
||||
assert source == "user_interjection"
|
||||
assert meta == {"priority": "notice"}
|
||||
# Meta carries the priority AND the user's raw words, so the FE renders
|
||||
# a clean "queued message" bubble while ``content`` keeps the framed,
|
||||
# model-facing wording.
|
||||
assert meta == {"priority": "notice", "message": "hows it going?"}
|
||||
# Framed as the user's words (known #2 — keeps user, not operator,
|
||||
# authority, especially on the native path), not the raw text.
|
||||
assert content.endswith("User message: hows it going?")
|
||||
@@ -3523,9 +3534,11 @@ class TestMetacognitiveBuffers:
|
||||
with patch("turnstone.core.session.save_message"):
|
||||
session._emit_pending_user_nudges()
|
||||
assert session.ui.on_system_turn.call_count == 1
|
||||
content, source = session.ui.on_system_turn.call_args.args
|
||||
content, source, meta = session.ui.on_system_turn.call_args.args
|
||||
assert content == "watch out"
|
||||
assert source == "correction"
|
||||
# ``correction`` is a static nudge — no structured per-kind meta.
|
||||
assert meta is None
|
||||
|
||||
def test_emit_user_nudges_swallows_on_system_turn_failure(self, tmp_db):
|
||||
"""A UI hook that raises (queue full, unexpected bug) must not abort
|
||||
@@ -4388,9 +4401,24 @@ class TestSessionUIBaseSystemTurnHook:
|
||||
|
||||
ui = _RecordingUI()
|
||||
ui.on_system_turn("watch out", "correction")
|
||||
# A static-text kind carries no structured meta → ``meta`` is None.
|
||||
assert ui.events == [
|
||||
{"type": "system_turn", "content": "watch out", "source": "correction"}
|
||||
{
|
||||
"type": "system_turn",
|
||||
"content": "watch out",
|
||||
"source": "correction",
|
||||
"meta": None,
|
||||
}
|
||||
]
|
||||
# A structured kind rides its per-kind meta on the event so the FE
|
||||
# rebuilds the card live, in lockstep with /history replay.
|
||||
ui.on_system_turn("ci failed", "watch_triggered", {"watch_name": "ci", "poll_count": 3})
|
||||
assert ui.events[-1] == {
|
||||
"type": "system_turn",
|
||||
"content": "ci failed",
|
||||
"source": "watch_triggered",
|
||||
"meta": {"watch_name": "ci", "poll_count": 3},
|
||||
}
|
||||
|
||||
def test_on_system_turn_carries_each_source_kind(self):
|
||||
from turnstone.core.session_ui_base import SessionUIBase
|
||||
|
||||
@@ -36,6 +36,36 @@ class TestSaveAndLoadMessages:
|
||||
assert msgs[1]["role"] == "assistant"
|
||||
assert msgs[1]["content"] == "world"
|
||||
|
||||
def test_system_turn_meta_roundtrip(self, backend):
|
||||
# The structured per-kind operator meta saved on a ``system`` row
|
||||
# round-trips back as ``_source_meta`` through the real insert + SELECT
|
||||
# (the ``conversations.meta`` column, migration 060).
|
||||
import json
|
||||
|
||||
backend.register_workstream("s1")
|
||||
backend.save_message("s1", "user", "go")
|
||||
backend.save_message(
|
||||
"s1",
|
||||
"system",
|
||||
"ci failed",
|
||||
source="watch_triggered",
|
||||
meta=json.dumps({"watch_name": "ci", "command": "make test", "poll_count": 3}),
|
||||
)
|
||||
msgs = backend.load_messages("s1")
|
||||
assert msgs[1] == {
|
||||
"role": "system",
|
||||
"content": "ci failed",
|
||||
"_source": "watch_triggered",
|
||||
"_source_meta": {"watch_name": "ci", "command": "make test", "poll_count": 3},
|
||||
}
|
||||
|
||||
def test_ordinary_row_has_null_meta(self, backend):
|
||||
# A non-operator row carries no meta — no ``_source_meta`` on reload.
|
||||
backend.register_workstream("s1")
|
||||
backend.save_message("s1", "user", "hello")
|
||||
msgs = backend.load_messages("s1")
|
||||
assert "_source_meta" not in msgs[0]
|
||||
|
||||
def test_tool_call_grouping(self, backend):
|
||||
import json
|
||||
|
||||
|
||||
+81
-19
@@ -8,6 +8,7 @@ from turnstone.core.tool_advisory import (
|
||||
SYSTEM_TURN_SOURCES,
|
||||
make_system_turn,
|
||||
parse_priority,
|
||||
render_output_guard_text,
|
||||
render_user_interjection,
|
||||
)
|
||||
|
||||
@@ -23,31 +24,40 @@ class TestMakeSystemTurn:
|
||||
"content": "check auth too",
|
||||
}
|
||||
|
||||
def test_meta_keys_underscore_prefixed(self) -> None:
|
||||
turn = make_system_turn(
|
||||
"watch_triggered", "ci failed", watch_name="ci", priority="important"
|
||||
)
|
||||
assert turn["_watch_name"] == "ci"
|
||||
assert turn["_priority"] == "important"
|
||||
assert turn["role"] == "system"
|
||||
assert turn["_source"] == "watch_triggered"
|
||||
assert turn["content"] == "ci failed"
|
||||
def test_meta_carried_as_source_meta_dict(self) -> None:
|
||||
# Meta rides as ONE ``_source_meta`` dict (not scattered ``_``-prefixed
|
||||
# siblings) — one carrier mapping to one storage column / one FE field.
|
||||
turn = make_system_turn("watch_triggered", "ci failed", watch_name="ci", poll_count=3)
|
||||
assert turn == {
|
||||
"role": "system",
|
||||
"_source": "watch_triggered",
|
||||
"content": "ci failed",
|
||||
"_source_meta": {"watch_name": "ci", "poll_count": 3},
|
||||
}
|
||||
|
||||
def test_already_underscored_meta_kept(self) -> None:
|
||||
turn = make_system_turn("repeat", "stop repeating", _event_id=7)
|
||||
assert turn["_event_id"] == 7
|
||||
def test_no_meta_omits_source_meta(self) -> None:
|
||||
# A kind with no structured fields carries no ``_source_meta`` key.
|
||||
turn = make_system_turn("output_guard", "flag (HIGH)")
|
||||
assert turn == {
|
||||
"role": "system",
|
||||
"_source": "output_guard",
|
||||
"content": "flag (HIGH)",
|
||||
}
|
||||
assert "_source_meta" not in turn
|
||||
|
||||
def test_unknown_source_rejected(self) -> None:
|
||||
with pytest.raises(ValueError, match="unknown system-turn source"):
|
||||
make_system_turn("bogus", "x")
|
||||
|
||||
def test_meta_key_colliding_with_source_rejected(self) -> None:
|
||||
# `source=` can't even reach **meta — it's a named parameter, so Python
|
||||
# raises TypeError first. The only reachable collision is an explicit
|
||||
# ``_source=`` meta key, which the builder rejects rather than letting it
|
||||
# silently clobber the validated source.
|
||||
with pytest.raises(ValueError, match="collides with reserved"):
|
||||
make_system_turn("repeat", "x", _source="evil")
|
||||
def test_meta_namespaced_cannot_clobber_reserved_keys(self) -> None:
|
||||
# Because meta rides namespaced inside ``_source_meta``, a meta key that
|
||||
# mirrors a reserved top-level key (``role`` / ``_source``) lands inside
|
||||
# the dict and cannot overwrite the validated turn fields — so the old
|
||||
# collision guard is no longer needed (the shape makes it impossible).
|
||||
turn = make_system_turn("repeat", "x", role="evil", _source="spoof")
|
||||
assert turn["role"] == "system"
|
||||
assert turn["_source"] == "repeat"
|
||||
assert turn["_source_meta"] == {"role": "evil", "_source": "spoof"}
|
||||
|
||||
def test_vocabulary_mirrors_nudge_map_both_directions(self) -> None:
|
||||
# The nudge-derived sources must equal _NUDGE_MAP exactly: a new nudge
|
||||
@@ -60,6 +70,58 @@ class TestMakeSystemTurn:
|
||||
assert nudge_sources == set(_NUDGE_MAP)
|
||||
|
||||
|
||||
class TestRenderOutputGuardText:
|
||||
"""render_output_guard_text() projects the structured guard meta to prose."""
|
||||
|
||||
def test_full_finding(self) -> None:
|
||||
text = render_output_guard_text(
|
||||
{
|
||||
"flags": ["aws_key", "private_key"],
|
||||
"risk_level": "high",
|
||||
"annotations": ["matched AKIA…", "PEM header"],
|
||||
"redacted": True,
|
||||
}
|
||||
)
|
||||
assert text == (
|
||||
"Output guard: aws_key, private_key (HIGH)\n"
|
||||
" matched AKIA…\n"
|
||||
" PEM header\n"
|
||||
"Credentials have been redacted. Do not attempt to reconstruct redacted values."
|
||||
)
|
||||
|
||||
def test_no_annotations_no_redaction(self) -> None:
|
||||
text = render_output_guard_text(
|
||||
{"flags": ["pii"], "risk_level": "low", "annotations": [], "redacted": False}
|
||||
)
|
||||
assert text == "Output guard: pii (LOW)"
|
||||
|
||||
def test_missing_keys_default_gracefully(self) -> None:
|
||||
# Defensive: a partial meta dict never raises.
|
||||
assert render_output_guard_text({}) == "Output guard: (NONE)"
|
||||
|
||||
|
||||
class TestMetaIsWireStripped:
|
||||
"""The structured operator meta must never reach the LLM wire."""
|
||||
|
||||
def test_source_meta_stripped_by_sanitize(self) -> None:
|
||||
from turnstone.core.providers._openai_common import sanitize_messages
|
||||
|
||||
out = sanitize_messages(
|
||||
[
|
||||
{"role": "user", "content": "hi"},
|
||||
{
|
||||
"role": "system",
|
||||
"_source": "watch_triggered",
|
||||
"content": "ci failed",
|
||||
"_source_meta": {"command": "rm -rf /", "watch_name": "ci"},
|
||||
},
|
||||
]
|
||||
)
|
||||
# No leading-underscore side channel survives to the wire.
|
||||
assert all(not k.startswith("_") for m in out for k in m)
|
||||
assert out[1] == {"role": "system", "content": "ci failed"}
|
||||
|
||||
|
||||
class TestParsePriority:
|
||||
"""parse_priority() extracts !!! prefix as priority signal."""
|
||||
|
||||
|
||||
@@ -90,6 +90,13 @@ _ROUNDTRIP: list[dict[str, Any]] = [
|
||||
},
|
||||
{"role": "system", "content": "guard note", "_source": "output_guard"},
|
||||
{"role": "system", "content": "you are an assistant"}, # base prompt, no _source
|
||||
# Operator turn with structured per-kind meta (the watch-result card source).
|
||||
{
|
||||
"role": "system",
|
||||
"content": "ci failed",
|
||||
"_source": "watch_triggered",
|
||||
"_source_meta": {"watch_name": "ci", "command": "make test", "poll_count": 3},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -109,6 +116,30 @@ def test_turns_from_dicts_preserves_order_and_count() -> None:
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Field mapping — the side channels become typed Turn fields.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_source_meta_maps_to_meta_extra() -> None:
|
||||
# The per-kind operator meta rides a single ``_source_meta`` dict and lands
|
||||
# in ``Turn.meta.extra["source_meta"]`` (and round-trips back out).
|
||||
t = turn_from_dict(
|
||||
{
|
||||
"role": "system",
|
||||
"content": "ci failed",
|
||||
"_source": "watch_triggered",
|
||||
"_source_meta": {"watch_name": "ci", "poll_count": 3},
|
||||
}
|
||||
)
|
||||
assert t.meta.extra["source_meta"] == {"watch_name": "ci", "poll_count": 3}
|
||||
assert turn_to_dict(t)["_source_meta"] == {"watch_name": "ci", "poll_count": 3}
|
||||
|
||||
|
||||
def test_empty_source_meta_omitted() -> None:
|
||||
# An empty meta dict is not carried (no key on the Turn, none re-emitted).
|
||||
t = turn_from_dict(
|
||||
{"role": "system", "content": "n", "_source": "correction", "_source_meta": {}}
|
||||
)
|
||||
assert "source_meta" not in t.meta.extra
|
||||
assert "_source_meta" not in turn_to_dict(t)
|
||||
|
||||
|
||||
def test_source_maps_to_source_field() -> None:
|
||||
t = turn_from_dict({"role": "system", "content": "n", "_source": "tool_error"})
|
||||
assert t.role is Role.SYSTEM
|
||||
|
||||
@@ -336,6 +336,10 @@ class TestBuildWatchReminder:
|
||||
assert reminder["type"] == "watch_triggered"
|
||||
assert reminder["watch_name"] == "pr-review"
|
||||
assert reminder["command"] == "gh pr view --json state"
|
||||
# The raw shell output rides as its own field so the FE card body shows
|
||||
# it alone (no header / command repeat); the wire ``text`` keeps the
|
||||
# full prose for the model.
|
||||
assert reminder["output"] == '{"state": "MERGED"}'
|
||||
assert reminder["poll_count"] == 5
|
||||
assert reminder["max_polls"] == 100
|
||||
assert reminder["is_final"] is True
|
||||
|
||||
+4
-2
@@ -301,12 +301,14 @@ class TerminalUI(SessionUI):
|
||||
sys.stdout.write(f"{RED}{message}{RESET}\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
def on_system_turn(self, content: str, source: str) -> None:
|
||||
def on_system_turn(self, content: str, source: str, meta: dict[str, Any] | None = None) -> None:
|
||||
"""Render a first-class operator-context system turn as an
|
||||
``[operator · source] text`` line in the terminal — the CLI's
|
||||
equivalent of the web UI's operator bubble. Terminal output is
|
||||
anchored by flow (the line lands directly after the turn it
|
||||
relates to), so no DOM anchoring is needed.
|
||||
relates to), so no DOM anchoring is needed. ``meta`` (the structured
|
||||
per-kind fields the web card uses) is unused here: the formatted body
|
||||
in ``content`` already reads well in a terminal.
|
||||
"""
|
||||
label = "operator" + (f" · {source}" if source else "")
|
||||
sys.stdout.write(f"{YELLOW}[{label}]{RESET} {content}\n")
|
||||
|
||||
@@ -39,6 +39,7 @@ from turnstone.core.log import get_logger
|
||||
from turnstone.core.metacognition import (
|
||||
_cooldown_allows,
|
||||
format_idle_children_nudge,
|
||||
sanitize_name,
|
||||
should_nudge,
|
||||
)
|
||||
from turnstone.core.trajectory import Role
|
||||
@@ -205,6 +206,21 @@ class CoordinatorIdleObserver:
|
||||
if not text: # belt-and-braces: formatter empty-input guard
|
||||
return
|
||||
|
||||
# Structured ``_source_meta`` for the FE idle-children card — the same
|
||||
# child list ``format_idle_children_nudge`` rendered into ``text`` above,
|
||||
# so the card and the model-facing prose derive from one source. Names
|
||||
# are sanitized identically (``sanitize_name``) so a steering-vector /
|
||||
# angle-bracket name can't reach the operator card any more than the
|
||||
# text; the FE additionally renders every field via ``textContent``.
|
||||
children_meta = [
|
||||
{
|
||||
"ws_id": str(c.get("ws_id", "")),
|
||||
"name": sanitize_name(str(c.get("name", ""))),
|
||||
"state": str(c.get("state", "")),
|
||||
}
|
||||
for c in active
|
||||
]
|
||||
|
||||
# Bind ws.id + user_id by closure so the predicate captures the
|
||||
# workstream identity (not the live ``ws`` reference, which
|
||||
# could mutate). The predicate runs at drain time outside the
|
||||
@@ -235,6 +251,7 @@ class CoordinatorIdleObserver:
|
||||
text,
|
||||
"any",
|
||||
valid_until=_still_has_active_children,
|
||||
metadata={"children": children_meta},
|
||||
)
|
||||
with self._fire_counts_lock:
|
||||
ws_caps = self._fire_counts.setdefault(ws_id, {})
|
||||
|
||||
@@ -391,6 +391,180 @@
|
||||
return appendMsg(role, esc(text), opts);
|
||||
}
|
||||
|
||||
// Structured ``.msg.watch-result`` card for a ``watch_triggered``
|
||||
// operator-context system turn — command-preview header + shell-output body
|
||||
// + poll-counter footer. ``content`` is the formatted watch body (the system
|
||||
// turn's content); ``meta`` carries the structured fields (``watch_name`` /
|
||||
// ``command`` / ``poll_count`` / ``max_polls`` / ``is_final``) delivered live
|
||||
// on the ``system_turn`` SSE event and on the ``/history`` projection. All
|
||||
// text goes through ``textContent`` so shell output containing angle brackets
|
||||
// / scripts / steering bytes renders inertly. Mirrors the interactive pane's
|
||||
// _buildWatchResultBubble.
|
||||
function appendWatchResult(meta, content) {
|
||||
const el = document.createElement("div");
|
||||
el.className = "msg watch-result";
|
||||
el.setAttribute("role", "article");
|
||||
el.setAttribute("data-ts-role", "watch");
|
||||
el.setAttribute("aria-label", "watch");
|
||||
const header = document.createElement("div");
|
||||
header.className = "msg-watch-header";
|
||||
header.textContent =
|
||||
"watch" + (meta.watch_name ? " · " + String(meta.watch_name) : "");
|
||||
el.appendChild(header);
|
||||
if (meta.command) {
|
||||
const cmd = document.createElement("div");
|
||||
cmd.className = "msg-watch-cmd";
|
||||
cmd.textContent = "$ " + String(meta.command);
|
||||
el.appendChild(cmd);
|
||||
}
|
||||
const body = document.createElement("pre");
|
||||
body.className = "msg-watch-body";
|
||||
// Prefer the structured ``output`` (raw shell output alone) so the body
|
||||
// doesn't re-print the header / ``$ command`` lines the chrome already
|
||||
// shows; fall back to the full turn ``content`` for legacy turns that
|
||||
// predate the ``output`` meta field (migration 060 is additive).
|
||||
body.textContent =
|
||||
meta.output != null ? String(meta.output) : content || "";
|
||||
el.appendChild(body);
|
||||
if (meta.poll_count != null && meta.max_polls != null) {
|
||||
const footer = document.createElement("div");
|
||||
footer.className = "msg-watch-footer";
|
||||
const finalSuffix = meta.is_final ? " · final" : "";
|
||||
footer.textContent =
|
||||
"poll " +
|
||||
String(meta.poll_count) +
|
||||
"/" +
|
||||
String(meta.max_polls) +
|
||||
finalSuffix;
|
||||
el.appendChild(footer);
|
||||
}
|
||||
messagesEl.appendChild(el);
|
||||
_scheduleScroll();
|
||||
return el;
|
||||
}
|
||||
|
||||
// Structured ``.msg.guard-finding`` card for an ``output_guard``
|
||||
// operator-context system turn. ``meta`` carries ``{flags, risk_level,
|
||||
// annotations, redacted}``. Reuses the tool-row warning chip's risk / flags
|
||||
// / redaction vocabulary (``.coord-tool-row-warning``) so guard findings read
|
||||
// identically wherever they surface, then appends the annotations the inline
|
||||
// tool chip omits. All text via textContent. Mirrors the interactive pane's
|
||||
// _buildGuardFindingBubble.
|
||||
function appendGuardFinding(meta) {
|
||||
const el = document.createElement("div");
|
||||
el.className = "msg guard-finding";
|
||||
el.setAttribute("role", "article");
|
||||
el.setAttribute("data-ts-role", "output_guard");
|
||||
el.setAttribute("aria-label", "output guard");
|
||||
const risk = String(meta.risk_level || "medium");
|
||||
const warn = document.createElement("div");
|
||||
warn.className = "coord-tool-row-warning coord-tool-row-warning--" + risk;
|
||||
warn.setAttribute("role", "status");
|
||||
const label = document.createElement("span");
|
||||
label.className = "coord-tool-row-warning-label";
|
||||
label.textContent = "⚠ " + risk.toUpperCase();
|
||||
warn.appendChild(label);
|
||||
const flags = Array.isArray(meta.flags) ? meta.flags : [];
|
||||
if (flags.length) {
|
||||
warn.appendChild(document.createTextNode(" " + flags.join(", ")));
|
||||
}
|
||||
if (meta.redacted) {
|
||||
const redacted = document.createElement("span");
|
||||
redacted.className = "coord-tool-row-warning-redacted";
|
||||
redacted.textContent = " (credentials redacted)";
|
||||
warn.appendChild(redacted);
|
||||
}
|
||||
el.appendChild(warn);
|
||||
const anns = Array.isArray(meta.annotations) ? meta.annotations : [];
|
||||
for (let i = 0; i < anns.length; i++) {
|
||||
const a = document.createElement("div");
|
||||
a.className = "msg-guard-annotation";
|
||||
a.textContent = String(anns[i]);
|
||||
el.appendChild(a);
|
||||
}
|
||||
messagesEl.appendChild(el);
|
||||
_scheduleScroll();
|
||||
return el;
|
||||
}
|
||||
|
||||
// Structured ``.msg.idle-children`` card for the coordinator-only
|
||||
// ``idle_children`` operator-context system turn — lists the child
|
||||
// workstreams still running while the coordinator went idle. ``meta.children``
|
||||
// is ``[{ws_id, name, state}]`` (names already ``sanitize_name``-cleaned at the
|
||||
// producer); rendered via textContent so a hostile workstream name is inert.
|
||||
function appendIdleChildren(meta) {
|
||||
const el = document.createElement("div");
|
||||
el.className = "msg idle-children";
|
||||
el.setAttribute("role", "article");
|
||||
el.setAttribute("data-ts-role", "idle_children");
|
||||
el.setAttribute("aria-label", "idle children");
|
||||
const children = Array.isArray(meta.children) ? meta.children : [];
|
||||
const header = document.createElement("div");
|
||||
header.className = "msg-idle-header";
|
||||
header.textContent =
|
||||
"idle · " +
|
||||
children.length +
|
||||
(children.length === 1
|
||||
? " child still running"
|
||||
: " children still running");
|
||||
el.appendChild(header);
|
||||
const list = document.createElement("ul");
|
||||
list.className = "msg-idle-list";
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
const c = children[i] || {};
|
||||
const li = document.createElement("li");
|
||||
li.className = "msg-idle-child";
|
||||
const name = document.createElement("span");
|
||||
name.className = "msg-idle-child-name";
|
||||
name.textContent = String(c.name || c.ws_id || "child");
|
||||
li.appendChild(name);
|
||||
if (c.state) {
|
||||
const state = document.createElement("span");
|
||||
state.className = "msg-idle-child-state";
|
||||
state.textContent = String(c.state);
|
||||
li.appendChild(state);
|
||||
}
|
||||
list.appendChild(li);
|
||||
}
|
||||
el.appendChild(list);
|
||||
messagesEl.appendChild(el);
|
||||
_scheduleScroll();
|
||||
return el;
|
||||
}
|
||||
|
||||
// "queued message" bubble for a ``user_interjection`` system turn — shows the
|
||||
// user's raw words (``meta.message``) rather than the model-directed framing
|
||||
// baked into ``content``, with brighter emphasis for ``!!!``-important
|
||||
// interjections. Reuses ``appendText`` (→ ``.msg.system-context``) + a class.
|
||||
function appendInterjection(meta, content) {
|
||||
const important = meta && meta.priority === "important";
|
||||
const text =
|
||||
meta && meta.message != null ? String(meta.message) : content || "";
|
||||
const el = appendText("system", text, {
|
||||
label: important ? "queued message · important" : "queued message",
|
||||
});
|
||||
el.classList.add("interjection");
|
||||
if (important) el.classList.add("important");
|
||||
return el;
|
||||
}
|
||||
|
||||
// Dispatch a first-class operator-context system turn to the right renderer.
|
||||
// Shared by the live ``system_turn`` SSE handler and history replay so the
|
||||
// two can't drift on which kinds get structured cards. ``watch_triggered`` /
|
||||
// ``output_guard`` / ``idle_children`` carry structured ``meta`` → cards;
|
||||
// ``user_interjection`` → a "queued message" bubble; everything else → the
|
||||
// labeled operator bubble.
|
||||
function renderSystemTurn(source, content, meta) {
|
||||
const m = meta && typeof meta === "object" ? meta : null;
|
||||
if (source === "watch_triggered" && m)
|
||||
return appendWatchResult(m, content || "");
|
||||
if (source === "output_guard" && m) return appendGuardFinding(m);
|
||||
if (source === "idle_children" && m) return appendIdleChildren(m);
|
||||
if (source === "user_interjection")
|
||||
return appendInterjection(m, content || "");
|
||||
return appendText("system", content || "", { label: source || "system" });
|
||||
}
|
||||
|
||||
// User-message bubble with attachment-pill cluster appended below
|
||||
// the text. Mirrors Pane.addUserMessage in the interactive UI so
|
||||
// live-send and history-replay both render the same chip strip the
|
||||
@@ -2251,14 +2425,12 @@
|
||||
break;
|
||||
case "system_turn":
|
||||
// First-class operator-context system turn (output-guard finding,
|
||||
// user interjection, metacognitive nudge — see make_system_turn).
|
||||
// Consolidates the legacy user_reminder / tool_reminder events into
|
||||
// one operator bubble rendered in trajectory sequence (it FOLLOWS
|
||||
// the turn it advises). ``ev.source`` carries the kind; the
|
||||
// ``system`` _MSG_VARIANTS entry gives it the operator styling.
|
||||
appendText("system", ev.content || "", {
|
||||
label: ev.source || "system",
|
||||
});
|
||||
// user interjection, metacognitive nudge, watch result — see
|
||||
// make_system_turn). Rendered in trajectory sequence (it FOLLOWS the
|
||||
// turn it advises). ``renderSystemTurn`` routes by ``ev.source`` to the
|
||||
// structured card (watch / guard / idle-children) or the operator bubble
|
||||
// (carrying ``ev.meta`` so cards rebuild identically live and on replay).
|
||||
renderSystemTurn(ev.source || "", ev.content || "", ev.meta);
|
||||
break;
|
||||
case "connected":
|
||||
// First yield from _coord_events_replay — populates the
|
||||
@@ -4626,11 +4798,12 @@
|
||||
label: role,
|
||||
});
|
||||
} else if (role === "system") {
|
||||
// First-class operator-context system turn — label with the
|
||||
// ``source`` kind (output_guard / user_interjection / ...);
|
||||
// the ``system`` _MSG_VARIANTS entry gives it operator styling.
|
||||
// First-class operator-context system turn — ``renderSystemTurn``
|
||||
// routes by ``m.source`` to the structured card (watch / guard /
|
||||
// idle-children) or the operator bubble, reading ``m.meta`` from the
|
||||
// ``/history`` projection so replay matches the live render exactly.
|
||||
if (!content) return;
|
||||
appendText(role, content, { label: m.source || "system" });
|
||||
renderSystemTurn(m.source || "", content, m.meta);
|
||||
} else {
|
||||
if (!content) return;
|
||||
appendText(role, content, { label: role });
|
||||
|
||||
@@ -560,6 +560,17 @@ def project_history_messages(
|
||||
if msg.get("_source"):
|
||||
entry["source"] = str(msg["_source"])
|
||||
|
||||
# (3b) ``_source_meta`` side-channel → top-level ``meta``. The
|
||||
# operator turn's structured per-kind fields (``watch_triggered``'s
|
||||
# ``watch_name`` / command / poll counters) — the FE branches on
|
||||
# ``source`` and uses these to rebuild per-kind rendering (the
|
||||
# watch-result card) instead of a plain operator bubble. Mirrors
|
||||
# the live ``on_system_turn`` SSE event's ``meta`` field so a
|
||||
# reconnecting tab renders identically.
|
||||
source_meta = msg.get("_source_meta")
|
||||
if isinstance(source_meta, dict) and source_meta:
|
||||
entry["meta"] = source_meta
|
||||
|
||||
# (4) Reasoning is already stamped by ``extract_reasoning_for_history``
|
||||
# (gated on the active model's surface_persisted_reasoning flag) —
|
||||
# pass it through. This projection never re-extracts from
|
||||
|
||||
@@ -47,6 +47,7 @@ def save_message(
|
||||
event_id: int | None = None,
|
||||
is_error: bool = False,
|
||||
producer: str | None = None,
|
||||
meta: str | None = None,
|
||||
) -> int:
|
||||
"""Log a message to the conversations table.
|
||||
|
||||
@@ -61,6 +62,13 @@ def save_message(
|
||||
time (``SessionUIBase._event_id``); the caller in ``session.py``
|
||||
passes ``self.ui._event_id`` so ``/history`` can return it as the
|
||||
``Last-Event-ID`` resume cursor. ``None`` for offline / bulk saves.
|
||||
|
||||
``meta`` is the pre-serialized JSON of a first-class ``system`` turn's
|
||||
structured per-kind operator-context fields (e.g. ``watch_triggered``'s
|
||||
``watch_name`` / ``command`` / poll counters) — the persisted twin of the
|
||||
in-memory ``Turn.meta.extra["source_meta"]`` / ``_source_meta`` side
|
||||
channel. ``None`` for ordinary rows and operator turns with no extra
|
||||
fields. Opaque to storage (like ``tool_calls`` / ``provider_data``).
|
||||
"""
|
||||
try:
|
||||
return get_storage().save_message(
|
||||
@@ -75,6 +83,7 @@ def save_message(
|
||||
event_id=event_id,
|
||||
is_error=is_error,
|
||||
producer=producer,
|
||||
meta=meta,
|
||||
)
|
||||
except Exception:
|
||||
log.warning("Failed to save message for ws=%s role=%s", ws_id, role, exc_info=True)
|
||||
|
||||
+40
-17
@@ -116,6 +116,7 @@ from turnstone.core.storage._utils import (
|
||||
)
|
||||
from turnstone.core.tool_advisory import (
|
||||
make_system_turn,
|
||||
render_output_guard_text,
|
||||
render_user_interjection,
|
||||
)
|
||||
from turnstone.core.tool_search import ToolSearchManager
|
||||
@@ -800,7 +801,9 @@ class SessionUI(Protocol):
|
||||
def on_status(self, usage: dict[str, Any], context_window: int, effort: str) -> None: ...
|
||||
def on_info(self, message: str) -> None: ...
|
||||
def on_error(self, message: str) -> None: ...
|
||||
def on_system_turn(self, content: str, source: str) -> None: ...
|
||||
def on_system_turn(
|
||||
self, content: str, source: str, meta: dict[str, Any] | None = None
|
||||
) -> None: ...
|
||||
def on_state_change(self, state: str) -> None: ...
|
||||
def on_rename(self, name: str) -> None: ...
|
||||
def on_intent_verdict(self, verdict: dict[str, Any]) -> None:
|
||||
@@ -2602,6 +2605,11 @@ class ChatSession:
|
||||
except (TypeError, ValueError):
|
||||
pd_str = None
|
||||
src = msg.get("_source")
|
||||
# Operator-context per-kind meta (``_source_meta`` dict) rides
|
||||
# the fork too, so a forked watch-result keeps its structured
|
||||
# card. Serialized to JSON for the ``conversations.meta`` column.
|
||||
sm = msg.get("_source_meta")
|
||||
meta_json = json.dumps(sm) if isinstance(sm, dict) and sm else None
|
||||
bulk_rows.append(
|
||||
{
|
||||
"ws_id": self._ws_id,
|
||||
@@ -2614,6 +2622,7 @@ class ChatSession:
|
||||
"source": src if isinstance(src, str) and src else None,
|
||||
"is_error": bool(msg.get("is_error", False)),
|
||||
"producer": msg.get("_producer"),
|
||||
"meta": meta_json,
|
||||
}
|
||||
)
|
||||
save_messages_bulk(bulk_rows)
|
||||
@@ -3728,21 +3737,29 @@ class ChatSession:
|
||||
throwing here must not abort the turn.
|
||||
|
||||
*source* must be one of :data:`tool_advisory.SYSTEM_TURN_SOURCES`;
|
||||
extra *meta* rides as leading-underscore sibling keys (stripped
|
||||
before the wire by ``sanitize_messages``).
|
||||
extra *meta* is the turn's structured per-kind data (e.g.
|
||||
``watch_triggered``'s ``watch_name`` / command / poll counters). It
|
||||
rides three boundaries in lockstep: the in-memory ``Turn`` (as
|
||||
``meta.extra["source_meta"]`` via :func:`make_system_turn` →
|
||||
``turn_from_dict``), the persisted ``conversations.meta`` column (JSON),
|
||||
and the live ``on_system_turn`` SSE hook — so a reconnecting tab and a
|
||||
live mirror both rebuild the same per-kind bubble. It is stripped before
|
||||
the LLM wire (``_source_meta`` is a ``_``-prefixed key).
|
||||
"""
|
||||
turn = make_system_turn(source, content, **meta)
|
||||
self.messages.append(turn_from_dict(turn))
|
||||
self._msg_tokens.append(max(1, int(self._msg_char_count(turn) / self._chars_per_token)))
|
||||
meta_json = json.dumps(meta) if meta else None
|
||||
save_message(
|
||||
self._ws_id,
|
||||
"system",
|
||||
content,
|
||||
source=source,
|
||||
event_id=self._ui_event_id(),
|
||||
meta=meta_json,
|
||||
)
|
||||
try:
|
||||
self.ui.on_system_turn(content, source)
|
||||
self.ui.on_system_turn(content, source, meta or None)
|
||||
except Exception:
|
||||
log.warning("ui.on_system_turn failed; system turn still appended", exc_info=True)
|
||||
|
||||
@@ -5887,19 +5904,19 @@ class ChatSession:
|
||||
"""
|
||||
specs: list[tuple[str, str, dict[str, Any]]] = []
|
||||
|
||||
# Output guard advisory — rendered inline (the wire/UI content the
|
||||
# model and operator see). Mirrors the legacy GuardAdvisory.render.
|
||||
# Output guard advisory. The structured finding is the source of
|
||||
# truth: build ``meta`` (flags / risk / annotations / redaction), then
|
||||
# derive the wire/UI text ``content`` from it via
|
||||
# ``render_output_guard_text`` so the prose the model reads and the FE
|
||||
# guard-finding card cannot drift. Mirrors the legacy GuardAdvisory.
|
||||
if assessment is not None:
|
||||
lines = [
|
||||
f"Output guard: {', '.join(assessment.flags)} ({assessment.risk_level.upper()})"
|
||||
]
|
||||
for ann in assessment.annotations:
|
||||
lines.append(f" {ann}")
|
||||
if assessment.sanitized is not None:
|
||||
lines.append(
|
||||
"Credentials have been redacted. Do not attempt to reconstruct redacted values."
|
||||
)
|
||||
specs.append(("output_guard", "\n".join(lines), {}))
|
||||
guard_meta: dict[str, Any] = {
|
||||
"flags": list(assessment.flags),
|
||||
"risk_level": assessment.risk_level,
|
||||
"annotations": list(assessment.annotations),
|
||||
"redacted": assessment.sanitized is not None,
|
||||
}
|
||||
specs.append(("output_guard", render_output_guard_text(guard_meta), guard_meta))
|
||||
|
||||
# Last-result-in-batch drain seams: queued user messages (Seam 1)
|
||||
# and tool/any-channel metacog nudges. Both fire once per batch so a
|
||||
@@ -5917,8 +5934,14 @@ class ChatSession:
|
||||
# where it enters as a real role=system message.
|
||||
if not text.strip():
|
||||
continue
|
||||
# ``framed`` (preamble + "User message: …") is the model-facing
|
||||
# content — it keeps the user's authority framing on the wire.
|
||||
# The structured meta carries the user's RAW words + priority so
|
||||
# the FE renders a clean "queued message" bubble (the operator
|
||||
# reads the message, not the model-directed preamble) with
|
||||
# priority emphasis. Both derive from ``(text, priority)``.
|
||||
framed = render_user_interjection(text, priority)
|
||||
specs.append(("user_interjection", framed, {"priority": priority}))
|
||||
specs.append(("user_interjection", framed, {"priority": priority, "message": text}))
|
||||
|
||||
# Metacognitive tool-channel drain. Queued by
|
||||
# ``_queue_tool_advisory`` from the tool_error / repeat
|
||||
|
||||
@@ -2363,23 +2363,29 @@ class SessionUIBase:
|
||||
def on_error(self, message: str) -> None:
|
||||
self._enqueue({"type": "error", "message": message})
|
||||
|
||||
def on_system_turn(self, content: str, source: str) -> None:
|
||||
def on_system_turn(self, content: str, source: str, meta: dict[str, Any] | None = None) -> None:
|
||||
"""Surface a first-class operator-context system turn as its own
|
||||
UI element.
|
||||
|
||||
Operator context (output-guard findings, user interjections,
|
||||
metacognitive nudges) lives in the conversation trajectory as a
|
||||
real ``{"role": "system", "_source": <kind>, ...}`` turn (see
|
||||
``tool_advisory.make_system_turn``). This event lets every
|
||||
metacognitive nudges, watch results) lives in the conversation
|
||||
trajectory as a real ``{"role": "system", "_source": <kind>, ...}``
|
||||
turn (see ``tool_advisory.make_system_turn``). This event lets every
|
||||
connected SSE consumer (other browser tabs, CLI mirrors, future
|
||||
channel adapters) render the operator bubble in lockstep with the
|
||||
originating tab's optimistic render. The history-replay path
|
||||
surfaces the same shape via ``project_history_messages`` (the REST
|
||||
``/history`` projection) so a tab reconnecting later renders the
|
||||
same bubble. ``source`` carries the turn's ``_source`` kind so
|
||||
the frontend can label / style the bubble.
|
||||
the frontend can label / style the bubble; ``meta`` carries the
|
||||
turn's structured per-kind fields (``watch_triggered``'s
|
||||
``watch_name`` / command / poll counters) so the frontend can rebuild
|
||||
per-kind rendering (the watch-result card). ``None`` for kinds with
|
||||
no structured data.
|
||||
"""
|
||||
self._enqueue({"type": "system_turn", "content": content, "source": source})
|
||||
self._enqueue(
|
||||
{"type": "system_turn", "content": content, "source": source, "meta": meta or None}
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Broadcast hooks — kind-specific transport.
|
||||
|
||||
@@ -308,6 +308,7 @@ class PostgreSQLBackend:
|
||||
event_id: int | None = None,
|
||||
is_error: bool = False,
|
||||
producer: str | None = None,
|
||||
meta: str | None = None,
|
||||
) -> int:
|
||||
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
content = sanitize_text(content)
|
||||
@@ -330,6 +331,7 @@ class PostgreSQLBackend:
|
||||
_source=source,
|
||||
event_id=event_id,
|
||||
is_error=is_error,
|
||||
meta=meta,
|
||||
)
|
||||
.returning(conversations.c.id)
|
||||
)
|
||||
@@ -366,6 +368,7 @@ class PostgreSQLBackend:
|
||||
"tool_calls": row.get("tool_calls"),
|
||||
"_source": sanitize_text(row.get("source")),
|
||||
"is_error": bool(row.get("is_error", False)),
|
||||
"meta": row.get("meta"),
|
||||
}
|
||||
)
|
||||
with self._conn() as conn:
|
||||
@@ -382,7 +385,7 @@ class PostgreSQLBackend:
|
||||
"""Fetch a ws's conversation rows + resolved attachment map (shared by
|
||||
:meth:`load_messages` and :meth:`load_message_turns`). The trailing
|
||||
``attachments`` ref-list column is split off and is NOT part of the
|
||||
positional tuple ``reconstruct_*`` unpacks (id..is_error)."""
|
||||
positional tuple ``reconstruct_*`` unpacks (id..meta)."""
|
||||
_cols = (
|
||||
conversations.c.id,
|
||||
conversations.c.role,
|
||||
@@ -394,6 +397,7 @@ class PostgreSQLBackend:
|
||||
conversations.c._source,
|
||||
conversations.c.event_id,
|
||||
conversations.c.is_error,
|
||||
conversations.c.meta,
|
||||
conversations.c.attachments,
|
||||
)
|
||||
with self._conn() as conn:
|
||||
@@ -412,7 +416,7 @@ class PostgreSQLBackend:
|
||||
.order_by(conversations.c.id)
|
||||
).fetchall()
|
||||
attachments = self._resolve_row_attachments(rows)
|
||||
msg_rows = [tuple(r)[:10] for r in rows]
|
||||
msg_rows = [tuple(r)[:11] for r in rows]
|
||||
return msg_rows, (attachments or None)
|
||||
|
||||
def load_messages(
|
||||
@@ -438,7 +442,7 @@ class PostgreSQLBackend:
|
||||
attachment_refs: dict[int, list[str]] = {}
|
||||
all_ids: set[str] = set()
|
||||
for r in rows:
|
||||
ids = _parse_attachment_refs(r[10])
|
||||
ids = _parse_attachment_refs(r[11])
|
||||
if ids:
|
||||
attachment_refs[r[0]] = ids
|
||||
all_ids.update(ids)
|
||||
|
||||
@@ -162,6 +162,7 @@ class StorageBackend(Protocol):
|
||||
event_id: int | None = None,
|
||||
is_error: bool = False,
|
||||
producer: str | None = None,
|
||||
meta: str | None = None,
|
||||
) -> int:
|
||||
"""Log a message to the conversations table.
|
||||
|
||||
@@ -178,6 +179,10 @@ class StorageBackend(Protocol):
|
||||
time (``SessionUIBase._event_id``) — the ``Last-Event-ID`` resume
|
||||
cursor space, distinct from the returned ``id`` PK. NULL when the
|
||||
caller has no live UI counter (offline / bulk / fork re-saves).
|
||||
|
||||
``meta`` is the pre-serialized JSON of an operator-context ``system``
|
||||
turn's structured per-kind fields (the ``_source_meta`` side channel);
|
||||
opaque to storage and NULL for ordinary rows.
|
||||
"""
|
||||
...
|
||||
|
||||
@@ -187,8 +192,8 @@ class StorageBackend(Protocol):
|
||||
Each dict must include ``ws_id``, ``role``, and ``content``
|
||||
(which may be ``None`` for assistant messages with only tool_calls).
|
||||
Optional keys: ``tool_name``, ``tool_call_id``, ``provider_data``,
|
||||
``tool_calls``, ``source``. Timestamp and workstream updated-at
|
||||
are handled internally.
|
||||
``tool_calls``, ``source``, ``meta``. Timestamp and workstream
|
||||
updated-at are handled internally.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@@ -65,6 +65,16 @@ conversations = sa.Table(
|
||||
# (JSON; NULL for turns with no attachments) — the sole message->blob link in
|
||||
# the content-addressed model; bytes resolve from workstream_attachments by id.
|
||||
sa.Column("attachments", sa.Text, nullable=True),
|
||||
# Structured per-kind operator-context metadata for a first-class ``system``
|
||||
# turn (JSON object; NULL for ordinary turns and for operator turns with no
|
||||
# extra fields). The persisted twin of the in-memory
|
||||
# ``Turn.meta.extra["source_meta"]`` / the ``_source_meta`` side channel:
|
||||
# ``watch_triggered`` carries ``{watch_name, command, poll_count, max_polls,
|
||||
# is_final}`` so ``/history`` can rebuild the structured watch-result card;
|
||||
# other kinds (``user_interjection`` → ``{priority}``) ride generically.
|
||||
# Stripped before the LLM wire (it is a ``_``-prefixed key by the time it
|
||||
# reaches a provider). Added in migration 060.
|
||||
sa.Column("meta", sa.Text, nullable=True),
|
||||
)
|
||||
|
||||
sa.Index("idx_conversations_timestamp", conversations.c.timestamp)
|
||||
|
||||
@@ -342,6 +342,7 @@ class SQLiteBackend:
|
||||
event_id: int | None = None,
|
||||
is_error: bool = False,
|
||||
producer: str | None = None,
|
||||
meta: str | None = None,
|
||||
) -> int:
|
||||
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
content = sanitize_text(content)
|
||||
@@ -364,6 +365,7 @@ class SQLiteBackend:
|
||||
"_source": source,
|
||||
"event_id": event_id,
|
||||
"is_error": is_error,
|
||||
"meta": meta,
|
||||
},
|
||||
)
|
||||
if result.lastrowid is None:
|
||||
@@ -414,6 +416,7 @@ class SQLiteBackend:
|
||||
"tool_calls": row.get("tool_calls"),
|
||||
"_source": sanitize_text(row.get("source")),
|
||||
"is_error": bool(row.get("is_error", False)),
|
||||
"meta": row.get("meta"),
|
||||
}
|
||||
)
|
||||
with self._conn() as conn:
|
||||
@@ -442,7 +445,7 @@ class SQLiteBackend:
|
||||
Shared by :meth:`load_messages` (→ dicts, resolved for display) and
|
||||
:meth:`load_message_turns` (→ canonical Turns for resume). The trailing
|
||||
``attachments`` ref-list column is split off to resolve blobs and is NOT
|
||||
part of the positional tuple ``reconstruct_*`` unpacks (id..is_error).
|
||||
part of the positional tuple ``reconstruct_*`` unpacks (id..meta).
|
||||
"""
|
||||
_cols = (
|
||||
conversations.c.id,
|
||||
@@ -455,6 +458,7 @@ class SQLiteBackend:
|
||||
conversations.c._source,
|
||||
conversations.c.event_id,
|
||||
conversations.c.is_error,
|
||||
conversations.c.meta,
|
||||
conversations.c.attachments,
|
||||
)
|
||||
with self._conn() as conn:
|
||||
@@ -477,7 +481,7 @@ class SQLiteBackend:
|
||||
).fetchall()
|
||||
|
||||
attachments = self._resolve_row_attachments(rows)
|
||||
msg_rows = [tuple(r)[:10] for r in rows]
|
||||
msg_rows = [tuple(r)[:11] for r in rows]
|
||||
return msg_rows, (attachments or None)
|
||||
|
||||
def load_messages(
|
||||
@@ -506,7 +510,7 @@ class SQLiteBackend:
|
||||
attachment_refs: dict[int, list[str]] = {}
|
||||
all_ids: set[str] = set()
|
||||
for r in rows:
|
||||
ids = _parse_attachment_refs(r[10])
|
||||
ids = _parse_attachment_refs(r[11])
|
||||
if ids:
|
||||
attachment_refs[r[0]] = ids
|
||||
all_ids.update(ids)
|
||||
|
||||
@@ -538,15 +538,17 @@ def reconstruct_messages(
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Reconstruct OpenAI message format from stored conversation rows.
|
||||
|
||||
Each *row* is an 8- or 9-tuple ``(id, role, content, tool_name,
|
||||
tool_call_id, provider_data, tool_calls_json, source [, event_id])``,
|
||||
ordered chronologically by row id. ``source`` is rehydrated as the
|
||||
``_source`` side channel. (The legacy ``_reminders`` column that used to
|
||||
ride here was dropped in migration 060 — operator context lives in
|
||||
first-class ``system`` turns now.) The optional 9th element
|
||||
``event_id`` (migration 059, the per-ws SSE ``Last-Event-ID`` resume
|
||||
cursor) is surfaced as the ``_event_id`` side-channel; legacy 9-tuple
|
||||
fixtures omit it (handled by the defensive unpack below).
|
||||
Each *row* is an 8-to-11-tuple ``(id, role, content, tool_name,
|
||||
tool_call_id, provider_data, tool_calls_json, source [, event_id [, is_error
|
||||
[, meta]]])``, ordered chronologically by row id. ``source`` is rehydrated
|
||||
as the ``_source`` side channel. (The legacy ``_reminders`` column that used
|
||||
to ride here was dropped in migration 060 — operator context lives in
|
||||
first-class ``system`` turns now.) The trailing optional elements —
|
||||
``event_id`` (migration 059, the per-ws SSE ``Last-Event-ID`` resume cursor →
|
||||
``_event_id``), ``is_error`` (migration 060, the persisted tool-result error
|
||||
flag), and ``meta`` (migration 060, an operator-context turn's structured
|
||||
``_source_meta``) — are handled by the defensive unpack so shorter legacy
|
||||
fixtures stay valid.
|
||||
|
||||
When ``attachments_by_msg`` is provided (keyed by row id, each value an
|
||||
ordered list of content-addressed attachment rows resolved from the
|
||||
@@ -624,6 +626,24 @@ def _native_from_provider_data(provider_data: str | None) -> ProviderNative | No
|
||||
return None
|
||||
|
||||
|
||||
def _source_meta_from_json(meta_json: str | None) -> dict[str, Any] | None:
|
||||
"""Decode the stored ``meta`` column into an operator-context meta dict.
|
||||
|
||||
The persisted twin of ``Turn.meta.extra["source_meta"]`` — a first-class
|
||||
``system`` turn's structured per-kind fields (e.g. ``watch_triggered``'s
|
||||
``watch_name`` / ``command`` / poll counters). A decode failure or a
|
||||
non-object payload yields ``None`` (the meta is dropped — the human-readable
|
||||
body still lives in ``content``).
|
||||
"""
|
||||
if not meta_json:
|
||||
return None
|
||||
try:
|
||||
parsed = json.loads(meta_json)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return None
|
||||
return parsed if isinstance(parsed, dict) and parsed else None
|
||||
|
||||
|
||||
def _tool_calls_from_json(tool_calls_json: str | None) -> tuple[ToolCall, ...]:
|
||||
"""Decode the stored ``tool_calls`` column into typed :class:`ToolCall`s."""
|
||||
if not tool_calls_json:
|
||||
@@ -663,11 +683,15 @@ def reconstruct_turns(
|
||||
for row in rows:
|
||||
(row_id, role, content, _tool_name, tc_id, provider_data, tool_calls_json, source) = row[:8]
|
||||
# event_id (col 9, migration 059) — the per-ws SSE Last-Event-ID cursor;
|
||||
# is_error (col 10, migration 060) rides last. Defensive length checks
|
||||
# keep pre-event_id / pre-is_error fixtures valid.
|
||||
# is_error (col 10, migration 060); meta (col 11, also migration 060 — the
|
||||
# operator-context per-kind ``source_meta``) rides last. Defensive
|
||||
# length checks keep pre-event_id / pre-is_error / pre-meta fixtures valid.
|
||||
event_id = int(row[8]) if len(row) > 8 and row[8] is not None else None
|
||||
is_error = bool(row[9]) if len(row) > 9 else False
|
||||
meta = TurnMeta(event_id=event_id)
|
||||
source_meta = _source_meta_from_json(row[10]) if len(row) > 10 else None
|
||||
if source_meta is not None:
|
||||
meta.extra["source_meta"] = source_meta
|
||||
src = str(source) if source else None
|
||||
|
||||
if role == "user":
|
||||
|
||||
@@ -28,7 +28,10 @@ This migration retires both carriers:
|
||||
|
||||
This migration also carries the additive front of the canonical-trajectory storage cut:
|
||||
it adds the ``is_error`` column (persisting the tool-result error flag, previously an
|
||||
in-memory-only message key) and tags legacy bare-list ``provider_data`` rows with their
|
||||
in-memory-only message key), the ``meta`` column (a first-class ``system`` turn's
|
||||
structured per-kind operator-context fields — ``watch_triggered``'s ``watch_name`` /
|
||||
``command`` / poll counters etc. — so ``/history`` rebuilds the structured watch-result
|
||||
card; additive, no backfill), and tags legacy bare-list ``provider_data`` rows with their
|
||||
generating provider as the ``{producer, blocks}`` envelope (producer inferred from block
|
||||
types — see ``_infer_producer``, which must match the live save's ``provider_name``
|
||||
values), so the lowering layer can replay the native lane verbatim only to its producer.
|
||||
@@ -271,6 +274,15 @@ def upgrade() -> None:
|
||||
# backfill below (step 3) fills it from the legacy message_id link and
|
||||
# then drops message_id/reserved_* (step 4).
|
||||
batch_op.add_column(sa.Column("attachments", sa.Text, nullable=True))
|
||||
# Structured per-kind operator-context metadata for a first-class
|
||||
# ``system`` turn (JSON object; NULL otherwise) — ``watch_triggered``'s
|
||||
# ``{watch_name, command, poll_count, max_polls, is_final}`` etc., the
|
||||
# persisted twin of the in-memory ``_source_meta`` side channel — so
|
||||
# ``/history`` and the live SSE replay rebuild the structured
|
||||
# watch-result card. Additive, no backfill (operator turns predating
|
||||
# this stay plain text bubbles; their structured fields are unrecoverable
|
||||
# from the flattened ``content``).
|
||||
batch_op.add_column(sa.Column("meta", sa.Text, nullable=True))
|
||||
with op.batch_alter_table("workstream_attachments") as batch_op:
|
||||
batch_op.add_column(
|
||||
sa.Column("refcount", sa.Integer, nullable=False, server_default=sa.text("0"))
|
||||
@@ -423,6 +435,7 @@ def downgrade() -> None:
|
||||
batch_op.add_column(sa.Column("_reminders", sa.Text, nullable=True))
|
||||
batch_op.drop_column("is_error")
|
||||
batch_op.drop_column("attachments")
|
||||
batch_op.drop_column("meta")
|
||||
with op.batch_alter_table("workstream_attachments") as batch_op:
|
||||
batch_op.add_column(sa.Column("message_id", sa.Integer, nullable=True))
|
||||
batch_op.add_column(sa.Column("reserved_for_msg_id", sa.Text, nullable=True))
|
||||
|
||||
@@ -54,6 +54,29 @@ _USER_INTERJECTION_IMPORTANT_PREAMBLE: Final = (
|
||||
_USER_INTERJECTION_BODY_MARKER: Final = "\n\nUser message: "
|
||||
|
||||
|
||||
def render_output_guard_text(meta: dict[str, Any]) -> str:
|
||||
"""Render an ``output_guard`` system turn's text from its structured *meta*.
|
||||
|
||||
*meta* carries ``{flags, risk_level, annotations, redacted}``. Returns the
|
||||
operator/model-facing prose — the flag list + risk level, one indented line
|
||||
per annotation, and (when credentials were redacted) the do-not-reconstruct
|
||||
notice. This is the text projection of the structured guard finding: the
|
||||
producer (``ChatSession._collect_advisories``) builds *meta* and derives the
|
||||
turn ``content`` from it via this function, so the wire text and the FE
|
||||
guard-finding card cannot drift (both read the same *meta*).
|
||||
"""
|
||||
flags = meta.get("flags") or []
|
||||
risk_level = str(meta.get("risk_level") or "none")
|
||||
lines = [f"Output guard: {', '.join(flags)} ({risk_level.upper()})"]
|
||||
for ann in meta.get("annotations") or []:
|
||||
lines.append(f" {ann}")
|
||||
if meta.get("redacted"):
|
||||
lines.append(
|
||||
"Credentials have been redacted. Do not attempt to reconstruct redacted values."
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def render_user_interjection(message: str, priority: str) -> str:
|
||||
"""Frame a queued user *message* as user-authored operator context.
|
||||
|
||||
@@ -108,14 +131,22 @@ SYSTEM_TURN_SOURCES: Final = frozenset(
|
||||
def make_system_turn(source: str, content: str, **meta: Any) -> dict[str, Any]:
|
||||
"""Build a first-class operator-context system turn for ``self.messages``.
|
||||
|
||||
Returns ``{"role": "system", "_source": source, "content": content}``.
|
||||
*source* must be one of :data:`SYSTEM_TURN_SOURCES`. Extra keyword *meta*
|
||||
fields are attached as leading-underscore sibling keys (e.g.
|
||||
``watch_name="ci"`` → ``_watch_name``) so they ride the persisted record
|
||||
and the UI projection but are stripped before the LLM wire by
|
||||
``sanitize_messages``. Keys already underscore-prefixed are kept as-is; a
|
||||
meta key that normalises onto a reserved field (``_source``) is rejected so
|
||||
the validated source can't be silently clobbered.
|
||||
Returns ``{"role": "system", "_source": source, "content": content}``, plus a
|
||||
``_source_meta`` dict of the *meta* keyword fields when any are supplied.
|
||||
*source* must be one of :data:`SYSTEM_TURN_SOURCES`.
|
||||
|
||||
The *meta* fields are the turn's structured per-kind data (e.g.
|
||||
``watch_triggered``'s ``watch_name`` / ``command`` / ``poll_count`` /
|
||||
``max_polls`` / ``is_final``; ``user_interjection``'s ``priority``). They
|
||||
ride as ONE ``_source_meta`` dict — a single carrier that maps cleanly to the
|
||||
one ``conversations.meta`` storage column, the one ``Turn.meta.extra
|
||||
["source_meta"]`` field (via :func:`turnstone.core.trajectory.turn_from_dict`),
|
||||
and the one ``meta`` field the live ``on_system_turn`` SSE event / the
|
||||
``/history`` projection hand the frontend so it can rebuild per-kind rendering
|
||||
(the ``watch_triggered`` card). Being a ``_``-prefixed key, ``_source_meta``
|
||||
is stripped before the LLM wire by ``sanitize_messages`` (and the Anthropic
|
||||
native mid-conversation path copies only ``role`` + ``content``), so the
|
||||
structured fields never reach the model.
|
||||
|
||||
``content`` is stored and — on the native mid-conversation-system path
|
||||
(claude-opus-4-8) — sent to the model verbatim, so fence-escaping is NOT
|
||||
@@ -128,9 +159,6 @@ def make_system_turn(source: str, content: str, **meta: Any) -> dict[str, Any]:
|
||||
if source not in SYSTEM_TURN_SOURCES:
|
||||
raise ValueError(f"unknown system-turn source: {source!r}")
|
||||
turn: dict[str, Any] = {"role": "system", "_source": source, "content": content}
|
||||
for key, value in meta.items():
|
||||
norm = key if key.startswith("_") else f"_{key}"
|
||||
if norm in turn:
|
||||
raise ValueError(f"system-turn meta key {key!r} collides with reserved {norm!r}")
|
||||
turn[norm] = value
|
||||
if meta:
|
||||
turn["_source_meta"] = dict(meta)
|
||||
return turn
|
||||
|
||||
@@ -89,7 +89,11 @@ class TurnMeta:
|
||||
"""Sidecar metadata: never reaches the wire, never read by the lowering layer.
|
||||
|
||||
``event_id`` is the per-ws SSE ``Last-Event-ID`` resume cursor; ``extra`` holds
|
||||
open operator-turn metadata (e.g. ``watch_name``)."""
|
||||
open metadata under well-known keys — ``"source_meta"`` (an operator-context
|
||||
``system`` turn's structured per-kind fields, e.g. ``watch_triggered``'s
|
||||
``watch_name`` / ``command`` / poll counters; persisted in the
|
||||
``conversations.meta`` column, surfaced to the FE for per-kind rendering) and
|
||||
``"attachments_meta"`` (display metadata for by-reference attachments)."""
|
||||
|
||||
event_id: int | None = None
|
||||
extra: dict[str, Any] = field(default_factory=dict)
|
||||
@@ -240,6 +244,12 @@ def turn_from_dict(msg: dict[str, Any]) -> Turn:
|
||||
am = msg.get("_attachments_meta")
|
||||
if am is not None:
|
||||
meta.extra["attachments_meta"] = am
|
||||
# Operator-context per-kind structured fields (``watch_triggered`` etc.).
|
||||
# Carried as ONE dict so it maps to one ``conversations.meta`` column / one
|
||||
# FE ``meta`` field, rather than scattered ``_``-prefixed siblings.
|
||||
sm = msg.get("_source_meta")
|
||||
if sm:
|
||||
meta.extra["source_meta"] = sm
|
||||
|
||||
return Turn(
|
||||
role=role,
|
||||
@@ -280,6 +290,9 @@ def turn_to_dict(turn: Turn) -> dict[str, Any]:
|
||||
am = turn.meta.extra.get("attachments_meta")
|
||||
if am is not None:
|
||||
msg["_attachments_meta"] = am
|
||||
sm = turn.meta.extra.get("source_meta")
|
||||
if sm:
|
||||
msg["_source_meta"] = sm
|
||||
return msg
|
||||
|
||||
|
||||
|
||||
+18
-6
@@ -195,9 +195,15 @@ def format_watch_message(
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# The structured fields that ride the ``watch_triggered`` system turn's
|
||||
# ``_source_meta`` (delivered to the FE for the watch-result card). ``output``
|
||||
# is the raw (sanitized) shell output so the card body renders it alone, without
|
||||
# re-showing the header / command lines that ``format_watch_message`` bakes into
|
||||
# the turn's text ``content`` (which is what the model reads on the wire).
|
||||
WATCH_REMINDER_OPTIONAL_KEYS = (
|
||||
"watch_name",
|
||||
"command",
|
||||
"output",
|
||||
"poll_count",
|
||||
"max_polls",
|
||||
"is_final",
|
||||
@@ -217,13 +223,18 @@ def build_watch_reminder(
|
||||
) -> dict[str, Any]:
|
||||
"""Build a structured ``watch_triggered`` reminder dict.
|
||||
|
||||
Returns a dict with ``{type, text, watch_name, command, poll_count,
|
||||
max_polls, is_final}``. The ``text`` field is the formatted body
|
||||
Returns a dict with ``{type, text, watch_name, command, output,
|
||||
poll_count, max_polls, is_final}``. The ``text`` field is the formatted body
|
||||
(same content :func:`format_watch_message` produces) and becomes the
|
||||
content of the first-class ``{"role": "system", "_source":
|
||||
"watch_triggered"}`` turn the drain seam emits; the remaining fields
|
||||
ride as sibling metadata. Compaction / channel adapters keep seeing
|
||||
the human-readable shell output via the ``text`` field.
|
||||
``content`` of the first-class ``{"role": "system", "_source":
|
||||
"watch_triggered"}`` turn the drain seam emits — the model-facing prose, with
|
||||
the watch header / ``$ command`` / output all baked in. The remaining fields
|
||||
ride as the turn's structured ``_source_meta`` so the FE rebuilds the
|
||||
watch-result card from them; ``output`` is carried separately so the card
|
||||
body shows the raw shell output alone (without re-printing the header /
|
||||
command the chrome already renders). Both ``text`` and the structured fields
|
||||
derive from the same inputs, so they cannot drift. Compaction / channel
|
||||
adapters keep seeing the human-readable shell output via the ``text`` field.
|
||||
"""
|
||||
return {
|
||||
"type": "watch_triggered",
|
||||
@@ -240,6 +251,7 @@ def build_watch_reminder(
|
||||
),
|
||||
"watch_name": name,
|
||||
"command": command,
|
||||
"output": output,
|
||||
"poll_count": poll_count,
|
||||
"max_polls": max_polls,
|
||||
"is_final": is_final,
|
||||
|
||||
+1
-1
@@ -143,7 +143,7 @@ class NullUI:
|
||||
def on_error(self, message: str) -> None:
|
||||
pass
|
||||
|
||||
def on_system_turn(self, content: str, source: str) -> None:
|
||||
def on_system_turn(self, content: str, source: str, meta: dict[str, Any] | None = None) -> None:
|
||||
pass
|
||||
|
||||
def on_state_change(self, state: str) -> None:
|
||||
|
||||
@@ -62,6 +62,11 @@ class HistoryEvent(ServerEvent):
|
||||
* ``source`` — the operator-context kind on a ``system`` turn
|
||||
(``output_guard`` / ``user_interjection`` / ``tool_error`` / ...),
|
||||
or ``"system_nudge"`` on a wake-driven empty user turn
|
||||
* ``meta`` — structured per-kind fields on an operator-context
|
||||
``system`` turn (e.g. ``watch_triggered``'s ``{watch_name, command,
|
||||
poll_count, max_polls, is_final}``) so the renderer can rebuild
|
||||
per-kind UI (the watch-result card); absent when the kind carries no
|
||||
structured data
|
||||
* ``reasoning`` — concatenated reasoning text for assistant turns
|
||||
that round-tripped a thinking-block lane (Anthropic-with-thinking
|
||||
today; OpenAI Responses + Gemini in later phases). Present only
|
||||
|
||||
@@ -1003,11 +1003,13 @@
|
||||
}
|
||||
/* First-class operator-context system turn (role="system" in the
|
||||
trajectory) — the consolidation of the metacognition reminder /
|
||||
user-interjection / output-guard / watch-result bubbles into one turn
|
||||
type that FOLLOWS the turn it advises. Shared so the interactive UI and
|
||||
the console coord viewer render it identically. Yellow accent reads as
|
||||
"operator metadata" against the amber user colour and the cyan tool
|
||||
cards; deliberately quieter so it doesn't compete for attention. */
|
||||
user-interjection / output-guard bubbles into one turn type that FOLLOWS
|
||||
the turn it advises. Shared so the interactive UI and the console coord
|
||||
viewer render it identically. Yellow accent reads as "operator metadata"
|
||||
against the amber user colour and the cyan tool cards; deliberately quieter
|
||||
so it doesn't compete for attention. The ``watch_triggered`` kind carries
|
||||
structured per-kind meta and branches into the richer ``.msg.watch-result``
|
||||
card below instead of this plain bubble. */
|
||||
.msg.system-context {
|
||||
border-left-color: var(--yellow);
|
||||
color: var(--fg-dim);
|
||||
@@ -1025,6 +1027,107 @@
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* Structured watch-result card — the ``watch_triggered`` operator-context
|
||||
system turn renders here (command-preview header / shell-output body / poll
|
||||
counter footer) rather than as the plain ``.msg.system-context`` bubble,
|
||||
because it carries structured per-kind meta. Cyan accent ties it to the
|
||||
tool-surface vocabulary (it IS shell output). Built by
|
||||
``_buildWatchResultBubble`` (interactive) / ``appendWatchResult`` (coord). */
|
||||
.msg.watch-result {
|
||||
border-left-color: var(--cyan);
|
||||
background: var(--panel);
|
||||
padding: 8px 12px;
|
||||
margin: 6px 0;
|
||||
width: 100%;
|
||||
}
|
||||
.msg.watch-result .msg-watch-header {
|
||||
color: var(--cyan);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
text-transform: lowercase;
|
||||
}
|
||||
.msg.watch-result .msg-watch-cmd {
|
||||
color: var(--ink-3);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.msg.watch-result .msg-watch-body {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
/* Shell output can be wide (URLs, JSON, file lists) — break inside
|
||||
long words so the off-canvas mobile drawer doesn't blow horizontal
|
||||
layout. */
|
||||
word-break: break-word;
|
||||
}
|
||||
.msg.watch-result .msg-watch-footer {
|
||||
color: var(--ink-3);
|
||||
font-size: 11px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
/* Guard-finding card — the ``output_guard`` operator-context system turn. The
|
||||
inner warning element (``.output-warning`` interactive / ``.coord-tool-row-
|
||||
warning`` coord) carries the risk colour + flags + redaction; this wrapper
|
||||
hosts it as a standalone message row and styles the annotation detail lines
|
||||
the terse inline tool chip omits. Built by ``_buildGuardFindingBubble``
|
||||
(interactive) / ``appendGuardFinding`` (coord). */
|
||||
.msg.guard-finding {
|
||||
border-left-color: var(--yellow);
|
||||
padding: 6px 10px;
|
||||
}
|
||||
.msg.guard-finding .msg-guard-annotation {
|
||||
color: var(--ink-3);
|
||||
font-size: 11px;
|
||||
margin-top: 4px;
|
||||
margin-left: 8px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Idle-children card — the coordinator-only ``idle_children`` operator-context
|
||||
system turn lists the child workstreams still running while the coordinator
|
||||
went idle. Built by ``appendIdleChildren`` (coord). */
|
||||
.msg.idle-children {
|
||||
border-left-color: var(--yellow);
|
||||
padding: 6px 10px;
|
||||
}
|
||||
.msg.idle-children .msg-idle-header {
|
||||
color: var(--yellow);
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
text-transform: lowercase;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.msg.idle-children .msg-idle-list {
|
||||
margin: 0;
|
||||
padding-left: 16px;
|
||||
}
|
||||
.msg.idle-children .msg-idle-child {
|
||||
font-size: 12px;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
.msg.idle-children .msg-idle-child-state {
|
||||
color: var(--ink-4);
|
||||
margin-left: 6px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* Important user interjection — a ``!!!``-prefixed queued message that drained
|
||||
mid-turn. Brighter accent + heavier weight so an urgent operator interjection
|
||||
doesn't read as a quiet metadata note. ``notice`` interjections keep the
|
||||
default ``.system-context`` styling. */
|
||||
.msg.system-context.important {
|
||||
border-left-color: var(--accent);
|
||||
}
|
||||
.msg.system-context.important .msg-system-context-label {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* System-nudge marker — visible-but-thin replacement for the wake's
|
||||
synthetic empty user turn, so the operator-context `system` turns that
|
||||
follow it have a visible anchor instead of trailing an old user message.
|
||||
|
||||
+117
-9
@@ -244,24 +244,49 @@ class Pane {
|
||||
return el;
|
||||
}
|
||||
|
||||
addSystemContext(content, source) {
|
||||
addSystemContext(content, source, meta) {
|
||||
// First-class operator-context system turn — the consolidation of the
|
||||
// legacy metacognition reminders / user interjections / output-guard
|
||||
// notes into one role="system" trajectory turn. Rendered as a distinct
|
||||
// operator bubble in sequence (it FOLLOWS the turn it advises);
|
||||
// `source` carries the kind (user_interjection / output_guard /
|
||||
// tool_error / ...) for the bubble label.
|
||||
// tool_error / ...) for the bubble label. `watch_triggered` additionally
|
||||
// carries structured `meta` (watch_name / command / poll counters) → the
|
||||
// richer `.msg.watch-result` card instead of the plain operator bubble.
|
||||
this.removeEmptyState();
|
||||
if (source === "watch_triggered" && meta && typeof meta === "object") {
|
||||
const card = _buildWatchResultBubble(meta, content || "");
|
||||
this.messagesEl.appendChild(card);
|
||||
this.scrollToBottom(true);
|
||||
return card;
|
||||
}
|
||||
if (source === "output_guard" && meta && typeof meta === "object") {
|
||||
const card = _buildGuardFindingBubble(meta);
|
||||
this.messagesEl.appendChild(card);
|
||||
this.scrollToBottom(true);
|
||||
return card;
|
||||
}
|
||||
// user_interjection renders as a "queued message" bubble showing the user's
|
||||
// RAW words (`meta.message`) rather than the model-directed framing in
|
||||
// `content`, with brighter emphasis for `!!!`-important interjections.
|
||||
const isInterjection =
|
||||
source === "user_interjection" && meta && typeof meta === "object";
|
||||
const important = isInterjection && meta.priority === "important";
|
||||
const el = document.createElement("div");
|
||||
el.className = "msg system-context";
|
||||
el.className = "msg system-context" + (important ? " important" : "");
|
||||
const body = document.createElement("div");
|
||||
body.className = "msg-body";
|
||||
const labelEl = document.createElement("span");
|
||||
labelEl.className = "msg-system-context-label";
|
||||
labelEl.textContent = "operator" + (source ? " · " + String(source) : "");
|
||||
labelEl.textContent = isInterjection
|
||||
? "queued message" + (important ? " · important" : "")
|
||||
: "operator" + (source ? " · " + String(source) : "");
|
||||
const textEl = document.createElement("span");
|
||||
textEl.className = "msg-system-context-text";
|
||||
textEl.textContent = content || "";
|
||||
textEl.textContent =
|
||||
isInterjection && meta.message != null
|
||||
? String(meta.message)
|
||||
: content || "";
|
||||
body.appendChild(labelEl);
|
||||
body.appendChild(textEl);
|
||||
el.appendChild(body);
|
||||
@@ -1181,8 +1206,13 @@ class Pane {
|
||||
// user_reminder / tool_reminder events into one operator bubble
|
||||
// rendered in trajectory sequence (it FOLLOWS the turn it advises,
|
||||
// so by the time this SSE event arrives the related turn already
|
||||
// rendered). ``evt.source`` carries the kind for the bubble label.
|
||||
this.addSystemContext(evt.content || "", evt.source || "");
|
||||
// rendered). ``evt.source`` carries the kind for the bubble label;
|
||||
// ``evt.meta`` the structured per-kind fields (watch-result card).
|
||||
this.addSystemContext(
|
||||
evt.content || "",
|
||||
evt.source || "",
|
||||
evt.meta || null,
|
||||
);
|
||||
break;
|
||||
|
||||
case "message_queued":
|
||||
@@ -2124,8 +2154,13 @@ class Pane {
|
||||
// interjection, metacognitive nudge — see make_system_turn). These
|
||||
// now FOLLOW the turn they advise as their own rows; tool-channel
|
||||
// nudges and queued interjections that used to splice into the tool
|
||||
// result render here in sequence. `source` is the kind.
|
||||
this.addSystemContext(msg.content || "", msg.source || "");
|
||||
// result render here in sequence. `source` is the kind; `meta` the
|
||||
// structured per-kind fields (watch-result card) from /history.
|
||||
this.addSystemContext(
|
||||
msg.content || "",
|
||||
msg.source || "",
|
||||
msg.meta || null,
|
||||
);
|
||||
lastToolBlock = null;
|
||||
}
|
||||
}
|
||||
@@ -2712,6 +2747,79 @@ class Pane {
|
||||
}
|
||||
}
|
||||
|
||||
// Build a structured ``.msg.watch-result`` card for a ``watch_triggered``
|
||||
// operator-context system turn — command-preview header + shell-output body +
|
||||
// poll-counter footer. ``content`` is the formatted watch body (the system
|
||||
// turn's content); ``meta`` carries the structured fields (``watch_name`` /
|
||||
// ``command`` / ``poll_count`` / ``max_polls`` / ``is_final``) delivered live on
|
||||
// the ``system_turn`` SSE event and on the ``/history`` projection. All text
|
||||
// goes through ``textContent`` so shell output containing angle brackets /
|
||||
// scripts / steering bytes renders inertly. Mirrors the coordinator pane's
|
||||
// buildWatchResultBubble.
|
||||
function _buildWatchResultBubble(meta, content) {
|
||||
const el = document.createElement("div");
|
||||
el.className = "msg watch-result";
|
||||
el.setAttribute("role", "article");
|
||||
el.setAttribute("data-ts-role", "watch");
|
||||
el.setAttribute("aria-label", "watch");
|
||||
const header = document.createElement("div");
|
||||
header.className = "msg-watch-header";
|
||||
header.textContent =
|
||||
"watch" + (meta.watch_name ? " · " + String(meta.watch_name) : "");
|
||||
el.appendChild(header);
|
||||
if (meta.command) {
|
||||
const cmd = document.createElement("div");
|
||||
cmd.className = "msg-watch-cmd";
|
||||
cmd.textContent = "$ " + String(meta.command);
|
||||
el.appendChild(cmd);
|
||||
}
|
||||
const body = document.createElement("pre");
|
||||
body.className = "msg-watch-body";
|
||||
// Prefer the structured ``output`` (raw shell output alone) so the body
|
||||
// doesn't re-print the header / ``$ command`` lines the chrome already shows;
|
||||
// fall back to the full turn ``content`` for legacy turns that predate the
|
||||
// ``output`` meta field (migration 060 is additive — no backfill).
|
||||
body.textContent = meta.output != null ? String(meta.output) : content || "";
|
||||
el.appendChild(body);
|
||||
if (meta.poll_count != null && meta.max_polls != null) {
|
||||
const footer = document.createElement("div");
|
||||
footer.className = "msg-watch-footer";
|
||||
const finalSuffix = meta.is_final ? " · final" : "";
|
||||
footer.textContent =
|
||||
"poll " +
|
||||
String(meta.poll_count) +
|
||||
"/" +
|
||||
String(meta.max_polls) +
|
||||
finalSuffix;
|
||||
el.appendChild(footer);
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
// Build a structured ``.msg.guard-finding`` card for an ``output_guard``
|
||||
// operator-context system turn. ``meta`` carries the structured finding
|
||||
// ``{flags, risk_level, annotations, redacted}``. Reuses the tool-result
|
||||
// warning chip (``_buildOutputWarningEl``) for the risk / flags / redaction
|
||||
// header so the operator-context finding speaks the same visual vocabulary,
|
||||
// then appends the annotations (matched-pattern detail) — which the inline
|
||||
// tool chip omits to stay terse. All text via textContent.
|
||||
function _buildGuardFindingBubble(meta) {
|
||||
const el = document.createElement("div");
|
||||
el.className = "msg guard-finding";
|
||||
el.setAttribute("role", "article");
|
||||
el.setAttribute("data-ts-role", "output_guard");
|
||||
el.setAttribute("aria-label", "output guard");
|
||||
el.appendChild(_buildOutputWarningEl(meta));
|
||||
const anns = Array.isArray(meta.annotations) ? meta.annotations : [];
|
||||
for (let i = 0; i < anns.length; i++) {
|
||||
const a = document.createElement("div");
|
||||
a.className = "msg-guard-annotation";
|
||||
a.textContent = String(anns[i]);
|
||||
el.appendChild(a);
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
// Shared output-warning DOM builder — used by both replayHistory
|
||||
// (saved-workstream rendering) and the live appendToolOutput path
|
||||
// via showOutputWarning. Single source of truth keeps the two
|
||||
|
||||
Reference in New Issue
Block a user