'
@@ -282,29 +293,44 @@ def test_a_working_claim_can_name_a_widget_until_a_version_completes_it(page_dir
flags=re.DOTALL,
)
(page_dir / "versions" / "v4.html").write_text(without_seat)
- dropped = stamp(page_dir, 4, "Removed")
+ changed = stamp(page_dir, 4, "Changed presentation")
+ assert changed.exit_code == 0, changed.output
+
+ # Removing the subject itself would remove the Target Button. Publication still
+ # refuses that silent loss until the version names the work it answers.
+ without_target = re.sub(
+ r'.*?',
+ "",
+ without_seat,
+ count=1,
+ flags=re.DOTALL,
+ )
+ (page_dir / "versions" / "v5.html").write_text(without_target)
+ dropped = stamp(page_dir, 5, "Removed")
assert dropped.exit_code == 1
assert (
- "would remove the local seat for active work on 'rollout-card'"
+ "would remove the local target for active work on 'rollout-card'"
in dropped.output
)
- finished = stamp(page_dir, 4, "Removed", completes=("rollout-card",))
+ finished = stamp(page_dir, 5, "Removed", completes=("rollout-card",))
assert finished.exit_code == 0, finished.output
# Naming no active widget claim is an unearned settlement, not inert metadata.
- (page_dir / "versions" / "v5.html").write_text(
- without_seat.replace("
t", "t · v5")
+ (page_dir / "versions" / "v6.html").write_text(
+ without_target.replace("t", "t · v6")
)
- unearned = stamp(page_dir, 5, "Again", completes=("rollout-card",))
+ unearned = stamp(page_dir, 6, "Again", completes=("rollout-card",))
assert unearned.exit_code == 1
assert "no active widget work claim" in unearned.output
-def test_revendoring_cannot_remove_an_active_widget_work_seat(page_dir):
- """A layer transition changes the same declaration the status door trusted.
- Re-vendoring must not leave the page-wide claim standing while silently removing
- its local half; the active claim is settled by a version note, not by new assets."""
+def test_revendoring_can_change_x_work_while_the_target_button_holds_a_claim(page_dir):
+ """x-work admits an initial claim; it is not the claim's only later seat.
+
+ Re-vendoring can remove that declaration while the live widget remains, because
+ the page-edge Target Button continues to present the already-admitted work.
+ """
work_page = PAGE.replace(
'',
'\n'
@@ -317,8 +343,6 @@ def test_revendoring_cannot_remove_an_active_widget_work_seat(page_dir):
page_dir, "working", "checking the rollout", "--on", "rollout-card"
)
assert claimed.exit_code == 0, claimed.output
- before = (page_dir / "registry.json").read_bytes()
-
card = json.loads((schema_model.DEFAULT_PACKAGE / "registry.json").read_text())[
"lf-card"
]
@@ -329,10 +353,72 @@ def test_revendoring_cannot_remove_an_active_widget_work_seat(page_dir):
result = CliRunner().invoke(cli_model.cli, ["page", "init", str(page_dir)])
- assert result.exit_code == 1
- assert "incoming layer would remove the local seat" in result.output
- assert "'rollout-card'" in result.output
- assert (page_dir / "registry.json").read_bytes() == before
+ assert result.exit_code == 0, result.output
+ registry = json.loads((page_dir / "registry.json").read_text())
+ assert "x-work" not in registry["lf-card"]
+ claim = next(
+ update
+ for update in state_json(page_dir)["updates"]
+ if update["source"] == "claim"
+ )
+ assert claim["target"] == {"kind": "widget", "id": "rollout-card"}
+ assert claim["disposition"] == "effective"
+
+
+def test_a_recordless_receipt_from_a_stale_revision_waits_for_a_later_note(page_dir):
+ """A completion move can arrive from a live revision after another is active.
+
+ A version note older than the move cannot answer it. Its receipt can still admit
+ an explicit claim without x-work; only the next note settles the move, while the
+ claim itself remains at the widget's Target Button until an explicit --completes
+ note answers that separate work lifecycle.
+ """
+ work_page = PAGE.replace(
+ "", '', 1
+ )
+ (page_dir / "versions" / "v1.html").write_text(work_page)
+ publish(page_dir)
+ (page_dir / "versions" / "v2.html").write_text(
+ work_page.replace("t", "t · v2")
+ )
+ advanced = stamp(page_dir, 2, "Checked the surrounding plan")
+ assert advanced.exit_code == 0, advanced.output
+
+ # The reader still has r1 open after r2 became active. That move is new work,
+ # not something the earlier r2 note could already have answered.
+ answer = events_model.append_event(
+ page_dir,
+ {
+ "kind": "action",
+ "author": "user",
+ "revision": 1,
+ "widget": "plan-choice",
+ "action": "answer",
+ "detail": {},
+ },
+ )
+ acknowledgments = page_state(page_dir)["browser"]["acknowledgments"]
+ assert any(receipt["event"] == answer["id"] for receipt in acknowledgments)
+
+ claimed = _status(
+ page_dir, "working", "checking the completed choice", "--on", "plan-choice"
+ )
+ assert claimed.exit_code == 0, claimed.output
+ (page_dir / "versions" / "v3.html").write_text(
+ work_page.replace("t", "t · v3")
+ )
+ answered = stamp(page_dir, 3, "Answered the completed choice")
+ assert answered.exit_code == 0, answered.output
+
+ acknowledgments = page_state(page_dir)["browser"]["acknowledgments"]
+ assert not any(receipt["event"] == answer["id"] for receipt in acknowledgments)
+ claim = next(
+ update
+ for update in state_json(page_dir)["updates"]
+ if update["source"] == "claim"
+ )
+ assert claim["target"] == {"kind": "widget", "id": "plan-choice"}
+ assert claim["disposition"] == "effective"
@pytest.mark.parametrize("target", ["effort", "flag-first"])
@@ -460,23 +546,31 @@ def test_wait_prints_unacknowledged_user_events_and_flips_status(page_dir, capsy
events_model.append_event(
page_dir, {"kind": "comment", "id": "c2", "author": "user", "text": "later"}
)
- session_model.cmd_ack(page_dir, 2)
- session_model.cmd_ack(page_dir, 2) # retries are harmless
- assert files_model.read_json(page_dir / "cursor.json")["seq"] == 2
+ delivered_through = shown[-1]["seq"]
+ session_model.cmd_ack(page_dir, delivered_through)
+ session_model.cmd_ack(page_dir, delivered_through) # retries are harmless
+ assert files_model.read_json(page_dir / "cursor.json")["seq"] == delivered_through
assert page_state(page_dir)["pending"] == 1
assert session_model.cmd_wait(page_dir) == 0
- assert [
- json.loads(line)["id"] for line in capsys.readouterr().out.splitlines()[1:]
- ] == ["c2"]
- session_model.cmd_ack(page_dir, 3)
+ [later] = [json.loads(line) for line in capsys.readouterr().out.splitlines()[1:]]
+ assert later["id"] == "c2"
+ session_model.cmd_ack(page_dir, later["seq"])
assert page_state(page_dir)["pending"] == 0
- # The wait status is marked a handoff, which dates the claim: the agent's own
- # `leaf status` clears the mark, so the mark surviving is a pickup that never landed.
+ # Pickup is exact log evidence and does not rewrite the agent's page-wide claim.
status = files_model.read_json(page_dir / "status.json")
- assert (status["state"], status["handoff"]) == ("working", True)
+ assert status["state"] == "waiting"
+ pickups = [e for e in events_model.read_events(page_dir) if e["kind"] == "pickup"]
+ assert [e["events"] for e in pickups] == [["c1", shown[1]["id"]], ["c2"]]
+ stored = [
+ json.loads(line)
+ for line in (page_dir / "comments.jsonl").read_text().splitlines()
+ ]
+ assert all("seq" not in event for event in stored if event["kind"] == "pickup")
session_model.cmd_status(page_dir, "working", "revising the plan")
- assert "handoff" not in files_model.read_json(page_dir / "status.json")
+ assert (
+ files_model.read_json(page_dir / "status.json")["detail"] == "revising the plan"
+ )
# A worker's report wakes the watcher like a user event — it is the
# orchestrator's to fold into a version — but the reader's banner count
@@ -522,6 +616,8 @@ def test_wait_repeats_a_stable_transport_neutral_batch_until_ack(page_dir):
retry = CliRunner().invoke(cli_model.cli, ["wait", str(page_dir)])
assert retry.exit_code == 0, retry.output
assert json.loads(retry.output.splitlines()[0]) == header
+ pickups = [e for e in events_model.read_events(page_dir) if e["kind"] == "pickup"]
+ assert len(pickups) == 1 and pickups[0]["events"] == ["c1"]
# If wait output was lost or truncated, retrieving it again before ack can
# include a newer event. The old event keeps the same page-and-seq identity
@@ -535,7 +631,7 @@ def test_wait_repeats_a_stable_transport_neutral_batch_until_ack(page_dir):
json.loads(line) for line in grown.output.strip().splitlines()
]
assert grown_header == header
- assert [event["seq"] for event in grown_events] == [1, 2]
+ assert [event["seq"] for event in grown_events] == [1, 3]
def test_a_delivered_reply_carries_the_conversation_it_lands_in(page_dir, capsys):
@@ -595,7 +691,7 @@ def test_a_delivered_reply_carries_the_conversation_it_lands_in(page_dir, capsys
# A comment that opens a thread states its own anchor on its own line, so
# there is nothing behind it to carry.
- session_model.cmd_ack(page_dir, events_model.read_events(page_dir)[-1]["seq"])
+ session_model.cmd_ack(page_dir, last_deliverable_seq(page_dir))
events_model.append_event(
page_dir,
{"kind": "comment", "author": "user", "text": "separately — the rollout"},
@@ -606,7 +702,7 @@ def test_a_delivered_reply_carries_the_conversation_it_lands_in(page_dir, capsys
# The reader closing a thread from the panel posts a resolve, whose only
# pointer at the conversation is the message it names.
- session_model.cmd_ack(page_dir, events_model.read_events(page_dir)[-1]["seq"])
+ session_model.cmd_ack(page_dir, last_deliverable_seq(page_dir))
events_model.append_event(
page_dir, {"kind": "resolve", "author": "user", "parent": followed["id"]}
)
@@ -685,7 +781,7 @@ def test_a_delivered_gesture_on_a_sent_widget_carries_its_conversation(
# Standing, so a later delivery in this thread carries what the reader
# settled. Without it the agent meets the question with no answer under it
# and replies reopening a list they have already ticked.
- session_model.cmd_ack(page_dir, events_model.read_events(page_dir)[-1]["seq"])
+ session_model.cmd_ack(page_dir, last_deliverable_seq(page_dir))
events_model.append_event(
page_dir,
{
@@ -704,7 +800,7 @@ def test_a_delivered_gesture_on_a_sent_widget_carries_its_conversation(
# Taken back, and the conversation stops carrying it — the log keeps the
# gesture, and no reading of the log stands on it.
- session_model.cmd_ack(page_dir, events_model.read_events(page_dir)[-1]["seq"])
+ session_model.cmd_ack(page_dir, last_deliverable_seq(page_dir))
events_model.append_event(
page_dir, {"kind": "undo", "author": "user", "undoes": chose["id"]}
)
@@ -937,7 +1033,7 @@ def test_an_undo_of_a_page_decision_carries_the_thread_it_reopens(page_dir, caps
the same reading the accept did."""
_settling_page(page_dir)
accepted = events_model.append_event(page_dir, dict(SETTLING_ACCEPT))
- session_model.cmd_ack(page_dir, events_model.read_events(page_dir)[-1]["seq"])
+ session_model.cmd_ack(page_dir, last_deliverable_seq(page_dir))
capsys.readouterr()
events_model.append_event(
page_dir, {"kind": "undo", "author": "user", "undoes": accepted["id"]}
@@ -957,7 +1053,7 @@ def test_exact_thread_history_and_wait_share_indirect_resolution_events(
directly. Exact history and live delivery use one Leaf-owned membership join."""
_settling_page(page_dir)
accepted = events_model.append_event(page_dir, dict(SETTLING_ACCEPT))
- session_model.cmd_ack(page_dir, events_model.read_events(page_dir)[-1]["seq"])
+ session_model.cmd_ack(page_dir, last_deliverable_seq(page_dir))
capsys.readouterr()
rejected = events_model.append_event(
@@ -1148,7 +1244,7 @@ def test_the_envelope_stops_growing_with_the_conversation(page_dir, capsys):
assert session_model.cmd_wait(page_dir) == 0
header, *_rest = capsys.readouterr().out.strip().splitlines()
headers.append(len(header))
- session_model.cmd_ack(page_dir, events_model.read_events(page_dir)[-1]["seq"])
+ session_model.cmd_ack(page_dir, last_deliverable_seq(page_dir))
capsys.readouterr()
# Flat, not merely slower: twenty further exchanges add only the few
@@ -1215,7 +1311,7 @@ def test_the_bound_keeps_the_message_a_carried_gesture_needs(page_dir, capsys):
"text": f"turn {turn}",
},
)["id"]
- session_model.cmd_ack(page_dir, events_model.read_events(page_dir)[-1]["seq"])
+ session_model.cmd_ack(page_dir, last_deliverable_seq(page_dir))
capsys.readouterr()
events_model.append_event(
page_dir,
@@ -1782,9 +1878,10 @@ def test_one_wait_watches_every_page_the_session_holds(
lines = capsys.readouterr().out.strip().splitlines()
assert json.loads(lines[0]) == {"page": str(second), "threads": []}
assert [json.loads(line)["text"] for line in lines[1:]] == ["hi"]
- # The page that spoke picked up the handoff status; the other is untouched.
- assert files_model.read_json(second / "status.json")["state"] == "working"
+ # The page that spoke records exact pickup; neither page's status is rewritten.
+ assert files_model.read_json(second / "status.json")["state"] == "waiting"
assert files_model.read_json(page_dir / "status.json")["state"] == "waiting"
+ assert events_model.read_events(second)[-1]["kind"] == "pickup"
# Idling one leaf leaves the watch to the others; idling the last ends it.
session_model.cmd_ack(second, 1)
@@ -1979,6 +2076,7 @@ def test_codex_receipt_advances_after_page_ownership_transfers(page_dir):
"event_ids": [delivered["id"]],
}
+ codex_model.finish_intent({"id": "original", "host": "codex"}, intent)
codex_model.finish_intent({"id": "original", "host": "codex"}, intent)
assert files_model.read_json(page_dir / "cursor.json") == {"seq": delivered["seq"]}
@@ -1988,7 +2086,12 @@ def test_codex_receipt_advances_after_page_ownership_transfers(page_dir):
"waiting",
"successor is listening",
)
- assert "handoff" not in status
+ pickups = [
+ event
+ for event in events_model.read_events(page_dir)
+ if event["kind"] == "pickup"
+ ]
+ assert len(pickups) == 1 and pickups[0]["events"] == [delivered["id"]]
def test_codex_restart_finishes_an_accepted_intent_without_queueing_again(
@@ -3090,7 +3193,7 @@ def test_an_acknowledged_comment_nobody_answered_holds_the_turn(claimed, capsys)
asked = events_model.append_event(
claimed, {"kind": "comment", "author": "user", "text": "why B?"}
)
- session_model.cmd_ack(claimed, events_model.read_events(claimed)[-1]["seq"])
+ session_model.cmd_ack(claimed, last_deliverable_seq(claimed))
hooks_model.cmd_hook({"hook_event_name": "Stop", "session_id": "s1"})
answer = json.loads(capsys.readouterr().out)
@@ -3134,7 +3237,7 @@ def test_an_acknowledged_comment_nobody_answered_holds_the_turn(claimed, capsys)
hooks_model.cmd_hook({"hook_event_name": "Stop", "session_id": "s1"})
assert capsys.readouterr().out == ""
- session_model.cmd_ack(claimed, events_model.read_events(claimed)[-1]["seq"])
+ session_model.cmd_ack(claimed, last_deliverable_seq(claimed))
hooks_model.cmd_hook({"hook_event_name": "Stop", "session_id": "s1"})
assert asked["id"] in json.loads(capsys.readouterr().out)["reason"]
conversation_model.cmd_reply(
@@ -3148,7 +3251,7 @@ def test_an_acknowledged_comment_nobody_answered_holds_the_turn(claimed, capsys)
moot = events_model.append_event(
claimed, {"kind": "comment", "author": "user", "text": "and C?"}
)
- session_model.cmd_ack(claimed, events_model.read_events(claimed)[-1]["seq"])
+ session_model.cmd_ack(claimed, last_deliverable_seq(claimed))
hooks_model.cmd_hook({"hook_event_name": "Stop", "session_id": "s1"})
assert moot["id"] in json.loads(capsys.readouterr().out)["reason"]
conversation_model.cmd_resolve(claimed, moot["id"])
@@ -3170,7 +3273,7 @@ def test_an_acknowledged_comment_nobody_answered_holds_the_turn(claimed, capsys)
claimed,
{"kind": "reply", "author": "user", "parent": ask["id"], "text": "sqlite"},
)
- session_model.cmd_ack(claimed, events_model.read_events(claimed)[-1]["seq"])
+ session_model.cmd_ack(claimed, last_deliverable_seq(claimed))
hooks_model.cmd_hook({"hook_event_name": "Stop", "session_id": "s1"})
assert ask["id"] in json.loads(capsys.readouterr().out)["reason"]
conversation_model.cmd_reply(claimed, ask["id"], "sqlite it is", None)
@@ -3212,7 +3315,7 @@ def test_a_clarification_thread_carries_a_version_response_while_the_reader_owns
"text": "Add the camera first.",
},
)
- session_model.cmd_ack(claimed, events_model.read_events(claimed)[-1]["seq"])
+ session_model.cmd_ack(claimed, last_deliverable_seq(claimed))
hooks_model.cmd_hook({"hook_event_name": "Stop", "session_id": "s1"})
assert proposal["id"] in json.loads(capsys.readouterr().out)["reason"]
@@ -3243,7 +3346,7 @@ def test_a_clarification_thread_carries_a_version_response_while_the_reader_owns
"text": "Yes.",
},
)
- session_model.cmd_ack(claimed, events_model.read_events(claimed)[-1]["seq"])
+ session_model.cmd_ack(claimed, last_deliverable_seq(claimed))
hooks_model.cmd_hook({"hook_event_name": "Stop", "session_id": "s1"})
reason = json.loads(capsys.readouterr().out)["reason"]
assert proposal["id"] in reason
@@ -3278,7 +3381,7 @@ def test_the_guard_survives_a_page_vendored_before_the_layer_moved(claimed, caps
asked = events_model.append_event(
claimed, {"kind": "comment", "author": "user", "text": "why B?"}
)
- session_model.cmd_ack(claimed, events_model.read_events(claimed)[-1]["seq"])
+ session_model.cmd_ack(claimed, last_deliverable_seq(claimed))
hooks_model.cmd_hook({"hook_event_name": "Stop", "session_id": "s1"})
answer = json.loads(capsys.readouterr().out)
assert answer["decision"] == "block"
@@ -4100,7 +4203,7 @@ def test_a_reaction_holds_no_turn_as_an_unanswered_decision(claimed, capsys):
claimed,
{"kind": "reply", "author": "user", "parent": answer["id"], "token": "ok"},
)
- session_model.cmd_ack(claimed, events_model.read_events(claimed)[-1]["seq"])
+ session_model.cmd_ack(claimed, last_deliverable_seq(claimed))
hooks_model.cmd_hook({"hook_event_name": "Stop", "session_id": "s1"})
assert capsys.readouterr().out == ""
@@ -4110,6 +4213,6 @@ def test_a_reaction_holds_no_turn_as_an_unanswered_decision(claimed, capsys):
claimed,
{"kind": "reply", "author": "user", "parent": answer["id"], "text": "but C?"},
)
- session_model.cmd_ack(claimed, events_model.read_events(claimed)[-1]["seq"])
+ session_model.cmd_ack(claimed, last_deliverable_seq(claimed))
hooks_model.cmd_hook({"hook_event_name": "Stop", "session_id": "s1"})
assert json.loads(capsys.readouterr().out)["decision"] == "block"
diff --git a/tests/test_render_conversations.py b/tests/test_render_conversations.py
index 7c6d9a59a2..8cfd16f409 100644
--- a/tests/test_render_conversations.py
+++ b/tests/test_render_conversations.py
@@ -241,9 +241,7 @@ def test_a_work_claim_cannot_move_a_later_control_under_the_pointer(browser, ser
)
assert claimed.exit_code == 0, claimed.output
told(page)
- expect(page.locator(f'.lf-thread[data-id="{source}"] .lf-work-line')).to_have_count(
- 1
- )
+ expect(page.locator(f'.lf-thread[data-id="{source}"] .lf-receipt')).to_have_count(1)
after = target_card.evaluate("el => el.getBoundingClientRect().top")
assert after == pytest.approx(before, abs=1), (
f"the work claim moved the later card from {before:.1f}px to {after:.1f}px"
@@ -1294,7 +1292,7 @@ def test_a_render_arriving_mid_fold_keeps_the_place_the_fold_is_holding(browser,
the motion starts, and a hold that reads it again mid-fold pins a card above the
fold while everything past it — the successor the reader was aiming at among it —
goes on moving. The arrival here is another thread's reply, which is one of several:
- the two-second heartbeat repaints the work lines under the same hold, and so does a
+ the two-second heartbeat repaints the receipts under the same hold, and so does a
narrowing.
"""
page, errors = open_page(
@@ -1422,9 +1420,10 @@ def test_a_fold_hands_off_without_reapplying_movement_already_held(browser, serv
"}",
[source, successor],
)
- assert setup == {"sourceAbove": True, "successorVisible": True}, (
- f"the fold is not above every visible fallback: {setup}"
- )
+ assert setup == {
+ "sourceAbove": True,
+ "successorVisible": True,
+ }, f"the fold is not above every visible fallback: {setup}"
resolved_box = resolved_card.bounding_box()
page.mouse.move(
resolved_box["x"] + resolved_box["width"] / 2,
diff --git a/tests/test_render_export.py b/tests/test_render_export.py
index 660a401dad..0f90f50592 100644
--- a/tests/test_render_export.py
+++ b/tests/test_render_export.py
@@ -271,7 +271,7 @@ def test_a_gloss_keeps_its_explanation_in_static_media(browser, serve, tmp_path)
def test_an_export_drops_a_live_widget_work_claim(browser, serve, tmp_path):
- """A local work line is live runtime chrome even though its seat is in the page.
+ """A local receipt is live runtime chrome even though its seat is in the page.
A standalone copy has no agent behind it, so preserving the rendered sentence
would turn a provisional claim into a frozen lie."""
work_page = leaf_page(
@@ -303,7 +303,7 @@ def test_an_export_drops_a_live_widget_work_claim(browser, serve, tmp_path):
errors = watched(page)
page.goto(out.as_uri(), wait_until="load")
- expect(page.locator(".lf-work-line")).to_have_count(0)
+ expect(page.locator(".lf-receipt")).to_have_count(0)
expect(page.locator("#rollout-card")).not_to_contain_text("checking the shard")
assert errors == []
page.close()
diff --git a/tests/test_render_options.py b/tests/test_render_options.py
index d60da68b3d..d6ac4b9ae9 100644
--- a/tests/test_render_options.py
+++ b/tests/test_render_options.py
@@ -10,6 +10,8 @@
from leaf import cli as cli_model
from leaf import event_log as events_model
from leaf import schema as schema_model
+from leaf import service as service_model
+from leaf import session as session_model
from leaf.render_gate import version as render_gate_model
from playwright.sync_api import expect
from render_support import (
@@ -1352,6 +1354,78 @@ def test_a_pick_states_the_whole_set(browser, serve):
page.close()
+def test_a_widget_move_reuses_one_target_button_until_the_page_honors_it(
+ browser, serve
+):
+ """A widget needs no x-work declaration to acknowledge the reader's move.
+
+ The owner's existing page-edge Button keeps its DOM identity while durable
+ transport acceptance advances Sent to Picked up and a real claim makes it Active.
+ Once authored markup records the choice, the Button returns to Outcome.
+ """
+ url = serve(DECISION_PAGE)
+ page, errors = open_page(browser, live_url(url))
+ d = serve.page_dir
+
+ page.locator("#job-mounts").click()
+ round_trip(page)
+ action = next(
+ event
+ for event in reversed(sent_events(d))
+ if event.get("widget") == "jobs" and event.get("action") == "choose"
+ )
+ logged_action = next(
+ event for event in events_model.read_events(d) if event["id"] == action["id"]
+ )
+ receipt = page.locator('[data-lf-margin-for="jobs"] > .lf-margin-marker')
+ expect(receipt).to_have_attribute("data-lf-kinds", "outcome")
+ expect(receipt.locator(".lf-margin-action-glyph")).to_have_text("✓")
+ expect(receipt).to_have_attribute("aria-label", re.compile(r"^Sent, "))
+ expect(page.locator("#jobs > .lf-receipt")).to_have_count(0)
+ receipt.evaluate("node => { node.dataset.identityProbe = 'kept' }")
+
+ with service_model.PageTransaction(d) as transaction:
+ session_model.record_pickup(transaction, [logged_action])
+ session_model.cmd_ack(d, logged_action["seq"])
+ told(page)
+ expect(receipt).to_have_attribute("data-lf-kinds", "outcome")
+ expect(receipt).to_have_attribute("aria-label", re.compile(r"^Picked up, "))
+ expect(receipt).to_have_attribute("data-identity-probe", "kept")
+
+ active = CliRunner().invoke(
+ cli_model.cli,
+ ["status", str(d), "working", "checking the mounts", "--on", "jobs"],
+ )
+ assert active.exit_code == 0, active.output
+ told(page)
+ expect(receipt).to_have_attribute("data-lf-kinds", "outcome")
+ expect(receipt.locator(".lf-margin-action-glyph")).to_have_text("●")
+ expect(receipt).to_have_attribute("aria-label", re.compile("checking the mounts"))
+ expect(receipt).to_have_attribute("data-identity-probe", "kept")
+
+ # The receipt admitted this claim without an x-work declaration. Its page-edge
+ # Target Button is still a local seat, so an unrelated revision cannot wedge the
+ # authoring loop merely because the widget has no content or conversation seat.
+ unrelated = DECISION_PAGE.replace(
+ 'Three jobs
', 'Three jobs, checked
'
+ )
+ stamp_page(d, unrelated, "Checked the surrounding plan")
+ wait_for_revision(page, 2)
+ expect(receipt.locator(".lf-margin-action-glyph")).to_have_text("●")
+ expect(receipt).to_have_attribute("aria-label", re.compile("checking the mounts"))
+ expect(receipt).to_have_attribute("data-identity-probe", "kept")
+
+ honored = DECISION_PAGE.replace(
+ ' .lf-work-line")
- expect(card_line).to_have_text(
- re.compile(r"^Claude is on this — checking the shard\s*just now$")
+ card_button = page.locator(
+ '[data-lf-margin-for="card-migration"] > .lf-margin-marker'
)
- expect(card_line).to_be_visible()
- card_words, card_age = card_line.locator(
- ":scope > span, :scope > time"
- ).evaluate_all("els => els.map(el => el.getBoundingClientRect().bottom)")
- assert abs(card_words - card_age) <= 1, (
- "the age splits a wrapped card sentence instead of following its last line"
+ expect(card_button).to_have_attribute("data-lf-kinds", "activity")
+ expect(card_button.locator(".lf-margin-action-glyph")).to_have_text("●")
+ expect(card_button).to_have_attribute(
+ "aria-label", re.compile("checking the shard")
)
+ card_button.click()
+ expect(page.locator(".lf-live")).to_contain_text("checking the shard")
expect(page.locator(".lf-thread")).to_have_count(0)
- expect(page.locator(".lf-panel .lf-work-line")).to_have_count(0)
- expect(card_line).to_have_class(re.compile(r"\blf-ui\b"))
- assert card_line.get_attribute("data-lf-gen") == "1"
+ expect(page.locator(".lf-panel .lf-receipt")).to_have_count(0)
+ expect(page.locator("#card-migration > .lf-receipt")).to_have_count(0)
+ expect(card_button).to_have_class(re.compile(r"\blf-margin-action\b"))
# An unrelated version leaves the card coordinate standing.
stamp_page(d, work_page, "Elsewhere")
wait_for_revision(page, 2)
- expect(card_line).to_have_count(1)
+ expect(card_button).to_have_count(1)
# A new claim belongs to v2 and does not appear in a pinned v1 page.
claim("card-migration", "checking the fallback")
- expect(card_line).to_contain_text("checking the fallback")
+ expect(card_button).to_have_attribute(
+ "aria-label", re.compile("checking the fallback")
+ )
pinned, pinned_errors = open_page(browser, url, pin=True)
- expect(pinned.locator("#card-migration > .lf-work-line")).to_have_count(0)
+ expect(
+ pinned.locator('[data-lf-margin-for="card-migration"] > .lf-margin-marker')
+ ).to_have_count(0)
assert pinned_errors == []
pinned.close()
@@ -1536,14 +1613,14 @@ def claim(subject, detail):
completes=("card-migration",),
)
wait_for_revision(page, 3)
- expect(page.locator(".lf-work-line")).to_have_count(0)
+ expect(page.locator(".lf-receipt")).to_have_count(0)
assert errors == []
page.close()
def test_local_work_chrome_does_not_take_its_holder_gesture(browser, serve, tmp_path):
"""A customization may deliberately give a container member a content seat.
- The runtime's generated line is still apparatus rather than that member's own
+ The runtime's generated Button is still apparatus rather than that member's own
gesture: clicking status about an option must not choose the option."""
option = json.loads((schema_model.DEFAULT_PACKAGE / "registry.json").read_text())[
"lf-option"
@@ -1568,13 +1645,13 @@ def test_local_work_chrome_does_not_take_its_holder_gesture(browser, serve, tmp_
assert result.exit_code == 0, result.output
told(page)
- work_line = page.locator("#job-mounts > .lf-work-line")
- expect(work_line).to_be_visible()
- work_line.click()
+ work_button = page.locator('[data-lf-margin-for="job-mounts"] > .lf-margin-marker')
+ expect(work_button).to_have_attribute("data-lf-kinds", "activity")
+ work_button.click()
# A press that does nothing states no fact to wait on, so the control is the same
# gesture where it is supposed to work: pick the neighbouring option and let its
- # send settle. One outbox in gesture order means a pick the work line had taken
+ # send settle. One outbox in gesture order means a pick the receipt had taken
# would already stand ahead of this one, so the whole log can be read once.
page.locator("#job-heater").click()
round_trip(page)
@@ -1612,14 +1689,14 @@ def test_settled_widget_work_leaves_a_declared_shadow_tree(browser, serve):
)
assert claimed.exit_code == 0, claimed.output
told(page)
- work_line = page.locator("#shadow-card > .lf-work-line")
- expect(work_line).to_have_count(1)
+ work_button = page.locator('[data-lf-margin-for="shadow-card"] > .lf-margin-marker')
+ expect(work_button).to_have_attribute("data-lf-kinds", "activity")
page.evaluate(
"""() => document.getElementById('patch').shadowRoot.append(
document.getElementById('shadow-card'))"""
)
- expect(work_line).to_have_count(1)
+ expect(work_button).to_have_count(1)
settled = stamp_page(
d,
@@ -1629,12 +1706,12 @@ def test_settled_widget_work_leaves_a_declared_shadow_tree(browser, serve):
)
assert settled["version"] == 2
told(page)
- expect(work_line).to_have_count(0)
+ expect(work_button).to_have_count(0)
assert errors == []
page.close()
-def test_widget_work_keeps_its_style_in_a_declared_shadow_tree(browser, serve):
+def test_widget_work_keeps_its_button_style_in_a_declared_shadow_tree(browser, serve):
"""The same declaration-backed seat may be staged into an x-shadow widget;
crossing that supported boundary must not turn the shared local line back into an
unstyled block."""
@@ -1659,15 +1736,16 @@ def test_widget_work_keeps_its_style_in_a_declared_shadow_tree(browser, serve):
)
assert result.exit_code == 0, result.output
told(page)
- work_line = page.locator("#shadow-card > .lf-work-line")
- expect(work_line).to_have_css("display", "flex")
+ work_button = page.locator('[data-lf-margin-for="shadow-card"] > .lf-margin-marker')
+ expect(work_button).to_have_css("display", "flex")
page.evaluate(
"""() => document.getElementById('patch').shadowRoot.append(
document.getElementById('shadow-card'))"""
)
- expect(work_line).to_have_css("display", "flex")
- expect(work_line).to_have_css("border-left-style", "dashed")
+ expect(work_button).to_have_css("display", "flex")
+ expect(work_button).to_have_css("border-radius", "999px")
+ expect(work_button).to_have_css("min-height", "30px")
assert errors == []
page.close()
@@ -1900,7 +1978,13 @@ def test_a_specimen_holds_a_wide_exhibit_inside_the_column(browser, serve):
can fail."""
page, errors = open_page(browser, serve(SPECIMEN_PAGE))
assert errors == []
+ page.evaluate(
+ """() => document.addEventListener('lf-margin-layout', () => {
+ window.lfMarginLayoutWidth = innerWidth;
+ })"""
+ )
resized(page, 380, 900)
+ page.wait_for_function("() => window.lfMarginLayoutWidth === 380")
wide = page.evaluate(
"() => [document.documentElement.scrollWidth,"
" document.documentElement.clientWidth,"
@@ -2004,6 +2088,14 @@ def test_a_specimen_in_a_reply_is_quoted_there_too(browser, serve):
assert [(e["widget"], e["detail"]) for e in actions] == [
("rp-live", {"options": ["rp-stage"]})
]
+ receipt = page.locator(
+ f'#rp-live > .lf-receipt[data-receipt-id="{actions[0]["id"]}"]'
+ )
+ expect(receipt).to_contain_text("✓ Sent")
+ with service_model.PageTransaction(d) as transaction:
+ session_model.record_pickup(transaction, actions)
+ told(page)
+ expect(receipt).to_contain_text("✓ Picked up")
assert page.locator("#rp-quoted lf-option[chosen]").count() == 0
page.close()
diff --git a/tests/test_render_startup.py b/tests/test_render_startup.py
index eec910d5d7..4b0a50c4df 100644
--- a/tests/test_render_startup.py
+++ b/tests/test_render_startup.py
@@ -20,6 +20,7 @@
from leaf import http as http_model
from leaf import render_checks as render_checks_model
from leaf import service as service_model
+from leaf import session as session_model
from playwright.sync_api import TimeoutError as PlaywrightTimeout
from playwright.sync_api import expect
from render_support import (
@@ -1911,7 +1912,6 @@ def declare(
detail="",
*,
agent="Claude",
- handoff=False,
quiet_for=0,
turn_ended=None,
session_pid=None,
@@ -1926,8 +1926,6 @@ def declare(
"detail": detail,
"ts": ts.isoformat(timespec="seconds"),
}
- if handoff:
- status["handoff"] = True
if claimed:
record_claim(
d,
@@ -2026,14 +2024,6 @@ def declare(
"Claude isn't watching right now. 1 update waiting. It picks them up next turn."
)
- # The failure the whole mechanism exists for: `leaf wait` printed, set this
- # status, and Claude never came back. The handoff mark is what dates it.
- declare("working", "picking up 1 update", handoff=True, quiet_for=20 * 60)
- expect(text).to_have_text(
- "Claude last checked in 20m ago. 1 update waiting. Nudge it in the terminal."
- )
- expect(dot).to_have_class(re.compile(r"\baway\b"))
-
# With nobody listening the same ending carries the remedy, because the reader's
# next word has nowhere to land until a session picks the page up again.
declare("working", "running the migration", quiet_for=6 * 60, turn_ended=5 * 60)
@@ -2120,7 +2110,7 @@ def claim(detail):
def test_a_thread_says_what_the_agent_is_doing_about_it(
browser, serve, tmp_path, dead_pid
):
- """The banner says what the agent is doing; a work line says which of the reader's
+ """The banner says what the agent is doing; a receipt says which of the reader's
questions it is doing it about. Both are one claim written by one command
(`leaf status … --on`), which is what makes a delegate's check-in keep the page's
line true as well as its own thread's.
@@ -2140,8 +2130,21 @@ def test_a_thread_says_what_the_agent_is_doing_about_it(
held, other = comments[0]["id"], comments[1]["id"]
page.keyboard.press("c")
expect(page.locator(".lf-panel")).to_be_visible()
- work_line = page.locator(".lf-work-line")
- expect(work_line).to_have_count(0)
+ receipts = page.locator(".lf-receipt")
+ held_receipt = page.locator(f'.lf-thread[data-id="{held}"] .lf-receipt')
+ other_receipt = page.locator(f'.lf-thread[data-id="{other}"] .lf-receipt')
+ expect(receipts).to_have_count(2)
+ expect(held_receipt).to_contain_text("✓ Sent")
+ held_receipt.evaluate("node => { node.dataset.identityProbe = 'kept' }")
+
+ # Durable transport acceptance advances the exact same row in place. It does
+ # not claim that work has started and does not disturb another reader move.
+ with service_model.PageTransaction(d) as transaction:
+ session_model.record_pickup(transaction, [comments[0]])
+ told(page)
+ expect(held_receipt).to_contain_text("✓ Picked up")
+ expect(held_receipt).to_have_attribute("data-identity-probe", "kept")
+ expect(other_receipt).to_contain_text("✓ Sent")
def status(*args):
assert (
@@ -2152,26 +2155,26 @@ def status(*args):
status("working", "reading the reconnect traces", "--on", held)
# One line, on the thread it names: a mark that stood on every open thread would
# say only that the agent is busy, which the banner above already says.
- expect(work_line).to_have_count(1)
- expect(work_line).to_have_text(
- re.compile(r"^Claude is on this — reading the reconnect traces\s*just now$")
- )
- expect(page.locator(f'.lf-thread[data-id="{held}"] .lf-work-line')).to_have_count(1)
- expect(page.locator(f'.lf-thread[data-id="{other}"] .lf-work-line')).to_have_count(
- 0
- )
+ expect(receipts).to_have_count(2)
+ expect(held_receipt).to_have_text(
+ re.compile(r"^● Active — reading the reconnect traces\s*just now$")
+ )
+ expect(held_receipt).to_have_attribute("data-identity-probe", "kept")
+ expect(held_receipt).to_have_count(1)
+ expect(other_receipt).to_have_count(1)
+ expect(other_receipt).to_contain_text("✓ Sent")
# Under the words that asked and above the box that answers, so it reads in the
# thread's own order: what you said, what has been said back, what is being done.
assert page.evaluate(
f"""() => {{
const thread = document.querySelector('.lf-thread[data-id="{held}"]');
const kids = [...thread.children];
- return kids.findIndex((el) => el.matches('.lf-work-line'))
+ return kids.findIndex((el) => el.matches('.lf-receipt'))
> kids.findLastIndex((el) => el.matches('.lf-msg.user'))
- && kids.findIndex((el) => el.matches('.lf-work-line'))
+ && kids.findIndex((el) => el.matches('.lf-receipt'))
< kids.findIndex((el) => el.matches('.lf-compose'));
}}"""
- ), "the work line is not between the thread's last message and its reply box"
+ ), "the receipt is not between the thread's last message and its reply box"
# A later claim about the page as a whole is not an answer to the thread, so the
# line stands: the two seats are one claim, and only one of them has been rewritten.
@@ -2179,8 +2182,8 @@ def status(*args):
expect(page.locator(".lf-status-text")).to_have_text(
re.compile(r"^Claude is working — drafting v2")
)
- expect(work_line).to_have_count(1)
- expect(work_line).to_contain_text("reading the reconnect traces")
+ expect(held_receipt).to_have_count(1)
+ expect(held_receipt).to_contain_text("reading the reconnect traces")
# The answer is what ends it.
events_model.append_event(
@@ -2197,19 +2200,22 @@ def status(*args):
expect(page.locator(f'.lf-thread[data-id="{held}"] .lf-msg.claude')).to_have_count(
1
)
- expect(work_line).to_have_count(0)
+ expect(held_receipt).to_have_count(0)
+ expect(receipts).to_have_count(1)
# And a claim the agent renews after answering stands again: its line is on the thread
# a second time, which is a fact about now rather than about what was said.
status("working", "re-running it against the rolling deploy", "--on", held)
- expect(work_line).to_have_count(1)
+ expect(held_receipt).to_have_count(1)
+ expect(receipts).to_have_count(2)
# A conversation the reader has closed asks nothing and shows nothing, for the same
# reason its reply box is gone.
events_model.append_event(d, {"kind": "resolve", "author": "user", "parent": held})
told(page)
expect(page.locator(".lf-details summary")).to_have_text("Resolved (1)")
- expect(work_line).to_have_count(0)
+ expect(held_receipt).to_have_count(0)
+ expect(receipts).to_have_count(1)
# Reopening restores a claim that no reply answered. The local line still goes
# with the page claim it is part of: once nothing holds the page, it cannot keep
@@ -2218,13 +2224,40 @@ def status(*args):
d, {"kind": "unresolve", "author": "user", "parent": held}
)
told(page)
- expect(work_line).to_have_count(1)
+ expect(held_receipt).to_have_count(1)
+ expect(receipts).to_have_count(2)
record_claim(d, id="s", pid=dead_pid)
told(page)
expect(page.locator(".lf-status-text")).to_have_text(
re.compile(r"^No session holds this page\.")
)
- expect(work_line).to_have_count(0)
+ expect(held_receipt).to_have_count(0)
+ expect(receipts).to_have_count(1)
+ assert errors == []
+ page.close()
+
+
+def test_an_unpicked_move_says_it_is_waiting_after_the_short_grace(browser, serve):
+ """Silence changes the wording, not the durable phase or the interaction seat."""
+ url = serve(LONG_PAGE)
+ d = serve.page_dir
+ old = (datetime.now().astimezone() - timedelta(minutes=3)).isoformat(
+ timespec="seconds"
+ )
+ comment = events_model.append_event(
+ d,
+ {
+ "kind": "comment",
+ "author": "user",
+ "revision": 1,
+ "text": "Did this reach anyone?",
+ "ts": old,
+ },
+ )
+ page, errors = open_page(browser, url)
+ page.keyboard.press("c")
+ receipt = page.locator(f'.lf-thread[data-id="{comment["id"]}"] .lf-receipt')
+ expect(receipt).to_contain_text("○ Waiting for pickup")
assert errors == []
page.close()
@@ -2249,7 +2282,7 @@ def test_a_work_line_says_when_its_claim_has_gone_quiet(browser, serve, tmp_path
held = next(e for e in events_model.read_events(d) if e["kind"] == "comment")["id"]
page.keyboard.press("c")
expect(page.locator(".lf-panel")).to_be_visible()
- work_line = page.locator(".lf-work-line")
+ work_line = page.locator(".lf-receipt")
def claim(claim_ts, session="s"):
"""A page claim made now, carrying local work last renewed whenever."""
diff --git a/tests/test_smoke.py b/tests/test_smoke.py
index ca3aaf7db3..5d7dea70ce 100644
--- a/tests/test_smoke.py
+++ b/tests/test_smoke.py
@@ -21,9 +21,12 @@ def test_ship_review_asks_are_directly_answerable(browser, serve):
example = ROOT / "examples" / "ship-review.html"
page, errors = open_page(browser, serve(example))
- expect(page.locator(".lf-decisions")).to_have_text("Asks (2)")
+ expect(page.locator(".lf-decisions")).to_have_text("Asks (1)")
expect(page.locator("#off-workaround-review .lf-pick")).to_have_count(2)
expect(page.locator("#off-workaround-review .lf-pick").first).to_be_visible()
+ expect(page.locator("#off-workaround-approve .lf-pick")).to_have_attribute(
+ "aria-checked", "true"
+ )
assert errors == []
page.close()