agents-relay 1.0.4 → 1.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/adapters.js +80 -31
- package/dist/reconciler.js +6 -4
- package/package.json +1 -1
- package/skills/chatgpt-browser-worker/SKILL.md +102 -0
- package/skills/chatgpt-browser-worker/agents/browser-worker.agent.md +113 -0
- package/skills/chatgpt-browser-worker/references/contract.md +132 -0
- package/skills/chatgpt-browser-worker/references/orchestration.md +101 -0
- package/skills/chatgpt-browser-worker/scripts/_create_bh.py +158 -0
- package/skills/chatgpt-browser-worker/scripts/_operate_bh.py +198 -0
- package/skills/chatgpt-browser-worker/scripts/contract.py +151 -0
- package/skills/chatgpt-browser-worker/scripts/create.py +84 -0
- package/skills/chatgpt-browser-worker/scripts/create_bh.py +34 -0
- package/skills/chatgpt-browser-worker/scripts/operate_bh.py +36 -0
- package/skills/chatgpt-browser-worker/scripts/operations.py +179 -0
- package/skills/chatgpt-browser-worker/tests/fixtures/relay_lifecycle.json +21 -0
- package/skills/chatgpt-browser-worker/tests/test_agent_definition.py +27 -0
- package/skills/chatgpt-browser-worker/tests/test_contract.py +315 -0
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
HERE = Path(__file__).resolve().parents[1]
|
|
12
|
+
spec = importlib.util.spec_from_file_location("chatgpt_browser_worker_contract", HERE / "scripts/contract.py")
|
|
13
|
+
assert spec and spec.loader
|
|
14
|
+
contract = importlib.util.module_from_spec(spec)
|
|
15
|
+
spec.loader.exec_module(contract)
|
|
16
|
+
sys.modules["contract"] = contract
|
|
17
|
+
|
|
18
|
+
create_spec = importlib.util.spec_from_file_location("chatgpt_browser_worker_create", HERE / "scripts/create.py")
|
|
19
|
+
assert create_spec and create_spec.loader
|
|
20
|
+
create = importlib.util.module_from_spec(create_spec)
|
|
21
|
+
create_spec.loader.exec_module(create)
|
|
22
|
+
|
|
23
|
+
operations_spec = importlib.util.spec_from_file_location("chatgpt_browser_worker_operations", HERE / "scripts/operations.py")
|
|
24
|
+
assert operations_spec and operations_spec.loader
|
|
25
|
+
operations = importlib.util.module_from_spec(operations_spec)
|
|
26
|
+
operations_spec.loader.exec_module(operations)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def state(status="created", project=None):
|
|
30
|
+
return {
|
|
31
|
+
"schema_version": 1,
|
|
32
|
+
"thread_id": "thread-1",
|
|
33
|
+
"project": project or {"id": "project-1", "name": "neo"},
|
|
34
|
+
"status": status,
|
|
35
|
+
"requested_thinking_level": "high",
|
|
36
|
+
"effective_thinking_level": "unknown",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_create_and_resume_require_verified_project_and_preserve_level_request():
|
|
41
|
+
created = contract.validate_request(
|
|
42
|
+
{"operation": "create", "project": {"name": "neo"}, "prompt": "hello", "thinking_level": "high"}
|
|
43
|
+
)
|
|
44
|
+
resumed = contract.validate_request(
|
|
45
|
+
{"operation": "resume", "thread_id": "thread-1", "project": {"id": "project-1", "name": "neo"}}
|
|
46
|
+
)
|
|
47
|
+
assert created["project"] == {"name": "neo"}
|
|
48
|
+
assert created["thinking_level"] == "high"
|
|
49
|
+
assert resumed["thread_id"] == "thread-1"
|
|
50
|
+
with pytest.raises(contract.ContractError):
|
|
51
|
+
contract.validate_request({"operation": "create", "prompt": "hello", "thinking_level": "high"})
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_project_mismatch_is_rejected_and_unknown_effective_level_is_valid():
|
|
55
|
+
current = contract.validate_state(state())
|
|
56
|
+
assert current["effective_thinking_level"] == "unknown"
|
|
57
|
+
with pytest.raises(contract.ContractError, match="Project"):
|
|
58
|
+
contract.transition(current, "running", observed_project={"id": "other", "name": "other"})
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_project_id_is_optional_at_a_browser_boundary():
|
|
62
|
+
assert contract.project_matches({"id": "project-1", "name": "neo"}, {"name": "neo"})
|
|
63
|
+
assert not contract.project_matches({"id": "project-1", "name": "neo"}, {"id": "other", "name": "neo"})
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_completion_requires_explicit_transition_and_delete_is_terminal():
|
|
67
|
+
running = contract.transition(state(), "running")
|
|
68
|
+
waiting = contract.transition(running, "awaiting_result")
|
|
69
|
+
completed = contract.transition(waiting, "completed")
|
|
70
|
+
assert completed["status"] == "completed"
|
|
71
|
+
deleted = contract.transition(completed, "deleted")
|
|
72
|
+
assert deleted["status"] == "deleted"
|
|
73
|
+
with pytest.raises(contract.ContractError, match="terminal"):
|
|
74
|
+
contract.transition(deleted, "running")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_result_requires_observed_assistant_message_and_matching_identity():
|
|
78
|
+
result = contract.validate_result(
|
|
79
|
+
{
|
|
80
|
+
"thread_id": "thread-1",
|
|
81
|
+
"status": "completed",
|
|
82
|
+
"text": "normalized answer",
|
|
83
|
+
"message_id": "message-1",
|
|
84
|
+
},
|
|
85
|
+
thread_id="thread-1",
|
|
86
|
+
)
|
|
87
|
+
assert result["message_id"] == "message-1"
|
|
88
|
+
with pytest.raises(contract.ContractError):
|
|
89
|
+
contract.validate_result(
|
|
90
|
+
{"thread_id": "thread-1", "status": "completed", "text": "answer"},
|
|
91
|
+
thread_id="thread-1",
|
|
92
|
+
)
|
|
93
|
+
with pytest.raises(contract.ContractError, match="different thread"):
|
|
94
|
+
contract.validate_result(
|
|
95
|
+
{
|
|
96
|
+
"thread_id": "other",
|
|
97
|
+
"status": "completed",
|
|
98
|
+
"text": "answer",
|
|
99
|
+
"message_id": "message-1",
|
|
100
|
+
},
|
|
101
|
+
thread_id="thread-1",
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_contract_module_has_no_browser_runtime_dependency():
|
|
106
|
+
source = (HERE / "scripts/contract.py").read_text()
|
|
107
|
+
assert "import browser_harness" not in source
|
|
108
|
+
assert "import macbridge" not in source.lower()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class FakePort:
|
|
112
|
+
def __init__(self, project=None, sent=None):
|
|
113
|
+
self.project = project or {"id": "project-1", "name": "neo"}
|
|
114
|
+
self.sent = sent or {"thread_id": "thread-42", "conversation_url": "https://chatgpt.com/c/thread-42", "prompt_sent": True}
|
|
115
|
+
self.calls = []
|
|
116
|
+
|
|
117
|
+
def select_project(self, project):
|
|
118
|
+
self.calls.append(("select_project", project))
|
|
119
|
+
return self.project
|
|
120
|
+
|
|
121
|
+
def set_thinking_level(self, level):
|
|
122
|
+
self.calls.append(("set_thinking_level", level))
|
|
123
|
+
return {"effective_thinking_level": level, "label": level.title()}
|
|
124
|
+
|
|
125
|
+
def send_prompt(self, prompt):
|
|
126
|
+
self.calls.append(("send_prompt", prompt))
|
|
127
|
+
return self.sent
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class ExistingThreadPort:
|
|
131
|
+
def __init__(self, project=None, status="awaiting_result", result=None):
|
|
132
|
+
self.project = project or {"id": "project-1", "name": "neo"}
|
|
133
|
+
self.status = status
|
|
134
|
+
self.result = result or {
|
|
135
|
+
"thread_id": "thread-1",
|
|
136
|
+
"status": "completed",
|
|
137
|
+
"text": "done",
|
|
138
|
+
"message_id": "message-1",
|
|
139
|
+
}
|
|
140
|
+
self.calls = []
|
|
141
|
+
|
|
142
|
+
def open_thread(self, thread_id):
|
|
143
|
+
self.calls.append(("open_thread", thread_id))
|
|
144
|
+
return {"thread_id": thread_id, "project": self.project, "status": self.status}
|
|
145
|
+
|
|
146
|
+
def send_prompt(self, prompt):
|
|
147
|
+
self.calls.append(("send_prompt", prompt))
|
|
148
|
+
return {"prompt_sent": True}
|
|
149
|
+
|
|
150
|
+
def read_status(self):
|
|
151
|
+
self.calls.append(("read_status",))
|
|
152
|
+
return {"status": self.status, "progress": "visible"}
|
|
153
|
+
|
|
154
|
+
def read_result(self):
|
|
155
|
+
self.calls.append(("read_result",))
|
|
156
|
+
return self.result
|
|
157
|
+
|
|
158
|
+
def delete_thread(self):
|
|
159
|
+
self.calls.append(("delete_thread",))
|
|
160
|
+
return {"thread_id": "thread-1", "outcome": "deleted", "verified": True}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_create_returns_durable_identity_project_binding_and_evidence():
|
|
164
|
+
port = FakePort()
|
|
165
|
+
persisted = []
|
|
166
|
+
result = create.create_thread(
|
|
167
|
+
port,
|
|
168
|
+
{"operation": "create", "project": {"name": "neo"}, "prompt": "hello", "thinking_level": "high"},
|
|
169
|
+
persist=persisted.append,
|
|
170
|
+
now=lambda: "2026-09-19T12:00:00Z",
|
|
171
|
+
)
|
|
172
|
+
assert result["thread_id"] == "thread-42"
|
|
173
|
+
assert result["project"] == port.project
|
|
174
|
+
assert result["effective_thinking_level"] == "high"
|
|
175
|
+
assert result["evidence"]["thread"]["prompt_sent"] is True
|
|
176
|
+
assert persisted == [result]
|
|
177
|
+
assert [call[0] for call in port.calls] == ["select_project", "set_thinking_level", "send_prompt"]
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def test_create_default_leaves_platform_thinking_unchanged():
|
|
181
|
+
port = FakePort()
|
|
182
|
+
result = create.create_thread(
|
|
183
|
+
port,
|
|
184
|
+
{"operation": "create", "project": {"name": "neo"}, "prompt": "hello", "thinking_level": "default"},
|
|
185
|
+
)
|
|
186
|
+
assert result["requested_thinking_level"] == "default"
|
|
187
|
+
assert result["effective_thinking_level"] == "unknown"
|
|
188
|
+
assert [call[0] for call in port.calls] == ["select_project", "send_prompt"]
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def test_create_rejects_unverified_project_and_missing_thread_identity():
|
|
192
|
+
with pytest.raises(contract.ContractError, match="different Project"):
|
|
193
|
+
create.create_thread(
|
|
194
|
+
FakePort(project={"name": "other"}),
|
|
195
|
+
{"operation": "create", "project": {"name": "neo"}, "prompt": "hello", "thinking_level": "default"},
|
|
196
|
+
)
|
|
197
|
+
with pytest.raises(contract.ContractError, match="thread_id"):
|
|
198
|
+
create.create_thread(
|
|
199
|
+
FakePort(sent={"prompt_sent": True}),
|
|
200
|
+
{"operation": "create", "project": {"name": "neo"}, "prompt": "hello", "thinking_level": "default"},
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def test_resume_opens_durable_thread_and_requires_observed_project():
|
|
205
|
+
port = ExistingThreadPort()
|
|
206
|
+
persisted = []
|
|
207
|
+
result = operations.resume_thread(
|
|
208
|
+
port,
|
|
209
|
+
{"operation": "resume", "thread_id": "thread-1", "project": {"name": "neo"}},
|
|
210
|
+
state=state("running"),
|
|
211
|
+
persist=persisted.append,
|
|
212
|
+
now=lambda: "2026-09-19T12:03:00Z",
|
|
213
|
+
)
|
|
214
|
+
assert result["status"] == "awaiting_result"
|
|
215
|
+
assert result["updated_at"] == "2026-09-19T12:03:00Z"
|
|
216
|
+
assert persisted == [result]
|
|
217
|
+
with pytest.raises(contract.ContractError, match="Project"):
|
|
218
|
+
operations.resume_thread(
|
|
219
|
+
ExistingThreadPort(project={"id": "other", "name": "other"}),
|
|
220
|
+
{"operation": "resume", "thread_id": "thread-1", "project": {"name": "neo"}},
|
|
221
|
+
state=state("running"),
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def test_continue_status_and_result_persist_observed_transitions():
|
|
226
|
+
port = ExistingThreadPort(status="running")
|
|
227
|
+
persisted = []
|
|
228
|
+
running = operations.continue_thread(port, state("completed"), "continue", persist=persisted.append)
|
|
229
|
+
assert running["status"] == "running"
|
|
230
|
+
observed = operations.inspect_thread(port, running, persist=persisted.append)
|
|
231
|
+
assert observed["status"] == "running"
|
|
232
|
+
port.status = "completed"
|
|
233
|
+
result = operations.result_thread(port, observed, persist=persisted.append)
|
|
234
|
+
assert result["text"] == "done"
|
|
235
|
+
assert [call[0] for call in port.calls] == ["open_thread", "send_prompt", "read_status", "read_result"]
|
|
236
|
+
assert [item["status"] for item in persisted] == ["running", "running", "completed"]
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def test_result_without_observed_assistant_message_does_not_complete_thread():
|
|
240
|
+
port = ExistingThreadPort(result={"thread_id": "thread-1", "status": "awaiting_result"})
|
|
241
|
+
with pytest.raises(contract.ContractError, match="completed"):
|
|
242
|
+
operations.result_thread(port, state("running"))
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def test_delete_persists_verified_tombstone_and_is_idempotent():
|
|
246
|
+
port = ExistingThreadPort()
|
|
247
|
+
persisted = []
|
|
248
|
+
deleted = operations.delete_thread(
|
|
249
|
+
port, state("completed"), persist=persisted.append,
|
|
250
|
+
now=lambda: "2026-09-19T12:04:00Z",
|
|
251
|
+
)
|
|
252
|
+
assert deleted["status"] == "deleted"
|
|
253
|
+
assert deleted["evidence"]["cleanup"]["verified"] is True
|
|
254
|
+
assert deleted["updated_at"] == "2026-09-19T12:04:00Z"
|
|
255
|
+
assert [call[0] for call in port.calls] == ["delete_thread"]
|
|
256
|
+
|
|
257
|
+
again = operations.delete_thread(port, deleted, persist=persisted.append)
|
|
258
|
+
assert again["status"] == "deleted"
|
|
259
|
+
assert [call[0] for call in port.calls] == ["delete_thread"]
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def test_delete_accepts_verified_not_found_for_retry_safety():
|
|
263
|
+
class MissingPort(ExistingThreadPort):
|
|
264
|
+
def delete_thread(self):
|
|
265
|
+
return {"thread_id": "thread-1", "outcome": "not_found", "verified": True}
|
|
266
|
+
|
|
267
|
+
deleted = operations.delete_thread(MissingPort(), state("failed"))
|
|
268
|
+
assert deleted["status"] == "deleted"
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def test_cleanup_rejects_unverified_or_unrelated_observations():
|
|
272
|
+
with pytest.raises(contract.ContractError, match="verified"):
|
|
273
|
+
contract.validate_cleanup_observation(
|
|
274
|
+
{"thread_id": "thread-1", "outcome": "deleted", "verified": False},
|
|
275
|
+
thread_id="thread-1",
|
|
276
|
+
)
|
|
277
|
+
with pytest.raises(contract.ContractError, match="different thread"):
|
|
278
|
+
contract.validate_cleanup_observation(
|
|
279
|
+
{"thread_id": "other", "outcome": "deleted", "verified": True},
|
|
280
|
+
thread_id="thread-1",
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def test_agents_relay_fixture_drives_one_serial_browser_thread_lifecycle():
|
|
285
|
+
fixture = json.loads((HERE / "tests" / "fixtures" / "relay_lifecycle.json").read_text())
|
|
286
|
+
port = ExistingThreadPort(status="completed", result=fixture["result"])
|
|
287
|
+
port.project = fixture["project"]
|
|
288
|
+
persisted = []
|
|
289
|
+
|
|
290
|
+
created = create.create_thread(
|
|
291
|
+
FakePort(project=fixture["project"], sent=fixture["thread"]),
|
|
292
|
+
fixture["create"],
|
|
293
|
+
persist=persisted.append,
|
|
294
|
+
now=lambda: "2026-09-19T12:00:00Z",
|
|
295
|
+
)
|
|
296
|
+
resumed = operations.resume_thread(
|
|
297
|
+
port,
|
|
298
|
+
{"operation": "resume", "thread_id": created["thread_id"], "project": fixture["project"]},
|
|
299
|
+
state=created,
|
|
300
|
+
persist=persisted.append,
|
|
301
|
+
now=lambda: "2026-09-19T12:01:00Z",
|
|
302
|
+
)
|
|
303
|
+
result = operations.result_thread(
|
|
304
|
+
port, resumed, persist=persisted.append, now=lambda: "2026-09-19T12:02:00Z"
|
|
305
|
+
)
|
|
306
|
+
deleted = operations.delete_thread(
|
|
307
|
+
port, resumed, persist=persisted.append, now=lambda: "2026-09-19T12:03:00Z"
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
assert fixture["job_id"] and fixture["task_id"]
|
|
311
|
+
assert created["thread_id"] == fixture["thread"]["thread_id"]
|
|
312
|
+
assert result["text"] == fixture["result"]["text"]
|
|
313
|
+
assert deleted["status"] == "deleted"
|
|
314
|
+
assert [call[0] for call in port.calls] == ["open_thread", "read_result", "delete_thread"]
|
|
315
|
+
assert [item["status"] for item in persisted] == ["running", "completed", "completed", "deleted"]
|