optio-opencode 0.1.9__tar.gz → 0.2.1__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 (37) hide show
  1. {optio_opencode-0.1.9 → optio_opencode-0.2.1}/PKG-INFO +2 -2
  2. {optio_opencode-0.1.9 → optio_opencode-0.2.1}/pyproject.toml +2 -2
  3. {optio_opencode-0.1.9 → optio_opencode-0.2.1}/src/optio_opencode/prompt.py +3 -0
  4. {optio_opencode-0.1.9 → optio_opencode-0.2.1}/src/optio_opencode/session.py +83 -63
  5. {optio_opencode-0.1.9 → optio_opencode-0.2.1}/src/optio_opencode.egg-info/PKG-INFO +2 -2
  6. {optio_opencode-0.1.9 → optio_opencode-0.2.1}/src/optio_opencode.egg-info/SOURCES.txt +2 -0
  7. {optio_opencode-0.1.9 → optio_opencode-0.2.1}/src/optio_opencode.egg-info/requires.txt +1 -1
  8. optio_opencode-0.2.1/tests/test_agent_sender_opencode.py +29 -0
  9. {optio_opencode-0.1.9 → optio_opencode-0.2.1}/tests/test_host_local.py +21 -3
  10. {optio_opencode-0.1.9 → optio_opencode-0.2.1}/tests/test_host_primitives_remote.py +16 -0
  11. optio_opencode-0.2.1/tests/test_resume_sentence_opencode.py +7 -0
  12. {optio_opencode-0.1.9 → optio_opencode-0.2.1}/tests/test_session_seed.py +21 -10
  13. {optio_opencode-0.1.9 → optio_opencode-0.2.1}/README.md +0 -0
  14. {optio_opencode-0.1.9 → optio_opencode-0.2.1}/setup.cfg +0 -0
  15. {optio_opencode-0.1.9 → optio_opencode-0.2.1}/src/optio_opencode/__init__.py +0 -0
  16. {optio_opencode-0.1.9 → optio_opencode-0.2.1}/src/optio_opencode/host_actions.py +0 -0
  17. {optio_opencode-0.1.9 → optio_opencode-0.2.1}/src/optio_opencode/seed_manifest.py +0 -0
  18. {optio_opencode-0.1.9 → optio_opencode-0.2.1}/src/optio_opencode/snapshots.py +0 -0
  19. {optio_opencode-0.1.9 → optio_opencode-0.2.1}/src/optio_opencode/types.py +0 -0
  20. {optio_opencode-0.1.9 → optio_opencode-0.2.1}/src/optio_opencode.egg-info/dependency_links.txt +0 -0
  21. {optio_opencode-0.1.9 → optio_opencode-0.2.1}/src/optio_opencode.egg-info/top_level.txt +0 -0
  22. {optio_opencode-0.1.9 → optio_opencode-0.2.1}/tests/test_host_actions.py +0 -0
  23. {optio_opencode-0.1.9 → optio_opencode-0.2.1}/tests/test_host_primitives_local.py +0 -0
  24. {optio_opencode-0.1.9 → optio_opencode-0.2.1}/tests/test_host_remote_resume.py +0 -0
  25. {optio_opencode-0.1.9 → optio_opencode-0.2.1}/tests/test_host_resume.py +0 -0
  26. {optio_opencode-0.1.9 → optio_opencode-0.2.1}/tests/test_prompt.py +0 -0
  27. {optio_opencode-0.1.9 → optio_opencode-0.2.1}/tests/test_purge_seed.py +0 -0
  28. {optio_opencode-0.1.9 → optio_opencode-0.2.1}/tests/test_sanity.py +0 -0
  29. {optio_opencode-0.1.9 → optio_opencode-0.2.1}/tests/test_seed_config.py +0 -0
  30. {optio_opencode-0.1.9 → optio_opencode-0.2.1}/tests/test_session_blob_hooks.py +0 -0
  31. {optio_opencode-0.1.9 → optio_opencode-0.2.1}/tests/test_session_hooks.py +0 -0
  32. {optio_opencode-0.1.9 → optio_opencode-0.2.1}/tests/test_session_local.py +0 -0
  33. {optio_opencode-0.1.9 → optio_opencode-0.2.1}/tests/test_session_remote.py +0 -0
  34. {optio_opencode-0.1.9 → optio_opencode-0.2.1}/tests/test_session_resume.py +0 -0
  35. {optio_opencode-0.1.9 → optio_opencode-0.2.1}/tests/test_smart_install.py +0 -0
  36. {optio_opencode-0.1.9 → optio_opencode-0.2.1}/tests/test_snapshots.py +0 -0
  37. {optio_opencode-0.1.9 → optio_opencode-0.2.1}/tests/test_types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: optio-opencode
3
- Version: 0.1.9
3
+ Version: 0.2.1
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
@@ -22,7 +22,7 @@ Requires-Python: >=3.11
22
22
  Description-Content-Type: text/markdown
23
23
  Requires-Dist: optio-core<0.3,>=0.2
24
24
  Requires-Dist: optio-host<0.3,>=0.2
25
- Requires-Dist: optio-agents<0.2,>=0.1
25
+ Requires-Dist: optio-agents<0.3,>=0.2
26
26
  Requires-Dist: asyncssh>=2.14
27
27
  Provides-Extra: dev
28
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.9"
7
+ version = "0.2.1"
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"
@@ -30,7 +30,7 @@ dependencies = [
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
32
  "optio-host>=0.2,<0.3",
33
- "optio-agents>=0.1,<0.2",
33
+ "optio-agents>=0.2,<0.3",
34
34
  "asyncssh>=2.14",
35
35
  ]
36
36
 
@@ -81,6 +81,9 @@ the situation as a resume:
81
81
 
82
82
  If a resume slips past unnoticed, a failing tool call is the
83
83
  next-best signal — re-check `./resume.log` then.
84
+
85
+ You may also be notified of a resume by a `System:` message on your input
86
+ channel; when you see one, follow the `resume.log` procedure above.
84
87
  """
85
88
 
86
89
 
@@ -31,6 +31,7 @@ from optio_core.context import ProcessContext
31
31
  from optio_core.models import BasicAuth, TaskInstance
32
32
 
33
33
  from optio_agents import HookContext
34
+ from optio_agents import RESUME_NOTICE, SYSTEM_MESSAGE_PREFIX
34
35
  from optio_host.host import Host, LocalHost, ProcessHandle, RemoteHost
35
36
  from optio_host.paths import task_dir
36
37
  from optio_agents.protocol.session import _SessionFailed, run_log_protocol_session
@@ -95,74 +96,76 @@ async def run_opencode_session(ctx: ProcessContext, config: OpencodeTaskConfig)
95
96
  # server (for the seed model default) before terminating it.
96
97
  worker_port: int | None = None
97
98
 
98
- # --- resume decision (BEFORE the protocol session starts) -------------
99
- resume_requested = bool(getattr(ctx, "resume", False))
100
- snapshot: dict | None = None
101
- if resume_requested:
102
- snapshot = await load_latest_snapshot(
103
- ctx._db, prefix=ctx._prefix, process_id=ctx.process_id,
104
- )
99
+ # Set by _prepare (the driver runs it after the workdir wipe, before the
100
+ # optio.log tail); read by the body and the teardown finally.
101
+ resuming = False
105
102
 
106
- # Connect + install BEFORE deciding fresh vs resume. The resume path
107
- # needs ``opencode import`` to replay the saved session DB, which
108
- # requires opencode to be installed on the host and resolved to an
109
- # absolute path. Hoisting also lets the fresh path skip the redundant
110
- # ``host.connect()`` later. ``setup_workdir`` is idempotent (mkdir -p)
111
- # and the protocol driver still calls it again for the fresh path —
112
- # harmless. Install progress reports through ``ctx``, so the
113
- # dashboard sees activity from the very first step.
114
103
  await host.connect()
115
- await host.setup_workdir()
116
- opencode_exec = await host_actions.ensure_opencode_installed(
117
- HookContext(ctx, host),
118
- install_if_missing=config.install_if_missing,
119
- install_dir=config.opencode_install_dir,
120
- )
121
104
 
122
- # Resume restore must run BEFORE the protocol session begins, so the
123
- # driver's tail_task does not subscribe to the restored stale optio.log
124
- # (which contains last run's DONE / ERROR events). The body below sees
125
- # ``resuming`` already decided.
126
- resuming = snapshot is not None
127
- if resuming:
128
- await host.remove_file(opencode_db)
129
- try:
130
- await host.restore_workdir(_stream_blob(ctx, snapshot["workdirBlobId"]))
131
- session_bytes_raw = await _read_blob_bytes(ctx, snapshot["sessionBlobId"])
132
- decrypt = config.session_blob_decrypt or (lambda b: b)
133
- session_bytes = decrypt(session_bytes_raw)
134
- await host_actions.opencode_import(
135
- host, opencode_db, session_bytes,
136
- opencode_executable=opencode_exec,
137
- )
138
- # Move the restored log channel out of the way BEFORE the
139
- # protocol driver subscribes its tail. The snapshot tar
140
- # includes optio.log from the previous run; without rotation,
141
- # ``tail -F -n +1`` would re-emit every old DELIVERABLE /
142
- # DONE / ERROR line and the resumed process would terminate
143
- # within seconds of launch. Preserve the historical content
144
- # by appending it to optio.log.old.
145
- await _rotate_optio_log(host)
146
- preserved_session_id = snapshot["sessionId"]
147
- except Exception as resume_exc:
148
- # If the failure was the session-blob decrypt hook raising,
149
- # this indicates the snapshot was tampered with or the
150
- # consumer's keypair changed. Fail loud — silently dropping
151
- # to fresh-start would mask the security-relevant signal.
152
- if "decrypt" in repr(resume_exc).lower() and "blob" in repr(resume_exc).lower():
153
- _LOG.error(
154
- "resume restore failed inside session_blob_decrypt; "
155
- "refusing to fall through to fresh-start. Operator must "
156
- "investigate the snapshot blob.",
157
- )
158
- raise
159
- _LOG.exception(
160
- "resume restore failed; falling back to fresh-start path "
161
- "(Mongo blob preserved for inspection)",
105
+ async def _prepare(host: Host, hook_ctx: HookContext) -> None:
106
+ """Install opencode and restore a resume snapshot.
107
+
108
+ Handed to run_log_protocol_session, which runs it AFTER
109
+ host.setup_workdir() wiped the workdir and BEFORE it subscribes the
110
+ optio.log tail. The resume path needs ``opencode import`` to replay
111
+ the saved session DB (so opencode must be installed + resolved to an
112
+ absolute path first), and the restored optio.log is rotated away below
113
+ before the tail can re-emit its stale DELIVERABLE/DONE/ERROR lines.
114
+ """
115
+ nonlocal opencode_exec, resuming, preserved_session_id
116
+ opencode_exec = await host_actions.ensure_opencode_installed(
117
+ hook_ctx,
118
+ install_if_missing=config.install_if_missing,
119
+ install_dir=config.opencode_install_dir,
120
+ )
121
+
122
+ resume_requested = bool(getattr(ctx, "resume", False))
123
+ snapshot: dict | None = None
124
+ if resume_requested:
125
+ snapshot = await load_latest_snapshot(
126
+ ctx._db, prefix=ctx._prefix, process_id=ctx.process_id,
162
127
  )
128
+
129
+ resuming = snapshot is not None
130
+ if resuming:
163
131
  await host.remove_file(opencode_db)
164
- resuming = False
165
- preserved_session_id = None
132
+ try:
133
+ await host.restore_workdir(_stream_blob(ctx, snapshot["workdirBlobId"]))
134
+ session_bytes_raw = await _read_blob_bytes(ctx, snapshot["sessionBlobId"])
135
+ decrypt = config.session_blob_decrypt or (lambda b: b)
136
+ session_bytes = decrypt(session_bytes_raw)
137
+ await host_actions.opencode_import(
138
+ host, opencode_db, session_bytes,
139
+ opencode_executable=opencode_exec,
140
+ )
141
+ # Move the restored log channel out of the way BEFORE the
142
+ # protocol driver subscribes its tail. The snapshot tar
143
+ # includes optio.log from the previous run; without rotation,
144
+ # ``tail -F -n +1`` would re-emit every old DELIVERABLE /
145
+ # DONE / ERROR line and the resumed process would terminate
146
+ # within seconds of launch. Preserve the historical content
147
+ # by appending it to optio.log.old.
148
+ await _rotate_optio_log(host)
149
+ preserved_session_id = snapshot["sessionId"]
150
+ except Exception as resume_exc:
151
+ # If the failure was the session-blob decrypt hook raising,
152
+ # this indicates the snapshot was tampered with or the
153
+ # consumer's keypair changed. Fail loud — silently dropping
154
+ # to fresh-start would mask the security-relevant signal.
155
+ if "decrypt" in repr(resume_exc).lower() and "blob" in repr(resume_exc).lower():
156
+ _LOG.error(
157
+ "resume restore failed inside session_blob_decrypt; "
158
+ "refusing to fall through to fresh-start. Operator must "
159
+ "investigate the snapshot blob.",
160
+ )
161
+ raise
162
+ _LOG.exception(
163
+ "resume restore failed; falling back to fresh-start path "
164
+ "(Mongo blob preserved for inspection)",
165
+ )
166
+ await host.remove_file(opencode_db)
167
+ resuming = False
168
+ preserved_session_id = None
166
169
 
167
170
  async def _opencode_body(host: Host, hook_ctx: HookContext) -> None:
168
171
  """Opencode-specific body that runs inside the protocol driver.
@@ -327,6 +330,13 @@ async def run_opencode_session(ctx: ProcessContext, config: OpencodeTaskConfig)
327
330
  await _post_opencode_prompt(
328
331
  worker_port, password, session_id, AUTO_START_PROMPT,
329
332
  )
333
+ elif resuming and config.supports_resume:
334
+ # Push notification: make the resumed agent NOTICE the resume
335
+ # promptly (resume.log remains the pull-based source of truth).
336
+ await _post_opencode_prompt(
337
+ worker_port, password, session_id,
338
+ f"{SYSTEM_MESSAGE_PREFIX}{RESUME_NOTICE}",
339
+ )
330
340
 
331
341
  # --- await opencode subprocess exit -----------------------------
332
342
  # The protocol driver runs this body alongside the tail dispatcher
@@ -343,6 +353,14 @@ async def run_opencode_session(ctx: ProcessContext, config: OpencodeTaskConfig)
343
353
  # --- run the protocol session -----------------------------------------
344
354
  # host.connect() already happened up-front (before install + resume).
345
355
  session_error: BaseException | None = None
356
+
357
+ async def _agent_sender(message: str) -> None:
358
+ # worker_port / session_id are set by _opencode_body at launch;
359
+ # password is established at function scope. _post_opencode_prompt
360
+ # raises on a non-2xx / unreachable worker, which
361
+ # send_to_agent converts to False.
362
+ await _post_opencode_prompt(worker_port, password, session_id, message)
363
+
346
364
  try:
347
365
  # before_execute is wired manually inside _opencode_body (after
348
366
  # install, before launch) per opencode's documented timing.
@@ -352,9 +370,11 @@ async def run_opencode_session(ctx: ProcessContext, config: OpencodeTaskConfig)
352
370
  await run_log_protocol_session(
353
371
  host, ctx,
354
372
  body=_opencode_body,
373
+ prepare=_prepare,
355
374
  on_deliverable=config.on_deliverable,
356
375
  after_execute=config.after_execute,
357
376
  protocol=protocol,
377
+ agent_sender=_agent_sender,
358
378
  )
359
379
  except _SessionFailed as fail:
360
380
  session_error = fail
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: optio-opencode
3
- Version: 0.1.9
3
+ Version: 0.2.1
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
@@ -22,7 +22,7 @@ Requires-Python: >=3.11
22
22
  Description-Content-Type: text/markdown
23
23
  Requires-Dist: optio-core<0.3,>=0.2
24
24
  Requires-Dist: optio-host<0.3,>=0.2
25
- Requires-Dist: optio-agents<0.2,>=0.1
25
+ Requires-Dist: optio-agents<0.3,>=0.2
26
26
  Requires-Dist: asyncssh>=2.14
27
27
  Provides-Extra: dev
28
28
  Requires-Dist: pytest>=8.0; extra == "dev"
@@ -12,6 +12,7 @@ src/optio_opencode.egg-info/SOURCES.txt
12
12
  src/optio_opencode.egg-info/dependency_links.txt
13
13
  src/optio_opencode.egg-info/requires.txt
14
14
  src/optio_opencode.egg-info/top_level.txt
15
+ tests/test_agent_sender_opencode.py
15
16
  tests/test_host_actions.py
16
17
  tests/test_host_local.py
17
18
  tests/test_host_primitives_local.py
@@ -20,6 +21,7 @@ tests/test_host_remote_resume.py
20
21
  tests/test_host_resume.py
21
22
  tests/test_prompt.py
22
23
  tests/test_purge_seed.py
24
+ tests/test_resume_sentence_opencode.py
23
25
  tests/test_sanity.py
24
26
  tests/test_seed_config.py
25
27
  tests/test_session_blob_hooks.py
@@ -1,6 +1,6 @@
1
1
  optio-core<0.3,>=0.2
2
2
  optio-host<0.3,>=0.2
3
- optio-agents<0.2,>=0.1
3
+ optio-agents<0.3,>=0.2
4
4
  asyncssh>=2.14
5
5
 
6
6
  [dev]
@@ -0,0 +1,29 @@
1
+ import pytest
2
+
3
+ import optio_opencode.session as S
4
+ from optio_agents import RESUME_NOTICE, SYSTEM_MESSAGE_PREFIX
5
+
6
+
7
+ @pytest.mark.asyncio
8
+ async def test_post_prompt_signature_used_by_sender(monkeypatch):
9
+ """The opencode sender forwards (port, password, session_id, message) to
10
+ _post_opencode_prompt verbatim. Mirrors the closure built in
11
+ run_opencode_session."""
12
+ calls = []
13
+
14
+ async def fake_post(port, password, session_id, message):
15
+ calls.append((port, password, session_id, message))
16
+
17
+ monkeypatch.setattr(S, "_post_opencode_prompt", fake_post)
18
+
19
+ worker_port, password, session_id = 4321, "pw", "sess-1"
20
+
21
+ async def _agent_sender(message: str) -> None:
22
+ await S._post_opencode_prompt(worker_port, password, session_id, message)
23
+
24
+ await _agent_sender("hello")
25
+ assert calls == [(4321, "pw", "sess-1", "hello")]
26
+
27
+
28
+ def test_resume_notice_string():
29
+ assert f"{SYSTEM_MESSAGE_PREFIX}{RESUME_NOTICE}" == "System: you have been resumed"
@@ -22,9 +22,27 @@ def local_host(tmp_workdir):
22
22
  async def test_setup_workdir_creates_workdir(local_host):
23
23
  await local_host.setup_workdir()
24
24
  assert os.path.isdir(local_host.workdir)
25
- # As of the optio-host split, setup_workdir mkdirs the workdir only.
26
- # The protocol-specific deliverables/ + optio.log are owned by the
27
- # protocol session driver in optio_agents.protocol.session.
25
+ # setup_workdir destructively (re)creates the workdir (see the clean-start
26
+ # test below). The protocol-specific deliverables/ + optio.log are owned by
27
+ # the protocol session driver in optio_agents.protocol.session.
28
+
29
+
30
+ async def test_setup_workdir_wipes_stale_workdir_but_keeps_taskdir(local_host):
31
+ # Clean-start invariant: a destructive wipe of the workdir on every setup, so
32
+ # leftovers from a prior run (e.g. a force-cancel that skipped teardown) never
33
+ # leak into a fresh run. taskdir-side state (opencode.db / .env) lives OUTSIDE
34
+ # the workdir and must survive.
35
+ await local_host.setup_workdir()
36
+ with open(os.path.join(local_host.workdir, "stale.txt"), "w") as fh:
37
+ fh.write("leftover from a previous run")
38
+ taskdir_db = os.path.join(local_host.taskdir, "opencode.db")
39
+ with open(taskdir_db, "w") as fh:
40
+ fh.write("session transcript")
41
+
42
+ await local_host.setup_workdir()
43
+
44
+ assert not os.path.exists(os.path.join(local_host.workdir, "stale.txt"))
45
+ assert os.path.exists(taskdir_db)
28
46
 
29
47
 
30
48
  @pytest.mark.asyncio
@@ -240,3 +240,19 @@ async def test_remote_setup_workdir_sets_taskdir_and_workdir_mode_0o700(remote_h
240
240
  res_w = await remote_host._conn.run(f"stat -c %a {qw}", check=True)
241
241
  assert res_t.stdout.strip() == "700", f"taskdir mode is {res_t.stdout!r}"
242
242
  assert res_w.stdout.strip() == "700", f"workdir mode is {res_w.stdout!r}"
243
+
244
+
245
+ @pytest.mark.asyncio
246
+ async def test_remote_setup_workdir_wipes_stale_workdir_but_keeps_taskdir(remote_host: RemoteHost):
247
+ """Clean-start invariant asserted over SSH (mirrors the local test)."""
248
+ await remote_host.setup_workdir()
249
+ qt = shlex.quote(remote_host.taskdir)
250
+ qw = shlex.quote(remote_host.workdir)
251
+ await remote_host._conn.run(
252
+ f"touch {qw}/stale.txt {qt}/opencode.db", check=True,
253
+ )
254
+ await remote_host.setup_workdir()
255
+ stale = await remote_host._conn.run(f"test -e {qw}/stale.txt; echo $?")
256
+ keep = await remote_host._conn.run(f"test -e {qt}/opencode.db; echo $?")
257
+ assert stale.stdout.strip() == "1", "workdir was not wiped"
258
+ assert keep.stdout.strip() == "0", "taskdir state was lost"
@@ -0,0 +1,7 @@
1
+ from optio_opencode.prompt import _render_resume_section
2
+
3
+
4
+ def test_resume_section_mentions_system_notification():
5
+ section = _render_resume_section(None)
6
+ assert "`System:` message" in section
7
+ assert "resume.log" in section
@@ -308,9 +308,10 @@ async def test_second_session_consumes_seed(
308
308
  async def test_auto_start_posts_on_fresh_and_not_on_resume(
309
309
  mongo_db, task_root, _supply_scenario, monkeypatch,
310
310
  ):
311
- """auto_start=True POSTs the kickoff prompt on a fresh launch and is
312
- suppressed on resume (the restored session already carries its
313
- conversation)."""
311
+ """auto_start=True POSTs the kickoff prompt on a fresh launch; on resume
312
+ the kickoff is suppressed (the restored session already carries its
313
+ conversation) but a System: resume notice is POSTed so the agent notices
314
+ the resume."""
314
315
  import optio_opencode.session as session_mod
315
316
 
316
317
  _supply_scenario["name"] = "happy"
@@ -335,19 +336,29 @@ async def test_auto_start_posts_on_fresh_and_not_on_resume(
335
336
  # fall back to a fresh launch and POST the kickoff prompt again.
336
337
  before_execute=_plant_env,
337
338
  ))
338
- assert len(posts) == 1
339
- posted_session_id, posted_message = posts[0]
340
- assert posted_message == session_mod.AUTO_START_PROMPT
341
- assert posted_session_id # a real (pre-created) session id
342
-
343
- # resume the same process must NOT POST again
339
+ # The 'happy' scenario also emits a DELIVERABLE, which the mandatory
340
+ # acknowledgment POSTs as a "deliverable ...: accepted" ack via the same
341
+ # _post_opencode_prompt. This test is about the kickoff / resume-notice
342
+ # posts, so filter those out.
343
+ notice = f"{session_mod.SYSTEM_MESSAGE_PREFIX}{session_mod.RESUME_NOTICE}"
344
+ kickoffs = [m for _sid, m in posts if m == session_mod.AUTO_START_PROMPT]
345
+ notices = [m for _sid, m in posts if m == notice]
346
+ assert kickoffs == [session_mod.AUTO_START_PROMPT] # kickoff posted exactly once
347
+ assert notices == [] # no resume notice on fresh
348
+ assert all(sid for sid, _m in posts) # real (pre-created) session ids
349
+
350
+ # resume the same process → the kickoff is suppressed, but a System:
351
+ # resume notice is POSTed so the agent notices the resume.
344
352
  ctx_resume = await _make_ctx(mongo_db, pid, resume=True)
345
353
  await run_opencode_session(ctx_resume, OpencodeTaskConfig(
346
354
  consumer_instructions="(scenario: happy)",
347
355
  auto_start=True,
348
356
  before_execute=_plant_env,
349
357
  ))
350
- assert len(posts) == 1, posts
358
+ kickoffs = [m for _sid, m in posts if m == session_mod.AUTO_START_PROMPT]
359
+ notices = [m for _sid, m in posts if m == notice]
360
+ assert kickoffs == [session_mod.AUTO_START_PROMPT] # no NEW kickoff on resume
361
+ assert notices == [notice] # resume notice posted once
351
362
 
352
363
 
353
364
  def test_post_opencode_prompt_uses_prompt_async_parts_body(monkeypatch):
File without changes
File without changes