py-opencode-wrapper 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 (24) hide show
  1. {py_opencode_wrapper-0.1.2 → py_opencode_wrapper-0.1.4}/PKG-INFO +9 -6
  2. {py_opencode_wrapper-0.1.2 → py_opencode_wrapper-0.1.4}/README.md +8 -5
  3. {py_opencode_wrapper-0.1.2 → py_opencode_wrapper-0.1.4}/opencode_wrapper/client.py +10 -0
  4. {py_opencode_wrapper-0.1.2 → py_opencode_wrapper-0.1.4}/opencode_wrapper/config.py +3 -0
  5. {py_opencode_wrapper-0.1.2 → py_opencode_wrapper-0.1.4}/py_opencode_wrapper.egg-info/PKG-INFO +9 -6
  6. {py_opencode_wrapper-0.1.2 → py_opencode_wrapper-0.1.4}/py_opencode_wrapper.egg-info/SOURCES.txt +2 -0
  7. {py_opencode_wrapper-0.1.2 → py_opencode_wrapper-0.1.4}/pyproject.toml +1 -1
  8. py_opencode_wrapper-0.1.4/tests/test_config_instructions.py +83 -0
  9. py_opencode_wrapper-0.1.4/tests/test_integration_instructions.py +166 -0
  10. {py_opencode_wrapper-0.1.2 → py_opencode_wrapper-0.1.4}/opencode_wrapper/__init__.py +0 -0
  11. {py_opencode_wrapper-0.1.2 → py_opencode_wrapper-0.1.4}/opencode_wrapper/errors.py +0 -0
  12. {py_opencode_wrapper-0.1.2 → py_opencode_wrapper-0.1.4}/opencode_wrapper/events.py +0 -0
  13. {py_opencode_wrapper-0.1.2 → py_opencode_wrapper-0.1.4}/py_opencode_wrapper.egg-info/dependency_links.txt +0 -0
  14. {py_opencode_wrapper-0.1.2 → py_opencode_wrapper-0.1.4}/py_opencode_wrapper.egg-info/requires.txt +0 -0
  15. {py_opencode_wrapper-0.1.2 → py_opencode_wrapper-0.1.4}/py_opencode_wrapper.egg-info/top_level.txt +0 -0
  16. {py_opencode_wrapper-0.1.2 → py_opencode_wrapper-0.1.4}/setup.cfg +0 -0
  17. {py_opencode_wrapper-0.1.2 → py_opencode_wrapper-0.1.4}/tests/test_client_async.py +0 -0
  18. {py_opencode_wrapper-0.1.2 → py_opencode_wrapper-0.1.4}/tests/test_config_permission.py +0 -0
  19. {py_opencode_wrapper-0.1.2 → py_opencode_wrapper-0.1.4}/tests/test_event_parser.py +0 -0
  20. {py_opencode_wrapper-0.1.2 → py_opencode_wrapper-0.1.4}/tests/test_integration_external_directory.py +0 -0
  21. {py_opencode_wrapper-0.1.2 → py_opencode_wrapper-0.1.4}/tests/test_integration_multi_agent_weather.py +0 -0
  22. {py_opencode_wrapper-0.1.2 → py_opencode_wrapper-0.1.4}/tests/test_integration_opencode.py +0 -0
  23. {py_opencode_wrapper-0.1.2 → py_opencode_wrapper-0.1.4}/tests/test_integration_parallel.py +0 -0
  24. {py_opencode_wrapper-0.1.2 → py_opencode_wrapper-0.1.4}/tests/test_run_result_fuzzy_text.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: py-opencode-wrapper
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: Async Python wrapper for OpenCode CLI (opencode run --format json)
5
5
  Project-URL: Homepage, https://github.com/idailylife/oc_py_wrapper
6
6
  Project-URL: Repository, https://github.com/idailylife/oc_py_wrapper
@@ -101,12 +101,13 @@ Per-call JSON is merged and passed as `OPENCODE_CONFIG_CONTENT` (see [OpenCode c
101
101
 
102
102
  | Field | Purpose |
103
103
  |--------|---------|
104
- | `permission` | `permission` map (`allow` / `ask` / `deny`, patterns) |
104
+ | `permission` | `permission` map (`allow` / `deny`, patterns) |
105
105
  | `mcp` | MCP server definitions |
106
106
  | `tools` | Enable/disable tools (including MCP globs) |
107
107
  | `config_overrides` | Any extra top-level config keys to deep-merge |
108
108
 
109
109
  Optional env tuning: `disable_autoupdate=True` sets `OPENCODE_DISABLE_AUTOUPDATE=1`.
110
+ Note: `ask` is intentionally rejected in subprocess mode (no interactive terminal); use `allow` or `deny`.
110
111
 
111
112
  ## CLI arguments
112
113
 
@@ -147,13 +148,15 @@ Default `pytest -q` runs **all** tests; use `-m "not integration"` in CI without
147
148
 
148
149
  ## Concurrency notes
149
150
 
150
- When running many tasks with `asyncio.gather`, two mitigations are active by default:
151
+ When running many tasks with `asyncio.gather`, three protections are enabled by default:
151
152
 
152
- **Startup serialisation** — `AsyncOpenCodeClient` accepts `startup_concurrency` (default `1`) and `startup_delay_s` (default `0.3`). Only one process at a time enters its SQLite initialisation window; all processes run concurrently afterwards. This avoids a known opencode bug where `PRAGMA journal_mode = WAL` races against `PRAGMA busy_timeout` during concurrent startup, causing immediate crashes.
153
+ **Startup serialisation** — `startup_concurrency=1` and `startup_delay_s=0.3` limit how many processes enter SQLite startup at once, reducing WAL-initialisation race crashes.
153
154
 
154
- **Automatic retry** — `async_run` accepts `max_retries` (default `2`) and `retry_delay_s` (default `1.0`). If opencode exits non-zero and stderr contains SQLite lock indicators, the call is retried with a short backoff. Non-SQLite failures are raised immediately.
155
+ **DB isolation** — `isolate_db=True` gives each run a private `XDG_DATA_HOME`, so concurrent runs do not contend on the same `opencode.db` during tool execution.
155
156
 
156
- Set `startup_concurrency=0` (unlimited) and `max_retries=0` to opt out of both behaviours.
157
+ **Automatic retry** `async_run(max_retries=2, retry_delay_s=1.0)` retries known SQLite-startup crashes with short backoff. Non-SQLite failures still fail fast.
158
+
159
+ Set `startup_concurrency=0`, `isolate_db=False`, and `max_retries=0` to opt out.
157
160
 
158
161
  ## Notes
159
162
 
@@ -88,12 +88,13 @@ Per-call JSON is merged and passed as `OPENCODE_CONFIG_CONTENT` (see [OpenCode c
88
88
 
89
89
  | Field | Purpose |
90
90
  |--------|---------|
91
- | `permission` | `permission` map (`allow` / `ask` / `deny`, patterns) |
91
+ | `permission` | `permission` map (`allow` / `deny`, patterns) |
92
92
  | `mcp` | MCP server definitions |
93
93
  | `tools` | Enable/disable tools (including MCP globs) |
94
94
  | `config_overrides` | Any extra top-level config keys to deep-merge |
95
95
 
96
96
  Optional env tuning: `disable_autoupdate=True` sets `OPENCODE_DISABLE_AUTOUPDATE=1`.
97
+ Note: `ask` is intentionally rejected in subprocess mode (no interactive terminal); use `allow` or `deny`.
97
98
 
98
99
  ## CLI arguments
99
100
 
@@ -134,13 +135,15 @@ Default `pytest -q` runs **all** tests; use `-m "not integration"` in CI without
134
135
 
135
136
  ## Concurrency notes
136
137
 
137
- When running many tasks with `asyncio.gather`, two mitigations are active by default:
138
+ When running many tasks with `asyncio.gather`, three protections are enabled by default:
138
139
 
139
- **Startup serialisation** — `AsyncOpenCodeClient` accepts `startup_concurrency` (default `1`) and `startup_delay_s` (default `0.3`). Only one process at a time enters its SQLite initialisation window; all processes run concurrently afterwards. This avoids a known opencode bug where `PRAGMA journal_mode = WAL` races against `PRAGMA busy_timeout` during concurrent startup, causing immediate crashes.
140
+ **Startup serialisation** — `startup_concurrency=1` and `startup_delay_s=0.3` limit how many processes enter SQLite startup at once, reducing WAL-initialisation race crashes.
140
141
 
141
- **Automatic retry** — `async_run` accepts `max_retries` (default `2`) and `retry_delay_s` (default `1.0`). If opencode exits non-zero and stderr contains SQLite lock indicators, the call is retried with a short backoff. Non-SQLite failures are raised immediately.
142
+ **DB isolation** — `isolate_db=True` gives each run a private `XDG_DATA_HOME`, so concurrent runs do not contend on the same `opencode.db` during tool execution.
142
143
 
143
- Set `startup_concurrency=0` (unlimited) and `max_retries=0` to opt out of both behaviours.
144
+ **Automatic retry** `async_run(max_retries=2, retry_delay_s=1.0)` retries known SQLite-startup crashes with short backoff. Non-SQLite failures still fail fast.
145
+
146
+ Set `startup_concurrency=0`, `isolate_db=False`, and `max_retries=0` to opt out.
144
147
 
145
148
  ## Notes
146
149
 
@@ -214,6 +214,16 @@ class AsyncOpenCodeClient:
214
214
  # and SQLite write locks during tool execution serialize the runs (37–46s delays).
215
215
  if self._isolate_db:
216
216
  xdg_tmpdir = tempfile.mkdtemp(prefix="oc_xdg_")
217
+ # Symlink auth.json so provider API keys (stored by `opencode auth`)
218
+ # are visible in the isolated data dir. Without this, providers
219
+ # that rely on auth.json (rather than env-var keys) fail with
220
+ # "Model not found" because the provider never activates.
221
+ real_xdg = env.get("XDG_DATA_HOME", str(Path.home() / ".local" / "share"))
222
+ real_auth = Path(real_xdg) / "opencode" / "auth.json"
223
+ if real_auth.is_file():
224
+ iso_oc_dir = Path(xdg_tmpdir) / "opencode"
225
+ iso_oc_dir.mkdir(parents=True, exist_ok=True)
226
+ (iso_oc_dir / "auth.json").symlink_to(real_auth)
217
227
  env = {**env, "XDG_DATA_HOME": xdg_tmpdir}
218
228
  else:
219
229
  xdg_tmpdir = None
@@ -55,6 +55,7 @@ class RunConfig:
55
55
  permission: PermissionMap | None = None
56
56
  mcp: dict[str, Any] | None = None
57
57
  tools: dict[str, Any] | None = None
58
+ instructions: list[str] | None = None
58
59
  config_overrides: dict[str, Any] | None = None
59
60
 
60
61
  def build_opencode_config_dict(self) -> dict[str, Any]:
@@ -68,6 +69,8 @@ class RunConfig:
68
69
  merged = _deep_merge(merged, {"mcp": dict(self.mcp)})
69
70
  if self.tools is not None:
70
71
  merged = _deep_merge(merged, {"tools": dict(self.tools)})
72
+ if self.instructions is not None:
73
+ merged = _deep_merge(merged, {"instructions": list(self.instructions)})
71
74
  return merged
72
75
 
73
76
  def opencode_config_content_json(self) -> str | None:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: py-opencode-wrapper
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: Async Python wrapper for OpenCode CLI (opencode run --format json)
5
5
  Project-URL: Homepage, https://github.com/idailylife/oc_py_wrapper
6
6
  Project-URL: Repository, https://github.com/idailylife/oc_py_wrapper
@@ -101,12 +101,13 @@ Per-call JSON is merged and passed as `OPENCODE_CONFIG_CONTENT` (see [OpenCode c
101
101
 
102
102
  | Field | Purpose |
103
103
  |--------|---------|
104
- | `permission` | `permission` map (`allow` / `ask` / `deny`, patterns) |
104
+ | `permission` | `permission` map (`allow` / `deny`, patterns) |
105
105
  | `mcp` | MCP server definitions |
106
106
  | `tools` | Enable/disable tools (including MCP globs) |
107
107
  | `config_overrides` | Any extra top-level config keys to deep-merge |
108
108
 
109
109
  Optional env tuning: `disable_autoupdate=True` sets `OPENCODE_DISABLE_AUTOUPDATE=1`.
110
+ Note: `ask` is intentionally rejected in subprocess mode (no interactive terminal); use `allow` or `deny`.
110
111
 
111
112
  ## CLI arguments
112
113
 
@@ -147,13 +148,15 @@ Default `pytest -q` runs **all** tests; use `-m "not integration"` in CI without
147
148
 
148
149
  ## Concurrency notes
149
150
 
150
- When running many tasks with `asyncio.gather`, two mitigations are active by default:
151
+ When running many tasks with `asyncio.gather`, three protections are enabled by default:
151
152
 
152
- **Startup serialisation** — `AsyncOpenCodeClient` accepts `startup_concurrency` (default `1`) and `startup_delay_s` (default `0.3`). Only one process at a time enters its SQLite initialisation window; all processes run concurrently afterwards. This avoids a known opencode bug where `PRAGMA journal_mode = WAL` races against `PRAGMA busy_timeout` during concurrent startup, causing immediate crashes.
153
+ **Startup serialisation** — `startup_concurrency=1` and `startup_delay_s=0.3` limit how many processes enter SQLite startup at once, reducing WAL-initialisation race crashes.
153
154
 
154
- **Automatic retry** — `async_run` accepts `max_retries` (default `2`) and `retry_delay_s` (default `1.0`). If opencode exits non-zero and stderr contains SQLite lock indicators, the call is retried with a short backoff. Non-SQLite failures are raised immediately.
155
+ **DB isolation** — `isolate_db=True` gives each run a private `XDG_DATA_HOME`, so concurrent runs do not contend on the same `opencode.db` during tool execution.
155
156
 
156
- Set `startup_concurrency=0` (unlimited) and `max_retries=0` to opt out of both behaviours.
157
+ **Automatic retry** `async_run(max_retries=2, retry_delay_s=1.0)` retries known SQLite-startup crashes with short backoff. Non-SQLite failures still fail fast.
158
+
159
+ Set `startup_concurrency=0`, `isolate_db=False`, and `max_retries=0` to opt out.
157
160
 
158
161
  ## Notes
159
162
 
@@ -11,9 +11,11 @@ py_opencode_wrapper.egg-info/dependency_links.txt
11
11
  py_opencode_wrapper.egg-info/requires.txt
12
12
  py_opencode_wrapper.egg-info/top_level.txt
13
13
  tests/test_client_async.py
14
+ tests/test_config_instructions.py
14
15
  tests/test_config_permission.py
15
16
  tests/test_event_parser.py
16
17
  tests/test_integration_external_directory.py
18
+ tests/test_integration_instructions.py
17
19
  tests/test_integration_multi_agent_weather.py
18
20
  tests/test_integration_opencode.py
19
21
  tests/test_integration_parallel.py
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "py-opencode-wrapper"
3
- version = "0.1.2"
3
+ version = "0.1.4"
4
4
  description = "Async Python wrapper for OpenCode CLI (opencode run --format json)"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -0,0 +1,83 @@
1
+ """Tests for ``RunConfig.instructions`` field."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ from opencode_wrapper.client import build_env
8
+ from opencode_wrapper.config import RunConfig
9
+
10
+
11
+ def test_instructions_single_file() -> None:
12
+ cfg = RunConfig(instructions=["AGENT.md"])
13
+ d = cfg.build_opencode_config_dict()
14
+ assert d["instructions"] == ["AGENT.md"]
15
+
16
+
17
+ def test_instructions_multiple_files() -> None:
18
+ cfg = RunConfig(instructions=["CONTRIBUTING.md", "docs/guidelines.md"])
19
+ d = cfg.build_opencode_config_dict()
20
+ assert d["instructions"] == ["CONTRIBUTING.md", "docs/guidelines.md"]
21
+
22
+
23
+ def test_instructions_glob_pattern() -> None:
24
+ cfg = RunConfig(instructions=[".cursor/rules/*.md"])
25
+ d = cfg.build_opencode_config_dict()
26
+ assert d["instructions"] == [".cursor/rules/*.md"]
27
+
28
+
29
+ def test_instructions_none_omits_key() -> None:
30
+ cfg = RunConfig(instructions=None)
31
+ d = cfg.build_opencode_config_dict()
32
+ assert "instructions" not in d
33
+
34
+
35
+ def test_instructions_empty_list() -> None:
36
+ cfg = RunConfig(instructions=[])
37
+ d = cfg.build_opencode_config_dict()
38
+ assert d["instructions"] == []
39
+
40
+
41
+ def test_instructions_in_opencode_config_content_json() -> None:
42
+ cfg = RunConfig(instructions=["AGENT.md", "docs/*.md"])
43
+ env = build_env(cfg, base={})
44
+ payload = json.loads(env["OPENCODE_CONFIG_CONTENT"])
45
+ assert payload["instructions"] == ["AGENT.md", "docs/*.md"]
46
+
47
+
48
+ def test_instructions_deep_merges_with_config_overrides() -> None:
49
+ """instructions field wins over config_overrides when both set."""
50
+ cfg = RunConfig(
51
+ config_overrides={"instructions": ["old.md"]},
52
+ instructions=["new.md"],
53
+ )
54
+ d = cfg.build_opencode_config_dict()
55
+ # instructions applied after config_overrides, so it overwrites
56
+ assert d["instructions"] == ["new.md"]
57
+
58
+
59
+ def test_instructions_config_overrides_used_when_no_instructions_field() -> None:
60
+ cfg = RunConfig(config_overrides={"instructions": ["via_overrides.md"]})
61
+ d = cfg.build_opencode_config_dict()
62
+ assert d["instructions"] == ["via_overrides.md"]
63
+
64
+
65
+ def test_instructions_combined_with_permission_and_mcp() -> None:
66
+ cfg = RunConfig(
67
+ permission={"bash": "allow"},
68
+ mcp={"my-server": {"type": "local", "command": ["npx", "my-mcp"]}},
69
+ instructions=["AGENT.md"],
70
+ )
71
+ d = cfg.build_opencode_config_dict()
72
+ assert d["instructions"] == ["AGENT.md"]
73
+ assert d["permission"]["bash"] == "allow"
74
+ assert d["mcp"]["my-server"]["type"] == "local"
75
+
76
+
77
+ def test_instructions_not_mutated() -> None:
78
+ """build_opencode_config_dict returns a copy; original list is not mutated."""
79
+ original = ["AGENT.md"]
80
+ cfg = RunConfig(instructions=original)
81
+ d = cfg.build_opencode_config_dict()
82
+ d["instructions"].append("extra.md")
83
+ assert cfg.instructions == ["AGENT.md"]
@@ -0,0 +1,166 @@
1
+ """Integration test: verify ``RunConfig.instructions`` is actually loaded by opencode.
2
+
3
+ Two complementary strategies:
4
+
5
+ Strategy A — "probe keyword":
6
+ The instruction defines a magic keyword → fixed reply mapping.
7
+ Send the keyword; assert the exact reply appears.
8
+ Send the keyword WITHOUT the instruction; assert the reply is absent.
9
+ This is the most reliable approach: the response is deterministic and the
10
+ keyword is meaningless without the instruction, so false positives are impossible.
11
+
12
+ Strategy B — "canary prefix" (kept as a cross-check):
13
+ The instruction mandates a unique token at the start of every response.
14
+ Ask the model directly what the token is; assert it appears.
15
+
16
+ Requires: real ``opencode`` on PATH + configured provider.
17
+ Run::
18
+
19
+ pytest -m integration -q tests/test_integration_instructions.py
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import os
25
+ from pathlib import Path
26
+
27
+ import pytest
28
+
29
+ from opencode_wrapper import AsyncOpenCodeClient, RunConfig
30
+
31
+ PROBE_KEYWORD = "__probe_instruction__"
32
+ PROBE_REPLY = "MAKE AGENTS GREAT AGAIN!"
33
+
34
+ # Unique enough that the model won't emit it by accident.
35
+ CANARY = "XCANARY_INST_77Z"
36
+
37
+
38
+ @pytest.fixture
39
+ def probe_instruction_file(integration_workspace: Path) -> Path:
40
+ """Write the probe instruction file inside the workspace so the model can read it."""
41
+ f = integration_workspace / "probe_instructions.md"
42
+ f.write_text(
43
+ f'When the user inputs exactly "{PROBE_KEYWORD}", '
44
+ f'reply with exactly "{PROBE_REPLY}" and nothing else.\n',
45
+ encoding="utf-8",
46
+ )
47
+ return f
48
+
49
+
50
+ @pytest.fixture
51
+ def canary_instruction_file(integration_workspace: Path) -> Path:
52
+ """Write the canary instruction file inside the workspace so the model can read it."""
53
+ f = integration_workspace / "canary_instructions.md"
54
+ f.write_text(
55
+ f"Always begin every response with the exact token: {CANARY}\n",
56
+ encoding="utf-8",
57
+ )
58
+ return f
59
+
60
+
61
+ @pytest.mark.integration
62
+ @pytest.mark.asyncio
63
+ async def test_probe_keyword_triggers_defined_reply(
64
+ opencode_path: str,
65
+ integration_workspace: Path,
66
+ probe_instruction_file: Path,
67
+ ) -> None:
68
+ """Magic keyword defined in the instruction file produces the exact expected reply."""
69
+ client = AsyncOpenCodeClient(binary=opencode_path)
70
+ timeout = float(os.environ.get("OPENCODE_INTEGRATION_TIMEOUT_S", "300"))
71
+
72
+ result = await client.async_run(
73
+ PROBE_KEYWORD,
74
+ integration_workspace,
75
+ run_cfg=RunConfig(
76
+ disable_autoupdate=True,
77
+ instructions=[str(probe_instruction_file)],
78
+ ),
79
+ timeout_s=timeout,
80
+ )
81
+
82
+ assert result.exit_code == 0
83
+ blob = (result.final_text or "") + "\n".join(str(e) for e in result.events)
84
+ assert PROBE_REPLY in blob, (
85
+ f"Expected reply {PROBE_REPLY!r} not found — instruction file may not have been loaded.\n"
86
+ f"final_text={result.final_text!r}\nstderr={result.stderr!r}"
87
+ )
88
+
89
+
90
+ @pytest.mark.integration
91
+ @pytest.mark.asyncio
92
+ async def test_probe_keyword_no_reply_without_instruction(
93
+ opencode_path: str,
94
+ integration_workspace: Path,
95
+ ) -> None:
96
+ """Same keyword WITHOUT the instruction does not produce the probe reply."""
97
+ client = AsyncOpenCodeClient(binary=opencode_path)
98
+ timeout = float(os.environ.get("OPENCODE_INTEGRATION_TIMEOUT_S", "300"))
99
+
100
+ result = await client.async_run(
101
+ PROBE_KEYWORD,
102
+ integration_workspace,
103
+ run_cfg=RunConfig(agent="plan", disable_autoupdate=True),
104
+ timeout_s=timeout,
105
+ )
106
+
107
+ assert result.exit_code == 0
108
+ blob = (result.final_text or "") + "\n".join(str(e) for e in result.events)
109
+ assert PROBE_REPLY not in blob, (
110
+ f"Probe reply appeared without instruction — false positive.\n"
111
+ f"final_text={result.final_text!r}"
112
+ )
113
+
114
+
115
+ @pytest.mark.integration
116
+ @pytest.mark.asyncio
117
+ async def test_canary_instruction_file_is_loaded(
118
+ opencode_path: str,
119
+ integration_workspace: Path,
120
+ canary_instruction_file: Path,
121
+ ) -> None:
122
+ """Model response contains canary token when instruction file is provided."""
123
+ client = AsyncOpenCodeClient(binary=opencode_path)
124
+ timeout = float(os.environ.get("OPENCODE_INTEGRATION_TIMEOUT_S", "300"))
125
+
126
+ result = await client.async_run(
127
+ "Your instructions define a mandatory response prefix token. Output that token and nothing else.",
128
+ integration_workspace,
129
+ run_cfg=RunConfig(
130
+ disable_autoupdate=True,
131
+ instructions=[str(canary_instruction_file)],
132
+ ),
133
+ timeout_s=timeout,
134
+ )
135
+
136
+ assert result.exit_code == 0
137
+ blob = (result.final_text or "") + "\n".join(str(e) for e in result.events)
138
+ assert CANARY in blob, (
139
+ f"Canary token {CANARY!r} not found — instruction file may not have been loaded.\n"
140
+ f"final_text={result.final_text!r}\nstderr={result.stderr!r}"
141
+ )
142
+
143
+
144
+ @pytest.mark.integration
145
+ @pytest.mark.asyncio
146
+ async def test_canary_instruction_absent_without_field(
147
+ opencode_path: str,
148
+ integration_workspace: Path,
149
+ ) -> None:
150
+ """Same prompt without instruction file does NOT produce the canary token."""
151
+ client = AsyncOpenCodeClient(binary=opencode_path)
152
+ timeout = float(os.environ.get("OPENCODE_INTEGRATION_TIMEOUT_S", "300"))
153
+
154
+ result = await client.async_run(
155
+ "Your instructions define a mandatory response prefix token. Output that token and nothing else.",
156
+ integration_workspace,
157
+ run_cfg=RunConfig(agent="plan", disable_autoupdate=True),
158
+ timeout_s=timeout,
159
+ )
160
+
161
+ assert result.exit_code == 0
162
+ blob = (result.final_text or "") + "\n".join(str(e) for e in result.events)
163
+ assert CANARY not in blob, (
164
+ f"Canary token appeared without instruction — something is wrong.\n"
165
+ f"final_text={result.final_text!r}"
166
+ )