optio-opencode 0.1.2__tar.gz → 0.1.4__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.
Files changed (31) hide show
  1. {optio_opencode-0.1.2 → optio_opencode-0.1.4}/PKG-INFO +4 -3
  2. {optio_opencode-0.1.2 → optio_opencode-0.1.4}/pyproject.toml +4 -3
  3. {optio_opencode-0.1.2 → optio_opencode-0.1.4}/src/optio_opencode/__init__.py +1 -2
  4. {optio_opencode-0.1.2 → optio_opencode-0.1.4}/src/optio_opencode/prompt.py +23 -35
  5. {optio_opencode-0.1.2 → optio_opencode-0.1.4}/src/optio_opencode/session.py +63 -7
  6. {optio_opencode-0.1.2 → optio_opencode-0.1.4}/src/optio_opencode/types.py +13 -6
  7. {optio_opencode-0.1.2 → optio_opencode-0.1.4}/src/optio_opencode.egg-info/PKG-INFO +4 -3
  8. optio_opencode-0.1.4/src/optio_opencode.egg-info/requires.txt +8 -0
  9. {optio_opencode-0.1.2 → optio_opencode-0.1.4}/tests/test_host_local.py +3 -3
  10. {optio_opencode-0.1.2 → optio_opencode-0.1.4}/tests/test_session_hooks.py +3 -3
  11. {optio_opencode-0.1.2 → optio_opencode-0.1.4}/tests/test_session_local.py +179 -0
  12. {optio_opencode-0.1.2 → optio_opencode-0.1.4}/tests/test_session_resume.py +11 -3
  13. {optio_opencode-0.1.2 → optio_opencode-0.1.4}/tests/test_smart_install.py +8 -8
  14. {optio_opencode-0.1.2 → optio_opencode-0.1.4}/tests/test_types.py +13 -0
  15. optio_opencode-0.1.2/src/optio_opencode.egg-info/requires.txt +0 -7
  16. {optio_opencode-0.1.2 → optio_opencode-0.1.4}/README.md +0 -0
  17. {optio_opencode-0.1.2 → optio_opencode-0.1.4}/setup.cfg +0 -0
  18. {optio_opencode-0.1.2 → optio_opencode-0.1.4}/src/optio_opencode/host_actions.py +0 -0
  19. {optio_opencode-0.1.2 → optio_opencode-0.1.4}/src/optio_opencode/snapshots.py +0 -0
  20. {optio_opencode-0.1.2 → optio_opencode-0.1.4}/src/optio_opencode.egg-info/SOURCES.txt +0 -0
  21. {optio_opencode-0.1.2 → optio_opencode-0.1.4}/src/optio_opencode.egg-info/dependency_links.txt +0 -0
  22. {optio_opencode-0.1.2 → optio_opencode-0.1.4}/src/optio_opencode.egg-info/top_level.txt +0 -0
  23. {optio_opencode-0.1.2 → optio_opencode-0.1.4}/tests/test_host_primitives_local.py +0 -0
  24. {optio_opencode-0.1.2 → optio_opencode-0.1.4}/tests/test_host_primitives_remote.py +0 -0
  25. {optio_opencode-0.1.2 → optio_opencode-0.1.4}/tests/test_host_remote_resume.py +0 -0
  26. {optio_opencode-0.1.2 → optio_opencode-0.1.4}/tests/test_host_resume.py +0 -0
  27. {optio_opencode-0.1.2 → optio_opencode-0.1.4}/tests/test_prompt.py +0 -0
  28. {optio_opencode-0.1.2 → optio_opencode-0.1.4}/tests/test_sanity.py +0 -0
  29. {optio_opencode-0.1.2 → optio_opencode-0.1.4}/tests/test_session_blob_hooks.py +0 -0
  30. {optio_opencode-0.1.2 → optio_opencode-0.1.4}/tests/test_session_remote.py +0 -0
  31. {optio_opencode-0.1.2 → optio_opencode-0.1.4}/tests/test_snapshots.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: optio-opencode
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: Run opencode web as an optio task; local subprocess or remote via SSH.
5
5
  Author-email: Kristof Csillag <kristof.csillag@deai-labs.com>
6
6
  License-Expression: Apache-2.0
@@ -20,8 +20,9 @@ Classifier: Topic :: Software Development :: Code Generators
20
20
  Classifier: Framework :: AsyncIO
21
21
  Requires-Python: >=3.11
22
22
  Description-Content-Type: text/markdown
23
- Requires-Dist: optio-core<0.2,>=0.1
24
- Requires-Dist: optio-host<0.2,>=0.1
23
+ Requires-Dist: optio-core<0.3,>=0.2
24
+ Requires-Dist: optio-host<0.3,>=0.2
25
+ Requires-Dist: optio-agents<0.2,>=0.1
25
26
  Requires-Dist: asyncssh>=2.14
26
27
  Provides-Extra: dev
27
28
  Requires-Dist: pytest>=8.0; extra == "dev"
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "optio-opencode"
7
- version = "0.1.2"
7
+ version = "0.1.4"
8
8
  description = "Run opencode web as an optio task; local subprocess or remote via SSH."
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -26,10 +26,11 @@ classifiers = [
26
26
  "Framework :: AsyncIO",
27
27
  ]
28
28
  dependencies = [
29
- "optio-core>=0.1,<0.2",
29
+ "optio-core>=0.2,<0.3",
30
30
  # 0.1.1 introduces the bind_addr kwarg on Host.establish_tunnel —
31
31
  # required for OPTIO_WIDGET_TUNNEL_BIND to work.
32
- "optio-host>=0.1,<0.2",
32
+ "optio-host>=0.2,<0.3",
33
+ "optio-agents>=0.1,<0.2",
33
34
  "asyncssh>=2.14",
34
35
  ]
35
36
 
@@ -2,9 +2,8 @@
2
2
 
3
3
  import logging as _logging
4
4
 
5
+ from optio_agents import HookContext, HookContextProtocol
5
6
  from optio_host import (
6
- HookContext,
7
- HookContextProtocol,
8
7
  HostCommandError,
9
8
  RunResult,
10
9
  SSHConfig,
@@ -7,45 +7,20 @@ addressed. The consumer's own task description is then appended verbatim.
7
7
  """
8
8
 
9
9
 
10
- BASE_PROMPT_PRE = """# Coordination protocol with the host (optio-opencode)
10
+ from optio_agents.protocol.prompt import LOG_CHANNEL_PROMPT
11
+
12
+
13
+ _OPENCODE_INTRO = """# Coordination protocol with the host (optio-opencode)
11
14
 
12
15
  You are running inside a coordination harness. Follow these conventions
13
16
  throughout the session.
14
17
 
15
- ## Log channel
16
-
17
- Append one line per entry to `./optio.log` in this directory. Each line
18
- must start with one of:
19
-
20
- - `STATUS:` — progress update for the human. Optional leading percent,
21
- e.g. `STATUS: 50% counting my fingers`.
22
- - `DELIVERABLE:` — absolute or workdir-relative path to a file you've
23
- just produced, e.g. `DELIVERABLE: ./deliverables/summary.md`.
24
- - `DONE` — you have finished the task. May be followed by an optional
25
- summary on the same line: `DONE: wrote the report`.
26
- - `ERROR` — you cannot continue. May be followed by an optional
27
- message: `ERROR: provider auth failed`.
28
-
29
- **Every entry must end with a newline character (`\\n`).** The host
30
- reads `optio.log` with a line-oriented tailer that only emits a line
31
- once it sees `\\n`; an entry written without a trailing newline (e.g.
32
- via `printf 'DONE'`) will be buffered indefinitely and never reach the
33
- host. Use `echo`, `>>` redirection of a heredoc, or any other mechanism
34
- that guarantees a trailing newline. If unsure, double-check with
35
- `tail -c 1 ./optio.log` — the result must be a newline.
36
-
37
- After writing `DONE` or `ERROR`, the session will terminate. Do not
38
- write further lines.
39
-
40
- ## Deliverables
41
-
42
- Place files you want to hand back to the host under `./deliverables/`.
43
- For each file, write a `DELIVERABLE:` log line *after* the file exists
44
- and its contents are final. The host fetches files by reading these
45
- log lines.
46
18
  """
47
19
 
48
20
 
21
+ BASE_PROMPT_PRE = _OPENCODE_INTRO + LOG_CHANNEL_PROMPT
22
+
23
+
49
24
  BASE_PROMPT_POST = """## Task
50
25
 
51
26
  Here comes the description of your actual task to complete. Throughout
@@ -79,9 +54,18 @@ caveats:**
79
54
 
80
55
  ### Detecting a resume: `resume.log`
81
56
 
82
- Each session start (fresh or resumed) appends one ISO 8601 timestamp
83
- to `./resume.log`. The very first line is the original launch
84
- timestamp; each subsequent line is a resume.
57
+ Each session start (fresh or resumed) appends one line to
58
+ `./resume.log`. Line format:
59
+
60
+ ```
61
+ <ISO 8601 UTC timestamp>[ REFRESHED:<comma-separated filenames>]
62
+ ```
63
+
64
+ The very first line is the original launch timestamp; each subsequent
65
+ line is a resume. The optional `REFRESHED:` suffix signals that the
66
+ harness rewrote the listed files on that resume (e.g.
67
+ `2026-05-28T13:15:42Z REFRESHED:AGENTS.md`) — your in-memory copy of
68
+ those files is stale and must be re-read before continuing.
85
69
 
86
70
  **At the start of every new incoming user message, read
87
71
  `./resume.log` first.** Compare the latest line to the value you
@@ -92,6 +76,10 @@ the situation as a resume:
92
76
  outside the workdir are still where you left them.
93
77
  - Re-establish anything that's gone (re-launch a server, re-fetch a
94
78
  file, etc.) before continuing.
79
+ - **If the latest line carries a `REFRESHED:` suffix, re-read each
80
+ listed file** (e.g. `cat ./AGENTS.md`) — the harness updated it
81
+ since your last context snapshot and the version you remember is
82
+ out of date.
95
83
  - Then resume the work you were doing.
96
84
 
97
85
  If a resume slips past unnoticed, a failing tool call is the
@@ -7,7 +7,7 @@ Section 4 of the design spec. The public entry point is the factory
7
7
 
8
8
  Most of the per-session work is generic log/deliverables protocol
9
9
  plumbing (parse ``optio.log``, fetch deliverables, watch for cancel) and
10
- lives in ``optio_host.protocol.run_log_protocol_session``. This module
10
+ lives in ``optio_agents.protocol.run_log_protocol_session``. This module
11
11
  keeps only the opencode-specific work — write AGENTS.md / opencode.json,
12
12
  install/launch the opencode binary, set up tunnel and widget, and the
13
13
  resume/snapshot brackets around the protocol session.
@@ -29,10 +29,10 @@ from typing import AsyncIterator, Callable
29
29
  from optio_core.context import ProcessContext
30
30
  from optio_core.models import BasicAuth, TaskInstance
31
31
 
32
- from optio_host.context import HookContext
32
+ from optio_agents import HookContext
33
33
  from optio_host.host import Host, LocalHost, ProcessHandle, RemoteHost
34
34
  from optio_host.paths import task_dir
35
- from optio_host.protocol.session import _SessionFailed, run_log_protocol_session
35
+ from optio_agents.protocol.session import _SessionFailed, run_log_protocol_session
36
36
  from optio_opencode import host_actions
37
37
  from optio_opencode.prompt import compose_agents_md
38
38
  from optio_opencode.snapshots import (
@@ -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
@@ -469,16 +475,26 @@ async def _rotate_optio_log(host: Host) -> None:
469
475
  await host.write_text("optio.log", "")
470
476
 
471
477
 
472
- async def _append_resume_log_entry(host) -> None:
473
- """Append one ISO 8601 UTC timestamp line to <workdir>/resume.log.
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.
474
487
 
475
488
  Creates the file if missing (via shell `>>`). Caller is responsible
476
489
  for gating this on config.supports_resume.
477
490
  """
478
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)}"
479
495
  target = f"{host.workdir}/resume.log"
480
496
  result = await host.run_command(
481
- f"echo {shlex.quote(ts)} >> {shlex.quote(target)}"
497
+ f"echo {shlex.quote(line)} >> {shlex.quote(target)}"
482
498
  )
483
499
  if result.exit_code != 0:
484
500
  raise RuntimeError(
@@ -487,6 +503,46 @@ async def _append_resume_log_entry(host) -> None:
487
503
  )
488
504
 
489
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
+
490
546
  def _pick_local_workdir() -> str:
491
547
  return tempfile.mkdtemp(prefix="optio-opencode-")
492
548
 
@@ -1,16 +1,15 @@
1
1
  """Public data types for optio-opencode consumers.
2
2
 
3
- The generic ``DeliverableCallback`` / ``HookCallback`` types and
4
- ``SSHConfig`` are owned by ``optio-host`` (since they describe the
5
- log/deliverables protocol and SSH config in general). This module
6
- re-exports them so existing ``from optio_opencode.types import ...``
7
- imports keep working unchanged.
3
+ The generic ``DeliverableCallback`` / ``HookCallback`` types are owned by
4
+ ``optio-agents`` (they describe the log/deliverables protocol); ``SSHConfig``
5
+ is owned by ``optio-host``. This module re-exports them so existing
6
+ ``from optio_opencode.types import ...`` imports keep working unchanged.
8
7
  """
9
8
 
10
9
  from dataclasses import dataclass, field
11
10
  from typing import Any, Callable
12
11
 
13
- from optio_host.protocol.session import DeliverableCallback, HookCallback
12
+ from optio_agents.protocol.session import DeliverableCallback, HookCallback
14
13
  from optio_host.types import SSHConfig
15
14
 
16
15
 
@@ -43,6 +42,14 @@ class OpencodeTaskConfig:
43
42
  # raises a config error: asymmetric usage is always a mistake.
44
43
  session_blob_encrypt: Callable[[bytes], bytes] | None = None
45
44
  session_blob_decrypt: Callable[[bytes], bytes] | None = None
45
+ # Optional hook fired on resume only (never on fresh start). Receives
46
+ # the original task config; returns a (possibly mutated/replaced) config.
47
+ # The harness re-renders AGENTS.md from the returned config and writes
48
+ # it back to the workdir only when it differs from the file on disk.
49
+ # When written, the harness tags the new line in resume.log with
50
+ # `REFRESHED:AGENTS.md` so the agent knows to re-read. None (default)
51
+ # → no refresh; the resumed session keeps its original AGENTS.md.
52
+ on_resume_refresh: Callable[["OpencodeTaskConfig"], "OpencodeTaskConfig"] | None = None
46
53
 
47
54
  def __post_init__(self) -> None:
48
55
  e = self.session_blob_encrypt is not None
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: optio-opencode
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: Run opencode web as an optio task; local subprocess or remote via SSH.
5
5
  Author-email: Kristof Csillag <kristof.csillag@deai-labs.com>
6
6
  License-Expression: Apache-2.0
@@ -20,8 +20,9 @@ Classifier: Topic :: Software Development :: Code Generators
20
20
  Classifier: Framework :: AsyncIO
21
21
  Requires-Python: >=3.11
22
22
  Description-Content-Type: text/markdown
23
- Requires-Dist: optio-core<0.2,>=0.1
24
- Requires-Dist: optio-host<0.2,>=0.1
23
+ Requires-Dist: optio-core<0.3,>=0.2
24
+ Requires-Dist: optio-host<0.3,>=0.2
25
+ Requires-Dist: optio-agents<0.2,>=0.1
25
26
  Requires-Dist: asyncssh>=2.14
26
27
  Provides-Extra: dev
27
28
  Requires-Dist: pytest>=8.0; extra == "dev"
@@ -0,0 +1,8 @@
1
+ optio-core<0.3,>=0.2
2
+ optio-host<0.3,>=0.2
3
+ optio-agents<0.2,>=0.1
4
+ asyncssh>=2.14
5
+
6
+ [dev]
7
+ pytest>=8.0
8
+ pytest-asyncio>=0.23
@@ -24,7 +24,7 @@ async def test_setup_workdir_creates_workdir(local_host):
24
24
  assert os.path.isdir(local_host.workdir)
25
25
  # As of the optio-host split, setup_workdir mkdirs the workdir only.
26
26
  # The protocol-specific deliverables/ + optio.log are owned by the
27
- # protocol session driver in optio_host.protocol.session.
27
+ # protocol session driver in optio_agents.protocol.session.
28
28
 
29
29
 
30
30
  @pytest.mark.asyncio
@@ -136,7 +136,7 @@ async def test_tail_file_yields_appended_lines(local_host):
136
136
 
137
137
 
138
138
  async def test_fetch_deliverable_text(local_host):
139
- from optio_host.protocol.session import fetch_deliverable_text
139
+ from optio_agents.protocol.session import fetch_deliverable_text
140
140
  await local_host.setup_workdir()
141
141
  os.makedirs(os.path.join(local_host.workdir, "deliverables"), exist_ok=True)
142
142
  target = os.path.join(local_host.workdir, "deliverables", "a.txt")
@@ -146,7 +146,7 @@ async def test_fetch_deliverable_text(local_host):
146
146
 
147
147
 
148
148
  async def test_fetch_deliverable_non_utf8_raises(local_host):
149
- from optio_host.protocol.session import fetch_deliverable_text
149
+ from optio_agents.protocol.session import fetch_deliverable_text
150
150
  await local_host.setup_workdir()
151
151
  os.makedirs(os.path.join(local_host.workdir, "deliverables"), exist_ok=True)
152
152
  target = os.path.join(local_host.workdir, "deliverables", "b.bin")
@@ -97,7 +97,7 @@ class _RecordingFakeHost:
97
97
  return b""
98
98
 
99
99
  async def run_command(self, *a, **kw):
100
- from optio_host.context import RunResult
100
+ from optio_host.host import RunResult
101
101
  self.timeline.append(f"run_command:{a[0]}")
102
102
  return RunResult(stdout="", stderr="", exit_code=0)
103
103
 
@@ -255,8 +255,8 @@ async def test_on_deliverable_receives_hook_ctx_and_can_use_host_primitives(tmp_
255
255
  )
256
256
  # We don't run a full session here; we directly invoke
257
257
  # _deliverable_fetch_loop with a constructed HookContext.
258
- from optio_host.protocol.session import _deliverable_fetch_loop
259
- from optio_host.context import HookContext
258
+ from optio_agents.protocol.session import _deliverable_fetch_loop
259
+ from optio_agents import HookContext
260
260
 
261
261
  queue = asyncio.Queue()
262
262
  await queue.put(("/wd/deliverables/x.txt", "x.txt"))
@@ -348,6 +348,185 @@ async def test_append_resume_log_entry_appends_on_repeat_call(tmp_workdir):
348
348
  assert lines[0] != lines[1]
349
349
 
350
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
+
351
530
  async def test_session_local_supports_resume_false_skips_resume_log(
352
531
  ctx_and_captures, _supply_scenario, tmp_workdir, monkeypatch,
353
532
  ):
@@ -184,7 +184,15 @@ async def test_resume_appends_second_line_to_resume_log(mongo_db, task_root):
184
184
  lines = [line for line in contents.splitlines() if line]
185
185
  assert len(lines) == 2, f"expected 2 lines, got {len(lines)}: {contents!r}"
186
186
 
187
- iso_re = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$")
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
+ )
188
194
  for line in lines:
189
- assert iso_re.match(line), f"non-ISO-8601 line: {line!r}"
190
- assert lines[0] <= lines[1], f"timestamps not monotonic: {lines!r}"
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}"
@@ -2,7 +2,7 @@
2
2
 
3
3
  import pytest
4
4
 
5
- from optio_host.context import RunResult
5
+ from optio_host.host import RunResult
6
6
 
7
7
 
8
8
  class _FakeHost:
@@ -170,7 +170,7 @@ class _ExecutingFakeCtx:
170
170
 
171
171
 
172
172
  async def test_install_opencode_from_zip_happy_path(zip_server, tmp_path):
173
- from optio_host.context import HookContext
173
+ from optio_agents import HookContext
174
174
  from optio_host.host import LocalHost
175
175
  from optio_opencode.host_actions import _install_opencode_from_zip
176
176
 
@@ -212,7 +212,7 @@ async def test_install_opencode_from_zip_happy_path(zip_server, tmp_path):
212
212
 
213
213
  async def test_install_opencode_from_zip_cleans_up_tempdir(zip_server, tmp_path):
214
214
  """The temp dir created on the host should be removed after install."""
215
- from optio_host.context import HookContext
215
+ from optio_agents import HookContext
216
216
  from optio_host.host import LocalHost
217
217
  from optio_opencode.host_actions import _install_opencode_from_zip
218
218
 
@@ -264,7 +264,7 @@ async def test_install_opencode_from_zip_cleans_up_tempdir(zip_server, tmp_path)
264
264
 
265
265
  async def test_ensure_opencode_installed_returns_existing_path_when_ok(monkeypatch):
266
266
  """When smart-install says 'ok', resolve the on-PATH path and return it."""
267
- from optio_host.context import HookContext
267
+ from optio_agents import HookContext
268
268
  from optio_opencode import host_actions
269
269
 
270
270
  async def stub_check(host, *, install_dir=None):
@@ -295,7 +295,7 @@ async def test_ensure_opencode_installed_returns_existing_path_when_ok(monkeypat
295
295
 
296
296
 
297
297
  async def test_ensure_opencode_installed_raises_when_install_disabled(monkeypatch):
298
- from optio_host.context import HookContext
298
+ from optio_agents import HookContext
299
299
  from optio_opencode import host_actions
300
300
 
301
301
  async def stub_check(host, *, install_dir=None):
@@ -324,7 +324,7 @@ async def test_ensure_opencode_installed_installs_when_download_required(
324
324
  monkeypatch, zip_server, tmp_path,
325
325
  ):
326
326
  """End-to-end with a real LocalHost + fake zip server: install path."""
327
- from optio_host.context import HookContext
327
+ from optio_agents import HookContext
328
328
  from optio_host.host import LocalHost
329
329
  from optio_opencode import host_actions
330
330
 
@@ -394,7 +394,7 @@ async def test_ensure_opencode_installed_respects_custom_install_dir(
394
394
  ):
395
395
  """End-to-end: an explicit ``install_dir`` flows through to the
396
396
  install target and to the post-ok ``command -v`` PATH augmentation."""
397
- from optio_host.context import HookContext
397
+ from optio_agents import HookContext
398
398
  from optio_host.host import LocalHost
399
399
  from optio_opencode import host_actions
400
400
 
@@ -439,7 +439,7 @@ async def test_ensure_opencode_installed_default_install_dir_is_home_local_bin(
439
439
  ):
440
440
  """When ``install_dir`` is omitted, the default is
441
441
  ``<host_home>/.local/bin``."""
442
- from optio_host.context import HookContext
442
+ from optio_agents import HookContext
443
443
  from optio_opencode import host_actions
444
444
 
445
445
  fake_home = tmp_path / "home"
@@ -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
@@ -1,7 +0,0 @@
1
- optio-core<0.2,>=0.1
2
- optio-host<0.2,>=0.1
3
- asyncssh>=2.14
4
-
5
- [dev]
6
- pytest>=8.0
7
- pytest-asyncio>=0.23
File without changes
File without changes