File size: 6,666 Bytes
5b4c023
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8c7a278
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5b4c023
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
import asyncio
from pathlib import Path

from frontend.web_runtime import WebRuntime
from llm_panda.execution.cancellation import (
    CancellationToken,
    ExecutionInterrupted,
)


ROOT = Path(__file__).resolve().parents[1]


def test_reload_snapshot_preserves_runtime_state_and_event_history():
    runtime = WebRuntime(max_events=10)

    async def exercise():
        await runtime.publish({"type": "log", "message": "planning"})
        await runtime.publish(
            {"type": "plan", "plan": [{"step": 1}], "generation": 0}
        )
        await runtime.publish(
            {
                "type": "state",
                "joints": [0.1, 0.2],
                "objects": {"banana": {"pos": [0.4, 0.1, 0.3]}},
            },
            journal=False,
        )

    asyncio.run(exercise())
    snapshot = runtime.snapshot()

    assert snapshot["state"]["joints"] == [0.1, 0.2]
    assert snapshot["plan"]["plan"] == [{"step": 1}]
    assert [event["message"] for event in snapshot["events"] if event["type"] == "log"] == [
        "planning"
    ]


def test_new_command_cancels_active_and_latest_pending_wins():
    runtime = WebRuntime()
    first = runtime.submit("first")
    active = runtime.take_pending()

    second = runtime.submit("second")
    third = runtime.submit("third")

    assert active["task_id"] == first["task_id"]
    assert active["token"].requested
    pending = runtime.take_pending()
    assert pending["task_id"] == third["task_id"]
    assert pending["task_id"] != second["task_id"]


def test_stale_result_is_journaled_but_does_not_replace_latest_result():
    runtime = WebRuntime()
    first = runtime.submit("first")
    runtime.take_pending()
    second = runtime.submit("second")

    async def exercise():
        await runtime.publish(
            {
                "type": "result",
                "generation": first["generation"],
                "success": False,
            }
        )
        await runtime.publish(
            {
                "type": "result",
                "generation": second["generation"],
                "success": True,
            }
        )

    asyncio.run(exercise())

    assert runtime.latest_result["generation"] == second["generation"]
    assert runtime.latest_result["success"]


def test_event_journal_is_bounded_and_replayable():
    runtime = WebRuntime(max_events=3)

    async def exercise():
        for index in range(5):
            await runtime.publish({"type": "log", "message": str(index)})

    asyncio.run(exercise())

    assert [event["message"] for event in runtime.events_after(0)] == [
        "2",
        "3",
        "4",
    ]
    assert [event["message"] for event in runtime.events_after(4)] == ["4"]


def test_cancellation_token_raises_only_after_request():
    token = CancellationToken()
    token.checkpoint()
    token.request("new command")

    try:
        token.checkpoint()
    except ExecutionInterrupted as exc:
        assert "new command" in str(exc)
    else:
        raise AssertionError("checkpoint did not interrupt")


def test_runtime_conversation_survives_client_disconnect():
    runtime = WebRuntime()
    runtime.conversation_manager.observe(
        "move banana",
        [
            {
                "function": "clarify",
                "args": {
                    "message": "Where?",
                    "missing_fields": ["destination"],
                    "partial_intent": {
                        "action": "pick_and_place",
                        "object_id": "banana",
                    },
                },
            }
        ],
    )

    class Socket:
        def __init__(self):
            self.events = []

        async def send_json(self, event):
            self.events.append(event)

    socket = Socket()

    async def connect_disconnect():
        await runtime.subscribe(socket)
        await runtime.publish({"type": "log", "message": "waiting"})
        await runtime.unsubscribe(socket)

    asyncio.run(connect_disconnect())

    assert runtime.snapshot()["conversation_pending"]
    assert socket.events[0]["message"] == "waiting"


def test_explicit_reset_clears_ui_history_but_keeps_monotonic_sequence():
    runtime = WebRuntime()

    async def exercise():
        await runtime.publish({"type": "log", "message": "old"})

    asyncio.run(exercise())
    old_seq = runtime.snapshot()["last_event_seq"]
    runtime.clear_task_history()
    asyncio.run(runtime.publish({"type": "log", "message": "new"}))

    snapshot = runtime.snapshot()
    assert [event["message"] for event in snapshot["events"]] == ["new"]
    assert snapshot["last_event_seq"] > old_seq


def test_cancel_all_interrupts_active_and_discards_pending_command():
    runtime = WebRuntime()
    runtime.submit("active")
    active = runtime.take_pending()
    runtime.submit("pending")

    assert runtime.cancel_all("reset")
    assert active["token"].requested
    assert not runtime.has_pending


def test_repeated_standalone_cancel_is_idempotent():
    runtime = WebRuntime()
    runtime.submit("active")
    active = runtime.take_pending()

    assert runtime.cancel_all("user_cancelled")
    assert runtime.cancel_all("user_cancelled")
    assert active["token"].requested
    assert runtime.active_generation == active["generation"]
    assert not runtime.has_pending


def test_interruption_is_recorded_without_completing_the_goal():
    runtime = WebRuntime()

    runtime.conversation_manager.record_interruption("move banana")

    last_turn = runtime.conversation_manager.recent_turns[-1]
    assert last_turn.kind == "interrupted"
    assert "move banana" in last_turn.content


def test_web_pipeline_keeps_resolved_goal_across_replans_and_goal_check():
    source = (ROOT / "frontend" / "interactive_server.py").read_text(
        encoding="utf-8"
    )

    assert "resolved_goal = None" in source
    assert "resolved_goal.canonical_instruction" in source
    assert 'plan_kwargs["resolve_goal"] = False' in source
    assert "resolved_goal=resolved_goal" in source


def test_web_replan_limit_uses_environment_configuration():
    source = (ROOT / "frontend" / "interactive_server.py").read_text(
        encoding="utf-8"
    )

    assert 'os.environ.get("MAX_REPLANS", "3")' in source
    assert "MAX_REPLANS = 999999" not in source


def test_scene_reset_clears_cli_conversation_memory():
    source = (ROOT / "main.py").read_text(encoding="utf-8")
    reset_body = source.split("def reset_objects", 1)[1].split(
        "def change_environment",
        1,
    )[0]

    assert '"conversation_manager"' in reset_body
    assert "conversation_manager.clear()" in reset_body