optio-opencode 0.1.1__tar.gz → 0.1.3__tar.gz
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.
- {optio_opencode-0.1.1 → optio_opencode-0.1.3}/PKG-INFO +1 -1
- {optio_opencode-0.1.1 → optio_opencode-0.1.3}/pyproject.toml +1 -1
- {optio_opencode-0.1.1 → optio_opencode-0.1.3}/src/optio_opencode/host_actions.py +8 -1
- {optio_opencode-0.1.1 → optio_opencode-0.1.3}/src/optio_opencode/prompt.py +16 -3
- {optio_opencode-0.1.1 → optio_opencode-0.1.3}/src/optio_opencode/session.py +76 -12
- {optio_opencode-0.1.1 → optio_opencode-0.1.3}/src/optio_opencode/types.py +8 -0
- {optio_opencode-0.1.1 → optio_opencode-0.1.3}/src/optio_opencode.egg-info/PKG-INFO +1 -1
- {optio_opencode-0.1.1 → optio_opencode-0.1.3}/tests/test_host_local.py +44 -0
- {optio_opencode-0.1.1 → optio_opencode-0.1.3}/tests/test_session_hooks.py +1 -1
- {optio_opencode-0.1.1 → optio_opencode-0.1.3}/tests/test_session_local.py +181 -1
- {optio_opencode-0.1.1 → optio_opencode-0.1.3}/tests/test_session_resume.py +13 -4
- {optio_opencode-0.1.1 → optio_opencode-0.1.3}/tests/test_types.py +13 -0
- {optio_opencode-0.1.1 → optio_opencode-0.1.3}/README.md +0 -0
- {optio_opencode-0.1.1 → optio_opencode-0.1.3}/setup.cfg +0 -0
- {optio_opencode-0.1.1 → optio_opencode-0.1.3}/src/optio_opencode/__init__.py +0 -0
- {optio_opencode-0.1.1 → optio_opencode-0.1.3}/src/optio_opencode/snapshots.py +0 -0
- {optio_opencode-0.1.1 → optio_opencode-0.1.3}/src/optio_opencode.egg-info/SOURCES.txt +0 -0
- {optio_opencode-0.1.1 → optio_opencode-0.1.3}/src/optio_opencode.egg-info/dependency_links.txt +0 -0
- {optio_opencode-0.1.1 → optio_opencode-0.1.3}/src/optio_opencode.egg-info/requires.txt +0 -0
- {optio_opencode-0.1.1 → optio_opencode-0.1.3}/src/optio_opencode.egg-info/top_level.txt +0 -0
- {optio_opencode-0.1.1 → optio_opencode-0.1.3}/tests/test_host_primitives_local.py +0 -0
- {optio_opencode-0.1.1 → optio_opencode-0.1.3}/tests/test_host_primitives_remote.py +0 -0
- {optio_opencode-0.1.1 → optio_opencode-0.1.3}/tests/test_host_remote_resume.py +0 -0
- {optio_opencode-0.1.1 → optio_opencode-0.1.3}/tests/test_host_resume.py +0 -0
- {optio_opencode-0.1.1 → optio_opencode-0.1.3}/tests/test_prompt.py +0 -0
- {optio_opencode-0.1.1 → optio_opencode-0.1.3}/tests/test_sanity.py +0 -0
- {optio_opencode-0.1.1 → optio_opencode-0.1.3}/tests/test_session_blob_hooks.py +0 -0
- {optio_opencode-0.1.1 → optio_opencode-0.1.3}/tests/test_session_remote.py +0 -0
- {optio_opencode-0.1.1 → optio_opencode-0.1.3}/tests/test_smart_install.py +0 -0
- {optio_opencode-0.1.1 → optio_opencode-0.1.3}/tests/test_snapshots.py +0 -0
|
@@ -328,6 +328,7 @@ async def launch_opencode(
|
|
|
328
328
|
*,
|
|
329
329
|
ready_timeout_s: float = 30.0,
|
|
330
330
|
opencode_executable: str = "opencode",
|
|
331
|
+
hostname: str = "127.0.0.1",
|
|
331
332
|
) -> tuple[ProcessHandle, int]:
|
|
332
333
|
"""Launch ``opencode web`` on ``host``; wait for the listening URL.
|
|
333
334
|
|
|
@@ -340,6 +341,12 @@ async def launch_opencode(
|
|
|
340
341
|
directory to PATH so opencode's automatic browser-launch is
|
|
341
342
|
suppressed.
|
|
342
343
|
|
|
344
|
+
``hostname`` is passed to ``opencode web --hostname=`` so callers
|
|
345
|
+
can bind to a non-loopback interface when consumers reach the server
|
|
346
|
+
across a network boundary (e.g. LocalHost inside a docker container
|
|
347
|
+
serving a sibling API-proxy container). Defaults to ``127.0.0.1`` to
|
|
348
|
+
keep RemoteHost-over-SSH and single-host deployments unchanged.
|
|
349
|
+
|
|
343
350
|
Returns ``(handle, opencode_port)``. Caller is responsible for
|
|
344
351
|
eventually terminating the handle via ``host.terminate_subprocess``.
|
|
345
352
|
"""
|
|
@@ -374,7 +381,7 @@ async def launch_opencode(
|
|
|
374
381
|
f"exec env "
|
|
375
382
|
f"OPENCODE_SERVER_PASSWORD=\"$(cat {shlex.quote(host.workdir + '/' + pw_file)})\" "
|
|
376
383
|
f"BROWSER=true "
|
|
377
|
-
f"{opencode_executable} web --port=0 --hostname=
|
|
384
|
+
f"{opencode_executable} web --port=0 --hostname={shlex.quote(hostname)}"
|
|
378
385
|
)
|
|
379
386
|
|
|
380
387
|
# Prepend the noop-browsers bin dir to PATH via env on launch_subprocess.
|
|
@@ -79,9 +79,18 @@ caveats:**
|
|
|
79
79
|
|
|
80
80
|
### Detecting a resume: `resume.log`
|
|
81
81
|
|
|
82
|
-
Each session start (fresh or resumed) appends one
|
|
83
|
-
|
|
84
|
-
|
|
82
|
+
Each session start (fresh or resumed) appends one line to
|
|
83
|
+
`./resume.log`. Line format:
|
|
84
|
+
|
|
85
|
+
```
|
|
86
|
+
<ISO 8601 UTC timestamp>[ REFRESHED:<comma-separated filenames>]
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
The very first line is the original launch timestamp; each subsequent
|
|
90
|
+
line is a resume. The optional `REFRESHED:` suffix signals that the
|
|
91
|
+
harness rewrote the listed files on that resume (e.g.
|
|
92
|
+
`2026-05-28T13:15:42Z REFRESHED:AGENTS.md`) — your in-memory copy of
|
|
93
|
+
those files is stale and must be re-read before continuing.
|
|
85
94
|
|
|
86
95
|
**At the start of every new incoming user message, read
|
|
87
96
|
`./resume.log` first.** Compare the latest line to the value you
|
|
@@ -92,6 +101,10 @@ the situation as a resume:
|
|
|
92
101
|
outside the workdir are still where you left them.
|
|
93
102
|
- Re-establish anything that's gone (re-launch a server, re-fetch a
|
|
94
103
|
file, etc.) before continuing.
|
|
104
|
+
- **If the latest line carries a `REFRESHED:` suffix, re-read each
|
|
105
|
+
listed file** (e.g. `cat ./AGENTS.md`) — the harness updated it
|
|
106
|
+
since your last context snapshot and the version you remember is
|
|
107
|
+
out of date.
|
|
95
108
|
- Then resume the work you were doing.
|
|
96
109
|
|
|
97
110
|
If a resume slips past unnoticed, a failing tool call is the
|
|
@@ -160,6 +160,7 @@ async def run_opencode_session(ctx: ProcessContext, config: OpencodeTaskConfig)
|
|
|
160
160
|
"""
|
|
161
161
|
nonlocal launched_handle, opencode_exec, session_id, preserved_session_id
|
|
162
162
|
|
|
163
|
+
refreshed_files: list[str] = []
|
|
163
164
|
if not resuming:
|
|
164
165
|
# Fresh start: the protocol driver has already created the
|
|
165
166
|
# workdir, deliverables/ subdir, and empty optio.log. Ensure
|
|
@@ -189,9 +190,14 @@ async def run_opencode_session(ctx: ProcessContext, config: OpencodeTaskConfig)
|
|
|
189
190
|
# self-healing (snapshot lookup returns None → fresh-start
|
|
190
191
|
# fallback) handles the rare case where the flag is true but
|
|
191
192
|
# no snapshot exists.
|
|
193
|
+
else:
|
|
194
|
+
# Resume: when on_resume_refresh is wired, recompute AGENTS.md
|
|
195
|
+
# from the refreshed config and overwrite the workdir copy if
|
|
196
|
+
# the rendered text differs from the snapshot-restored file.
|
|
197
|
+
refreshed_files = await _maybe_refresh_on_resume(host, hook_ctx, config)
|
|
192
198
|
|
|
193
199
|
if config.supports_resume:
|
|
194
|
-
await _append_resume_log_entry(host)
|
|
200
|
+
await _append_resume_log_entry(host, refreshed=refreshed_files)
|
|
195
201
|
|
|
196
202
|
# opencode is already installed by run_opencode_session before
|
|
197
203
|
# this body runs (so resume restore can call opencode_import
|
|
@@ -210,14 +216,6 @@ async def run_opencode_session(ctx: ProcessContext, config: OpencodeTaskConfig)
|
|
|
210
216
|
host, opencode_executable=opencode_exec,
|
|
211
217
|
)
|
|
212
218
|
version_suffix = f" {version}" if version else ""
|
|
213
|
-
ctx.report_progress(None, f"Launching opencode{version_suffix}…")
|
|
214
|
-
handle, opencode_port = await host_actions.launch_opencode(
|
|
215
|
-
host, password,
|
|
216
|
-
ready_timeout_s=READY_TIMEOUT_S,
|
|
217
|
-
opencode_executable=opencode_exec,
|
|
218
|
-
)
|
|
219
|
-
launched_handle = handle
|
|
220
|
-
|
|
221
219
|
# --- tunnel + widget registration --------------------------------
|
|
222
220
|
# By default the SSH tunnel listens on 127.0.0.1 — only the worker
|
|
223
221
|
# process (this engine) can reach it. For multi-container deploys
|
|
@@ -230,6 +228,22 @@ async def run_opencode_session(ctx: ProcessContext, config: OpencodeTaskConfig)
|
|
|
230
228
|
# 127.0.0.1 so single-host deploys are unchanged.
|
|
231
229
|
bind_addr = os.environ.get("OPTIO_WIDGET_TUNNEL_BIND", "127.0.0.1")
|
|
232
230
|
upstream_host = os.environ.get("OPTIO_WIDGET_TUNNEL_HOST", "127.0.0.1")
|
|
231
|
+
|
|
232
|
+
# LocalHost has no SSH tunnel — establish_tunnel is a no-op — so
|
|
233
|
+
# opencode itself must bind to ``bind_addr`` for sibling containers
|
|
234
|
+
# to reach it. RemoteHost keeps opencode bound to the remote's
|
|
235
|
+
# loopback; the SSH tunnel on the engine side handles exposure.
|
|
236
|
+
opencode_hostname = bind_addr if isinstance(host, LocalHost) else "127.0.0.1"
|
|
237
|
+
|
|
238
|
+
ctx.report_progress(None, f"Launching opencode{version_suffix}…")
|
|
239
|
+
handle, opencode_port = await host_actions.launch_opencode(
|
|
240
|
+
host, password,
|
|
241
|
+
ready_timeout_s=READY_TIMEOUT_S,
|
|
242
|
+
opencode_executable=opencode_exec,
|
|
243
|
+
hostname=opencode_hostname,
|
|
244
|
+
)
|
|
245
|
+
launched_handle = handle
|
|
246
|
+
|
|
233
247
|
worker_port = await host.establish_tunnel(opencode_port, bind_addr=bind_addr)
|
|
234
248
|
|
|
235
249
|
if preserved_session_id is not None:
|
|
@@ -461,16 +475,26 @@ async def _rotate_optio_log(host: Host) -> None:
|
|
|
461
475
|
await host.write_text("optio.log", "")
|
|
462
476
|
|
|
463
477
|
|
|
464
|
-
async def _append_resume_log_entry(
|
|
465
|
-
|
|
478
|
+
async def _append_resume_log_entry(
|
|
479
|
+
host, *, refreshed: list[str] | None = None,
|
|
480
|
+
) -> None:
|
|
481
|
+
"""Append one line to <workdir>/resume.log.
|
|
482
|
+
|
|
483
|
+
Line format: ``<ISO 8601 UTC timestamp>[ REFRESHED:<comma-separated names>]``.
|
|
484
|
+
The optional ``REFRESHED:`` suffix signals that the harness rewrote
|
|
485
|
+
the listed files on this session start. Agents are instructed (via
|
|
486
|
+
the resume section of AGENTS.md) to re-read tagged files.
|
|
466
487
|
|
|
467
488
|
Creates the file if missing (via shell `>>`). Caller is responsible
|
|
468
489
|
for gating this on config.supports_resume.
|
|
469
490
|
"""
|
|
470
491
|
ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
492
|
+
line = ts
|
|
493
|
+
if refreshed:
|
|
494
|
+
line = f"{ts} REFRESHED:{','.join(refreshed)}"
|
|
471
495
|
target = f"{host.workdir}/resume.log"
|
|
472
496
|
result = await host.run_command(
|
|
473
|
-
f"echo {shlex.quote(
|
|
497
|
+
f"echo {shlex.quote(line)} >> {shlex.quote(target)}"
|
|
474
498
|
)
|
|
475
499
|
if result.exit_code != 0:
|
|
476
500
|
raise RuntimeError(
|
|
@@ -479,6 +503,46 @@ async def _append_resume_log_entry(host) -> None:
|
|
|
479
503
|
)
|
|
480
504
|
|
|
481
505
|
|
|
506
|
+
async def _maybe_refresh_on_resume(
|
|
507
|
+
host, hook_ctx, config: OpencodeTaskConfig,
|
|
508
|
+
) -> list[str]:
|
|
509
|
+
"""Run the on_resume_refresh hook (if any) and rewrite AGENTS.md when
|
|
510
|
+
the rendered content differs from the workdir copy.
|
|
511
|
+
|
|
512
|
+
Returns the list of filenames the harness rewrote (currently at most
|
|
513
|
+
``["AGENTS.md"]``), suitable for tagging the next ``resume.log`` line.
|
|
514
|
+
A hook that raises is logged and ignored — the resumed session keeps
|
|
515
|
+
whatever AGENTS.md the snapshot restored.
|
|
516
|
+
"""
|
|
517
|
+
if config.on_resume_refresh is None:
|
|
518
|
+
return []
|
|
519
|
+
try:
|
|
520
|
+
new_config = config.on_resume_refresh(config)
|
|
521
|
+
except Exception:
|
|
522
|
+
_LOG.exception(
|
|
523
|
+
"on_resume_refresh raised; keeping existing AGENTS.md from snapshot",
|
|
524
|
+
)
|
|
525
|
+
return []
|
|
526
|
+
new_agents_md = compose_agents_md(
|
|
527
|
+
new_config.consumer_instructions,
|
|
528
|
+
workdir_exclude=new_config.workdir_exclude,
|
|
529
|
+
supports_resume=new_config.supports_resume,
|
|
530
|
+
)
|
|
531
|
+
try:
|
|
532
|
+
existing = await hook_ctx.read_text_from_host("AGENTS.md", silent=True)
|
|
533
|
+
except FileNotFoundError:
|
|
534
|
+
existing = None
|
|
535
|
+
except Exception:
|
|
536
|
+
_LOG.exception(
|
|
537
|
+
"failed to read existing AGENTS.md on resume; rewriting unconditionally",
|
|
538
|
+
)
|
|
539
|
+
existing = None
|
|
540
|
+
if existing == new_agents_md:
|
|
541
|
+
return []
|
|
542
|
+
await host.write_text("AGENTS.md", new_agents_md)
|
|
543
|
+
return ["AGENTS.md"]
|
|
544
|
+
|
|
545
|
+
|
|
482
546
|
def _pick_local_workdir() -> str:
|
|
483
547
|
return tempfile.mkdtemp(prefix="optio-opencode-")
|
|
484
548
|
|
|
@@ -43,6 +43,14 @@ class OpencodeTaskConfig:
|
|
|
43
43
|
# raises a config error: asymmetric usage is always a mistake.
|
|
44
44
|
session_blob_encrypt: Callable[[bytes], bytes] | None = None
|
|
45
45
|
session_blob_decrypt: Callable[[bytes], bytes] | None = None
|
|
46
|
+
# Optional hook fired on resume only (never on fresh start). Receives
|
|
47
|
+
# the original task config; returns a (possibly mutated/replaced) config.
|
|
48
|
+
# The harness re-renders AGENTS.md from the returned config and writes
|
|
49
|
+
# it back to the workdir only when it differs from the file on disk.
|
|
50
|
+
# When written, the harness tags the new line in resume.log with
|
|
51
|
+
# `REFRESHED:AGENTS.md` so the agent knows to re-read. None (default)
|
|
52
|
+
# → no refresh; the resumed session keeps its original AGENTS.md.
|
|
53
|
+
on_resume_refresh: Callable[["OpencodeTaskConfig"], "OpencodeTaskConfig"] | None = None
|
|
46
54
|
|
|
47
55
|
def __post_init__(self) -> None:
|
|
48
56
|
e = self.session_blob_encrypt is not None
|
|
@@ -64,6 +64,50 @@ async def test_launch_times_out_on_no_url(tmp_path):
|
|
|
64
64
|
pass
|
|
65
65
|
|
|
66
66
|
|
|
67
|
+
async def test_launch_opencode_passes_hostname_into_cmd(local_host, monkeypatch):
|
|
68
|
+
"""Multi-container deploys need opencode bound to a non-loopback
|
|
69
|
+
interface so a sibling API-proxy container can reach it. The
|
|
70
|
+
``hostname`` kwarg must propagate into the ``opencode web
|
|
71
|
+
--hostname=`` argument."""
|
|
72
|
+
captured: dict[str, str] = {}
|
|
73
|
+
|
|
74
|
+
async def fake_launch_subprocess(self, cmd, *, env=None, cwd=None):
|
|
75
|
+
captured["cmd"] = cmd
|
|
76
|
+
raise RuntimeError("stop before waiting on stdout")
|
|
77
|
+
|
|
78
|
+
monkeypatch.setattr(LocalHost, "launch_subprocess", fake_launch_subprocess)
|
|
79
|
+
|
|
80
|
+
await local_host.setup_workdir()
|
|
81
|
+
with pytest.raises(RuntimeError, match="stop before"):
|
|
82
|
+
await host_actions.launch_opencode(
|
|
83
|
+
local_host, password="pw",
|
|
84
|
+
ready_timeout_s=1.0,
|
|
85
|
+
opencode_executable="opencode",
|
|
86
|
+
hostname="0.0.0.0",
|
|
87
|
+
)
|
|
88
|
+
assert "--hostname=0.0.0.0" in captured["cmd"]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
async def test_launch_opencode_default_hostname_is_loopback(local_host, monkeypatch):
|
|
92
|
+
"""Default keeps single-host / RemoteHost-over-SSH behaviour intact."""
|
|
93
|
+
captured: dict[str, str] = {}
|
|
94
|
+
|
|
95
|
+
async def fake_launch_subprocess(self, cmd, *, env=None, cwd=None):
|
|
96
|
+
captured["cmd"] = cmd
|
|
97
|
+
raise RuntimeError("stop")
|
|
98
|
+
|
|
99
|
+
monkeypatch.setattr(LocalHost, "launch_subprocess", fake_launch_subprocess)
|
|
100
|
+
|
|
101
|
+
await local_host.setup_workdir()
|
|
102
|
+
with pytest.raises(RuntimeError, match="stop"):
|
|
103
|
+
await host_actions.launch_opencode(
|
|
104
|
+
local_host, password="pw",
|
|
105
|
+
ready_timeout_s=1.0,
|
|
106
|
+
opencode_executable="opencode",
|
|
107
|
+
)
|
|
108
|
+
assert "--hostname=127.0.0.1" in captured["cmd"]
|
|
109
|
+
|
|
110
|
+
|
|
67
111
|
async def test_tail_file_yields_appended_lines(local_host):
|
|
68
112
|
await local_host.setup_workdir()
|
|
69
113
|
log_path = os.path.join(local_host.workdir, "optio.log")
|
|
@@ -118,7 +118,7 @@ def _patch_host_actions(monkeypatch, host):
|
|
|
118
118
|
async def _version(_host, *, opencode_executable="opencode"):
|
|
119
119
|
return None
|
|
120
120
|
|
|
121
|
-
async def _launch(_host, _password, *, ready_timeout_s=30.0, opencode_executable="opencode"):
|
|
121
|
+
async def _launch(_host, _password, *, ready_timeout_s=30.0, opencode_executable="opencode", hostname="127.0.0.1"):
|
|
122
122
|
host.timeline.append("launch_opencode")
|
|
123
123
|
raise RuntimeError("test never gets past launch")
|
|
124
124
|
|
|
@@ -105,7 +105,7 @@ def _supply_scenario(monkeypatch):
|
|
|
105
105
|
orig_launch = host_actions.launch_opencode
|
|
106
106
|
scenario_holder: dict = {"name": "happy"}
|
|
107
107
|
|
|
108
|
-
async def _launch(host, password, *, ready_timeout_s=30.0, opencode_executable="opencode"):
|
|
108
|
+
async def _launch(host, password, *, ready_timeout_s=30.0, opencode_executable="opencode", hostname="127.0.0.1"):
|
|
109
109
|
del opencode_executable # we substitute fully
|
|
110
110
|
return await orig_launch(
|
|
111
111
|
host, password,
|
|
@@ -114,6 +114,7 @@ def _supply_scenario(monkeypatch):
|
|
|
114
114
|
f"{sys.executable} {FAKE_OPENCODE} "
|
|
115
115
|
f"--scenario {scenario_holder['name']}"
|
|
116
116
|
),
|
|
117
|
+
hostname=hostname,
|
|
117
118
|
)
|
|
118
119
|
monkeypatch.setattr(host_actions, "launch_opencode", _launch)
|
|
119
120
|
|
|
@@ -347,6 +348,185 @@ async def test_append_resume_log_entry_appends_on_repeat_call(tmp_workdir):
|
|
|
347
348
|
assert lines[0] != lines[1]
|
|
348
349
|
|
|
349
350
|
|
|
351
|
+
async def test_append_resume_log_entry_with_refreshed_tag(tmp_workdir):
|
|
352
|
+
"""Passing refreshed=[...] adds ` REFRESHED:<files>` suffix to the line."""
|
|
353
|
+
import os
|
|
354
|
+
import re
|
|
355
|
+
from optio_host.host import LocalHost
|
|
356
|
+
from optio_opencode.session import _append_resume_log_entry
|
|
357
|
+
|
|
358
|
+
host = LocalHost(taskdir=tmp_workdir)
|
|
359
|
+
await host.setup_workdir()
|
|
360
|
+
|
|
361
|
+
await _append_resume_log_entry(host, refreshed=["AGENTS.md"])
|
|
362
|
+
|
|
363
|
+
resume_log = os.path.join(host.workdir, "resume.log")
|
|
364
|
+
with open(resume_log) as f:
|
|
365
|
+
lines = [line for line in f.read().splitlines() if line]
|
|
366
|
+
assert len(lines) == 1
|
|
367
|
+
assert re.match(
|
|
368
|
+
r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z REFRESHED:AGENTS\.md$",
|
|
369
|
+
lines[0],
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
async def test_append_resume_log_entry_with_multiple_refreshed_files(tmp_workdir):
|
|
374
|
+
"""refreshed=[a, b] → REFRESHED:a,b (comma-separated, no spaces)."""
|
|
375
|
+
import os
|
|
376
|
+
import re
|
|
377
|
+
from optio_host.host import LocalHost
|
|
378
|
+
from optio_opencode.session import _append_resume_log_entry
|
|
379
|
+
|
|
380
|
+
host = LocalHost(taskdir=tmp_workdir)
|
|
381
|
+
await host.setup_workdir()
|
|
382
|
+
|
|
383
|
+
await _append_resume_log_entry(host, refreshed=["AGENTS.md", "opencode.json"])
|
|
384
|
+
|
|
385
|
+
resume_log = os.path.join(host.workdir, "resume.log")
|
|
386
|
+
with open(resume_log) as f:
|
|
387
|
+
lines = [line for line in f.read().splitlines() if line]
|
|
388
|
+
assert len(lines) == 1
|
|
389
|
+
assert re.match(
|
|
390
|
+
r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z REFRESHED:AGENTS\.md,opencode\.json$",
|
|
391
|
+
lines[0],
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
async def test_append_resume_log_entry_empty_refreshed_omits_tag(tmp_workdir):
|
|
396
|
+
"""refreshed=[] → bare timestamp, no REFRESHED: suffix (same as None)."""
|
|
397
|
+
import os
|
|
398
|
+
import re
|
|
399
|
+
from optio_host.host import LocalHost
|
|
400
|
+
from optio_opencode.session import _append_resume_log_entry
|
|
401
|
+
|
|
402
|
+
host = LocalHost(taskdir=tmp_workdir)
|
|
403
|
+
await host.setup_workdir()
|
|
404
|
+
|
|
405
|
+
await _append_resume_log_entry(host, refreshed=[])
|
|
406
|
+
|
|
407
|
+
resume_log = os.path.join(host.workdir, "resume.log")
|
|
408
|
+
with open(resume_log) as f:
|
|
409
|
+
lines = [line for line in f.read().splitlines() if line]
|
|
410
|
+
assert len(lines) == 1
|
|
411
|
+
assert re.match(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$", lines[0])
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
# ---- resume refresh -------------------------------------------------------
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
async def test_maybe_refresh_on_resume_no_hook(tmp_workdir):
|
|
418
|
+
"""on_resume_refresh=None → returns [] and does not write AGENTS.md."""
|
|
419
|
+
import os
|
|
420
|
+
from optio_host.host import LocalHost
|
|
421
|
+
from optio_opencode.session import _maybe_refresh_on_resume
|
|
422
|
+
from optio_opencode.types import OpencodeTaskConfig
|
|
423
|
+
|
|
424
|
+
host = LocalHost(taskdir=tmp_workdir)
|
|
425
|
+
await host.setup_workdir()
|
|
426
|
+
config = OpencodeTaskConfig(consumer_instructions="x")
|
|
427
|
+
|
|
428
|
+
refreshed = await _maybe_refresh_on_resume(host, None, config)
|
|
429
|
+
|
|
430
|
+
assert refreshed == []
|
|
431
|
+
assert not os.path.exists(os.path.join(host.workdir, "AGENTS.md"))
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
async def test_maybe_refresh_on_resume_unchanged_content_skips_write(tmp_workdir):
|
|
435
|
+
"""Hook return identical to existing AGENTS.md → no write, [] returned."""
|
|
436
|
+
import os
|
|
437
|
+
from optio_host.host import LocalHost
|
|
438
|
+
from optio_opencode.prompt import compose_agents_md
|
|
439
|
+
from optio_opencode.session import _maybe_refresh_on_resume
|
|
440
|
+
from optio_opencode.types import OpencodeTaskConfig
|
|
441
|
+
|
|
442
|
+
host = LocalHost(taskdir=tmp_workdir)
|
|
443
|
+
await host.setup_workdir()
|
|
444
|
+
config = OpencodeTaskConfig(
|
|
445
|
+
consumer_instructions="task X",
|
|
446
|
+
on_resume_refresh=lambda c: c,
|
|
447
|
+
)
|
|
448
|
+
expected = compose_agents_md(
|
|
449
|
+
config.consumer_instructions,
|
|
450
|
+
workdir_exclude=config.workdir_exclude,
|
|
451
|
+
supports_resume=config.supports_resume,
|
|
452
|
+
)
|
|
453
|
+
await host.write_text("AGENTS.md", expected)
|
|
454
|
+
mtime_before = os.path.getmtime(os.path.join(host.workdir, "AGENTS.md"))
|
|
455
|
+
|
|
456
|
+
class _FakeHookCtx:
|
|
457
|
+
async def read_text_from_host(self, path, *, silent=False):
|
|
458
|
+
full = os.path.join(host.workdir, path)
|
|
459
|
+
with open(full) as f:
|
|
460
|
+
return f.read()
|
|
461
|
+
|
|
462
|
+
refreshed = await _maybe_refresh_on_resume(host, _FakeHookCtx(), config)
|
|
463
|
+
|
|
464
|
+
assert refreshed == []
|
|
465
|
+
mtime_after = os.path.getmtime(os.path.join(host.workdir, "AGENTS.md"))
|
|
466
|
+
assert mtime_after == mtime_before # write was skipped
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
async def test_maybe_refresh_on_resume_changed_content_writes(tmp_workdir):
|
|
470
|
+
"""Hook returns new instructions → AGENTS.md rewritten, ['AGENTS.md']."""
|
|
471
|
+
import os
|
|
472
|
+
from dataclasses import replace
|
|
473
|
+
from optio_host.host import LocalHost
|
|
474
|
+
from optio_opencode.prompt import compose_agents_md
|
|
475
|
+
from optio_opencode.session import _maybe_refresh_on_resume
|
|
476
|
+
from optio_opencode.types import OpencodeTaskConfig
|
|
477
|
+
|
|
478
|
+
host = LocalHost(taskdir=tmp_workdir)
|
|
479
|
+
await host.setup_workdir()
|
|
480
|
+
config = OpencodeTaskConfig(
|
|
481
|
+
consumer_instructions="old task",
|
|
482
|
+
on_resume_refresh=lambda c: replace(c, consumer_instructions="new task"),
|
|
483
|
+
)
|
|
484
|
+
old_agents = compose_agents_md(
|
|
485
|
+
"old task",
|
|
486
|
+
workdir_exclude=config.workdir_exclude,
|
|
487
|
+
supports_resume=config.supports_resume,
|
|
488
|
+
)
|
|
489
|
+
await host.write_text("AGENTS.md", old_agents)
|
|
490
|
+
|
|
491
|
+
class _FakeHookCtx:
|
|
492
|
+
async def read_text_from_host(self, path, *, silent=False):
|
|
493
|
+
full = os.path.join(host.workdir, path)
|
|
494
|
+
with open(full) as f:
|
|
495
|
+
return f.read()
|
|
496
|
+
|
|
497
|
+
refreshed = await _maybe_refresh_on_resume(host, _FakeHookCtx(), config)
|
|
498
|
+
|
|
499
|
+
assert refreshed == ["AGENTS.md"]
|
|
500
|
+
with open(os.path.join(host.workdir, "AGENTS.md")) as f:
|
|
501
|
+
content = f.read()
|
|
502
|
+
assert "new task" in content
|
|
503
|
+
assert "old task" not in content
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
async def test_maybe_refresh_on_resume_hook_raises_keeps_existing(tmp_workdir):
|
|
507
|
+
"""Hook that raises → returns [] and leaves existing AGENTS.md alone."""
|
|
508
|
+
import os
|
|
509
|
+
from optio_host.host import LocalHost
|
|
510
|
+
from optio_opencode.session import _maybe_refresh_on_resume
|
|
511
|
+
from optio_opencode.types import OpencodeTaskConfig
|
|
512
|
+
|
|
513
|
+
def _boom(c):
|
|
514
|
+
raise RuntimeError("hook is broken")
|
|
515
|
+
|
|
516
|
+
host = LocalHost(taskdir=tmp_workdir)
|
|
517
|
+
await host.setup_workdir()
|
|
518
|
+
await host.write_text("AGENTS.md", "existing content")
|
|
519
|
+
config = OpencodeTaskConfig(
|
|
520
|
+
consumer_instructions="x", on_resume_refresh=_boom,
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
refreshed = await _maybe_refresh_on_resume(host, None, config)
|
|
524
|
+
|
|
525
|
+
assert refreshed == []
|
|
526
|
+
with open(os.path.join(host.workdir, "AGENTS.md")) as f:
|
|
527
|
+
assert f.read() == "existing content"
|
|
528
|
+
|
|
529
|
+
|
|
350
530
|
async def test_session_local_supports_resume_false_skips_resume_log(
|
|
351
531
|
ctx_and_captures, _supply_scenario, tmp_workdir, monkeypatch,
|
|
352
532
|
):
|
|
@@ -53,7 +53,7 @@ def _supply_scenario(monkeypatch):
|
|
|
53
53
|
orig_launch = host_actions.launch_opencode
|
|
54
54
|
holder = {"name": "happy"}
|
|
55
55
|
|
|
56
|
-
async def _launch(host, password, *, ready_timeout_s=30.0, opencode_executable="opencode"):
|
|
56
|
+
async def _launch(host, password, *, ready_timeout_s=30.0, opencode_executable="opencode", hostname="127.0.0.1"):
|
|
57
57
|
del opencode_executable
|
|
58
58
|
return await orig_launch(
|
|
59
59
|
host, password,
|
|
@@ -61,6 +61,7 @@ def _supply_scenario(monkeypatch):
|
|
|
61
61
|
opencode_executable=(
|
|
62
62
|
f"{sys.executable} {FAKE_OPENCODE} --scenario {holder['name']}"
|
|
63
63
|
),
|
|
64
|
+
hostname=hostname,
|
|
64
65
|
)
|
|
65
66
|
monkeypatch.setattr(host_actions, "launch_opencode", _launch)
|
|
66
67
|
|
|
@@ -183,7 +184,15 @@ async def test_resume_appends_second_line_to_resume_log(mongo_db, task_root):
|
|
|
183
184
|
lines = [line for line in contents.splitlines() if line]
|
|
184
185
|
assert len(lines) == 2, f"expected 2 lines, got {len(lines)}: {contents!r}"
|
|
185
186
|
|
|
186
|
-
|
|
187
|
+
# Line format: `<ISO 8601 timestamp>[ REFRESHED:<comma-separated names>]`.
|
|
188
|
+
# This test exercises the no-refresh path (no on_resume_refresh hook),
|
|
189
|
+
# so every line is a bare timestamp; the regex still accepts the
|
|
190
|
+
# extended form to stay valid as the format evolves.
|
|
191
|
+
line_re = re.compile(
|
|
192
|
+
r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z(?: REFRESHED:\S+)?$"
|
|
193
|
+
)
|
|
187
194
|
for line in lines:
|
|
188
|
-
assert
|
|
189
|
-
|
|
195
|
+
assert line_re.match(line), f"unrecognized resume.log line: {line!r}"
|
|
196
|
+
# Timestamps (the leading token of each line) are monotonic.
|
|
197
|
+
timestamps = [line.split()[0] for line in lines]
|
|
198
|
+
assert timestamps[0] <= timestamps[1], f"timestamps not monotonic: {timestamps!r}"
|
|
@@ -95,3 +95,16 @@ def test_opencode_task_config_supports_resume_default_true():
|
|
|
95
95
|
def test_opencode_task_config_supports_resume_can_be_disabled():
|
|
96
96
|
cfg = OpencodeTaskConfig(consumer_instructions="x", supports_resume=False)
|
|
97
97
|
assert cfg.supports_resume is False
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_opencode_task_config_on_resume_refresh_default_none():
|
|
101
|
+
cfg = OpencodeTaskConfig(consumer_instructions="x")
|
|
102
|
+
assert cfg.on_resume_refresh is None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_opencode_task_config_on_resume_refresh_accepts_callable():
|
|
106
|
+
def _refresh(c):
|
|
107
|
+
return c
|
|
108
|
+
|
|
109
|
+
cfg = OpencodeTaskConfig(consumer_instructions="x", on_resume_refresh=_refresh)
|
|
110
|
+
assert cfg.on_resume_refresh is _refresh
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{optio_opencode-0.1.1 → optio_opencode-0.1.3}/src/optio_opencode.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|