diff --git a/docs/architecture/stable-assistant-turn-anchor-phase0.md b/docs/architecture/stable-assistant-turn-anchor-phase0.md index 53016d22..8e79fb54 100644 --- a/docs/architecture/stable-assistant-turn-anchor-phase0.md +++ b/docs/architecture/stable-assistant-turn-anchor-phase0.md @@ -295,6 +295,24 @@ activity order for Compact Worklog and Transparent Stream renderers. The Transparent Stream raw `content[]` helper remains a fallback for settled messages that do not yet carry `_anchor_activity_scene`. +### Settled Fallback Ownership Matrix + +After the mixed `content[]` bridge, `_anchor_activity_scene` is the semantic +owner for settled assistant activity whenever it is present. Legacy raw-message +paths stay as compatibility fallbacks for older or non-anchor transcripts; they +must not compete with an anchor-owned turn. + +| Surface | Anchor-present owner | Compatibility fallback | Fallback exit | +| --- | --- | --- | --- | +| Settled Compact Worklog activity | `_renderSettledAnchorSceneForMessage()` renders `activity_scene_v1` rows before the final answer. | Legacy `S.toolCalls`, `tool_calls`, `_partial_tool_calls`, and raw `content[].tool_use` rebuilds. | `anchorOwnedAssistantRawIdxs` excludes the anchor-owned turn from message metadata scans, fallback source collection, tool buckets, thinking buckets, and worklog-source mirroring. | +| Settled Transparent Stream activity | `_renderSettledAnchorSceneTransparentForMessage()` renders the same scene rows as transparent event rows or inline prose before the final answer segment. | `_transparentStreamOrderedParts()` rebuilds raw `content[]` order for historical messages without an anchor scene. | `_transparentStreamOrderedParts()` returns `null` when `message._anchor_activity_scene` exists, leaving the dedicated anchor renderer in charge. | +| Historical / non-anchor transcripts | No anchor owner is available. | Raw `content[]`, persisted `session.tool_calls`, role=`tool` rows, and partial tool-call snapshots continue to recover visible tool/prose history. | None. These paths remain until replay/runtime-journal coverage proves the same transcript shapes hydrate through anchors. | +| Live reattach / session switch | `renderLiveAnchorActivityScene()` consumes the projected live scene, and session switch first attempts runtime-journal anchor scene restore. | Saved live DOM snapshots and `INFLIGHT` tool replay. | Snapshot fallback only runs when no usable live anchor scene can be rendered. | + +This matrix is an audit baseline, not permission to delete fallbacks. Fallback +removal should wait for replay/runtime-journal parity tests that prove all +supported settled transcript sources hydrate through anchors. + ## Source Event Classification Phase 0 classifies current sources before changing render behavior: diff --git a/tests/test_anchor_fallback_ownership.py b/tests/test_anchor_fallback_ownership.py new file mode 100644 index 00000000..3051ee0d --- /dev/null +++ b/tests/test_anchor_fallback_ownership.py @@ -0,0 +1,215 @@ +"""Anchor fallback ownership guards for the settled activity scene. + +The Stable Assistant Turn Anchor should own settled activity when a message has +`_anchor_activity_scene`. Raw `content[]` ordering and legacy settled tool-card +rebuilds are still required for historical/non-anchor transcripts, but they must +exit before competing with anchor-owned turns. +""" + +from __future__ import annotations + +import json +import subprocess +import textwrap +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8") +PHASE0_DOC = ( + ROOT / "docs" / "architecture" / "stable-assistant-turn-anchor-phase0.md" +).read_text(encoding="utf-8") + + +def _run_node_script(script: str) -> str: + result = subprocess.run( + ["node", "-e", script], + check=True, + cwd=ROOT, + text=True, + capture_output=True, + ) + return result.stdout.strip() + + +def _function_body(src: str, name: str) -> str: + start = src.find(f"function {name}") + assert start != -1, f"{name} not found" + params = src.find("(", start) + assert params != -1, f"{name} params not found" + depth = 0 + close = -1 + for idx in range(params, len(src)): + if src[idx] == "(": + depth += 1 + elif src[idx] == ")": + depth -= 1 + if depth == 0: + close = idx + break + assert close != -1, f"{name} params did not close" + brace = src.find("{", close) + assert brace != -1, f"{name} body not found" + depth = 0 + for idx in range(brace, len(src)): + if src[idx] == "{": + depth += 1 + elif src[idx] == "}": + depth -= 1 + if depth == 0: + return src[brace + 1 : idx] + raise AssertionError(f"{name} body did not close") + + +def test_phase0_doc_records_settled_fallback_ownership_matrix(): + assert "### Settled Fallback Ownership Matrix" in PHASE0_DOC + assert "_anchor_activity_scene` is the semantic" in PHASE0_DOC + assert "| Settled Compact Worklog activity |" in PHASE0_DOC + assert "| Settled Transparent Stream activity |" in PHASE0_DOC + assert "| Historical / non-anchor transcripts |" in PHASE0_DOC + assert "This matrix is an audit baseline, not permission to delete fallbacks." in PHASE0_DOC + + +def test_transparent_raw_content_helper_is_fallback_only_when_anchor_scene_absent(): + helper = _function_body(UI_JS, "_transparentStreamOrderedParts") + + transparent_gate = helper.index("!isTransparentStream()) return null;") + role_gate = helper.index("!message||message.role!=='assistant'||message._live") + anchor_exit = helper.index("if(message._anchor_activity_scene) return null;") + content_loop = helper.index("for(const part of message.content)") + fallback_return = helper.index("return hasText&&hasTool?ordered:null;") + + assert transparent_gate < role_gate < anchor_exit < content_loop < fallback_return + assert "part.type==='text'" in helper + assert "part.type==='tool_use'" in helper + + +def test_transparent_raw_content_fallback_exits_for_anchor_owned_messages(): + script = textwrap.dedent( + f""" + const fs = require('fs'); + const uiPath = {json.dumps(str(ROOT / "static" / "ui.js"))}; + const src = fs.readFileSync(uiPath, 'utf8'); + + function functionSource(source, name) {{ + const start = source.indexOf(`function ${{name}}`); + if (start < 0) throw new Error(`${{name}} not found`); + const params = source.indexOf('(', start); + let depth = 0; + let close = -1; + for (let idx = params; idx < source.length; idx += 1) {{ + if (source[idx] === '(') depth += 1; + else if (source[idx] === ')') {{ + depth -= 1; + if (depth === 0) {{ + close = idx; + break; + }} + }} + }} + const brace = source.indexOf('{{', close); + depth = 0; + for (let idx = brace; idx < source.length; idx += 1) {{ + if (source[idx] === '{{') depth += 1; + else if (source[idx] === '}}') {{ + depth -= 1; + if (depth === 0) return source.slice(start, idx + 1); + }} + }} + throw new Error(`${{name}} body did not close`); + }} + + let transparentStream = true; + function isTransparentStream() {{ + return transparentStream; + }} + + eval(functionSource(src, '_transparentStreamOrderedParts')); + + const anchorOwned = {{ + role: 'assistant', + content: [ + {{ type: 'text', text: 'Checked the repo state.' }}, + {{ type: 'tool_use', id: 'toolu_anchor', name: 'terminal', input: {{ cmd: 'git status' }} }}, + ], + _anchor_activity_scene: {{ + schema_version: 'activity_scene_v1', + activity_rows: [], + }}, + }}; + const historical = {{ + role: 'assistant', + content: [ + {{ type: 'text', text: 'Checked the repo state.' }}, + {{ type: 'tool_use', id: 'toolu_history', name: 'terminal', input: {{ cmd: 'git status' }} }}, + ], + }}; + + const anchorResult = _transparentStreamOrderedParts(anchorOwned); + const historicalResult = _transparentStreamOrderedParts(historical); + transparentStream = false; + const disabledResult = _transparentStreamOrderedParts(historical); + + console.log(JSON.stringify({{ + anchorResult, + historicalResult, + disabledResult, + }})); + """ + ) + + result = json.loads(_run_node_script(script)) + + assert result["anchorResult"] is None + assert result["disabledResult"] is None + assert [part["kind"] for part in result["historicalResult"]] == ["text", "tool"] + assert result["historicalResult"][0]["text"] == "Checked the repo state." + assert result["historicalResult"][1] == { + "kind": "tool", + "toolUseId": "toolu_history", + "name": "terminal", + "input": {"cmd": "git status"}, + } + + +def test_settled_legacy_tool_rebuild_excludes_anchor_owned_turns(): + render = _function_body(UI_JS, "renderMessages") + + set_decl = render.index("const anchorOwnedAssistantRawIdxs=new Set();") + collect_segments = render.index("turn.querySelectorAll('.assistant-segment[data-msg-idx]')") + metadata_scan = render.index("const hasMessageToolMetadata=") + fallback_sources = render.index("const fallbackToolSources=[];") + source_collect = render.index("fallbackToolSources.push({m,rawIdx});") + + assert set_decl < collect_segments < metadata_scan < fallback_sources < source_collect + assert "!anchorOwnedAssistantRawIdxs.has(S.messages.indexOf(m))" in render + assert "if(anchorOwnedAssistantRawIdxs.has(rawIdx)) return;" in render + + +def test_settled_legacy_activity_buckets_skip_anchor_owned_turns_before_rendering(): + render = _function_body(UI_JS, "renderMessages") + + tool_loop = render.index("for(const tc of (S.toolCalls||[])){") + tool_skip = render.index("if(anchorOwnedAssistantRawIdxs.has(aIdx)) continue;", tool_loop) + thinking_loop = render.index("for(const aIdx of assistantThinking.keys()){") + thinking_skip = render.index("if(anchorOwnedAssistantRawIdxs.has(aIdx)) continue;", thinking_loop) + worklog_loop = render.index("for(const [aIdx,seg] of assistantSegments){") + worklog_skip = render.index("if(anchorOwnedAssistantRawIdxs.has(aIdx)) continue;", worklog_loop) + anchor_render = render.index("_renderSettledAnchorSceneForMessage(msg, seg, rawIdx)") + + assert tool_loop < tool_skip < thinking_loop < thinking_skip < worklog_loop < worklog_skip + assert worklog_skip < anchor_render + + +def test_anchor_settled_renderers_remain_the_primary_scene_path(): + settled = _function_body(UI_JS, "_renderSettledAnchorSceneForMessage") + transparent = _function_body(UI_JS, "_renderSettledAnchorSceneTransparentForMessage") + + assert "if(!message||!message._anchor_activity_scene||!segment) return false;" in settled + assert "return _renderSettledAnchorSceneTransparentForMessage(message,segment,rawIdx);" in settled + assert "_anchorSceneRowsForRendering(scene,{settled:true})" in settled + assert "group.setAttribute('data-anchor-settled-scene-owner','1');" in settled + + assert "if(!message||!message._anchor_activity_scene||!segment) return false;" in transparent + assert "_anchorSceneRowsForRendering(scene,{settled:true})" in transparent + assert "_anchorSceneTransparentNodeForRow(row,{settled:true,finalAnswer})" in transparent