refactor: sync upstream tool framework and service refactors#270
Conversation
📝 WalkthroughWalkthroughReplaces the in-repo filesystem with an external package, migrates many tool schemas from Zod to effect/Schema, reimplements ripgrep as an Effect service/installer with streaming APIs, adds BOM-aware I/O and integrates it into patch/edit/write flows, and reworks session compaction and tool-output truncation/config. Changes
Sequence Diagram(s)sequenceDiagram
participant Client as CLI/Server
participant Runtime as AppRuntime
participant Ripgrep as Ripgrep.Service
participant FS as AppFileSystem
participant Spawner as ChildProcessSpawner
Client->>Runtime: request (search/files/tree)
Runtime->>Ripgrep: AppRuntime.runPromise(use Ripgrep.Service)
Ripgrep->>FS: ensure/install rg binary (download & extract)
Ripgrep->>Spawner: spawn rg process and stream stdout/stderr
Spawner-->>Ripgrep: stdout/stderr lines + exit code
Ripgrep->>Runtime: stream/collect structured results
Runtime->>Client: return structured JSON or raw tree output
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related issues
Possibly related PRs
Suggested labels
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Code Review
This pull request migrates the tool parameter validation system from Zod to Effect Schema across the codebase, including core tool definitions and the registry. It also introduces a file-locking mechanism and Byte Order Mark (BOM) handling in the EditTool, alongside refactoring the ReadTool to support file warming via LSP. Review feedback identifies a critical security risk in the ReadTool where the removal of a file size check could lead to memory exhaustion. Additionally, the new locking mechanism in EditTool may cause a memory leak, and some redundant code blocks require cleanup.
ebd1167 to
42557db
Compare
37c79c1 to
2229f47
Compare
There was a problem hiding this comment.
Actionable comments posted: 14
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
packages/opencode/src/tool/edit.ts (1)
89-109:⚠️ Potential issue | 🟠 MajorRecompute the returned diff after formatter rewrites newly created files.
In the
oldString === ""branch,contentNewis updated afterformat.file()/Bom.syncFile(), butdiffis still the pre-format patch. The tool metadata and snapshot diff can now disagree with what was actually written to disk whenever formatting changes the new file.Proposed fix
yield* afs.writeWithDirs(filePath, Bom.join(contentNew, desiredBom)) if (yield* format.file(filePath)) { contentNew = yield* Bom.syncFile(afs, filePath, desiredBom) } + diff = trimDiff( + createTwoFilesPatch( + filePath, + filePath, + normalizeLineEndings(contentOld), + normalizeLineEndings(contentNew), + ), + ) yield* bus.publish(File.Event.Edited, { file: filePath })🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/opencode/src/tool/edit.ts` around lines 89 - 109, The branch handling params.oldString === "" builds diff from contentNew before running format.file() so the metadata/diff can mismatch the final on-disk contents; after writing and calling format.file() / Bom.syncFile() recompute diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew)) (using the possibly-updated contentNew from Bom.syncFile) and update the metadata passed to ctx.ask (and any snapshot/diff variables) so the permission prompt and saved diff reflect the formatted file. Refer to symbols: params.oldString, contentOld, contentNew, diff, createTwoFilesPatch, trimDiff, format.file, Bom.syncFile, ctx.ask, and afs.writeWithDirs.packages/opencode/src/session/compaction.ts (1)
236-241:⚠️ Potential issue | 🟠 MajorSize recent turns with the same serialization options used for compaction.
select()budgets recent turns with fulltoModelMessagesEffect(...), butprocessCompaction()later strips media and truncates tool output. That makes the tail-selection pass overestimate turn size and compact away recent context that would actually fit.Suggested fix
- const msgs = yield* MessageV2.toModelMessagesEffect(input.messages, input.model) + const msgs = yield* MessageV2.toModelMessagesEffect(input.messages, input.model, { + stripMedia: true, + toolOutputMaxChars: TOOL_OUTPUT_MAX_CHARS, + })Also applies to: 405-408
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/opencode/src/session/compaction.ts` around lines 236 - 241, The recent-turn sizing in SessionCompaction.estimate currently serializes messages with MessageV2.toModelMessagesEffect(...) but does not apply the same media-stripping and tool-output truncation that processCompaction() uses, causing overestimates; update estimate() to serialize messages using the identical transformation used by processCompaction (i.e., apply the same media stripping and tool-output truncation logic or call the shared serializer/transform function before Token.estimate) so select() budgets match actual compacted size, and apply the same change to the other occurrence around lines 405–408.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@packages/opencode/src/cli/cmd/debug/ripgrep.ts`:
- Around line 52-64: The rg files call ignores the --query flag; update the
options passed to Ripgrep.Service.files to include the query when present (use
args.query in the object you pass to rg.files) so the service receives the
filter before the Stream pipeline (refer to Ripgrep.Service and the files(...)
call and the args.query/args.glob symbols). Ensure you only set the query field
when args.query is provided so existing behavior is preserved.
In `@packages/opencode/src/patch/index.ts`:
- Around line 337-346: The patch currently strips BOM via Bom.split before
creating unified_diff and newContent (variables unifiedDiff/newContent), which
hides BOM changes that are later applied by joining with fileUpdate.bom; update
the flow to surface BOM transitions explicitly: modify the ApplyPatchFileUpdate
/ ApplyPatchFileChange interfaces to carry a boolean or enum like bom_changed
(and the original/new BOM values), detect BOM differences between
originalContent.bom and next.bom when calling generateUnifiedDiff, include that
BOM change flag in the returned patch payload alongside unified_diff and content
(or else reject updates that introduce a new BOM), and ensure callers use that
flag to either include a BOM line in the diff preview or block silent BOM byte
changes.
In `@packages/opencode/src/session/compaction.ts`:
- Around line 159-181: splitTurn currently allows the preserved tail to begin
mid-turn (e.g., on an assistant/tool message) which breaks turn coherence;
update splitTurn so the candidate start does not begin inside the turn after the
opening user message — either iterate start from input.turn.start (not
turn.start + 1) or explicitly require that input.messages[start] is the original
turn opener (e.g., check the message role/id to ensure it's the user/opening
message) before calling input.estimate and returning a Tail; keep the budget and
estimate logic but skip any start that is not the turn boundary or not a
user/opening message.
In `@packages/opencode/src/session/message-v2.ts`:
- Around line 22-25: The truncateToolOutput helper currently keeps maxChars
characters and then appends a suffix, which can exceed the intended cap; change
truncateToolOutput to first build the suffix string, compute sliceLen =
Math.max(0, (maxChars ?? text.length) - suffix.length), then if text.length <=
(maxChars ?? text.length) return text; otherwise slice text to sliceLen and
compute omitted = text.length - sliceLen and return the sliced text plus the
suffix so the final string never exceeds maxChars; reference the
truncateToolOutput function and its local variables (maxChars, omitted) when
updating.
In `@packages/opencode/src/tool/agent.ts`:
- Around line 23-27: Change the schema for subagent_session_id to validate as a
SessionID brand instead of any string: replace Schema.optional(Schema.String)
for subagent_session_id with Schema.optional(SessionID) (or
Schema.optional(Schema.brand(Schema.String, SessionIDBrand)) if SessionID is
implemented as a brand) while keeping the existing annotation; decode/validate
this field up front (using SessionID) so later resume logic only falls back to
starting a new session when the ID is well-formed but not found, rather than
when it is malformed or typoed.
In `@packages/opencode/src/tool/bash.ts`:
- Around line 425-426: The tool description still interpolates static
Truncate.MAX_LINES / Truncate.MAX_BYTES while run() uses yield* trunc.limits();
update the description to use the actual runtime limits returned by
trunc.limits() (e.g., use the limits.maxLines and limits.maxBytes values or the
computed keep value) so the advertised tool_output.max_lines/max_bytes match the
configured ones; locate where the description/template is built and replace the
static Truncate.MAX_* references with the runtime limits from trunc.limits() (or
pass those values into the description generator) so the model sees the correct
limits.
In `@packages/opencode/src/tool/edit.ts`:
- Around line 35-45: The module-scoped Map locks and lock(filePath) create a
global, never-cleaned registry (locks, Semaphore.makeUnsafe,
AppFileSystem.resolve); move this per-directory/per-project state into an
InstanceState by creating the Map inside InstanceState.make (or using
ScopedCache provided there) so the Map and Semaphores are scoped to the instance
and will be disposed when the InstanceState is cleaned up; update lock(filePath)
to acquire the Map from that InstanceState/ScopedCache instead of the
module-level variable and ensure any Semaphore cleanup/disposal is performed in
the InstanceState teardown.
In `@packages/opencode/src/tool/grep.ts`:
- Around line 67-79: The code currently throws for any result where
result.partial && result.items.length === 0, wrongly treating unreadable-path
partials as an invalid regex; update the branch in the rg.search handling so you
do NOT throw for all partial+empty results — instead only throw when Ripgrep
explicitly signals an invalid-regex (e.g., check for an explicit exit/code or
error from the ripgrep service such as a returned exitCode === 2 or an error
message indicating "invalid regex"); otherwise let partial+empty fall through to
the normal empty return. Locate the condition around rg.search, result.partial,
result.items and params.pattern and replace the unconditional throw with a
guarded check for a distinct invalid-regex indicator from the ripgrep result
before throwing.
In `@packages/opencode/src/tool/read.ts`:
- Around line 27-34: The Parameters schema currently uses Schema.Number for
offset and limit which allows non-integer or non-positive values; update the
schema to require positive integers (e.g., use a custom integer validator or
Schema.Integer if available) and enforce constraints that offset >= 1 and limit
>= 1 (or apply min value for limit default behavior) so fractional or
zero/negative values are rejected; also add the same validation/guard where
offset/limit are parsed/used (the code around the read handling referenced in
the comment) to validate inputs before slicing, referencing the Parameters
object and the offset/limit fields so invalid values are rejected early.
In `@packages/opencode/src/tool/registry.ts`:
- Around line 145-152: The Schema.declare predicate for parameters currently
only returns a boolean from zodParams.safeParse(u).success, so
Schema.decodeUnknownEffect ends up returning the original input and skips Zod's
parsed.data (defaults, transforms, unknown-key stripping); update the predicate
passed to Schema.declare (the one creating parameters) to call
zodParams.safeParse(u) and, on success, return the parsed.data (not just true)
so the declared Schema preserves Zod's decoded/transformed value, and keep the
annotate({ [ZodOverride]: zodParams }) as-is; ensure this change makes
Schema.decodeUnknownEffect surface the parsed data from zodParams rather than
the raw input.
In `@packages/opencode/src/tool/webfetch.ts`:
- Around line 12-20: The timeout field in Parameters currently allows any
number; update the timeout schema so invalid values fail validation (e.g.,
require timeout > 0 and <= 120, and optionally an integer) instead of accepting
0/negative values. Replace Schema.optional(Schema.Number) for the timeout
property with a constrained number schema (use Schema.Number piped through range
checks such as greater-than 0 and less-than-or-equal-to 120, and/or an integer
check if desired), and keep the existing annotation describing the constraint so
requests with out-of-range timeouts are rejected before decoding/network calls;
reference the Parameters export and its timeout property when making the change.
In `@packages/opencode/src/tool/write.ts`:
- Around line 46-52: The current logic (involving Bom.readFile, Bom.split,
params.content, desiredBom, contentOld/contentNew and createTwoFilesPatch →
trimDiff) allows inserting a BOM from params.content without that byte-level
change appearing in the generated diff; fix by either (A) only ever
preserve/insert the original file's BOM (set desiredBom = source.bom) so BOM
insertion can't sneak in, or (B) explicitly include BOM state in the diff by
computing contentOld/contentNew with the BOM byte when desiredBom differs
(prepend U+FEFF to contentOld/contentNew before calling createTwoFilesPatch)
and/or surface a bomChanged flag in the diff metadata so callers see BOM
changes; update the code around desiredBom, contentOld, contentNew and the
creation of diff/trimDiff accordingly.
In `@packages/opencode/test/file/ripgrep.test.ts`:
- Around line 9-10: Replace the ad-hoc run wrapper with the repo-standard
testEffect harness: remove the local run helper that wraps
Effect.provide(Ripgrep.defaultLayer) and Effect.runPromise, and convert tests
that call run(...) into const it = testEffect(...) style, then append
.live(Ripgrep.defaultLayer) (or .live(...) as appropriate) to each test to
provide Ripgrep.defaultLayer at runtime; target usages of the run function and
references to Ripgrep.Service / Ripgrep.defaultLayer and replace them with
testEffect + .live so the test uses the standard runtime/layer setup instead of
an ad-hoc runtime.
In `@packages/opencode/test/tool/agent-rename.test.ts`:
- Around line 72-74: The test uses toBeUndefined() which can pass if task_id
exists but is set to undefined; update the assertion to check key membership on
Parameters.fields instead: verify that (Parameters.fields as Record<string,
unknown>) does NOT haveOwnProperty("task_id") (or use !("task_id" in
(Parameters.fields as any))) so the test fails if task_id is still present (even
with undefined), while keeping the existing check for subagent_session_id
presence.
---
Outside diff comments:
In `@packages/opencode/src/session/compaction.ts`:
- Around line 236-241: The recent-turn sizing in SessionCompaction.estimate
currently serializes messages with MessageV2.toModelMessagesEffect(...) but does
not apply the same media-stripping and tool-output truncation that
processCompaction() uses, causing overestimates; update estimate() to serialize
messages using the identical transformation used by processCompaction (i.e.,
apply the same media stripping and tool-output truncation logic or call the
shared serializer/transform function before Token.estimate) so select() budgets
match actual compacted size, and apply the same change to the other occurrence
around lines 405–408.
In `@packages/opencode/src/tool/edit.ts`:
- Around line 89-109: The branch handling params.oldString === "" builds diff
from contentNew before running format.file() so the metadata/diff can mismatch
the final on-disk contents; after writing and calling format.file() /
Bom.syncFile() recompute diff = trimDiff(createTwoFilesPatch(filePath, filePath,
contentOld, contentNew)) (using the possibly-updated contentNew from
Bom.syncFile) and update the metadata passed to ctx.ask (and any snapshot/diff
variables) so the permission prompt and saved diff reflect the formatted file.
Refer to symbols: params.oldString, contentOld, contentNew, diff,
createTwoFilesPatch, trimDiff, format.file, Bom.syncFile, ctx.ask, and
afs.writeWithDirs.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: eb3f8658-107b-4b7e-a6b4-31306fc5602d
⛔ Files ignored due to path filters (1)
packages/opencode/test/tool/__snapshots__/parameters.test.ts.snapis excluded by!**/*.snap
📒 Files selected for processing (78)
packages/opencode/src/agent/prompt/compaction.txtpackages/opencode/src/auth/index.tspackages/opencode/src/cli/cmd/debug/file.tspackages/opencode/src/cli/cmd/debug/ripgrep.tspackages/opencode/src/cli/cmd/run.tspackages/opencode/src/config/config.tspackages/opencode/src/effect/app-runtime.tspackages/opencode/src/file/index.tspackages/opencode/src/file/ripgrep.tspackages/opencode/src/filesystem/index.tspackages/opencode/src/format/index.tspackages/opencode/src/mcp/auth.tspackages/opencode/src/mcp/index.tspackages/opencode/src/patch/index.tspackages/opencode/src/project/project.tspackages/opencode/src/project/vcs.tspackages/opencode/src/provider/provider.tspackages/opencode/src/question/index.tspackages/opencode/src/server/instance/file.tspackages/opencode/src/server/instance/question.tspackages/opencode/src/session/compaction.tspackages/opencode/src/session/instruction.tspackages/opencode/src/session/message-v2.tspackages/opencode/src/session/overflow.tspackages/opencode/src/session/prompt.tspackages/opencode/src/skill/discovery.tspackages/opencode/src/skill/index.tspackages/opencode/src/snapshot/index.tspackages/opencode/src/storage/storage.tspackages/opencode/src/tool/agent.tspackages/opencode/src/tool/apply_patch.tspackages/opencode/src/tool/bash.tspackages/opencode/src/tool/codesearch.tspackages/opencode/src/tool/edit.tspackages/opencode/src/tool/external-directory.tspackages/opencode/src/tool/glob.tspackages/opencode/src/tool/grep.tspackages/opencode/src/tool/invalid.tspackages/opencode/src/tool/lsp.tspackages/opencode/src/tool/plan.tspackages/opencode/src/tool/question.tspackages/opencode/src/tool/read.tspackages/opencode/src/tool/registry.tspackages/opencode/src/tool/skill.tspackages/opencode/src/tool/todo.tspackages/opencode/src/tool/tool.tspackages/opencode/src/tool/trash.tspackages/opencode/src/tool/truncate.tspackages/opencode/src/tool/webfetch.tspackages/opencode/src/tool/websearch.tspackages/opencode/src/tool/write.tspackages/opencode/src/util/bom.tspackages/opencode/src/util/effect-zod.tspackages/opencode/src/worktree/index.tspackages/opencode/test/config/config.test.tspackages/opencode/test/config/pawwork-global-config.test.tspackages/opencode/test/file/ripgrep.test.tspackages/opencode/test/filesystem/filesystem.test.tspackages/opencode/test/project/project.test.tspackages/opencode/test/question/question.test.tspackages/opencode/test/question/schema.test.tspackages/opencode/test/session/compaction.test.tspackages/opencode/test/session/prompt-effect.test.tspackages/opencode/test/session/snapshot-tool-race.test.tspackages/opencode/test/storage/storage.test.tspackages/opencode/test/tool/agent-rename.test.tspackages/opencode/test/tool/apply_patch.test.tspackages/opencode/test/tool/bash.test.tspackages/opencode/test/tool/edit.test.tspackages/opencode/test/tool/external-directory.test.tspackages/opencode/test/tool/glob.test.tspackages/opencode/test/tool/grep.test.tspackages/opencode/test/tool/question.test.tspackages/opencode/test/tool/read.test.tspackages/opencode/test/tool/skill.test.tspackages/opencode/test/tool/tool-define.test.tspackages/opencode/test/tool/trash.test.tspackages/opencode/test/tool/write.test.ts
💤 Files with no reviewable changes (3)
- packages/opencode/test/tool/question.test.ts
- packages/opencode/test/session/compaction.test.ts
- packages/opencode/src/filesystem/index.ts
2229f47 to
5781bb7
Compare
Outside-diff comment replies
|
There was a problem hiding this comment.
Actionable comments posted: 7
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (7)
packages/opencode/src/skill/discovery.ts (2)
38-40:⚠️ Potential issue | 🟡 MinorUse graceful fallback for cache
exists()probes instead of hard-failing.In
download(), the early cache check at line 39 usesEffect.orDieonfs.exists(dest), which causes hard failure on transient filesystem errors (permissions, missing parent dirs). This breaks the graceful error handling pattern already applied elsewhere in the function for HTTP and write failures.Other files in the codebase (e.g.,
tool/write.ts,session/instruction.ts,config/config.ts) usefs.existsSafe()instead, which safely treats existence check failures as "file not present". Line 99 in the same file has the same pattern.Replace both occurrences with either
fs.existsSafe()or catch-based fallback to align with the function's overall error tolerance.♻️ Proposed fix
- if (yield* fs.exists(dest).pipe(Effect.orDie)) return true + const exists = yield* fs.exists(dest).pipe( + Effect.catchAll(() => + Effect.sync(() => { + log.warn("failed to probe cache for skill file; will attempt download", { url, dest }) + return false + }) + ) + ) + if (exists) return true🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/opencode/src/skill/discovery.ts` around lines 38 - 40, The early cache existence checks in Discovery.download (the Effect.fn "Discovery.download" generator) currently call fs.exists(dest).pipe(Effect.orDie) which hard-fails on transient FS errors; change both occurrences to use the safe probe (fs.existsSafe(dest)) or an equivalent try/catch fallback that treats errors as "not present" and returns false so the download flow preserves its graceful error handling for HTTP/write failures; update the two spots that call fs.exists(...).pipe(Effect.orDie) to call fs.existsSafe(...) (or wrap fs.exists in an Effect that maps failures to false) and ensure the rest of the generator logic remains unchanged.
55-105:⚠️ Potential issue | 🟡 MinorAdd URL validation to
pull()to prevent SSRF attacks.The
pull()function constructs and fetches URLs from user-configurable sources without validating the destination. Sinceurloriginates fromcfg.skills?.urlsin editable config files, a malicious configuration could exploit this for SSRF attacks (e.g.,http://127.0.0.1:8080/,http://169.254.169.254/).Consider validating:
- Scheme is
http/https- Hostname is not loopback (
127.0.0.1,localhost) or private ranges (10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,169.254.0.0/16)🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/opencode/src/skill/discovery.ts` around lines 55 - 105, The pull() function uses the incoming url directly (variables url, base, host) which allows SSRF; before constructing index or fetching, parse the input with new URL(url) and validate that protocol is http or https, then reject hosts that are loopback or in private/link-local ranges (e.g., 127.0.0.0/8, ::1, localhost, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16) — if the hostname resolves to a private IP also treat as invalid — log a warning via log.warn and return [] (or throw a controlled error) instead of proceeding to build index or calling HttpClientRequest.get; add this validation at the top of Effect.fn("Discovery.pull") so index, host and subsequent downloads never run for disallowed destinations.packages/opencode/src/format/index.ts (1)
120-157:⚠️ Potential issue | 🟡 Minor
ranis reported as true even when formatting fails.
ranis set before checking process exit code, so callers can gettrueafter only failed formatter runs.💡 Proposed fix
let ran = false for (const { item, cmd } of formatters) { - if (cmd === false) continue - ran = true + if (cmd === false) continue log.info("running", { command: cmd }) const replaced = cmd.map((x) => x.replace("$FILE", filepath)) const dir = yield* InstanceState.directory const code = yield* spawner @@ if (code !== 0) { log.error("failed", { command: cmd, ...item.environment, }) + continue } + ran = true } return ran🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/opencode/src/format/index.ts` around lines 120 - 157, The loop currently sets ran = true before checking the formatter process result so failed runs still mark success; change the logic in the for loop that iterates over formatters (the block using formatters, cmd, spawner.spawn and the code variable) to only set ran = true after verifying the spawned process exit code is 0 (i.e., after the code variable is checked and confirmed successful), leaving error logging (log.error) and the existing spawn catch behavior intact so ran reflects at least one successful formatter run.packages/opencode/src/question/index.ts (1)
226-243:⚠️ Potential issue | 🟠 MajorValidate
reply()against the pending question definition.This path currently accepts any
string[][]and resolves the deferred verbatim. A buggy client can therefore submit the wrong answer count, multiple answers to a single-select question, or labels that were never offered, and the waiting tool call will treat that malformed payload as valid.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/opencode/src/question/index.ts` around lines 226 - 243, The reply handler (Effect.fn "Question.reply") must validate incoming answers against the stored pending question before publishing Event.Replied or calling Deferred.succeed: check existing.info.question (or existing.info.options/metadata) to ensure the number of answer rows matches the question's expected answerCount, that single-select questions have at most one selected label, and that every label/value in input.answers exists in the question's defined choices; if validation fails, do NOT call bus.publish(Event.Replied) or Deferred.succeed — instead log.warn with details and call Deferred.fail(existing.deferred, new Error(...)) (or otherwise mark the deferred as rejected) so callers receive an error; only after passing these checks, map answers and proceed with yield* bus.publish(Event.Replied, ...) and yield* Deferred.succeed(existing.deferred, input.answers).packages/opencode/src/tool/bash.ts (1)
596-598:⚠️ Potential issue | 🟡 MinorReject
timeout = 0or document it explicitly.This guard only rejects negatives, but the error text says the timeout must be positive. Right now
0slips through and becomes an effective ~100 ms timeout becauserun()races againstEffect.sleep(input.timeout + 100).🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/opencode/src/tool/bash.ts` around lines 596 - 598, The current guard in the timeout validation only rejects negatives but allows timeout = 0 which becomes an unintended ~100ms timeout when run() races with Effect.sleep(input.timeout + 100); update the check on params.timeout to reject non-positive values (use params.timeout <= 0) and change the thrown message to state "Timeout must be greater than 0" (or alternatively, if zero should be allowed, document that behavior and adjust the race logic in run()/Effect.sleep accordingly); reference params.timeout, run(), and Effect.sleep when making the change.packages/opencode/src/session/compaction.ts (1)
236-241:⚠️ Potential issue | 🟠 MajorEstimate preserved-tail size with the same serialization options used for compaction.
select()budgets turns throughestimate(), butestimate()serializes full tool outputs/media while the real compaction request later uses{ stripMedia: true, toolOutputMaxChars: 2000 }. Large tool results are therefore overestimated and recent turns get dropped even though they would fit.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/opencode/src/session/compaction.ts` around lines 236 - 241, The estimate function (SessionCompaction.estimate) currently serializes full tool outputs via MessageV2.toModelMessagesEffect before Token.estimate, causing overestimates; update the call to MessageV2.toModelMessagesEffect (used inside estimate) to pass the same serialization/compaction options used by the actual compaction logic (e.g., { stripMedia: true, toolOutputMaxChars: 2000 }) so Token.estimate receives the stripped/limited message representation and produces a matching size estimate.packages/opencode/src/tool/edit.ts (1)
89-115:⚠️ Potential issue | 🟠 MajorReject directories before entering the create/overwrite path.
When
oldString === ""andfilePathalready points to a directory,existsSafe()is true and this branch callsBom.readFile(...)instead of returning the explicit"Path is a directory"error used below. Inside thisEffect.orDie()block that turns a user mistake into an internal failure.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/opencode/src/tool/edit.ts` around lines 89 - 115, When params.oldString === "" ensure you check whether filePath is a directory before calling Bom.readFile: use the filesystem helper (e.g., afs.isDirectory or afs.stat) immediately after existsSafe(filePath) and if it is a directory return the same explicit "Path is a directory" error used elsewhere instead of proceeding to Bom.readFile/overwrite; this avoids turning a user mistake into an internal failure in the create/overwrite path (references: params.oldString, afs.existsSafe, afs.isDirectory/afs.stat, Bom.readFile, Bom.join, format.file, Bom.syncFile).
♻️ Duplicate comments (7)
packages/opencode/test/tool/agent-rename.test.ts (1)
72-74:⚠️ Potential issue | 🟡 MinorStrengthen the removed-key assertion to avoid false positives.
toBeUndefined()does not prove the key is absent; it also passes when the key exists with anundefinedvalue. Assert key membership directly.Suggested fix
- expect((Parameters.fields as any).subagent_session_id).toBeDefined() - expect((Parameters.fields as Record<string, unknown>).task_id).toBeUndefined() + expect("subagent_session_id" in (Parameters.fields as Record<string, unknown>)).toBe(true) + expect("task_id" in (Parameters.fields as Record<string, unknown>)).toBe(false)🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/opencode/test/tool/agent-rename.test.ts` around lines 72 - 74, The test currently uses toBeUndefined() to check that task_id was removed which can be a false positive if the key exists with an undefined value; replace that assertion with an explicit key-membership check against Parameters.fields (e.g., use Object.prototype.hasOwnProperty.call(Parameters.fields, 'task_id') and expect that to be false) so you verify the key is absent; locate the assertions referencing Parameters and its fields, in particular the checks for subagent_session_id and task_id, and change the task_id assertion to assert non-membership rather than undefined value.packages/opencode/src/tool/registry.ts (1)
145-152:⚠️ Potential issue | 🟠 MajorPlugin arg wrapper validates but does not preserve Zod-decoded output.
Line 150 only checks
safeParse(...).success, so downstream decode can pass raw input through. Plugin tools then miss Zod parse-time behavior (defaults/transforms/unknown-key stripping) beforedef.execute(...)on Line 165.💡 Proposed fix
const zodParams = z.object(def.args) const parameters = Schema.declare<unknown>((u): u is unknown => zodParams.safeParse(u).success).annotate({ [ZodOverride]: zodParams, }) ... execute: (args, toolCtx) => Effect.gen(function* () { + const parsedArgs = zodParams.parse(args) const pluginCtx: PluginToolContext = { ...toolCtx, ask: (req) => toolCtx.ask(req), directory: ctx.directory, worktree: ctx.worktree, } - const result = yield* Effect.promise(() => def.execute(args as any, pluginCtx)) + const result = yield* Effect.promise(() => def.execute(parsedArgs as any, pluginCtx))Also applies to: 165-165
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/opencode/src/tool/registry.ts` around lines 145 - 152, The current wrapper only checks zodParams.safeParse(...).success so parsed transforms/defaults are lost; modify the wrapper around zodParams and the call site before def.execute to actually parse and pass the Zod-decoded value: have Schema.declare use zodParams.safeParse to both validate and obtain the parsed value (or call zodParams.parse) and ensure the value passed into def.execute is the parsed/normalized result (keep the ZodOverride annotation on parameters with zodParams). Update references to zodParams/parameters and the def.execute invocation to use the decoded output instead of the raw input.packages/opencode/src/tool/write.ts (1)
48-53:⚠️ Potential issue | 🟠 MajorBOM byte changes can bypass the visible diff.
Line [48] can enable BOM in the written file (
desiredBom), but Line [52] computes diff from BOM-stripped text only. That makes a real byte-level file change invisible in permission metadata.🔧 Proposed fix (surface BOM transitions in the diff)
const desiredBom = source.bom || next.bom const contentOld = source.text const contentNew = next.text - - const diff = trimDiff(createTwoFilesPatch(filepath, filepath, contentOld, contentNew)) + const diffOld = Bom.join(contentOld, source.bom) + const diffNew = Bom.join(contentNew, desiredBom) + const diff = trimDiff(createTwoFilesPatch(filepath, filepath, diffOld, diffNew))🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/opencode/src/tool/write.ts` around lines 48 - 53, The current diff uses BOM-stripped text (contentOld/contentNew) so a change in desiredBom (variable desiredBom) can be missed; update the diff generation before calling createTwoFilesPatch/trimDiff to account for BOM transitions by constructing the compared strings with the BOM when present (e.g., prefix contentOld/contentNew with the BOM char when source.bom/next.bom differ from false) or otherwise inject a small visible marker when desiredBom !== source.bom; ensure the code paths around createTwoFilesPatch(filepath, filepath, contentOld, contentNew) and the ctx.ask call reflect that BOM-aware content so byte-level BOM changes are surfaced in the produced diff.packages/opencode/src/session/message-v2.ts (1)
22-25:⚠️ Potential issue | 🟠 MajorReserve room for the truncation suffix.
This keeps
maxCharscharacters and then appends the omission note, so the final string can still exceed the compaction budget you were trying to enforce.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/opencode/src/session/message-v2.ts` around lines 22 - 25, The truncateToolOutput function currently keeps maxChars from text then appends the omission suffix, which can exceed the intended compaction budget; update truncateToolOutput so it first computes the omissionSuffix string and its length, then if maxChars is provided compute allowedTextLen = Math.max(0, maxChars - omissionSuffix.length) and slice text to allowedTextLen before appending the omissionSuffix (handle the case where maxChars <= omissionSuffix.length by returning only the suffix or an appropriately short value). Ensure you still compute omitted correctly based on the original text length and reference the function name truncateToolOutput and the omitted/suffix logic when making changes.packages/opencode/src/tool/grep.ts (1)
74-79:⚠️ Potential issue | 🟠 MajorDon't treat every empty partial result as an invalid regex.
result.partialis also the signal used later for inaccessible/skipped paths. With this branch, a search that hits unreadable paths and finds no matches now throwslikely invalid patterninstead of returning the normal empty/partial result.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/opencode/src/tool/grep.ts` around lines 74 - 79, The current branch throws on any empty partial result (using result.partial and params.pattern) which wrongly treats inaccessible/skipped-path partials as an invalid-regex; change the condition so we only throw when ripgrep explicitly indicates an invalid pattern (e.g., an exit code of 2 or an error message from the ripgrep output that names an invalid regex), otherwise return the normal empty/partial result. Update the check around result.partial && result.items.length === 0 to inspect the ripgrep-specific error/exit metadata (rather than blanket partial) in the grep/search handling code so skipped/unreadable-path partials still return empty without throwing.packages/opencode/src/tool/bash.ts (1)
584-589:⚠️ Potential issue | 🟡 MinorAdvertise the configured truncation limits.
run()now honorsyield* trunc.limits(), but the description still interpolatesTruncate.MAX_LINES/Truncate.MAX_BYTES. Whentool_output.max_linesortool_output.max_bytesis configured, the model is told the wrong output window.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/opencode/src/tool/bash.ts` around lines 584 - 589, The description string is using the static Truncate.MAX_LINES and Truncate.MAX_BYTES constants, but run() actually honors the configured truncation via trunc.limits(); update the interpolation so the description reports the active limits (call or use the result of trunc.limits() / yield* trunc.limits() used by run()) instead of Truncate.MAX_LINES / Truncate.MAX_BYTES. Locate the code that constructs description (uses DESCRIPTION.replaceAll(...)) in bash.ts and replace references to Truncate.MAX_LINES and Truncate.MAX_BYTES with the actual maxLines/maxBytes values returned by trunc.limits() (or the variable the run() flow already obtains) so the advertised output window matches the configured tool_output limits.packages/opencode/src/session/compaction.ts (1)
167-179:⚠️ Potential issue | 🟠 MajorKeep the retained tail aligned to a turn boundary.
splitTurn()can return any message after the opening user prompt, so the preserved tail may start with an assistant/tool message whose initiating user request was compacted away.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/opencode/src/session/compaction.ts` around lines 167 - 179, The loop may pick a start index that points into the middle of a turn (e.g., an assistant/tool message) so the preserved tail can lose its initiating user prompt; instead only consider starts that are turn boundaries (user message boundaries). Change the selection so for each candidate start you advance it to the next turn-start/user-message (use the existing splitTurn or check message.role === 'user' on input.messages) before calling input.estimate and before returning; ensure you return that adjusted start and its id from input.messages so the retained tail always begins at a user/turn boundary.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@packages/opencode/src/question/index.ts`:
- Around line 54-61: The options array validation must reject duplicate
option.label values (after trimming) so serialized replies aren’t ambiguous;
update the Schema.mutable(Schema.Array(Option)) validation for options to add a
check that maps each option's label (e.g., Option.label) through trim(), ensures
no empty labels, and that the set size equals the array length (reject
duplicates), returning a clear error message like "Option labels must be unique
(after trimming)". Implement this as an additional .check(...) on the same
options schema, referencing the options array and Option.label for
identification.
In `@packages/opencode/src/session/overflow.ts`:
- Around line 17-18: The code uses a fallback with || when computing cap which
treats 0 as absent; in the function in overflow.ts replace the fallback
expression that sets cap (currently using input.model.limit.input || context)
with nullish coalescing so that explicit 0 is preserved (use
input.model.limit.input ?? context), keeping the rest of the calculation
(Math.max(0, cap - reserved)) unchanged; target the cap binding and the use of
input.model.limit.input, context, and reserved when making the change.
In `@packages/opencode/src/tool/tool.ts`:
- Around line 34-42: The Def interface currently loses the tool-specific
metadata generic because execute(args, ctx: Context) does not carry M; change
the execute signature to execute(args: Schema.Schema.Type<Parameters>, ctx:
Context<M>) so the Context preserves the M generic, and make the Context type
generic (e.g., Context<M extends Metadata = Metadata>) with its metadata(...)
method typed to accept M; update any callers/implementations of Def.execute and
the Context definition to use the new generic to ensure the ExecuteResult<M> and
ctx.metadata types are checked against the same M.
- Around line 8-10: The Metadata type currently leaks `any` via the index
signature; change the declaration for Metadata to use unknown instead (e.g.,
make it a Record<string, unknown> or an interface extending Record<string,
unknown>) so consumers don't get implicit anys, and update any places that rely
on framework-owned fields (e.g., `truncated`) to explicitly narrow or assert
those fields where they are consumed rather than widening Metadata itself;
locate the Metadata symbol in tool.ts and replace the `[key: string]: any`
signature accordingly and add explicit type guards/casts at call sites that need
framework-owned keys.
- Around line 77-128: The wrap helper currently builds effects with Effect.gen;
change its constructor to Effect.fnUntraced to match tracing conventions
(replace the Effect.gen wrapper around the closure returned by wrap and ensure
any inner .pipe() operators are passed as extra arguments to Effect.fnUntraced
rather than using outer .pipe()). Also update the exported define and init
functions to use Effect.fn("Tool.define") and Effect.fn("Tool.init")
respectively (give them the named tracing span and move any pipeable operators
into their Effect.fn call), keeping the same behavior for decode, execute,
truncate, and agents usage; reference the wrap function and the exported
define/init functions when locating the changes.
In `@packages/opencode/test/file/ripgrep.test.ts`:
- Around line 172-175: The test hardcodes "/tmp/nonexistent-dir-12345"; replace
that with a platform-neutral temporary path by calling the tmpdir fixture to get
a fresh temp directory and constructing a guaranteed-nonexistent child path
(e.g., path.join(tmpdir(), "nonexistent-"+Date.now() or random suffix)) and pass
that to Ripgrep.Service.use -> rg.files({ cwd: ... }).keep using
Stream.runCollect and Effect.provide(Ripgrep.defaultLayer) /
Effect.runPromiseExit so the test is deterministic and cross-platform; use the
tmpdir helper from fixture/fixture.ts to obtain the base temp directory.
In `@packages/opencode/test/tool/tool-define.test.ts`:
- Around line 2-9: Replace the ad-hoc ManagedRuntime.make usage with the repo
test harness: remove ManagedRuntime.make(...) and instead wrap the test(s) with
testEffect(...) (import testEffect from "test/lib/effect.ts") and pass the
merged layers (Truncate.defaultLayer and Agent.defaultLayer) as the
environment/layers for testEffect; update any direct uses of the runtime
variable to rely on the Effect test callback provided by testEffect and keep
references to the same Layer.mergeAll(Truncate.defaultLayer, Agent.defaultLayer)
so the Agent and Truncate services are available to the tests.
---
Outside diff comments:
In `@packages/opencode/src/format/index.ts`:
- Around line 120-157: The loop currently sets ran = true before checking the
formatter process result so failed runs still mark success; change the logic in
the for loop that iterates over formatters (the block using formatters, cmd,
spawner.spawn and the code variable) to only set ran = true after verifying the
spawned process exit code is 0 (i.e., after the code variable is checked and
confirmed successful), leaving error logging (log.error) and the existing spawn
catch behavior intact so ran reflects at least one successful formatter run.
In `@packages/opencode/src/question/index.ts`:
- Around line 226-243: The reply handler (Effect.fn "Question.reply") must
validate incoming answers against the stored pending question before publishing
Event.Replied or calling Deferred.succeed: check existing.info.question (or
existing.info.options/metadata) to ensure the number of answer rows matches the
question's expected answerCount, that single-select questions have at most one
selected label, and that every label/value in input.answers exists in the
question's defined choices; if validation fails, do NOT call
bus.publish(Event.Replied) or Deferred.succeed — instead log.warn with details
and call Deferred.fail(existing.deferred, new Error(...)) (or otherwise mark the
deferred as rejected) so callers receive an error; only after passing these
checks, map answers and proceed with yield* bus.publish(Event.Replied, ...) and
yield* Deferred.succeed(existing.deferred, input.answers).
In `@packages/opencode/src/session/compaction.ts`:
- Around line 236-241: The estimate function (SessionCompaction.estimate)
currently serializes full tool outputs via MessageV2.toModelMessagesEffect
before Token.estimate, causing overestimates; update the call to
MessageV2.toModelMessagesEffect (used inside estimate) to pass the same
serialization/compaction options used by the actual compaction logic (e.g., {
stripMedia: true, toolOutputMaxChars: 2000 }) so Token.estimate receives the
stripped/limited message representation and produces a matching size estimate.
In `@packages/opencode/src/skill/discovery.ts`:
- Around line 38-40: The early cache existence checks in Discovery.download (the
Effect.fn "Discovery.download" generator) currently call
fs.exists(dest).pipe(Effect.orDie) which hard-fails on transient FS errors;
change both occurrences to use the safe probe (fs.existsSafe(dest)) or an
equivalent try/catch fallback that treats errors as "not present" and returns
false so the download flow preserves its graceful error handling for HTTP/write
failures; update the two spots that call fs.exists(...).pipe(Effect.orDie) to
call fs.existsSafe(...) (or wrap fs.exists in an Effect that maps failures to
false) and ensure the rest of the generator logic remains unchanged.
- Around line 55-105: The pull() function uses the incoming url directly
(variables url, base, host) which allows SSRF; before constructing index or
fetching, parse the input with new URL(url) and validate that protocol is http
or https, then reject hosts that are loopback or in private/link-local ranges
(e.g., 127.0.0.0/8, ::1, localhost, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16,
169.254.0.0/16) — if the hostname resolves to a private IP also treat as invalid
— log a warning via log.warn and return [] (or throw a controlled error) instead
of proceeding to build index or calling HttpClientRequest.get; add this
validation at the top of Effect.fn("Discovery.pull") so index, host and
subsequent downloads never run for disallowed destinations.
In `@packages/opencode/src/tool/bash.ts`:
- Around line 596-598: The current guard in the timeout validation only rejects
negatives but allows timeout = 0 which becomes an unintended ~100ms timeout when
run() races with Effect.sleep(input.timeout + 100); update the check on
params.timeout to reject non-positive values (use params.timeout <= 0) and
change the thrown message to state "Timeout must be greater than 0" (or
alternatively, if zero should be allowed, document that behavior and adjust the
race logic in run()/Effect.sleep accordingly); reference params.timeout, run(),
and Effect.sleep when making the change.
In `@packages/opencode/src/tool/edit.ts`:
- Around line 89-115: When params.oldString === "" ensure you check whether
filePath is a directory before calling Bom.readFile: use the filesystem helper
(e.g., afs.isDirectory or afs.stat) immediately after existsSafe(filePath) and
if it is a directory return the same explicit "Path is a directory" error used
elsewhere instead of proceeding to Bom.readFile/overwrite; this avoids turning a
user mistake into an internal failure in the create/overwrite path (references:
params.oldString, afs.existsSafe, afs.isDirectory/afs.stat, Bom.readFile,
Bom.join, format.file, Bom.syncFile).
---
Duplicate comments:
In `@packages/opencode/src/session/compaction.ts`:
- Around line 167-179: The loop may pick a start index that points into the
middle of a turn (e.g., an assistant/tool message) so the preserved tail can
lose its initiating user prompt; instead only consider starts that are turn
boundaries (user message boundaries). Change the selection so for each candidate
start you advance it to the next turn-start/user-message (use the existing
splitTurn or check message.role === 'user' on input.messages) before calling
input.estimate and before returning; ensure you return that adjusted start and
its id from input.messages so the retained tail always begins at a user/turn
boundary.
In `@packages/opencode/src/session/message-v2.ts`:
- Around line 22-25: The truncateToolOutput function currently keeps maxChars
from text then appends the omission suffix, which can exceed the intended
compaction budget; update truncateToolOutput so it first computes the
omissionSuffix string and its length, then if maxChars is provided compute
allowedTextLen = Math.max(0, maxChars - omissionSuffix.length) and slice text to
allowedTextLen before appending the omissionSuffix (handle the case where
maxChars <= omissionSuffix.length by returning only the suffix or an
appropriately short value). Ensure you still compute omitted correctly based on
the original text length and reference the function name truncateToolOutput and
the omitted/suffix logic when making changes.
In `@packages/opencode/src/tool/bash.ts`:
- Around line 584-589: The description string is using the static
Truncate.MAX_LINES and Truncate.MAX_BYTES constants, but run() actually honors
the configured truncation via trunc.limits(); update the interpolation so the
description reports the active limits (call or use the result of trunc.limits()
/ yield* trunc.limits() used by run()) instead of Truncate.MAX_LINES /
Truncate.MAX_BYTES. Locate the code that constructs description (uses
DESCRIPTION.replaceAll(...)) in bash.ts and replace references to
Truncate.MAX_LINES and Truncate.MAX_BYTES with the actual maxLines/maxBytes
values returned by trunc.limits() (or the variable the run() flow already
obtains) so the advertised output window matches the configured tool_output
limits.
In `@packages/opencode/src/tool/grep.ts`:
- Around line 74-79: The current branch throws on any empty partial result
(using result.partial and params.pattern) which wrongly treats
inaccessible/skipped-path partials as an invalid-regex; change the condition so
we only throw when ripgrep explicitly indicates an invalid pattern (e.g., an
exit code of 2 or an error message from the ripgrep output that names an invalid
regex), otherwise return the normal empty/partial result. Update the check
around result.partial && result.items.length === 0 to inspect the
ripgrep-specific error/exit metadata (rather than blanket partial) in the
grep/search handling code so skipped/unreadable-path partials still return empty
without throwing.
In `@packages/opencode/src/tool/registry.ts`:
- Around line 145-152: The current wrapper only checks
zodParams.safeParse(...).success so parsed transforms/defaults are lost; modify
the wrapper around zodParams and the call site before def.execute to actually
parse and pass the Zod-decoded value: have Schema.declare use
zodParams.safeParse to both validate and obtain the parsed value (or call
zodParams.parse) and ensure the value passed into def.execute is the
parsed/normalized result (keep the ZodOverride annotation on parameters with
zodParams). Update references to zodParams/parameters and the def.execute
invocation to use the decoded output instead of the raw input.
In `@packages/opencode/src/tool/write.ts`:
- Around line 48-53: The current diff uses BOM-stripped text
(contentOld/contentNew) so a change in desiredBom (variable desiredBom) can be
missed; update the diff generation before calling createTwoFilesPatch/trimDiff
to account for BOM transitions by constructing the compared strings with the BOM
when present (e.g., prefix contentOld/contentNew with the BOM char when
source.bom/next.bom differ from false) or otherwise inject a small visible
marker when desiredBom !== source.bom; ensure the code paths around
createTwoFilesPatch(filepath, filepath, contentOld, contentNew) and the ctx.ask
call reflect that BOM-aware content so byte-level BOM changes are surfaced in
the produced diff.
In `@packages/opencode/test/tool/agent-rename.test.ts`:
- Around line 72-74: The test currently uses toBeUndefined() to check that
task_id was removed which can be a false positive if the key exists with an
undefined value; replace that assertion with an explicit key-membership check
against Parameters.fields (e.g., use
Object.prototype.hasOwnProperty.call(Parameters.fields, 'task_id') and expect
that to be false) so you verify the key is absent; locate the assertions
referencing Parameters and its fields, in particular the checks for
subagent_session_id and task_id, and change the task_id assertion to assert
non-membership rather than undefined value.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: 7bc15788-80f6-4ab0-9134-3a5a737ab91e
⛔ Files ignored due to path filters (1)
packages/opencode/test/tool/__snapshots__/parameters.test.ts.snapis excluded by!**/*.snap
📒 Files selected for processing (78)
packages/opencode/src/agent/prompt/compaction.txtpackages/opencode/src/auth/index.tspackages/opencode/src/cli/cmd/debug/file.tspackages/opencode/src/cli/cmd/debug/ripgrep.tspackages/opencode/src/cli/cmd/run.tspackages/opencode/src/config/config.tspackages/opencode/src/effect/app-runtime.tspackages/opencode/src/file/index.tspackages/opencode/src/file/ripgrep.tspackages/opencode/src/filesystem/index.tspackages/opencode/src/format/index.tspackages/opencode/src/mcp/auth.tspackages/opencode/src/mcp/index.tspackages/opencode/src/patch/index.tspackages/opencode/src/project/project.tspackages/opencode/src/project/vcs.tspackages/opencode/src/provider/provider.tspackages/opencode/src/question/index.tspackages/opencode/src/server/instance/file.tspackages/opencode/src/server/instance/question.tspackages/opencode/src/session/compaction.tspackages/opencode/src/session/instruction.tspackages/opencode/src/session/message-v2.tspackages/opencode/src/session/overflow.tspackages/opencode/src/session/prompt.tspackages/opencode/src/skill/discovery.tspackages/opencode/src/skill/index.tspackages/opencode/src/snapshot/index.tspackages/opencode/src/storage/storage.tspackages/opencode/src/tool/agent.tspackages/opencode/src/tool/apply_patch.tspackages/opencode/src/tool/bash.tspackages/opencode/src/tool/codesearch.tspackages/opencode/src/tool/edit.tspackages/opencode/src/tool/external-directory.tspackages/opencode/src/tool/glob.tspackages/opencode/src/tool/grep.tspackages/opencode/src/tool/invalid.tspackages/opencode/src/tool/lsp.tspackages/opencode/src/tool/plan.tspackages/opencode/src/tool/question.tspackages/opencode/src/tool/read.tspackages/opencode/src/tool/registry.tspackages/opencode/src/tool/skill.tspackages/opencode/src/tool/todo.tspackages/opencode/src/tool/tool.tspackages/opencode/src/tool/trash.tspackages/opencode/src/tool/truncate.tspackages/opencode/src/tool/webfetch.tspackages/opencode/src/tool/websearch.tspackages/opencode/src/tool/write.tspackages/opencode/src/util/bom.tspackages/opencode/src/util/effect-zod.tspackages/opencode/src/worktree/index.tspackages/opencode/test/config/config.test.tspackages/opencode/test/config/pawwork-global-config.test.tspackages/opencode/test/file/ripgrep.test.tspackages/opencode/test/filesystem/filesystem.test.tspackages/opencode/test/project/project.test.tspackages/opencode/test/question/question.test.tspackages/opencode/test/question/schema.test.tspackages/opencode/test/session/compaction.test.tspackages/opencode/test/session/prompt-effect.test.tspackages/opencode/test/session/snapshot-tool-race.test.tspackages/opencode/test/storage/storage.test.tspackages/opencode/test/tool/agent-rename.test.tspackages/opencode/test/tool/apply_patch.test.tspackages/opencode/test/tool/bash.test.tspackages/opencode/test/tool/edit.test.tspackages/opencode/test/tool/external-directory.test.tspackages/opencode/test/tool/glob.test.tspackages/opencode/test/tool/grep.test.tspackages/opencode/test/tool/question.test.tspackages/opencode/test/tool/read.test.tspackages/opencode/test/tool/skill.test.tspackages/opencode/test/tool/tool-define.test.tspackages/opencode/test/tool/trash.test.tspackages/opencode/test/tool/write.test.ts
💤 Files with no reviewable changes (3)
- packages/opencode/test/session/compaction.test.ts
- packages/opencode/test/tool/question.test.ts
- packages/opencode/src/filesystem/index.ts
Astro-Han
left a comment
There was a problem hiding this comment.
Focused review for edge cases and regressions. I am adding one non-duplicate inline finding; several other concrete issues are already covered by existing review comments.
5781bb7 to
e8215a3
Compare
Outside-diff comment replies (round 2)Same disposition as the inline replies — this PR is
Bash timeout is the only one fixed in this round because it's a 1-character change ( |
There was a problem hiding this comment.
Actionable comments posted: 9
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
packages/opencode/src/tool/write.ts (1)
52-66:⚠️ Potential issue | 🟠 MajorFormatting can make the approved diff stale.
The permission prompt is built from
contentOld/contentNew, butformat.file(filepath)rewrites the file afterward andBom.syncFile()can rewrite it again. That means the user can approve one diff while the tool persists different bytes. Please base the prompt on the post-format content instead, or refresh the diff after formatting before this write is finalized.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/opencode/src/tool/write.ts` around lines 52 - 66, The prompt currently builds the diff from contentOld/contentNew before formatting and BOM sync, so users may approve stale bytes; change the flow so the diff shown to ctx.ask reflects the final bytes that will be written: either run the formatter on contentNew in-memory (or write+format+read back) and apply Bom.syncFile to that formatted content, then compute trimDiff(createTwoFilesPatch(...)) from contentOld and the post-format/post-BOM content; update the use of trimDiff/createTwoFilesPatch and the metadata.diff passed to ctx.ask accordingly, and only then call fs.writeWithDirs and the existing format.file/Bom.syncFile steps (or skip repeated rewrites if you already produced the final bytes in memory).
♻️ Duplicate comments (1)
packages/opencode/src/tool/tool.ts (1)
77-128: 🛠️ Refactor suggestion | 🟠 MajorUse
Effect.fn*for these tool constructors.
wrapis the internal helper here, anddefine/initare exported effect constructors, but all three still build rawEffect.gen(...)values. Please align them with the repo convention:Effect.fnUntracedforwrap, namedEffect.fn(...)fordefineandinit.As per coding guidelines, "Use
Effect.fn("Domain.method")for named/traced effects andEffect.fnUntracedfor internal helpers; these accept pipeable operators as extra arguments to avoid unnecessary outer.pipe()wrappers".Also applies to: 130-160
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/opencode/src/tool/tool.ts` around lines 77 - 128, Replace the raw Effect.gen usages with the repo-standard Effect.fn* variants: change wrap to return Effect.fnUntraced(...) instead of Effect.gen, and change the exported constructors define and init to use Effect.fn("Domain.define") / Effect.fn("Domain.init") respectively; inside wrap, remove the .pipe(...) call patterns (e.g., decode(args).pipe(Effect.mapError(...)) and the final .pipe(Effect.orDie, Effect.withSpan(...))) and instead pass those pipeable operators as extra arguments to Effect.fnUntraced so the operators run inside the effect (preserve the computed attrs for withSpan), keeping the same error-mapping and span attributes behavior and leaving function names/identifiers (wrap, define, init, toolInfo.execute, decode) intact.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@packages/opencode/src/file/ripgrep.ts`:
- Around line 303-320: The code downloads ripgrep into archive and immediately
writes and extracts it without integrity checks; update the download flow around
HttpClientRequest.get / bytes / archive / extract so you verify a pinned
checksum or signature for the downloaded bytes before calling fs.writeWithDirs
or extract: fetch or embed the expected checksum (e.g., alongside url or from a
trusted source), compute a secure hash (SHA-256) of bytes, compare it to the
expected value, and if it does not match fail the Effect with a clear error (do
not write or extract); only proceed to fs.writeWithDirs(archive, ...) and
extract(archive, config, target) after the checksum/signature verification
passes.
- Around line 326-335: The check() helper currently uses Effect.orDie on
fs.isDir(cwd), which turns real filesystem errors into defects; change check()
so it does not call orDie: call yield* fs.isDir(cwd) directly and if it returns
true return, otherwise yield* Effect.fail(...) to synthesize the ENOENT Error;
let any thrown filesystem errors from fs.isDir propagate normally instead of
being converted to defects (reference the check function and fs.isDir call to
locate the change).
In `@packages/opencode/src/format/index.ts`:
- Around line 116-123: The loop over formatters contains a redundant guard "if
(cmd === false) continue" because formatters is produced from getFormatter/get
checks already filtered for truthy cmd; remove that conditional from the for
(const { item, cmd } of formatters) loop (in the function where formatters is
set and iterated) so the loop only sets ran = true and executes formatting logic
for each entry; ensure no other code relies on cmd being false inside that loop.
In `@packages/opencode/src/session/compaction.ts`:
- Around line 159-181: The helper splitTurn currently returns a raw Effect.gen;
wrap it with Effect.fnUntraced so it follows tracing conventions and can accept
pipeable operators. Replace the current return of Effect.gen(...) in function
splitTurn with return Effect.fnUntraced(() => Effect.gen(function* () { ... }))
keeping the same generator body and the Tail/return shape (including references
to input.estimate, input.messages, input.turn, and budget) so behavior is
unchanged but the effect is untraced and pipe-friendly.
In `@packages/opencode/src/tool/grep.ts`:
- Around line 31-32: The execute function is hardcoded to an old arg shape
instead of deriving its type from the Parameters schema; update the execute
parameter typing so it uses the schema's Type (e.g. Schema.Schema.Type<typeof
Parameters> or a local alias like ParametersType) rather than the inline {
pattern: string; path?: string; include?: string } object, and ensure any needed
Schema import is present so execute(params: ParametersType, ctx: Tool.Context)
stays in sync with the Parameters schema (refer to Parameters and execute in
Tool.define).
In `@packages/opencode/src/tool/lsp.ts`:
- Around line 42-45: The execute handler's arg type is manually declared and can
drift from the runtime-decoded shape; replace the ad-hoc args type with the
schema-derived type to keep it in sync by using Schema.Schema.Type<typeof
Parameters> for the input parameter (i.e. change the first parameter of execute
from the current object shape to Schema.Schema.Type<typeof Parameters>), keeping
the ctx parameter as Tool.Context and leaving references to
operations/filePath/line/character intact so the implementation compiles against
the decoded schema.
In `@packages/opencode/test/file/ripgrep.test.ts`:
- Around line 83-97: The test "search returns matched rows with glob filter"
currently writes "needle" only to match.ts so it still passes if globbing is
ignored; change the setup to also write "needle" into the non-*.ts file (the
Bun.write call that creates "skip.txt"), then keep using Ripgrep.Service.use(rg
=> rg.search({ cwd: tmp.path, pattern: "needle", glob: ["*.ts"] })) and add an
explicit assertion that the skipped file is not returned (e.g., assert
result.items length is 1 and that none of result.items paths contain "skip.txt")
so the test will fail if the glob filter is ignored.
- Around line 179-212: The worker-mode test is a duplicate of the direct-mode
test; change the "ignores RIPGREP_CONFIG_PATH in worker mode" case to actually
exercise the worker-backed service instead of the direct service by invoking the
worker-specific entrypoint rather than Ripgrep.Service.use (which the first test
already covers). Locate the test that calls Ripgrep.Service.use and replace the
call with the service's worker API (for example Ripgrep.Service.useWorker,
Ripgrep.Service.spawnWorker, or passing a { worker: true } / { mode: "worker" }
option to the service/run helper depending on the codebase) so that rg.search is
executed through a real worker path, then keep the same setup and assertions
(expect(result.items).toHaveLength(1)).
In `@packages/opencode/test/tool/grep.test.ts`:
- Around line 16-22: Replace the manual ManagedRuntime setup that uses
Layer.mergeAll(CrossSpawnSpawner.defaultLayer, AppFileSystem.defaultLayer,
Truncate.defaultLayer, Agent.defaultLayer, Ripgrep.defaultLayer) with the
testEffect harness: create const it = testEffect(Layer.mergeAll(...)), then
convert each test to it.live("name", () => provideTmpdirInstance((dir) =>
Effect.gen(function*() { ... }))) and inside the generator yield services
directly via yield* Service.<serviceName> (matching existing usage for
CrossSpawnSpawner, AppFileSystem, Truncate, Agent, Ripgrep); ensure tests use
the fixture helpers and Effect.gen pattern instead of ManagedRuntime.
---
Outside diff comments:
In `@packages/opencode/src/tool/write.ts`:
- Around line 52-66: The prompt currently builds the diff from
contentOld/contentNew before formatting and BOM sync, so users may approve stale
bytes; change the flow so the diff shown to ctx.ask reflects the final bytes
that will be written: either run the formatter on contentNew in-memory (or
write+format+read back) and apply Bom.syncFile to that formatted content, then
compute trimDiff(createTwoFilesPatch(...)) from contentOld and the
post-format/post-BOM content; update the use of trimDiff/createTwoFilesPatch and
the metadata.diff passed to ctx.ask accordingly, and only then call
fs.writeWithDirs and the existing format.file/Bom.syncFile steps (or skip
repeated rewrites if you already produced the final bytes in memory).
---
Duplicate comments:
In `@packages/opencode/src/tool/tool.ts`:
- Around line 77-128: Replace the raw Effect.gen usages with the repo-standard
Effect.fn* variants: change wrap to return Effect.fnUntraced(...) instead of
Effect.gen, and change the exported constructors define and init to use
Effect.fn("Domain.define") / Effect.fn("Domain.init") respectively; inside wrap,
remove the .pipe(...) call patterns (e.g.,
decode(args).pipe(Effect.mapError(...)) and the final .pipe(Effect.orDie,
Effect.withSpan(...))) and instead pass those pipeable operators as extra
arguments to Effect.fnUntraced so the operators run inside the effect (preserve
the computed attrs for withSpan), keeping the same error-mapping and span
attributes behavior and leaving function names/identifiers (wrap, define, init,
toolInfo.execute, decode) intact.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: 1ca020ab-dc45-4047-b883-0ef8c7c2ee6d
📒 Files selected for processing (34)
packages/opencode/src/agent/prompt/compaction.txtpackages/opencode/src/cli/cmd/debug/file.tspackages/opencode/src/cli/cmd/debug/ripgrep.tspackages/opencode/src/config/config.tspackages/opencode/src/file/index.tspackages/opencode/src/file/ripgrep.tspackages/opencode/src/format/index.tspackages/opencode/src/patch/index.tspackages/opencode/src/question/index.tspackages/opencode/src/server/instance/file.tspackages/opencode/src/server/instance/question.tspackages/opencode/src/session/compaction.tspackages/opencode/src/session/message-v2.tspackages/opencode/src/session/overflow.tspackages/opencode/src/tool/apply_patch.tspackages/opencode/src/tool/bash.tspackages/opencode/src/tool/edit.tspackages/opencode/src/tool/glob.tspackages/opencode/src/tool/grep.tspackages/opencode/src/tool/lsp.tspackages/opencode/src/tool/question.tspackages/opencode/src/tool/read.tspackages/opencode/src/tool/registry.tspackages/opencode/src/tool/skill.tspackages/opencode/src/tool/todo.tspackages/opencode/src/tool/tool.tspackages/opencode/src/tool/truncate.tspackages/opencode/src/tool/webfetch.tspackages/opencode/src/tool/write.tspackages/opencode/test/file/ripgrep.test.tspackages/opencode/test/question/question.test.tspackages/opencode/test/question/schema.test.tspackages/opencode/test/session/compaction.test.tspackages/opencode/test/tool/grep.test.ts
💤 Files with no reviewable changes (1)
- packages/opencode/test/session/compaction.test.ts
e8215a3 to
19ba464
Compare
Re-evaluated per P0/P1/P2/P3 gradingAfter re-grading the deferrals against severity (must-fix on P0/P1; complexity-gated on P2/P3), 16 of the 22 previously-deferred items are now fixed in Fixed in this PR (16)
Genuinely deferred to follow-up (6)
Force-pushed |
There was a problem hiding this comment.
Actionable comments posted: 6
♻️ Duplicate comments (10)
packages/opencode/src/question/index.ts (1)
204-227:⚠️ Potential issue | 🟠 MajorNormalize option labels before checking uniqueness.
TrimmedStringtrims labels duringRequestdecoding, but this guard runs against the rawinput.questions."A"and" A "pass here, then collapse to the same stored label ininfo.questions, which reintroduces ambiguous reply mapping.♻️ Suggested fix
- // Replies are mapped back by label string, so duplicate labels within - // a question would make answers ambiguous. Reject before publishing. - for (const q of input.questions) { + const info = Schema.decodeUnknownSync(Request)({ + id, + sessionID: input.sessionID, + questions: input.questions, + tool: input.tool, + }) + + // Replies are mapped back by label string, so duplicate labels within + // a question would make answers ambiguous. Reject after normalization. + for (const q of info.questions) { const labels = q.options.map((o) => o.label) if (new Set(labels).size !== labels.length) { return yield* Effect.die( new Error( `Question "${q.question}" has duplicate option labels (${labels.join(", ")}). Labels must be unique within a question.`, ), ) } } - - const pending = (yield* InstanceState.get(state)).pending - const id = QuestionID.ascending() + + const pending = (yield* InstanceState.get(state)).pending log.info("asking", { id, questions: input.questions.length }) const deferred = yield* Deferred.make<ReadonlyArray<Answer>, RejectedError>() - const info = Schema.decodeUnknownSync(Request)({ - id, - sessionID: input.sessionID, - questions: input.questions, - tool: input.tool, - }) pending.set(id, { info, deferred })🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/opencode/src/question/index.ts` around lines 204 - 227, The uniqueness check is running against raw input.questions so labels like "A" and " A " bypass it but later collapse when decoding with Schema.decodeUnknownSync(Request); update the pre-publish guard to normalize labels the same way Request decoding does (e.g., trim using the same TrimmedString logic) before creating the Set: in the loop over input.questions compute const labels = q.options.map(o => normalizeLabel(o.label)) and use new Set(labels).size !== labels.length to detect duplicates, and include the normalized labels in the thrown Error message; keep the rest of the flow (Schema.decodeUnknownSync(Request), QuestionID.ascending, Deferred.make, InstanceState.get) unchanged.packages/opencode/src/tool/lsp.ts (1)
42-45: 🧹 Nitpick | 🔵 TrivialUse schema-derived type for
executeto prevent drift.The
executeparameter still uses an inline type rather thanSchema.Schema.Type<typeof Parameters>. IfParametersevolves (e.g., new fields, renamed properties), this inline type won't fail at compile time. Other migrated tools in this PR use the schema-derived type consistently.♻️ Suggested fix
- execute: ( - args: { operation: (typeof operations)[number]; filePath: string; line: number; character: number }, - ctx: Tool.Context, - ) => + execute: (args: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) =>🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/opencode/src/tool/lsp.ts` around lines 42 - 45, The execute parameter uses an inline type instead of the schema-derived type, so update the execute signature to use Schema.Schema.Type<typeof Parameters> for its args to prevent type drift; locate the execute function definition in this file (the execute: (args: {...}, ctx: Tool.Context) => ...) and replace the inline args type with Schema.Schema.Type<typeof Parameters> (ensure Parameters is the schema exported in this module and Schema is imported) so the compiler will catch future schema changes.packages/opencode/src/tool/agent.ts (1)
60-76:⚠️ Potential issue | 🟠 MajorDon’t silently fork a new subagent on resume lookup failures.
The new
SessionIDvalidation only catches malformed IDs.sessions.get(...).pipe(Effect.catchCause(() => Effect.succeed(undefined)))still converts every resume failure into “create a fresh session”, so a transient session-store error will lose the original subagent context instead of surfacing the failure. Fall back only on an explicit “not found” case.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/opencode/src/tool/agent.ts` around lines 60 - 76, The resume logic currently swallows all errors from sessions.get (via Effect.catchCause(() => Effect.succeed(undefined))), which turns transient store failures into silently forking a new subagent; change this so only an explicit "not found" / missing-session case falls back to undefined while other errors are propagated. In the block that uses agentSessionID and calls sessions.get(SessionID.make(agentSessionID)), replace the broad Effect.catchCause handler with logic that inspects the failure cause and returns undefined only when the cause indicates a not-found/missing session, otherwise re-raise or fail the effect; keep the preceding validation using Schema.decodeUnknownExit(SessionID) intact. Use the existing symbols agentSessionID, SessionID.make, sessions.get and Effect.catchCause (or Effect.catchSome/catchTag as appropriate) to implement this selective fallback.packages/opencode/src/tool/bash.ts (1)
584-589:⚠️ Potential issue | 🟡 MinorAdvertise the same truncation limits that
run()enforces.
run()now honorsyield* trunc.limits(), but the tool description still interpolatesTruncate.MAX_LINES/Truncate.MAX_BYTES. Whentool_output.max_linesortool_output.max_bytesis configured, the model is told the wrong output window.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/opencode/src/tool/bash.ts` around lines 584 - 589, The description string is interpolating Truncate.MAX_LINES and Truncate.MAX_BYTES, which can be out of sync with the actual truncation used by run(); instead call the truncation limits provider (trunc.limits() or yield* trunc.limits() depending on context) and interpolate its returned max_lines / max_bytes values into the description (replace usages of Truncate.MAX_LINES and Truncate.MAX_BYTES). Update the description creation near where description: DESCRIPTION.replaceAll... is built so it queries the current truncation limits from trunc.limits() (or the existing trunc variable) and uses those numeric values for ${maxLines} and ${maxBytes}.packages/opencode/src/tool/grep.ts (1)
31-32: 🛠️ Refactor suggestion | 🟠 MajorDerive
executeparams fromParameters.The inline object type can drift from the schema and silently bypass the compile-time guarantee this migration is supposed to add. Type
paramsasSchema.Schema.Type<typeof Parameters>instead.Suggested change
- execute: (params: { pattern: string; path?: string; include?: string }, ctx: Tool.Context) => + execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) =>#!/bin/bash set -euo pipefail echo "Current schema and execute signature in grep tool:" rg -n 'export const Parameters|execute:\s*\(params:' packages/opencode/src/tool/grep.ts echo echo "Tool.define typing context:" rg -n 'type Def|interface Def|define\(' packages/opencode/src/tool/tool.ts🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/opencode/src/tool/grep.ts` around lines 31 - 32, The execute parameter type should be derived from the Parameters schema instead of an inline object to prevent type drift: change the execute signature in the Tool.define call (the execute function that currently takes params: { pattern: string; path?: string; include?: string }) to use Schema.Schema.Type<typeof Parameters> as the params type, and add/ensure an import for Schema if not already present so the compiler can resolve Schema.Schema.Type.packages/opencode/test/file/ripgrep.test.ts (2)
86-97:⚠️ Potential issue | 🟡 MinorMake the glob-filter case fail when globbing regresses.
skip.txtnever contains"needle", so this still passes if the implementation ignoresgloband searches every file. Put a matching line in the non-*.tsfile and assert that it stays excluded.Suggested test hardening
- await Bun.write(path.join(dir, "skip.txt"), "const value = 'other'\n") + await Bun.write(path.join(dir, "skip.txt"), "const value = 'needle'\n") @@ expect(result.partial).toBe(false) expect(result.items).toHaveLength(1) expect(result.items[0]?.path.text).toContain("match.ts") expect(result.items[0]?.lines.text).toContain("needle") + expect(result.items.some((item) => item.path.text.includes("skip.txt"))).toBe(false)🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/opencode/test/file/ripgrep.test.ts` around lines 86 - 97, The test currently writes "needle" only to match.ts so a broken glob implementation that searches all files still passes; update the Bun.write for skip.txt to include the string "needle" (so skip.txt contains the match) and keep the rg.search call using pattern: "needle" and glob: ["*.ts"], then strengthen assertions after run(...) to still expect result.items.length === 1 and assert that none of the returned item paths contain "skip.txt" (e.g. check result.items for absence of "skip.txt") to ensure the glob filter excludes non-*.ts files; reference Bun.write, Ripgrep.Service.use / rg.search, pattern, glob, and result.items when making the changes.
199-210:⚠️ Potential issue | 🟡 MinorExercise the worker-backed path in the worker-mode test.
This case still runs the same
run(Ripgrep.Service.use(...rg.search...))path as the direct-mode test, so worker-only regressions will slip through.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/opencode/test/file/ripgrep.test.ts` around lines 199 - 210, The test currently calls the direct path Ripgrep.Service.use with rg.search which doesn't exercise the worker-backed code path; change the test to invoke the worker-backed entry (e.g. replace Ripgrep.Service.use(...) with the worker-backed variant such as Ripgrep.Service.useWorker(...) or the project’s equivalent worker API) and call the same search (rg.search({ cwd: tmp.path, pattern: "needle" })) inside that worker callback so the worker-only code path is exercised in this test.packages/opencode/src/file/ripgrep.ts (2)
334-342:⚠️ Potential issue | 🟠 MajorLet
check()return filesystem errors instead of defecting.The
Effect.orDieon Line 335 turns permission/stat failures into defects, sofiles,search, andtreecan no longer surface their declaredPlatformError | Errorfailures for real filesystem problems.Suggested fix
const check = Effect.fnUntraced(function* (cwd: string) { - if (yield* fs.isDir(cwd).pipe(Effect.orDie)) return + if (yield* fs.isDir(cwd)) return return yield* Effect.fail( Object.assign(new Error(`No such file or directory: '${cwd}'`), { code: "ENOENT",🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/opencode/src/file/ripgrep.ts` around lines 334 - 342, The check function currently uses fs.isDir(cwd).pipe(Effect.orDie) which converts real filesystem errors into defects; change check (the Effect.fnUntraced named check) to remove the .pipe(Effect.orDie) and instead handle the effectful result so that permission/IO errors are returned as failures (PlatformError | Error) — i.e., run fs.isDir(cwd) directly, map its boolean success path to returning void, and map failure cases to Effect.fail with the same enriched Error object (preserving code/errno/path) so callers like files, search, and tree can surface real filesystem errors instead of defects.
311-328:⚠️ Potential issue | 🟠 MajorVerify the downloaded ripgrep archive before extracting it.
Lines 318-328 download bytes from the network, cache them under
Global.Path.bin, and later execute the extracted binary without checking a pinned checksum or signature. A tampered release asset becomes persistent code execution on the host.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/opencode/src/file/ripgrep.ts` around lines 311 - 328, The download-and-extract flow for ripgrep (symbols: VERSION, filename, url, archive, HttpClientRequest.get, fs.writeWithDirs, extract, Global.Path.bin) lacks integrity/authenticity checks; before writing or extracting, fetch the expected checksum or signature from a trusted source (release metadata or bundled public key), compute the downloaded bytes' digest (e.g., SHA-256) or verify the detached GPG signature against the ripgrep release asset, compare them, and if the verification fails call Effect.fail with a clear error instead of proceeding to fs.writeWithDirs/extract; ensure the verification happens immediately after receiving bytes and before caching or extraction.packages/opencode/src/session/compaction.ts (1)
404-418:⚠️ Potential issue | 🟠 MajorPersist a full-summary boundary when no raw tail is kept.
This only hides prior history when
latestPrior.tailStartIdis present. If a compaction summarizes the entire filtered history,selected.tail_start_idisundefined, so the next round has no boundary to hide that already-summarized head; and Line 497 also leaves any oldercompactionPart.tail_start_idin place. Repeated compactions can still re-feed summarized messages or use a stale boundary. Use the completed-summary boundary whentail_start_idis absent, and clear/replace the stored boundary accordingly.Also applies to: 497-501
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/opencode/src/session/compaction.ts` around lines 404 - 418, The code only hides prior messages when latestPrior.tailStartId exists, so if a completed compaction summarized the entire filtered history (latestPrior present but tailStartId undefined) subsequent rounds will re-summarize the same head; update the logic around completedCompactions/prior/latestPrior to treat an absent tailStartId as a full-summary boundary by marking all prior indices as hidden (e.g., add all indices 0..history.length-1 to hidden) and also clear/replace any stored boundary field (references: latestPrior.tailStartId, latestPrior.summary, compactionPart.tail_start_id and selected.tail_start_id) so the persisted boundary reflects that the whole history was summarized; apply the same fix where compactionPart.tail_start_id is handled later (lines around 497-501) to avoid keeping a stale tail_start_id.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@packages/opencode/src/question/index.ts`:
- Around line 250-295: The reply validation currently allows empty arrays—add a
check in the reply() loop (where you iterate questions with q and answer from
input.answers) to reject answer.length === 0: log a warning (include requestID
and index), pending.delete(input.requestID), and yield*
Deferred.fail(existing.deferred, new RejectedError()); enforce this for both
single-select (q.multiple === false) and multi-select questions so no empty
answers can resolve the deferred.
In `@packages/opencode/src/tool/edit.ts`:
- Around line 130-132: The current Effect.catch around afs.stat(filePath)
swallows all errors and maps them to “not found”; change it so only the
filesystem NotFound case is turned into undefined while other errors are
rethrown. Replace the generic Effect.catch(...) used with afs.stat(filePath)
with a conditional catch that checks the error type/code (the NotFound
predicate) and returns Effect.succeed(undefined) only for that case, otherwise
re-raise the error (e.g., Effect.fail(err)); keep the subsequent checks on the
info variable and the existing error messages for missing vs directory.
In `@packages/opencode/src/tool/question.ts`:
- Around line 6-16: Add a focused test that restores direct coverage for the
migrated question schema by exercising the Parameters struct and the
Question.Prompt contract: write tests that validate accepted inputs of 1 and 4
questions succeed and rejected inputs of 0 and 5 questions fail with the proper
min/max error messages, and also include at least one negative test asserting an
invalid prompt shape is rejected (e.g., missing required fields on
Question.Prompt). Locate and call the Parameters validator (exported as
Parameters) and the Question.Prompt shape from the diff to perform validations
and assert the error messages match the Schema.isMinLength/Schema.isMaxLength
checks and the prompt-shape contract.
In `@packages/opencode/src/tool/truncate.ts`:
- Around line 76-83: The current Truncate.limits implementation swallows
Config.Service read errors by catching them and returning undefined, making real
config failures indistinguishable from no config; update the Effect pipeline so
that when configSvc (from Option via Effect.serviceOption(Config.Service)) is
Some, you do not catch errors from configSvc.value.get(), but instead let the
Effect fail (or explicitly log and rethrow) so callers can detect/read the
failure; only fall back to MAX_LINES/MAX_BYTES when Option.isNone(configSvc)
(service absent). Specifically, modify the Effect inside
Effect.fn("Truncate.limits") to remove or change the Effect.catch(() =>
Effect.succeed(undefined)) around configSvc.value.get(), and handle errors by
propagating them (or logging via the appropriate logger then rethrowing) while
still using MAX_LINES/MAX_BYTES only when the service is missing.
In `@packages/opencode/src/tool/write.ts`:
- Around line 57-72: The current flow calls ctx.ask() with a diff from
contentOld -> contentNew before format.file() may mutate the bytes, so change
the sequence: first write contentNew to disk with fs.writeWithDirs, run
format.file(filepath), then read back the actual bytes (finalContent), recompute
the diff with createTwoFilesPatch(filepath, filepath, contentOld, finalContent)
and trimDiff, and only then call yield* ctx.ask(...) with the recomputed diff
and same metadata (include filepath and bomChanged/desiredBom flags). After
approval call Bom.syncFile(fs, filepath, desiredBom) as before. Ensure you
reference ctx.ask, createTwoFilesPatch, trimDiff, fs.writeWithDirs, format.file,
Bom.syncFile, contentOld, contentNew, finalContent and filepath when making the
change.
In `@packages/opencode/src/util/effect-zod.ts`:
- Around line 61-62: toJsonSchema currently always re-walks the AST via
zod(schema) which ignores any pre-existing Zod surface attached as schema.zod;
update toJsonSchema to prefer and use schema.zod when present (e.g., const
zodSchema = (schema as any).zod ?? zod(schema)) and pass that zodSchema into
z.toJSONSchema so the emitted JSON Schema matches any hand-authored or wrapped
Zod validator; touch the toJsonSchema function and references to
zod(schema)/z.toJSONSchema accordingly.
---
Duplicate comments:
In `@packages/opencode/src/file/ripgrep.ts`:
- Around line 334-342: The check function currently uses
fs.isDir(cwd).pipe(Effect.orDie) which converts real filesystem errors into
defects; change check (the Effect.fnUntraced named check) to remove the
.pipe(Effect.orDie) and instead handle the effectful result so that
permission/IO errors are returned as failures (PlatformError | Error) — i.e.,
run fs.isDir(cwd) directly, map its boolean success path to returning void, and
map failure cases to Effect.fail with the same enriched Error object (preserving
code/errno/path) so callers like files, search, and tree can surface real
filesystem errors instead of defects.
- Around line 311-328: The download-and-extract flow for ripgrep (symbols:
VERSION, filename, url, archive, HttpClientRequest.get, fs.writeWithDirs,
extract, Global.Path.bin) lacks integrity/authenticity checks; before writing or
extracting, fetch the expected checksum or signature from a trusted source
(release metadata or bundled public key), compute the downloaded bytes' digest
(e.g., SHA-256) or verify the detached GPG signature against the ripgrep release
asset, compare them, and if the verification fails call Effect.fail with a clear
error instead of proceeding to fs.writeWithDirs/extract; ensure the verification
happens immediately after receiving bytes and before caching or extraction.
In `@packages/opencode/src/question/index.ts`:
- Around line 204-227: The uniqueness check is running against raw
input.questions so labels like "A" and " A " bypass it but later collapse when
decoding with Schema.decodeUnknownSync(Request); update the pre-publish guard to
normalize labels the same way Request decoding does (e.g., trim using the same
TrimmedString logic) before creating the Set: in the loop over input.questions
compute const labels = q.options.map(o => normalizeLabel(o.label)) and use new
Set(labels).size !== labels.length to detect duplicates, and include the
normalized labels in the thrown Error message; keep the rest of the flow
(Schema.decodeUnknownSync(Request), QuestionID.ascending, Deferred.make,
InstanceState.get) unchanged.
In `@packages/opencode/src/session/compaction.ts`:
- Around line 404-418: The code only hides prior messages when
latestPrior.tailStartId exists, so if a completed compaction summarized the
entire filtered history (latestPrior present but tailStartId undefined)
subsequent rounds will re-summarize the same head; update the logic around
completedCompactions/prior/latestPrior to treat an absent tailStartId as a
full-summary boundary by marking all prior indices as hidden (e.g., add all
indices 0..history.length-1 to hidden) and also clear/replace any stored
boundary field (references: latestPrior.tailStartId, latestPrior.summary,
compactionPart.tail_start_id and selected.tail_start_id) so the persisted
boundary reflects that the whole history was summarized; apply the same fix
where compactionPart.tail_start_id is handled later (lines around 497-501) to
avoid keeping a stale tail_start_id.
In `@packages/opencode/src/tool/agent.ts`:
- Around line 60-76: The resume logic currently swallows all errors from
sessions.get (via Effect.catchCause(() => Effect.succeed(undefined))), which
turns transient store failures into silently forking a new subagent; change this
so only an explicit "not found" / missing-session case falls back to undefined
while other errors are propagated. In the block that uses agentSessionID and
calls sessions.get(SessionID.make(agentSessionID)), replace the broad
Effect.catchCause handler with logic that inspects the failure cause and returns
undefined only when the cause indicates a not-found/missing session, otherwise
re-raise or fail the effect; keep the preceding validation using
Schema.decodeUnknownExit(SessionID) intact. Use the existing symbols
agentSessionID, SessionID.make, sessions.get and Effect.catchCause (or
Effect.catchSome/catchTag as appropriate) to implement this selective fallback.
In `@packages/opencode/src/tool/bash.ts`:
- Around line 584-589: The description string is interpolating
Truncate.MAX_LINES and Truncate.MAX_BYTES, which can be out of sync with the
actual truncation used by run(); instead call the truncation limits provider
(trunc.limits() or yield* trunc.limits() depending on context) and interpolate
its returned max_lines / max_bytes values into the description (replace usages
of Truncate.MAX_LINES and Truncate.MAX_BYTES). Update the description creation
near where description: DESCRIPTION.replaceAll... is built so it queries the
current truncation limits from trunc.limits() (or the existing trunc variable)
and uses those numeric values for ${maxLines} and ${maxBytes}.
In `@packages/opencode/src/tool/grep.ts`:
- Around line 31-32: The execute parameter type should be derived from the
Parameters schema instead of an inline object to prevent type drift: change the
execute signature in the Tool.define call (the execute function that currently
takes params: { pattern: string; path?: string; include?: string }) to use
Schema.Schema.Type<typeof Parameters> as the params type, and add/ensure an
import for Schema if not already present so the compiler can resolve
Schema.Schema.Type.
In `@packages/opencode/src/tool/lsp.ts`:
- Around line 42-45: The execute parameter uses an inline type instead of the
schema-derived type, so update the execute signature to use
Schema.Schema.Type<typeof Parameters> for its args to prevent type drift; locate
the execute function definition in this file (the execute: (args: {...}, ctx:
Tool.Context) => ...) and replace the inline args type with
Schema.Schema.Type<typeof Parameters> (ensure Parameters is the schema exported
in this module and Schema is imported) so the compiler will catch future schema
changes.
In `@packages/opencode/test/file/ripgrep.test.ts`:
- Around line 86-97: The test currently writes "needle" only to match.ts so a
broken glob implementation that searches all files still passes; update the
Bun.write for skip.txt to include the string "needle" (so skip.txt contains the
match) and keep the rg.search call using pattern: "needle" and glob: ["*.ts"],
then strengthen assertions after run(...) to still expect result.items.length
=== 1 and assert that none of the returned item paths contain "skip.txt" (e.g.
check result.items for absence of "skip.txt") to ensure the glob filter excludes
non-*.ts files; reference Bun.write, Ripgrep.Service.use / rg.search, pattern,
glob, and result.items when making the changes.
- Around line 199-210: The test currently calls the direct path
Ripgrep.Service.use with rg.search which doesn't exercise the worker-backed code
path; change the test to invoke the worker-backed entry (e.g. replace
Ripgrep.Service.use(...) with the worker-backed variant such as
Ripgrep.Service.useWorker(...) or the project’s equivalent worker API) and call
the same search (rg.search({ cwd: tmp.path, pattern: "needle" })) inside that
worker callback so the worker-only code path is exercised in this test.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: e5141b3a-4b4b-4ad8-9417-23f78e965f67
⛔ Files ignored due to path filters (1)
packages/opencode/test/tool/__snapshots__/parameters.test.ts.snapis excluded by!**/*.snap
📒 Files selected for processing (78)
packages/opencode/src/agent/prompt/compaction.txtpackages/opencode/src/auth/index.tspackages/opencode/src/cli/cmd/debug/file.tspackages/opencode/src/cli/cmd/debug/ripgrep.tspackages/opencode/src/cli/cmd/run.tspackages/opencode/src/config/config.tspackages/opencode/src/effect/app-runtime.tspackages/opencode/src/file/index.tspackages/opencode/src/file/ripgrep.tspackages/opencode/src/filesystem/index.tspackages/opencode/src/format/index.tspackages/opencode/src/mcp/auth.tspackages/opencode/src/mcp/index.tspackages/opencode/src/patch/index.tspackages/opencode/src/project/project.tspackages/opencode/src/project/vcs.tspackages/opencode/src/provider/provider.tspackages/opencode/src/question/index.tspackages/opencode/src/server/instance/file.tspackages/opencode/src/server/instance/question.tspackages/opencode/src/session/compaction.tspackages/opencode/src/session/instruction.tspackages/opencode/src/session/message-v2.tspackages/opencode/src/session/overflow.tspackages/opencode/src/session/prompt.tspackages/opencode/src/skill/discovery.tspackages/opencode/src/skill/index.tspackages/opencode/src/snapshot/index.tspackages/opencode/src/storage/storage.tspackages/opencode/src/tool/agent.tspackages/opencode/src/tool/apply_patch.tspackages/opencode/src/tool/bash.tspackages/opencode/src/tool/codesearch.tspackages/opencode/src/tool/edit.tspackages/opencode/src/tool/external-directory.tspackages/opencode/src/tool/glob.tspackages/opencode/src/tool/grep.tspackages/opencode/src/tool/invalid.tspackages/opencode/src/tool/lsp.tspackages/opencode/src/tool/plan.tspackages/opencode/src/tool/question.tspackages/opencode/src/tool/read.tspackages/opencode/src/tool/registry.tspackages/opencode/src/tool/skill.tspackages/opencode/src/tool/todo.tspackages/opencode/src/tool/tool.tspackages/opencode/src/tool/trash.tspackages/opencode/src/tool/truncate.tspackages/opencode/src/tool/webfetch.tspackages/opencode/src/tool/websearch.tspackages/opencode/src/tool/write.tspackages/opencode/src/util/bom.tspackages/opencode/src/util/effect-zod.tspackages/opencode/src/worktree/index.tspackages/opencode/test/config/config.test.tspackages/opencode/test/config/pawwork-global-config.test.tspackages/opencode/test/file/ripgrep.test.tspackages/opencode/test/filesystem/filesystem.test.tspackages/opencode/test/project/project.test.tspackages/opencode/test/question/question.test.tspackages/opencode/test/question/schema.test.tspackages/opencode/test/session/compaction.test.tspackages/opencode/test/session/prompt-effect.test.tspackages/opencode/test/session/snapshot-tool-race.test.tspackages/opencode/test/storage/storage.test.tspackages/opencode/test/tool/agent-rename.test.tspackages/opencode/test/tool/apply_patch.test.tspackages/opencode/test/tool/bash.test.tspackages/opencode/test/tool/edit.test.tspackages/opencode/test/tool/external-directory.test.tspackages/opencode/test/tool/glob.test.tspackages/opencode/test/tool/grep.test.tspackages/opencode/test/tool/question.test.tspackages/opencode/test/tool/read.test.tspackages/opencode/test/tool/skill.test.tspackages/opencode/test/tool/tool-define.test.tspackages/opencode/test/tool/trash.test.tspackages/opencode/test/tool/write.test.ts
💤 Files with no reviewable changes (3)
- packages/opencode/test/session/compaction.test.ts
- packages/opencode/test/tool/question.test.ts
- packages/opencode/src/filesystem/index.ts
19ba464 to
25a27dd
Compare
Outside-diff comment reply (
|
There was a problem hiding this comment.
Actionable comments posted: 5
♻️ Duplicate comments (13)
packages/opencode/src/tool/edit.ts (2)
99-104:⚠️ Potential issue | 🟠 MajorDon't let
newStringchange BOM state invisibly.
desiredBom = source.bom || next.bomstill lets the edit inject a BOM from the input, but both diffs are built from BOM-stripped text. That means the approval preview can miss a byte-level change at the start of the file.write.tsnow avoids this by preservingsource.bomonly; the edit flow needs the same rule unless BOM transitions are surfaced explicitly.Also applies to: 140-143
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/opencode/src/tool/edit.ts` around lines 99 - 104, The code currently allows params.newString to inject a BOM by using desiredBom = source.bom || next.bom; change this to preserve the original BOM only (desiredBom = source.bom) so edits cannot invisibly add a BOM; update the blocks that call Bom.readFile, Bom.split, set contentOld/contentNew, and compute diff (the region using variables source, next, desiredBom, contentOld, contentNew, createTwoFilesPatch, trimDiff) to use source.bom-only semantics and apply the same fix in the other occurrence mentioned (the block around lines 140-143).
94-96:⚠️ Potential issue | 🟡 MinorOnly translate
NotFoundinto “missing file” here.Both branches swallow every
afs.stat()failure. Permission and other filesystem errors then get reported as “not found” or silently ignored, which makes the failure mode misleading.Also applies to: 130-132
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/opencode/src/tool/edit.ts` around lines 94 - 96, The code currently swallows all errors from afs.stat(filePath) by returning undefined, which maps permission/IO errors to "missing file"; change the catch to only convert a NotFound error into undefined and rethrow any other error instead. Concretely, replace the broad Effect.catch(() => Effect.succeed(undefined)) on the afs.stat(filePath) call with a handler that inspects the caught error (e.g., error.tag or instance) and returns Effect.succeed(undefined) only when it indicates NotFound, otherwise rethrows or returns Effect.fail(error); apply the exact same change to the other afs.stat usage with the same pattern so only NotFound is translated to "missing file" and all other filesystem errors propagate.packages/opencode/src/tool/lsp.ts (1)
42-45: 🛠️ Refactor suggestion | 🟠 MajorBind
executetoSchema.Schema.Type<typeof Parameters>.This handler still duplicates the decoded shape manually, so the implementation can drift from
Parametersagain without TypeScript catching it.Suggested change
- execute: ( - args: { operation: (typeof operations)[number]; filePath: string; line: number; character: number }, - ctx: Tool.Context, - ) => + execute: (args: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) =>🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/opencode/src/tool/lsp.ts` around lines 42 - 45, The execute handler's args type is manually duplicated; update the signature to bind args to Schema.Schema.Type<typeof Parameters> so TypeScript enforces the decoded shape: replace the inline args type for execute with args: Schema.Schema.Type<typeof Parameters>, keep ctx: Tool.Context, and then adjust any internal references that expect the previous shape to match the Parameters-derived type; ensure the symbol Parameters is imported/visible in this file and update any downstream uses of execute to the new typed shape.packages/opencode/src/tool/write.ts (1)
70-77:⚠️ Potential issue | 🟠 MajorThe recomputed diff is unused, so the approval preview still doesn't reflect formatter rewrites.
diffis only consumed byctx.ask()on Lines 58-67. After Line 67, nothing reads the updated value from Lines 70-77, and the bus events on Lines 78-82 do not carry it. So this recomputation does not actually surface the post-format bytes anywhere.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/opencode/src/tool/write.ts` around lines 70 - 77, The recomputed diff (computed from finalContent after Bom.syncFile/formatting using createTwoFilesPatch + trimDiff) is never propagated to the approval preview or bus events; update the code so the post-format diff is actually used — either move the formatting+sync+diff recomputation so it occurs before ctx.ask(...) (so ctx.ask receives the post-format diff) or ensure the bus event and any downstream consumers use the recomputed diff/finalContent instead of the original diff (referencing variables filepath, contentOld, contentNew, finalContent, diff and functions/methods Bom.syncFile, format.file, createTwoFilesPatch, trimDiff, and ctx.ask).packages/opencode/test/file/ripgrep.test.ts (1)
83-97:⚠️ Potential issue | 🟡 MinorMake the glob-filter test fail if globbing regresses.
skip.txtstill has no"needle", so this stays green even ifglob: ["*.ts"]is ignored and both files are searched. Put a match in the non-*.tsfile and assert it remains excluded.🛠️ Suggested fix
test("search returns matched rows with glob filter", async () => { await using tmp = await tmpdir({ init: async (dir) => { await Bun.write(path.join(dir, "match.ts"), "const value = 'needle'\n") - await Bun.write(path.join(dir, "skip.txt"), "const value = 'other'\n") + await Bun.write(path.join(dir, "skip.txt"), "const value = 'needle'\n") }, }) const result = await run( Ripgrep.Service.use((rg) => rg.search({ cwd: tmp.path, pattern: "needle", glob: ["*.ts"] })), ) expect(result.partial).toBe(false) expect(result.items).toHaveLength(1) expect(result.items[0]?.path.text).toContain("match.ts") expect(result.items[0]?.lines.text).toContain("needle") + expect(result.items.some((item) => item.path.text.includes("skip.txt"))).toBe(false) })🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/opencode/test/file/ripgrep.test.ts` around lines 83 - 97, The test currently writes "needle" only into match.ts so it won't detect if glob filtering is ignored; update the tmpdir init in the test (the async init passed to tmpdir) to write "needle" into skip.txt as well (e.g., include "const value = 'needle'\n" in skip.txt), then after calling Ripgrep.Service.use((rg) => rg.search(...)) add an assertion that no returned item's path contains "skip.txt" (or explicitly expect all result.items paths to notContain "skip.txt") while keeping the existing expect(result.items).toHaveLength(1) and checks for "match.ts" and "needle" to ensure non-*.ts matches are excluded by the glob option on rg.search.packages/opencode/src/file/ripgrep.ts (1)
297-316:⚠️ Potential issue | 🔴 CriticalLet ripgrep bootstrap I/O fail through the declared error channel.
The cached binary-resolution path wraps
fs.isFile(...)andfs.ensureDir(...)withEffect.orDieat lines 300, 303, and 316. This converts filesystem errors to defects, but the public interface declaresfiles(),search(), andtree()return errors asPlatformError | Error.The code already acknowledges this pattern is incorrect: lines 335–338 explicitly state real FS errors should flow through the declared error channel instead of becoming defects. Remove
Effect.orDiefrom these three filesystem calls so errors propagate through the proper channel.🛠️ Suggested fix
Effect.gen(function* () { const system = yield* Effect.sync(() => which(process.platform === "win32" ? "rg.exe" : "rg")) - if (system && (yield* fs.isFile(system).pipe(Effect.orDie))) return system + if (system && (yield* fs.isFile(system))) return system const target = path.join(Global.Path.bin, `rg${process.platform === "win32" ? ".exe" : ""}`) - if (yield* fs.isFile(target).pipe(Effect.orDie)) return target + if (yield* fs.isFile(target)) return target log.info("downloading ripgrep", { url }) - yield* fs.ensureDir(Global.Path.bin).pipe(Effect.orDie) + yield* fs.ensureDir(Global.Path.bin)🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/opencode/src/file/ripgrep.ts` around lines 297 - 316, The cached binary-resolution Effect assigned to filepath is turning filesystem errors into defects by wrapping fs.isFile(...) and fs.ensureDir(...) calls with Effect.orDie; remove Effect.orDie from the three calls (the two fs.isFile(...) usages and the fs.ensureDir(...) call) so those IO errors propagate through the declared error channel (PlatformError | Error) instead of becoming defects—locate the Effect.gen block that builds filepath and adjust the usages of fs.isFile and fs.ensureDir accordingly, leaving the rest of the logic (which(...), path construction, download logic) unchanged.packages/opencode/src/tool/registry.ts (1)
145-152:⚠️ Potential issue | 🟠 MajorThis Zod bridge validates but never decodes.
Schema.declare(... => zodParams.safeParse(u).success)only returns a boolean, so plugin tools still receive the original input value instead of Zod’s parsed output. That drops defaults, transforms, and unknown-key stripping at runtime.In Effect 4, if `Schema.declare` is backed by a predicate that only returns `zodParams.safeParse(u).success`, does `Schema.decodeUnknownEffect` preserve Zod's `result.data`, or does it return the original input unchanged?packages/opencode/src/tool/truncate.ts (1)
76-83:⚠️ Potential issue | 🟠 MajorPropagate real config failures from
limits().Line 79 still turns a malformed or unreadable config into “use defaults”, which can silently raise truncation ceilings and increase context usage. Only fall back when
Config.Serviceis absent; once the service exists,configSvc.value.get()should fail loudly.♻️ Proposed fix
const limits = Effect.fn("Truncate.limits")(function* () { const configSvc = yield* Effect.serviceOption(Config.Service) if (Option.isNone(configSvc)) return { maxLines: MAX_LINES, maxBytes: MAX_BYTES } - const cfg = yield* configSvc.value.get().pipe(Effect.catch(() => Effect.succeed(undefined))) + const cfg = yield* configSvc.value.get() return { - maxLines: cfg?.tool_output?.max_lines ?? MAX_LINES, - maxBytes: cfg?.tool_output?.max_bytes ?? MAX_BYTES, + maxLines: cfg.tool_output?.max_lines ?? MAX_LINES, + maxBytes: cfg.tool_output?.max_bytes ?? MAX_BYTES, } })🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/opencode/src/tool/truncate.ts` around lines 76 - 83, The limits() generator currently swallows errors from configSvc.value.get() and returns defaults; change it so only the absence of Config.Service (Option.isNone(configSvc)) falls back to MAX_LINES/MAX_BYTES, but if configSvc exists then call configSvc.value.get() without the Effect.catch wrapper so any read/parse failures propagate (i.e., remove the Effect.catch(() => Effect.succeed(undefined)) around configSvc.value.get()); update the subsequent handling of cfg accordingly in the Truncate.limits function.packages/opencode/src/session/message-v2.ts (1)
22-33:⚠️ Potential issue | 🟡 Minor
truncateToolOutput()still breaks the cap for very small limits.If
maxCharsis smaller than the suffix itself, Lines 30-32 return the suffix alone, which is still longer thanmaxChars. That still skews compaction budgeting for smalltoolOutputMaxCharsvalues.♻️ Proposed fix
function truncateToolOutput(text: string, maxChars?: number) { if (!maxChars || text.length <= maxChars) return text // Reserve room for the suffix so the final string respects maxChars instead // of overshooting by ~64 chars per truncated tool result (compounds badly // with many truncated outputs in one compaction payload). const suffixTemplate = (n: number) => `\n[Tool output truncated for compaction: omitted ${n} chars]` - // Worst-case suffix length (digits of full text length is an upper bound). - const reserve = suffixTemplate(text.length).length - const sliceLen = Math.max(0, maxChars - reserve) - const omitted = text.length - sliceLen - return `${text.slice(0, sliceLen)}${suffixTemplate(omitted)}` + const maxSuffix = suffixTemplate(text.length) + if (maxChars <= maxSuffix.length) return maxSuffix.slice(0, maxChars) + const sliceLen = maxChars - maxSuffix.length + const omitted = text.length - sliceLen + const suffix = suffixTemplate(omitted) + return `${text.slice(0, maxChars - suffix.length)}${suffix}` }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/opencode/src/session/message-v2.ts` around lines 22 - 33, truncateToolOutput can produce a string longer than maxChars when the suffix itself exceeds maxChars; in truncateToolOutput(text, maxChars) compute reserve as you already do and if reserve >= maxChars (or sliceLen === 0) return only the suffix truncated to maxChars (e.g., suffixTemplate(omitted).slice(0, maxChars)) so the function always returns a string whose length <= maxChars; update the logic in truncateToolOutput to handle this edge case (refer to the suffixTemplate, reserve, sliceLen, and omitted variables).packages/opencode/src/tool/grep.ts (1)
81-82:⚠️ Potential issue | 🟠 MajorSurface partial-I/O empties instead of returning a clean miss.
Lines 81-82 return plain “No files found” even when
Ripgrep.Service.search()marked the result aspartial, so unreadable paths are reported as a definitive miss. Preserve the empty shape if you want, but append the skipped-path warning in the zero-match path too.♻️ Proposed fix
- if (result.items.length === 0) return empty + if (result.items.length === 0) { + if (result.partial) { + return { + ...empty, + output: `${empty.output}\n\n(Some paths were inaccessible and skipped)`, + } + } + return empty + }Also applies to: 142-145
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/opencode/src/tool/grep.ts` around lines 81 - 82, The code returns the canonical empty ("No files found") early when result.items.length === 0, but ignores result.partial from Ripgrep.Service.search(); change the branch that returns empty to preserve/augment the empty shape when result.partial is true by appending the skipped-path warning (the same warning used elsewhere) to the empty response so unreadable/skipped paths are reported as a partial miss; specifically update the check around result.items.length === 0 (and the similar block at lines 142-145) to inspect result.partial and merge the skipped-path message into the returned empty object instead of returning a clean miss.packages/opencode/src/question/index.ts (2)
265-276:⚠️ Potential issue | 🟠 MajorReject empty selections in
reply().
answer.length === 0still resolves the deferred for both single- and multi-select questions, so a buggy client can dismiss a prompt without going throughreject().♻️ Suggested fix
for (let i = 0; i < questions.length; i++) { const q = questions[i]! const answer = input.answers[i]! + if (answer.length === 0) { + log.warn("empty answer for question", { + requestID: input.requestID, + index: i, + }) + pending.delete(input.requestID) + yield* Deferred.fail(existing.deferred, new RejectedError()) + return + } if (!q.multiple && answer.length > 1) { log.warn("multiple answers to single-select question", { requestID: input.requestID, index: i,🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/opencode/src/question/index.ts` around lines 265 - 276, In the reply loop that iterates questions (using variables questions, input.answers and q.multiple), add a check for empty selections (answer.length === 0) and treat them like rejects: log a warning, pending.delete(input.requestID), and yield* Deferred.fail(existing.deferred, new RejectedError()); for single-select also keep the existing check for answer.length > 1; thus modify the block around q.multiple to first reject when answer.length === 0 (for both single- and multi-select) and continue using the current reject flow (log + pending.delete + Deferred.fail on existing.deferred) for invalid multiple selections as already implemented.
206-214:⚠️ Potential issue | 🟠 MajorNormalize labels before enforcing uniqueness.
This check runs before
Schema.decodeUnknownSync(Request)appliesTrimmedString, so"foo"and" foo "pass here and are then stored as the same label. Replies are still ambiguous in that case.♻️ Suggested fix
- for (const q of input.questions) { - const labels = q.options.map((o) => o.label) + for (const q of input.questions) { + const labels = q.options.map((o) => o.label.trim()) if (new Set(labels).size !== labels.length) { return yield* Effect.die( new Error( `Question "${q.question}" has duplicate option labels (${labels.join(", ")}). Labels must be unique within a question.`, ), ) } }A stricter option is to decode first and run the uniqueness check against the normalized
info.questions.Also applies to: 222-227
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/opencode/src/question/index.ts` around lines 206 - 214, The uniqueness check currently uses raw input.questions so labels like "foo" and " foo " bypass dedupe; decode and normalize before checking by running Schema.decodeUnknownSync(Request) (or otherwise applying TrimmedString) and perform the duplicate-label check against the decoded/normalized info.questions (or transformed labels array) instead of input.questions; update the logic around input.questions -> labels to trim/normalize each option.label (or use the decoded info.questions) before creating the Set so duplicates with whitespace are caught (also apply same change to the other check that mirrors this logic).packages/opencode/src/session/compaction.ts (1)
411-417:⚠️ Potential issue | 🟠 MajorHide the previous summarized head even when
tailStartIdis missing.If the last compaction kept no raw tail,
selected.tail_start_idwas stored asundefined, but that summary still covered the entire pre-summary history. This branch skips hiding anything in that case, so the next compaction sendspreviousSummaryand all of the already-summarized messages again.♻️ Suggested fix
- if (latestPrior?.tailStartId) { - const tailIndex = history.findIndex((m) => m.info.id === latestPrior.tailStartId) - if (tailIndex > 0) { - for (let i = 0; i < tailIndex; i++) hidden.add(i) - } - } + if (latestPrior) { + const tailIndex = + latestPrior.tailStartId !== undefined + ? history.findIndex((m) => m.info.id === latestPrior.tailStartId) + : latestPrior.assistantIndex + 1 + const hideUntil = tailIndex >= 0 ? tailIndex : latestPrior.assistantIndex + 1 + for (let i = 0; i < hideUntil; i++) hidden.add(i) + }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/opencode/src/session/compaction.ts` around lines 411 - 417, The current logic only hides prior messages when latestPrior.tailStartId is set; change it so the presence of latestPrior alone triggers hiding the previously-summarized head. Concretely, in the block around latestPrior (the variables prior, latestPrior, tailStartId, history, hidden), compute tailIndex = latestPrior.tailStartId ? history.findIndex(m => m.info.id === latestPrior.tailStartId) : history.findIndex(m => m.info.id === latestPrior.id) (or fallback to history.length if not found), then run the same for-loop for (let i = 0; i < tailIndex; i++) hidden.add(i) so a missing tailStartId still hides the entire prior summary range.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@packages/opencode/src/cli/cmd/debug/ripgrep.ts`:
- Around line 93-103: The command currently writes only results.items to stdout,
losing search debug metadata (results.partial and results.partialReason); update
the output to include the full results object returned from Ripgrep.Service.use
-> svc.search (or at least include results.partial and results.partialReason
alongside items) so partial-I/O and invalid-pattern cases are preserved in the
debug output; replace the
process.stdout.write(JSON.stringify(results.items,...)) call in ripgrep.ts with
a write of the complete results JSON (or a composed object { items, partial,
partialReason }) to retain the metadata.
In `@packages/opencode/src/file/ripgrep.ts`:
- Around line 224-225: The current check uses a truthy test on input.limit which
drops explicit 0 and makes ripgrep unbounded; update the logic where args are
built (the block that currently does if (input.limit)
args.push(`--max-count=${input.limit}`)) to either: 1) check for undefined
explicitly (if (input.limit !== undefined) ...) and push the --max-count when
provided, or 2) validate/reject zero earlier (e.g., throw or normalize when
input.limit === 0) so callers cannot accidentally pass 0; adjust the code that
pushes to args (the args.push(...) lines) accordingly to preserve an explicit
limit of 0 if you choose the explicit-check approach.
In `@packages/opencode/src/server/instance/file.ts`:
- Around line 24-28: Update the /find response schema inside the resolver so the
top-level object returned by Ripgrep.search() includes partialReason; currently
only items and partial are declared. Add a new property (e.g., partialReason:
z.string().nullable().optional()) next to items and partial in the z.object so
generated clients/docs can distinguish reasons like "invalid_pattern" vs
"partial_io" (reference the resolver and the Ripgrep.SearchMatch.zod usage to
locate the schema).
In `@packages/opencode/src/tool/todo.ts`:
- Around line 9-18: The TodoItem schema currently uses free-form strings for
status and priority; change those fields to closed enums so only allowed values
are accepted: replace status: Schema.String... with status:
Schema.Enum(["pending","in_progress","completed","cancelled"]) and replace
priority: Schema.String... with priority: Schema.Enum(["high","medium","low"]);
update the TodoItem declaration (and keep Parameters.todos referencing TodoItem)
so callers cannot submit invalid values and existing code that checks status ===
"completed" continues to work.
In `@packages/opencode/src/tool/write.ts`:
- Around line 37-38: The execute function currently hand-writes the decoded
params shape ({ content: string; filePath: string }) instead of using the
schema-derived Parameters type; update the execute signature to use the
Parameters type (i.e., accept params: Parameters) so it matches the pattern in
webfetch.ts/todo.ts and conforms to the Tool.Def contract, keeping ctx as
Tool.Context and preserving the function body.
---
Duplicate comments:
In `@packages/opencode/src/file/ripgrep.ts`:
- Around line 297-316: The cached binary-resolution Effect assigned to filepath
is turning filesystem errors into defects by wrapping fs.isFile(...) and
fs.ensureDir(...) calls with Effect.orDie; remove Effect.orDie from the three
calls (the two fs.isFile(...) usages and the fs.ensureDir(...) call) so those IO
errors propagate through the declared error channel (PlatformError | Error)
instead of becoming defects—locate the Effect.gen block that builds filepath and
adjust the usages of fs.isFile and fs.ensureDir accordingly, leaving the rest of
the logic (which(...), path construction, download logic) unchanged.
In `@packages/opencode/src/question/index.ts`:
- Around line 265-276: In the reply loop that iterates questions (using
variables questions, input.answers and q.multiple), add a check for empty
selections (answer.length === 0) and treat them like rejects: log a warning,
pending.delete(input.requestID), and yield* Deferred.fail(existing.deferred, new
RejectedError()); for single-select also keep the existing check for
answer.length > 1; thus modify the block around q.multiple to first reject when
answer.length === 0 (for both single- and multi-select) and continue using the
current reject flow (log + pending.delete + Deferred.fail on existing.deferred)
for invalid multiple selections as already implemented.
- Around line 206-214: The uniqueness check currently uses raw input.questions
so labels like "foo" and " foo " bypass dedupe; decode and normalize before
checking by running Schema.decodeUnknownSync(Request) (or otherwise applying
TrimmedString) and perform the duplicate-label check against the
decoded/normalized info.questions (or transformed labels array) instead of
input.questions; update the logic around input.questions -> labels to
trim/normalize each option.label (or use the decoded info.questions) before
creating the Set so duplicates with whitespace are caught (also apply same
change to the other check that mirrors this logic).
In `@packages/opencode/src/session/compaction.ts`:
- Around line 411-417: The current logic only hides prior messages when
latestPrior.tailStartId is set; change it so the presence of latestPrior alone
triggers hiding the previously-summarized head. Concretely, in the block around
latestPrior (the variables prior, latestPrior, tailStartId, history, hidden),
compute tailIndex = latestPrior.tailStartId ? history.findIndex(m => m.info.id
=== latestPrior.tailStartId) : history.findIndex(m => m.info.id ===
latestPrior.id) (or fallback to history.length if not found), then run the same
for-loop for (let i = 0; i < tailIndex; i++) hidden.add(i) so a missing
tailStartId still hides the entire prior summary range.
In `@packages/opencode/src/session/message-v2.ts`:
- Around line 22-33: truncateToolOutput can produce a string longer than
maxChars when the suffix itself exceeds maxChars; in truncateToolOutput(text,
maxChars) compute reserve as you already do and if reserve >= maxChars (or
sliceLen === 0) return only the suffix truncated to maxChars (e.g.,
suffixTemplate(omitted).slice(0, maxChars)) so the function always returns a
string whose length <= maxChars; update the logic in truncateToolOutput to
handle this edge case (refer to the suffixTemplate, reserve, sliceLen, and
omitted variables).
In `@packages/opencode/src/tool/edit.ts`:
- Around line 99-104: The code currently allows params.newString to inject a BOM
by using desiredBom = source.bom || next.bom; change this to preserve the
original BOM only (desiredBom = source.bom) so edits cannot invisibly add a BOM;
update the blocks that call Bom.readFile, Bom.split, set contentOld/contentNew,
and compute diff (the region using variables source, next, desiredBom,
contentOld, contentNew, createTwoFilesPatch, trimDiff) to use source.bom-only
semantics and apply the same fix in the other occurrence mentioned (the block
around lines 140-143).
- Around line 94-96: The code currently swallows all errors from
afs.stat(filePath) by returning undefined, which maps permission/IO errors to
"missing file"; change the catch to only convert a NotFound error into undefined
and rethrow any other error instead. Concretely, replace the broad
Effect.catch(() => Effect.succeed(undefined)) on the afs.stat(filePath) call
with a handler that inspects the caught error (e.g., error.tag or instance) and
returns Effect.succeed(undefined) only when it indicates NotFound, otherwise
rethrows or returns Effect.fail(error); apply the exact same change to the other
afs.stat usage with the same pattern so only NotFound is translated to "missing
file" and all other filesystem errors propagate.
In `@packages/opencode/src/tool/grep.ts`:
- Around line 81-82: The code returns the canonical empty ("No files found")
early when result.items.length === 0, but ignores result.partial from
Ripgrep.Service.search(); change the branch that returns empty to
preserve/augment the empty shape when result.partial is true by appending the
skipped-path warning (the same warning used elsewhere) to the empty response so
unreadable/skipped paths are reported as a partial miss; specifically update the
check around result.items.length === 0 (and the similar block at lines 142-145)
to inspect result.partial and merge the skipped-path message into the returned
empty object instead of returning a clean miss.
In `@packages/opencode/src/tool/lsp.ts`:
- Around line 42-45: The execute handler's args type is manually duplicated;
update the signature to bind args to Schema.Schema.Type<typeof Parameters> so
TypeScript enforces the decoded shape: replace the inline args type for execute
with args: Schema.Schema.Type<typeof Parameters>, keep ctx: Tool.Context, and
then adjust any internal references that expect the previous shape to match the
Parameters-derived type; ensure the symbol Parameters is imported/visible in
this file and update any downstream uses of execute to the new typed shape.
In `@packages/opencode/src/tool/truncate.ts`:
- Around line 76-83: The limits() generator currently swallows errors from
configSvc.value.get() and returns defaults; change it so only the absence of
Config.Service (Option.isNone(configSvc)) falls back to MAX_LINES/MAX_BYTES, but
if configSvc exists then call configSvc.value.get() without the Effect.catch
wrapper so any read/parse failures propagate (i.e., remove the Effect.catch(()
=> Effect.succeed(undefined)) around configSvc.value.get()); update the
subsequent handling of cfg accordingly in the Truncate.limits function.
In `@packages/opencode/src/tool/write.ts`:
- Around line 70-77: The recomputed diff (computed from finalContent after
Bom.syncFile/formatting using createTwoFilesPatch + trimDiff) is never
propagated to the approval preview or bus events; update the code so the
post-format diff is actually used — either move the formatting+sync+diff
recomputation so it occurs before ctx.ask(...) (so ctx.ask receives the
post-format diff) or ensure the bus event and any downstream consumers use the
recomputed diff/finalContent instead of the original diff (referencing variables
filepath, contentOld, contentNew, finalContent, diff and functions/methods
Bom.syncFile, format.file, createTwoFilesPatch, trimDiff, and ctx.ask).
In `@packages/opencode/test/file/ripgrep.test.ts`:
- Around line 83-97: The test currently writes "needle" only into match.ts so it
won't detect if glob filtering is ignored; update the tmpdir init in the test
(the async init passed to tmpdir) to write "needle" into skip.txt as well (e.g.,
include "const value = 'needle'\n" in skip.txt), then after calling
Ripgrep.Service.use((rg) => rg.search(...)) add an assertion that no returned
item's path contains "skip.txt" (or explicitly expect all result.items paths to
notContain "skip.txt") while keeping the existing
expect(result.items).toHaveLength(1) and checks for "match.ts" and "needle" to
ensure non-*.ts matches are excluded by the glob option on rg.search.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: 0542a7c9-b367-4e76-833a-10dbc4683273
📒 Files selected for processing (34)
packages/opencode/src/agent/prompt/compaction.txtpackages/opencode/src/cli/cmd/debug/file.tspackages/opencode/src/cli/cmd/debug/ripgrep.tspackages/opencode/src/config/config.tspackages/opencode/src/file/index.tspackages/opencode/src/file/ripgrep.tspackages/opencode/src/format/index.tspackages/opencode/src/patch/index.tspackages/opencode/src/question/index.tspackages/opencode/src/server/instance/file.tspackages/opencode/src/server/instance/question.tspackages/opencode/src/session/compaction.tspackages/opencode/src/session/message-v2.tspackages/opencode/src/session/overflow.tspackages/opencode/src/tool/apply_patch.tspackages/opencode/src/tool/bash.tspackages/opencode/src/tool/edit.tspackages/opencode/src/tool/glob.tspackages/opencode/src/tool/grep.tspackages/opencode/src/tool/lsp.tspackages/opencode/src/tool/question.tspackages/opencode/src/tool/read.tspackages/opencode/src/tool/registry.tspackages/opencode/src/tool/skill.tspackages/opencode/src/tool/todo.tspackages/opencode/src/tool/tool.tspackages/opencode/src/tool/truncate.tspackages/opencode/src/tool/webfetch.tspackages/opencode/src/tool/write.tspackages/opencode/test/file/ripgrep.test.tspackages/opencode/test/question/question.test.tspackages/opencode/test/question/schema.test.tspackages/opencode/test/session/compaction.test.tspackages/opencode/test/tool/grep.test.ts
💤 Files with no reviewable changes (1)
- packages/opencode/test/session/compaction.test.ts
Astro-Han
left a comment
There was a problem hiding this comment.
Fresh-eye review pass: one additional P2 finding.
…-error bridges Stage E migrated the Tool framework to Effect Schema but kept the tool implementations on Zod with @ts-expect-error markers as a temporary expedient. This commit closes that gap: * question namespace fully migrated to Effect Schema (Schema.Class for Option / Info / Prompt / Tool / Request / Reply, Schema.Trim + isMinLength/isMaxLength preserves PawWork's product-level char and count constraints with the original LLM-repair hint wording). Server route and tool wrappers updated to consume the new shapes; question.test.ts updated to use Schema.decodeUnknownSync; old test inputs that violated options.min(2) now provide 2 options each. * tool/grep.ts parameters migrated to Effect Schema (drops the Zod-vs-Schema @ts-expect-error). Subprocess body left on PawWork's inline ripgrep adapter — full Ripgrep.Service migration is a follow-up. * tool/registry.ts wraps plugin Zod params via Schema.declare + ZodOverride bridge (matches upstream pattern); drops the plugin loader and QuestionTool @ts-expect-error markers. * format.file return type now boolean (true = formatter ran), edit / write / apply_patch take theirs from upstream and short-circuit Bom re-read when no formatter applied. * patch/index.ts ApplyPatchFileUpdate gains bom: boolean and now splits / re-joins BOM around content updates so apply_patch preserves leading BOM on round-trip (#23797 backport). * truncate.ts gains Truncate.limits() reading tool_output config; config schema gains tool_output { max_lines, max_bytes }; bash.ts threads limits() through the truncation cap (#23770 backport). * lsp.ts adopts upstream's permission metadata + variable title; touchFile second arg stays on PawWork's boolean API (LSP service refactor is out of scope for this PR). * todo / glob / read / webfetch / skill / tool.ts: take upstream formatting/import-style verbatim. No remaining Zod-vs-Effect-Schema @ts-expect-error in the tool tree. Refs: #209
630d3f6 to
e42987c
Compare
Outside-diff comment reply (
|
There was a problem hiding this comment.
Actionable comments posted: 4
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (3)
packages/opencode/src/tool/glob.ts (1)
11-27: 🧹 Nitpick | 🔵 TrivialMake
Parametersthe single source of truth.The new multi-field tool input is declared with
Schema.Struct, then re-described manually in theexecutehandler. Converting it toSchema.Classand deriving the handler arg type fromtypeof Parametersaligns with the coding guideline to useSchema.Classfor multi-field Effect schemas, preventing the runtime/type contract from drifting.♻️ Proposed refactor
-export const Parameters = Schema.Struct({ +export class Parameters extends Schema.Class<Parameters>("GlobParameters")({ pattern: Schema.String.annotate({ description: "The glob pattern to match files against" }), path: Schema.optional(Schema.String).annotate({ description: `The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter "undefined" or "null" - simply omit it for the default behavior. Must be a valid directory path if provided.`, }), -}) +}) {} ... - execute: (params: { pattern: string; path?: string }, ctx: Tool.Context) => + execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) =>🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/opencode/src/tool/glob.ts` around lines 11 - 27, The Parameters schema is declared with Schema.Struct but the handler re-specifies the fields; change Parameters to use Schema.Class for multi-field Effect schemas and make it the single source of truth, then update GlobTool.execute's parameter typing to derive from typeof Parameters (e.g., execute: (params: typeof Parameters.Type, ctx: Tool.Context) => ...) so the runtime and TypeScript types stay in sync; ensure any code that previously referenced the manual param shape (in GlobTool, execute, and any helper functions) uses the derived type and remove the duplicated manual declaration.packages/opencode/src/agent/prompt/compaction.txt (1)
9-10:⚠️ Potential issue | 🟠 MajorRemove the conversation-language override.
packages/opencode/src/session/compaction.tsappends an exact Markdown template with fixed section headings and order. “Respond in the same language as the conversation” will push the model to translate those headings in non-English sessions, which breaks that contract.Suggested fix
-Do not answer the conversation itself. Do not mention that you are summarizing, compacting, or merging context. Respond in the same language as the conversation. +Do not answer the conversation itself. Do not mention that you are summarizing, compacting, or merging context.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/opencode/src/agent/prompt/compaction.txt` around lines 9 - 10, Remove the language-override sentence from the compaction system prompt so the fixed Markdown headings from packages/opencode/src/session/compaction.ts aren’t translated; specifically edit packages/opencode/src/agent/prompt/compaction.txt to delete the line "Respond in the same language as the conversation." and keep the rest of the template intact so compaction.ts continues to append the exact Markdown section headings and order without forcing language changes.packages/opencode/src/session/compaction.ts (1)
497-501:⚠️ Potential issue | 🟠 MajorClear
tail_start_idwhen the new selection has no tail.This update only runs when
selected.tail_start_idis truthy. If the same compaction part is retried and the latest selection returnsundefined, the old boundary stays persisted.completedCompactions()now treats that field as authoritative, so a stale value can hide unsummarized history in later compactions.♻️ Proposed fix
- if (compactionPart && selected.tail_start_id && compactionPart.tail_start_id !== selected.tail_start_id) { + if (compactionPart && compactionPart.tail_start_id !== selected.tail_start_id) { yield* session.updatePart({ ...compactionPart, tail_start_id: selected.tail_start_id, }) }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/opencode/src/session/compaction.ts` around lines 497 - 501, The current conditional only updates the compaction part when selected.tail_start_id is truthy, so a later selection that clears the tail (undefined) won't persist that change; change the condition to run whenever compactionPart exists and its tail_start_id differs from selected.tail_start_id (i.e., use compactionPart && compactionPart.tail_start_id !== selected.tail_start_id) and call session.updatePart with tail_start_id set to selected.tail_start_id (allowing it to be cleared), so completedCompactions() sees the authoritative cleared value.
♻️ Duplicate comments (3)
packages/opencode/src/tool/lsp.ts (1)
41-45: 🛠️ Refactor suggestion | 🟠 MajorBind
execute()toSchema.Schema.Type<typeof Parameters>.The handler is back to a handwritten object shape, so
Parameterscan drift from the runtime-decoded input again.♻️ Suggested change
- execute: ( - args: { operation: (typeof operations)[number]; filePath: string; line: number; character: number }, - ctx: Tool.Context, - ) => + execute: (args: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) =>Run this to confirm
packages/opencode/src/tool/lsp.tsis the outlier among the migrated tools:#!/bin/bash set -euo pipefail rg -nC2 'execute:\s*\((args|params):' \ packages/opencode/src/tool/lsp.ts \ packages/opencode/src/tool/webfetch.ts \ packages/opencode/src/tool/question.ts \ packages/opencode/src/tool/skill.ts \ packages/opencode/src/tool/edit.ts🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/opencode/src/tool/lsp.ts` around lines 41 - 45, The execute handler in lsp.ts uses a handwritten args shape so Parameters can drift from the runtime-decoded input; update the execute signature to type its first parameter as Schema.Schema.Type<typeof Parameters> (instead of the inline { operation: ...; filePath: ... } shape), ensuring you import/namespace Schema the same way other tools do and adjust any callers/uses inside execute to the strongly-typed params; specifically modify the execute definition that references Parameters to use Schema.Schema.Type<typeof Parameters> so compile-time types match the runtime decoder.packages/opencode/test/file/ripgrep.test.ts (1)
83-97:⚠️ Potential issue | 🟡 MinorMake the glob-filter test fail when globbing regresses.
skip.txtstill does not contain"needle", so this test passes even ifglobis ignored and the search scans every file. Seed the skipped file with a matching row and assert that it stays excluded.Suggested test tightening
- await Bun.write(path.join(dir, "skip.txt"), "const value = 'other'\n") + await Bun.write(path.join(dir, "skip.txt"), "const value = 'needle'\n") @@ expect(result.partial).toBe(false) expect(result.items).toHaveLength(1) expect(result.items[0]?.path.text).toContain("match.ts") expect(result.items[0]?.lines.text).toContain("needle") + expect(result.items.some((item) => item.path.text.includes("skip.txt"))).toBe(false)🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/opencode/test/file/ripgrep.test.ts` around lines 83 - 97, The test "search returns matched rows with glob filter" is too weak because skip.txt lacks the search term so the test would pass even if the glob filter is ignored; update the tmpdir init used by Ripgrep.Service.use in the test to write "needle" into skip.txt (or another matching string) and then assert that rg.search({ cwd: tmp.path, pattern: "needle", glob: ["*.ts"] }) returns only the match from "match.ts" (expect result.items length 1 and that the skipped file's match is not present), ensuring the glob filtering is actually enforced by Ripgrep.Service.use -> rg.search.packages/opencode/src/tool/write.ts (1)
57-76:⚠️ Potential issue | 🟠 MajorPost-format
diffis currently dropped on the floor.
diffis only consumed when the metadata object is created forctx.ask()at Lines 58-66. Reassigning the local variable at Line 76 does not update that already-built object, and nothing later in this function reads the new value, so the recompute has no effect.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/opencode/src/tool/write.ts` around lines 57 - 76, The posted diff recomputes diff after formatting but never uses it because ctx.ask(...) is called earlier with the original metadata.diff; fix by ensuring the metadata passed to ctx.ask contains the post-format diff: perform formatting and Bom.syncFile (using format.file and Bom.syncFile) before constructing the metadata object (or recompute and replace metadata.diff right before yielding ctx.ask), so that the trimDiff(createTwoFilesPatch(...)) result assigned to diff is the value included in the metadata sent in ctx.ask; reference variables/functions: diff, ctx.ask, metadata.diff, fs.writeWithDirs, format.file, Bom.syncFile, createTwoFilesPatch, trimDiff.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@packages/opencode/src/cli/cmd/debug/file.ts`:
- Around line 81-84: The output variable `files` from the Ripgrep call (via
AppRuntime.runPromise -> Ripgrep.Service.use -> rg.tree) is already a formatted
string, so change the debug print to emit raw text instead of JSON-encoding it;
replace the JSON.stringify usage with a plain text print (e.g.,
console.log(files) or process.stdout.write(files + '\n')) so the tree is printed
with proper newlines and formatting.
In `@packages/opencode/src/session/message-v2.ts`:
- Around line 748-750: The interrupted-tool branch still uses
part.state.metadata.output verbatim, bypassing the compaction cap; update the
branch that sets outputText for non-completed/error tool parts to pass the
interrupted output through truncateToolOutput(...) (same as the completed
branch) so toolOutputMaxChars is applied uniformly—search for the outputText
assignment and the use of part.state.metadata.output in message-v2.ts and
replace the raw metadata output with
truncateToolOutput(part.state.metadata.output, options?.toolOutputMaxChars) (or
reuse the existing truncateToolOutput call used for part.state.output) to
enforce the cap.
In `@packages/opencode/src/tool/edit.ts`:
- Line 48: The schema description for filePath (Schema.String.annotate) is too
strict—update its description to reflect the actual behavior of the edit tool
which accepts both absolute and relative paths (relative paths are resolved per
the implementation) instead of saying it must be an absolute path; change the
text in the filePath annotation to indicate it accepts absolute or relative
paths and how relative paths are interpreted.
In `@packages/opencode/src/tool/webfetch.ts`:
- Line 19: Update the Schema description for the timeout field in webfetch.ts
(the timeout: Schema.optional(...) declaration) to advertise the lower bound as
well (e.g. "Optional timeout in seconds (1–120)"/"must be a positive number up
to 120") so callers know 0 and negatives are invalid; keep the runtime guards in
execute() (the checks around lines rejecting 0/negative values) as the
enforcement mechanism but make the Schema description reflect that timeout must
be positive and not zero.
---
Outside diff comments:
In `@packages/opencode/src/agent/prompt/compaction.txt`:
- Around line 9-10: Remove the language-override sentence from the compaction
system prompt so the fixed Markdown headings from
packages/opencode/src/session/compaction.ts aren’t translated; specifically edit
packages/opencode/src/agent/prompt/compaction.txt to delete the line "Respond in
the same language as the conversation." and keep the rest of the template intact
so compaction.ts continues to append the exact Markdown section headings and
order without forcing language changes.
In `@packages/opencode/src/session/compaction.ts`:
- Around line 497-501: The current conditional only updates the compaction part
when selected.tail_start_id is truthy, so a later selection that clears the tail
(undefined) won't persist that change; change the condition to run whenever
compactionPart exists and its tail_start_id differs from selected.tail_start_id
(i.e., use compactionPart && compactionPart.tail_start_id !==
selected.tail_start_id) and call session.updatePart with tail_start_id set to
selected.tail_start_id (allowing it to be cleared), so completedCompactions()
sees the authoritative cleared value.
In `@packages/opencode/src/tool/glob.ts`:
- Around line 11-27: The Parameters schema is declared with Schema.Struct but
the handler re-specifies the fields; change Parameters to use Schema.Class for
multi-field Effect schemas and make it the single source of truth, then update
GlobTool.execute's parameter typing to derive from typeof Parameters (e.g.,
execute: (params: typeof Parameters.Type, ctx: Tool.Context) => ...) so the
runtime and TypeScript types stay in sync; ensure any code that previously
referenced the manual param shape (in GlobTool, execute, and any helper
functions) uses the derived type and remove the duplicated manual declaration.
---
Duplicate comments:
In `@packages/opencode/src/tool/lsp.ts`:
- Around line 41-45: The execute handler in lsp.ts uses a handwritten args shape
so Parameters can drift from the runtime-decoded input; update the execute
signature to type its first parameter as Schema.Schema.Type<typeof Parameters>
(instead of the inline { operation: ...; filePath: ... } shape), ensuring you
import/namespace Schema the same way other tools do and adjust any callers/uses
inside execute to the strongly-typed params; specifically modify the execute
definition that references Parameters to use Schema.Schema.Type<typeof
Parameters> so compile-time types match the runtime decoder.
In `@packages/opencode/src/tool/write.ts`:
- Around line 57-76: The posted diff recomputes diff after formatting but never
uses it because ctx.ask(...) is called earlier with the original metadata.diff;
fix by ensuring the metadata passed to ctx.ask contains the post-format diff:
perform formatting and Bom.syncFile (using format.file and Bom.syncFile) before
constructing the metadata object (or recompute and replace metadata.diff right
before yielding ctx.ask), so that the trimDiff(createTwoFilesPatch(...)) result
assigned to diff is the value included in the metadata sent in ctx.ask;
reference variables/functions: diff, ctx.ask, metadata.diff, fs.writeWithDirs,
format.file, Bom.syncFile, createTwoFilesPatch, trimDiff.
In `@packages/opencode/test/file/ripgrep.test.ts`:
- Around line 83-97: The test "search returns matched rows with glob filter" is
too weak because skip.txt lacks the search term so the test would pass even if
the glob filter is ignored; update the tmpdir init used by Ripgrep.Service.use
in the test to write "needle" into skip.txt (or another matching string) and
then assert that rg.search({ cwd: tmp.path, pattern: "needle", glob: ["*.ts"] })
returns only the match from "match.ts" (expect result.items length 1 and that
the skipped file's match is not present), ensuring the glob filtering is
actually enforced by Ripgrep.Service.use -> rg.search.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: 83d70cee-49e5-43a8-825a-efb41fbf66a0
📒 Files selected for processing (34)
packages/opencode/src/agent/prompt/compaction.txtpackages/opencode/src/cli/cmd/debug/file.tspackages/opencode/src/cli/cmd/debug/ripgrep.tspackages/opencode/src/config/config.tspackages/opencode/src/file/index.tspackages/opencode/src/file/ripgrep.tspackages/opencode/src/format/index.tspackages/opencode/src/patch/index.tspackages/opencode/src/question/index.tspackages/opencode/src/server/instance/file.tspackages/opencode/src/server/instance/question.tspackages/opencode/src/session/compaction.tspackages/opencode/src/session/message-v2.tspackages/opencode/src/session/overflow.tspackages/opencode/src/tool/apply_patch.tspackages/opencode/src/tool/bash.tspackages/opencode/src/tool/edit.tspackages/opencode/src/tool/glob.tspackages/opencode/src/tool/grep.tspackages/opencode/src/tool/lsp.tspackages/opencode/src/tool/question.tspackages/opencode/src/tool/read.tspackages/opencode/src/tool/registry.tspackages/opencode/src/tool/skill.tspackages/opencode/src/tool/todo.tspackages/opencode/src/tool/tool.tspackages/opencode/src/tool/truncate.tspackages/opencode/src/tool/webfetch.tspackages/opencode/src/tool/write.tspackages/opencode/test/file/ripgrep.test.tspackages/opencode/test/question/question.test.tspackages/opencode/test/question/schema.test.tspackages/opencode/test/session/compaction.test.tspackages/opencode/test/tool/grep.test.ts
💤 Files with no reviewable changes (1)
- packages/opencode/test/session/compaction.test.ts
Replace PawWork's standalone async Ripgrep.{search,files,tree} +
inline subprocess management in tool/grep.ts with upstream's full
Ripgrep.Service abstraction.
Performance: search results stream line-by-line via Stream.decodeText
+ splitLines + mapEffect(parse) + runCollect, parsed concurrently
with subprocess execution. Replaces buffered approach (whole stdout
into one string, then split/parse) which spiked memory on large
result sets and added subprocess-exit-to-first-result latency.
Callers migrated to the Service pattern:
- tool/grep.ts: rg.search() with abort signal
- server/instance/file.ts: AppRuntime.runPromise(Ripgrep.Service.use(...))
- cli/cmd/debug/{ripgrep,file}.ts: same
- file/index.ts: rg yielded once at layer init
Preserved PawWork behavior:
- Invalid regex (ripgrep exit code 2 with empty results) throws an
explicit error; without this guard the LLM would see "No files
found" and miss the pattern bug. Upstream Service marks code 2 as
partial without distinguishing invalid-regex from inaccessible-paths.
Removed:
- "kills ripgrep promptly when aborted" test — mocked at the
ChildProcessSpawner level which doesn't fit the new Service
abstraction. Subprocess lifecycle is now Service.search's
responsibility (raceAbort + scoped spawn).
Refs anomalyco/opencode 17701628bd
PawWork issue: #209
e42987c to
3155c4d
Compare
Outside-diff comment replies (round 6)
|
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
packages/opencode/src/file/index.ts (1)
392-403:⚠️ Potential issue | 🟡 MinorNormalize derived directory keys before appending
/.On Windows,
path.dirname(current)returns values likesrc\\nested, sodir + "/"stores mixed separators (src\\nested/). That makes cached directory results inconsistent and degrades slash-based lookups.🛠️ Proposed fix
- next.dirs.push(dir + "/") + next.dirs.push(dir.split(path.sep).join("/") + "/")🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/opencode/src/file/index.ts` around lines 392 - 403, The code pushes directory keys using path.dirname(current) and then appending "/" which on Windows yields mixed separators (e.g., "src\\nested/"); update the loop that computes dir (the block using path.dirname(current) and pushing to next.dirs and seen) to normalize dir to a consistent forward-slash form before adding the trailing slash (e.g., convert backslashes to "/" or use a POSIX-normalizer) so stored keys are uniform; ensure you check seen against the normalized key and push normalized + "/" to next.dirs (referencing the loop that uses files, current, path.dirname(current), seen, and next.dirs).
♻️ Duplicate comments (1)
packages/opencode/src/session/compaction.ts (1)
405-418:⚠️ Potential issue | 🟠 MajorHide fully summarized history even when
tailStartIdisundefined.A completed compaction can legitimately clear
tail_start_idwhen it summarized all prior history. In that casepreviousSummaryis still reused, but this branch hides nothing, so the next compaction sends the entire pre-summary history again and re-summarizes it.♻️ Possible fix
const latestPrior = prior.at(-1) - if (latestPrior?.tailStartId) { - const tailIndex = history.findIndex((m) => m.info.id === latestPrior.tailStartId) - if (tailIndex > 0) { - for (let i = 0; i < tailIndex; i++) hidden.add(i) - } + if (latestPrior?.summary) { + if (latestPrior.tailStartId) { + const tailIndex = history.findIndex((m) => m.info.id === latestPrior.tailStartId) + if (tailIndex > 0) { + for (let i = 0; i < tailIndex; i++) hidden.add(i) + } + } else { + for (let i = 0; i < latestPrior.userIndex; i++) hidden.add(i) + } }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/opencode/src/session/compaction.ts` around lines 405 - 418, The code currently only hides messages when latestPrior.tailStartId is set; add a branch to also hide all prior history when a completed compaction exists but tailStartId is undefined (meaning it summarized all previous messages). Concretely, after computing latestPrior, if latestPrior exists and latestPrior.tailStartId is undefined, mark indices 0..history.length-1 (or at least all indices before the current head) as hidden (e.g., loop i from 0 to history.length-1 and hidden.add(i)), preserving reuse of previousSummary; keep the existing tailStartId-based logic for the other case.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@packages/opencode/src/file/ripgrep.ts`:
- Around line 469-470: The loop in ripgrep.ts currently filters files with
file.includes(".opencode") which incorrectly matches any path containing that
substring; instead split the path into segments and test for an exact segment
named ".opencode" (e.g., split the file string on path separators or use
path.sep/path utilities) so only entries where one segment equals ".opencode"
are skipped — update the for (const file of list) check to test
pathSegments.includes(".opencode") using the existing variables 'file' and
'list'.
---
Outside diff comments:
In `@packages/opencode/src/file/index.ts`:
- Around line 392-403: The code pushes directory keys using
path.dirname(current) and then appending "/" which on Windows yields mixed
separators (e.g., "src\\nested/"); update the loop that computes dir (the block
using path.dirname(current) and pushing to next.dirs and seen) to normalize dir
to a consistent forward-slash form before adding the trailing slash (e.g.,
convert backslashes to "/" or use a POSIX-normalizer) so stored keys are
uniform; ensure you check seen against the normalized key and push normalized +
"/" to next.dirs (referencing the loop that uses files, current,
path.dirname(current), seen, and next.dirs).
---
Duplicate comments:
In `@packages/opencode/src/session/compaction.ts`:
- Around line 405-418: The code currently only hides messages when
latestPrior.tailStartId is set; add a branch to also hide all prior history when
a completed compaction exists but tailStartId is undefined (meaning it
summarized all previous messages). Concretely, after computing latestPrior, if
latestPrior exists and latestPrior.tailStartId is undefined, mark indices
0..history.length-1 (or at least all indices before the current head) as hidden
(e.g., loop i from 0 to history.length-1 and hidden.add(i)), preserving reuse of
previousSummary; keep the existing tailStartId-based logic for the other case.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: 5aa885e5-c7cc-4c5b-af1b-c4fbeda7b98d
📒 Files selected for processing (14)
packages/opencode/src/agent/prompt/compaction.txtpackages/opencode/src/cli/cmd/debug/file.tspackages/opencode/src/cli/cmd/debug/ripgrep.tspackages/opencode/src/file/index.tspackages/opencode/src/file/ripgrep.tspackages/opencode/src/server/instance/file.tspackages/opencode/src/session/compaction.tspackages/opencode/src/session/message-v2.tspackages/opencode/src/session/overflow.tspackages/opencode/src/tool/grep.tspackages/opencode/src/tool/read.tspackages/opencode/test/file/ripgrep.test.tspackages/opencode/test/session/compaction.test.tspackages/opencode/test/tool/grep.test.ts
💤 Files with no reviewable changes (1)
- packages/opencode/test/session/compaction.test.ts
Replace PawWork's anchored-summary prompt + algorithm with upstream's
structured Markdown template (## Goal / ## Constraints & Preferences
/ ## Progress / ## Key Decisions / ## Next Steps / ## Critical Context
/ ## Relevant Files). Tail-message preservation algorithm now uses
Turn / Tail / CompletedCompaction data structures.
Why: PawWork's anchored prompt was an unsynced inheritance, not a
documented product decision. Upstream's structured template gives
more reliable LLM compliance and a parseable shape for downstream
consumers.
Patched on top of upstream:
- overflow.ts: when `compaction.reserved` is set on a model without
an explicit input cap, honor reserved against context. Upstream's
branch dropped reserved in this code path; without the patch a
user config like `compaction: { reserved: 50_000 }` is silently
ignored. Reported via test "respects reserved override without
input caps".
Adapted for PawWork:
- BusEvent.define stays Zod-typed (PawWork's bus-event is Zod);
bridge with z.object + .zod on SessionID.
- message-v2.toModelMessages*: gained `toolOutputMaxChars` option so
compaction can truncate tool output before summarizing
(TOOL_OUTPUT_MAX_CHARS = 2000). Helper `truncateToolOutput` added
to message-v2.
Removed:
- "summarizes all history when latest turn exceeds tail budget" test
— asserted PawWork's old algo edge case (compact everything and
set tail_start_id=undefined). Upstream's algo always preserves the
last turn raw even when it exceeds budget, which is the better
product behavior (no immediate-context loss). Other 42 compaction
tests still pass.
Refs anomalyco/opencode 17701628bd
PawWork issue: #209
Two PawWork product fixes (commits 34b37d3, af6c079) were silently reverted when Stage E took theirs on tool/read.ts. Restore both on top of the upstream-aligned read.ts: * LSP warm hook removal — reading a file no longer calls lsp.touchFile. Aligns with industry baseline (Codex / Gemini / Claude Code skip this in their read paths) and matches PawWork's on-demand LSP activation model. LSP work happens only on edit / write / apply_patch where diagnostics are actually consumed. * Binary detection split — extension-based check (isBinaryByExt) rejects known binary extensions before sampling, with the generic .bin / .dat sniff-first carve-out so renamed image / PDF attachments survive. Content-based check (isBinaryByContent) rejects on null bytes or >30% non-printable. Error messages include "(extension: .X)" or "(content inspection)" so the LLM can disambiguate the rejection cause when retrying. * Oversized attachment guard — image / PDF over 5 MB is rejected before base64 encoding to keep the model context safe. Fixes 4 read.test.ts failures inherited from PR4 base. Refs anomalyco/opencode#23244 (Stage E origin), PawWork #232 PawWork issue: #209
3155c4d to
2b69f7f
Compare
Outside-diff comment replies (round 7)
|
There was a problem hiding this comment.
Actionable comments posted: 1
♻️ Duplicate comments (1)
packages/opencode/src/tool/read.ts (1)
28-34:⚠️ Potential issue | 🟡 MinorBroaden the parameter descriptions to match runtime behavior.
filePathis described as absolute-only, but Lines 165-168 resolve relative paths too. Likewise,offsetandlimitnow paginate directory entries at Lines 195-213, not just file lines. These annotations are shown to tool callers, so the mismatch is user-visible.♻️ Suggested wording update
export const Parameters = Schema.Struct({ - filePath: Schema.String.annotate({ description: "The absolute path to the file or directory to read" }), + filePath: Schema.String.annotate({ description: "The absolute or relative path to the file or directory to read" }), offset: Schema.optional(Schema.Number).annotate({ - description: "The line number to start reading from (1-indexed)", + description: "The 1-indexed line or directory-entry number to start reading from", }), limit: Schema.optional(Schema.Number).annotate({ - description: "The maximum number of lines to read (defaults to 2000)", + description: "The maximum number of lines or directory entries to read (defaults to 2000)", }), })🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/opencode/src/tool/read.ts` around lines 28 - 34, The parameter docs on the Schema annotations are too narrow: update the Schema annotations for filePath, offset, and limit in packages/opencode/src/tool/read.ts so they reflect actual runtime behavior — change filePath description to allow absolute or relative paths (since the code resolves relative paths), change offset to say it is a 1-indexed start for either file line reads or directory-entry pagination, and change limit to say it limits the maximum number of lines or directory entries returned (defaults to 2000); adjust the brief wording for clarity so callers see the correct behavior used by the resolve-relative-path and directory-pagination logic in this file.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@packages/opencode/src/tool/read.ts`:
- Around line 27-35: The Parameters export currently uses Schema.Struct; replace
it with Schema.Class to follow the Effect multi-field schema convention: change
the declaration of Parameters (export const Parameters = Schema.Struct({...}))
to use Schema.Class({...}) while preserving the same fields (filePath, offset,
limit) and their annotations so the schema shape and descriptions remain
identical.
---
Duplicate comments:
In `@packages/opencode/src/tool/read.ts`:
- Around line 28-34: The parameter docs on the Schema annotations are too
narrow: update the Schema annotations for filePath, offset, and limit in
packages/opencode/src/tool/read.ts so they reflect actual runtime behavior —
change filePath description to allow absolute or relative paths (since the code
resolves relative paths), change offset to say it is a 1-indexed start for
either file line reads or directory-entry pagination, and change limit to say it
limits the maximum number of lines or directory entries returned (defaults to
2000); adjust the brief wording for clarity so callers see the correct behavior
used by the resolve-relative-path and directory-pagination logic in this file.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: fe29c3ea-dd32-4877-b31c-609657f19fdd
📒 Files selected for processing (6)
packages/opencode/src/agent/prompt/compaction.txtpackages/opencode/src/session/compaction.tspackages/opencode/src/session/message-v2.tspackages/opencode/src/session/overflow.tspackages/opencode/src/tool/read.tspackages/opencode/test/session/compaction.test.ts
💤 Files with no reviewable changes (1)
- packages/opencode/test/session/compaction.test.ts
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (12)
- GitHub Check: unit-windows-opencode-config-project
- GitHub Check: unit-windows-app
- GitHub Check: unit-windows-opencode-session
- GitHub Check: unit-app
- GitHub Check: unit-windows-desktop
- GitHub Check: unit-windows-opencode-server-tools
- GitHub Check: unit-desktop
- GitHub Check: unit-opencode
- GitHub Check: typecheck
- GitHub Check: smoke-macos-arm64
- GitHub Check: e2e-artifacts
- GitHub Check: analyze-js-ts
🧰 Additional context used
📓 Path-based instructions (1)
packages/opencode/**/*.ts
📄 CodeRabbit inference engine (packages/opencode/AGENTS.md)
packages/opencode/**/*.ts: UseEffect.gen(function* () { ... })for Effect composition
UseEffect.fn("Domain.method")for named/traced effects andEffect.fnUntracedfor internal helpers; these accept pipeable operators as extra arguments to avoid unnecessary outer.pipe()wrappers
UseEffect.callbackfor callback-based APIs
PreferDateTime.nowAsDateovernew Date(yield* Clock.currentTimeMillis)when you need aDatein Effect code
UseSchema.Classfor multi-field data in Effect schemas
Use branded schemas (Schema.brand) for single-value types in Effect
UseSchema.TaggedErrorClassfor typed errors in Effect schemas
UseSchema.Defectinstead ofunknownfor defect-like causes in Effect code
InEffect.gen/Effect.fn, preferyield* new MyError(...)overyield* Effect.fail(new MyError(...))for direct early-failure branches
UsemakeRuntimefromsrc/effect/run-service.tsfor all services; it returns{ runPromise, runFork, runCallback }backed by a sharedmemoMapthat deduplicates layers
UseInstanceStatefromsrc/effect/instance-state.tsfor per-directory or per-project state that needs per-instance cleanup; do work directly in theInstanceState.makeclosure whereScopedCachehandles run-once semantics
UseEffect.addFinalizerorEffect.acquireReleaseinside theInstanceState.makeclosure for cleanup (subscriptions, process teardown, etc.)
UseEffect.forkScopedinside theInstanceState.makeclosure for background stream consumers — the fiber is interrupted when the instance is disposed
PreferFileSystem.FileSysteminstead of rawfs/promisesfor effectful file I/O in Effect services
PreferChildProcessSpawner.ChildProcessSpawnerwithChildProcess.make(...)instead of custom process wrappers in Effect services
PreferHttpClient.HttpClientinstead of rawfetchin Effect services
PreferPath.Path,Config,Clock, andDateTimeservices when those concerns are already inside Effect code
For backgroun...
Files:
packages/opencode/src/session/overflow.tspackages/opencode/src/session/message-v2.tspackages/opencode/src/tool/read.tspackages/opencode/src/session/compaction.ts
🧠 Learnings (49)
📓 Common learnings
Learnt from: Astro-Han
Repo: Astro-Han/pawwork PR: 270
File: packages/opencode/src/patch/index.ts:337-346
Timestamp: 2026-04-28T04:38:05.946Z
Learning: In Astro-Han/pawwork (`packages/opencode/src/patch/index.ts`), the BOM-transition surfacing gap — where `Bom.split` strips BOM before building `unified_diff`/`new_content` but `Bom.join` later re-attaches BOM on disk write, meaning BOM changes are not reflected in the diff payload — is intentionally deferred. PR `#270` is an upstream-sync graft; fixing the issue here would mix refactor + bugfix intents and drift the diff from the upstream baseline needed for clean future grafts. A dedicated follow-up PR (or upstream report) will address this. Do NOT re-flag the missing BOM-change surfacing in `ApplyPatchFileUpdate`/`ApplyPatchFileChange` as a blocking issue in PR `#270` or in future sync PRs that carry the same upstream baseline.
Learnt from: Astro-Han
Repo: Astro-Han/pawwork PR: 270
File: packages/opencode/src/tool/question.ts:6-16
Timestamp: 2026-04-28T06:47:20.342Z
Learning: In Astro-Han/pawwork, `packages/opencode/src/tool/question.ts` is a permanent PawWork carve-out: `Question.Prompt` (defined in `packages/opencode/src/question/index.ts`) remains a Zod schema, NOT an Effect Schema. The `ts-expect-error` boundary in `tool/registry.ts` marks the Zod ↔ Effect Schema seam. The dropped `packages/opencode/test/tool/question.test.ts` covered the old Zod-mixed surface. Schema-bound regression tests for this tool are intentionally deferred until `question/index.ts` migrates to Effect Schema in its own dedicated PR. Do NOT request restoring question tool tests in upstream-sync PRs; flag it only in the PR that migrates `question/index.ts` to Effect Schema.
Learnt from: Astro-Han
Repo: Astro-Han/pawwork PR: 270
File: packages/opencode/test/tool/tool-define.test.ts:2-9
Timestamp: 2026-04-28T04:56:21.338Z
Learning: In `packages/opencode/test/tool/tool-define.test.ts` (Astro-Han/pawwork, PR `#270`), the test suite intentionally uses a `ManagedRuntime.make(Layer.mergeAll(Truncate.defaultLayer, Agent.defaultLayer))` wrapper and raw `bun:test` `test(...)` cases rather than the `testEffect(...)` harness. This code was adopted wholesale from upstream via the graft strategy (per `project_upstream_strategy.md`). Migrating to `testEffect` is deferred to a follow-up PR so as not to mix refactor + harness-migration intents and to avoid drifting the diff from the upstream baseline needed for clean future grafts. Do NOT re-flag the `ManagedRuntime.make` usage in this file as needing harness migration until that follow-up lands.
Learnt from: Astro-Han
Repo: Astro-Han/pawwork PR: 270
File: packages/opencode/src/tool/tool.ts:34-42
Timestamp: 2026-04-28T04:56:21.528Z
Learning: In `packages/opencode/src/tool/tool.ts` (Astro-Han/pawwork, PR `#270`), the `Def.execute` signature uses `ctx: Context` (without the `M` generic) instead of `ctx: Context<M>`, meaning `ctx.metadata(...)` is not checked against the same `M` as `ExecuteResult<M>`. This is inherited upstream behavior adopted via the graft strategy in PR `#270` (an upstream-sync PR per `project_upstream_strategy.md`). Fixing it here would mix refactor + bugfix intents and drift the diff from the upstream baseline. The fix is deferred to a dedicated follow-up PR or upstream report. Do NOT re-flag the missing `Context<M>` generic in `Def.execute` during upstream-sync PRs; only flag it in a PawWork-authored PR that directly touches `tool.ts` type signatures.
Learnt from: Astro-Han
Repo: Astro-Han/pawwork PR: 270
File: packages/opencode/src/file/ripgrep.ts:29-118
Timestamp: 2026-04-28T07:27:41.971Z
Learning: In `packages/opencode/src/file/ripgrep.ts` (Astro-Han/pawwork), the internal Effect schemas `TimeStats`, `Stats`, `PathText`, `Begin`, `End`, `Summary`, and the exported `SearchMatch`/`Match`/`Result` union are all declared as `Schema.Struct` — matching upstream `dev:packages/opencode/src/file/ripgrep.ts` lines 60-118. PR `#270` is an upstream-sync graft per `project_upstream_strategy.md`. Converting these to `Schema.Class` would diverge from the upstream baseline and create recurring merge friction on future syncs. The `Schema.Class` convention applies to PawWork-authored schemas, not to upstream-grafted shapes in this file. Do NOT re-flag the use of `Schema.Struct` for these schemas in upstream-sync PRs; flag it only in a PR that simultaneously converts the upstream side, or in a dedicated PawWork carve-out PR for ripgrep schemas.
Learnt from: Astro-Han
Repo: Astro-Han/pawwork PR: 270
File: packages/opencode/test/tool/grep.test.ts:16-22
Timestamp: 2026-04-28T05:36:25.456Z
Learning: In `packages/opencode/test/tool/grep.test.ts` (Astro-Han/pawwork, PR `#270`), the test suite intentionally uses a manual `ManagedRuntime.make(Layer.mergeAll(...))` setup and raw `bun:test` test cases rather than the `testEffect(...)` harness. The migration to `testEffect` + `it.live(...)` + `provideTmpdirInstance(...)` is a whole-file rewrite deferred to a dedicated sweep PR that will migrate all tool tests still on the manual ManagedRuntime pattern together. Do NOT re-flag the ManagedRuntime setup in this file as needing harness migration until that sweep PR lands.
Learnt from: Astro-Han
Repo: Astro-Han/pawwork PR: 270
File: packages/opencode/test/file/ripgrep.test.ts:9-10
Timestamp: 2026-04-28T04:38:21.935Z
Learning: In `packages/opencode/test/file/ripgrep.test.ts` (Astro-Han/pawwork, PR `#270`), the tests intentionally use a local `run` helper (`effect.pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromise)`) rather than the `testEffect(...)` harness. This file was adopted from upstream wholesale as part of an upstream-sync graft; migrating to `testEffect` + `it.live(...)` was deferred to a separate follow-up PR to avoid mixing refactor and harness-migration intents within the sync. Do NOT re-flag the local `run` wrapper as needing harness migration until that follow-up lands.
Learnt from: Astro-Han
Repo: Astro-Han/pawwork PR: 270
File: packages/opencode/src/tool/edit.ts:35-45
Timestamp: 2026-04-28T04:38:11.727Z
Learning: In `packages/opencode/src/tool/edit.ts` (Astro-Han/pawwork, PR `#270`), the module-scoped `Map<string, Semaphore.Semaphore>` (`locks`) and `lock(filePath)` helper are intentionally left as a global, never-cleaned registry. PR `#270` is a pure upstream-sync graft and mixing in a bugfix would drift the diff from the upstream baseline. The fix (moving per-file semaphores into `InstanceState`/`ScopedCache` for per-instance lifecycle management) is tracked as a follow-up to land in its own PR or be reported upstream. Do NOT re-flag the absence of InstanceState wiring for the lock map in this file until the follow-up PR is opened.
Learnt from: Astro-Han
Repo: Astro-Han/pawwork PR: 270
File: packages/opencode/src/file/ripgrep.ts:469-470
Timestamp: 2026-04-28T08:29:02.858Z
Learning: In `packages/opencode/src/file/ripgrep.ts` (Astro-Han/pawwork, PR `#270`), the `tree()` function filters files with `if (file.includes(".opencode")) continue`, which is a raw substring match that would also drop unrelated paths whose names merely contain `.opencode` (e.g., `.opencode-backup`). The correct fix is `if (file.split(path.sep).includes(".opencode")) continue` to match only the exact path segment. However, upstream `dev:packages/opencode/src/file/ripgrep.ts` carries the identical false-positive filter. PR `#270` is an upstream-sync graft; fixing it here would diverge from the baseline. The fix is deferred to a PawWork ripgrep filter cleanup PR or an upstream-first fix, to land alongside the other ripgrep deferrals (Schema.Class, bytes-union, abort short-circuit, mixed-separator). Do NOT re-flag the `.opencode` substring filter in upstream-sync PRs.
Learnt from: Astro-Han
Repo: Astro-Han/pawwork PR: 270
File: packages/opencode/src/tool/todo.ts:9-18
Timestamp: 2026-04-28T06:51:54.812Z
Learning: In `packages/opencode/src/tool/todo.ts` (Astro-Han/pawwork), `TodoItem.status` and `TodoItem.priority` are intentionally declared as plain `Schema.String` rather than closed literal unions. This matches the upstream opencode baseline (`dev:packages/opencode/src/tool/todo.ts`). The tightening — `Schema.Literals(["pending","in_progress","completed","cancelled"])` for `status` and `Schema.Literals(["high","medium","low"])` for `priority` — is tracked as a follow-up under the harness/tool-set-v1 series (issue `#129`) to land either as part of a tool-schema tightening sweep or upstream-first. Do NOT re-flag the free-form strings for these fields in upstream-sync PRs; flag it only in a PawWork-authored PR or the dedicated sweep that touches `TodoItem` schema.
Learnt from: CR
Repo: Astro-Han/pawwork PR: 0
File: packages/opencode/AGENTS.md:0-0
Timestamp: 2026-04-20T14:36:21.288Z
Learning: Applies to packages/opencode/**/*.ts : Prefer `FileSystem.FileSystem` instead of raw `fs/promises` for effectful file I/O in Effect services
Learnt from: Astro-Han
Repo: Astro-Han/pawwork PR: 280
File: packages/opencode/src/tool/todo.ts:7-22
Timestamp: 2026-04-28T02:24:27.738Z
Learning: In `packages/opencode/src/tool/todo.ts` (Astro-Han/pawwork, PR `#280`), the `parameters` constant for `TodoWriteTool` intentionally duplicates the `Todo.Info` fields inline rather than referencing `Todo.Info` or `zodObject(Todo.Info)`. After the Effect Schema migration in this PR, `Todo.Info` is an Effect Schema, so `zodObject(Todo.Info)` returns `z.ZodObject<any>` — which erases per-field types that `Tool.define`'s `execute()` inference depends on. The inline Zod schema is a deliberate temporary measure; tool schemas will migrate to Effect Schema as a separate slice per `specs/effect/schema.md`. Do NOT suggest replacing the inline schema with `z.array(Todo.Info)` or `zodObject(Todo.Info)` until that migration lands.
Learnt from: Astro-Han
Repo: Astro-Han/pawwork PR: 270
File: packages/opencode/src/session/compaction.ts:169-191
Timestamp: 2026-04-28T05:36:22.128Z
Learning: In `packages/opencode/src/session/compaction.ts` (Astro-Han/pawwork), the internal helper `splitTurn` intentionally returns a raw `Effect.gen(...)` rather than being wrapped with `Effect.fnUntraced`. Converting `splitTurn` (and similar plain-Effect.gen internal helpers in this file, e.g. `turns`, `completedCompactions`, `buildPrompt`) to `Effect.fnUntraced` is a repo-wide convention sweep deferred to a dedicated follow-up PR. The same sweep covers `tool/tool.ts` and other helpers flagged in the same sync. Do NOT re-flag the absence of `Effect.fnUntraced` on `splitTurn` or other internal helpers in `compaction.ts` during upstream-sync PRs; flag it only in the dedicated convention-sweep PR.
Learnt from: CR
Repo: Astro-Han/pawwork PR: 0
File: packages/opencode/AGENTS.md:0-0
Timestamp: 2026-04-20T14:36:21.288Z
Learning: Applies to packages/opencode/**/*.ts : Prefer `Path.Path`, `Config`, `Clock`, and `DateTime` services when those concerns are already inside Effect code
Learnt from: CR
Repo: Astro-Han/pawwork PR: 0
File: packages/opencode/AGENTS.md:0-0
Timestamp: 2026-04-20T14:36:21.288Z
Learning: Applies to packages/opencode/**/*.ts : Use `InstanceState` from `src/effect/instance-state.ts` for per-directory or per-project state that needs per-instance cleanup; do work directly in the `InstanceState.make` closure where `ScopedCache` handles run-once semantics
📚 Learning: 2026-04-27T12:59:49.844Z
Learnt from: Astro-Han
Repo: Astro-Han/pawwork PR: 264
File: packages/opencode/test/session/prompt-effect.test.ts:0-0
Timestamp: 2026-04-27T12:59:49.844Z
Learning: In `packages/opencode/test/session/prompt-effect.test.ts` and `packages/opencode/src/session/diagnostics.ts` (PR `#264`), the recovery reminder copy differs between signature kinds: the input-repeat variant says "repeated the same tool input 3 times" (uses a literal count), while the target-repeat variant says "failed against the same target multiple times" (uses "multiple times" with no count). Assertions that check for injected reminder text in LLM inputs must accept both phrasings when a scenario produces both `input:` and `target:` signatures (e.g., `read` tool with a `filePath` parameter). Do NOT narrow the assertion to only the input-variant phrasing.
Applied to files:
packages/opencode/src/agent/prompt/compaction.txtpackages/opencode/src/session/overflow.tspackages/opencode/src/session/message-v2.tspackages/opencode/src/session/compaction.ts
📚 Learning: 2026-04-27T10:33:08.974Z
Learnt from: Astro-Han
Repo: Astro-Han/pawwork PR: 264
File: packages/opencode/src/session/prompt.ts:108-169
Timestamp: 2026-04-27T10:33:08.974Z
Learning: For Astro-Han/pawwork session processing code under packages/opencode/src/session/ (e.g., prompt.ts and processor.ts), this specific loop-gate TOCTOU/race between buildLoopContext() and recordSyntheticBlock/recordSyntheticStop is intentionally handled via idempotence guards (re-check sigKey presence and hasStopped inside the record helpers). Do not treat the absence of a per-parent Effect.Mutex as a blocking review issue for this known edge case; the residual window should only create extra synthetic parts and must not change the "turn ends" contract. Respect the existing code comment documenting the trade-off, and only escalate to a full-mutex approach if the race is observed in practice.
Applied to files:
packages/opencode/src/session/overflow.tspackages/opencode/src/session/message-v2.tspackages/opencode/src/session/compaction.ts
📚 Learning: 2026-04-28T04:37:56.001Z
Learnt from: Astro-Han
Repo: Astro-Han/pawwork PR: 270
File: packages/opencode/src/tool/webfetch.ts:12-20
Timestamp: 2026-04-28T04:37:56.001Z
Learning: In `packages/opencode/src/tool/webfetch.ts` (and analogously `packages/opencode/src/tool/read.ts`), numeric parameter bounds (e.g., `timeout > 0 && timeout <= 120`) are intentionally enforced via runtime guards inside `execute()` rather than `Schema.Number.pipe(Schema.greaterThan(0), Schema.lessThanOrEqualTo(120))` refinements. The reason is to preserve a simple `"type": "number"` JSON Schema shape advertised to model callers — schema refinements would add `minimum`/`maximum` constraints that could over-constrain LLM-generated tool calls. Do NOT suggest moving these checks into the `Parameters` schema.
Applied to files:
packages/opencode/src/session/overflow.tspackages/opencode/src/session/message-v2.ts
📚 Learning: 2026-04-26T16:34:57.130Z
Learnt from: Astro-Han
Repo: Astro-Han/pawwork PR: 247
File: packages/ui/src/components/message-part.tsx:1322-1324
Timestamp: 2026-04-26T16:34:57.130Z
Learning: In Astro-Han/pawwork (`packages/ui/src/components/message-part.tsx`), the `taskId` createMemo and `childSessionId` createMemo both intentionally read only from `partMetadata().sessionId` (populated post-execution), not from `input.task_id` / `input.subagent_session_id`. This has always been the case — the original code never read the input field either. Adding an `input.subagent_session_id` fallback would be a new capability, not a bug fix. Do NOT flag the absence of this fallback as a regression in PR `#247` or future PRs unless there is a concrete case where metadata is not populated.
Applied to files:
packages/opencode/src/session/overflow.tspackages/opencode/src/session/message-v2.tspackages/opencode/src/session/compaction.ts
📚 Learning: 2026-04-28T04:38:11.771Z
Learnt from: Astro-Han
Repo: Astro-Han/pawwork PR: 270
File: packages/opencode/src/tool/agent.ts:23-27
Timestamp: 2026-04-28T04:38:11.771Z
Learning: In Astro-Han/pawwork (`packages/opencode/src/tool/agent.ts`), the `subagent_session_id` field in the `Parameters` schema accepts any `Schema.String` rather than a branded `SessionID`. This is inherited upstream behavior (adopted in PR `#270`, an upstream-sync graft of upstream PR `#23244`). The fix — validating `subagent_session_id` as a `SessionID` brand up front so malformed/typo'd IDs fail explicitly rather than silently forking a new subagent session — is intentionally deferred to a follow-up PR or upstream report. Do NOT re-flag this as a blocking issue in PR `#270` or in future upstream-sync PRs that carry the same schema; flag it only in a PawWork-authored PR that touches `agent.ts` parameter validation.
Applied to files:
packages/opencode/src/session/overflow.tspackages/opencode/src/session/message-v2.tspackages/opencode/src/tool/read.tspackages/opencode/src/session/compaction.ts
📚 Learning: 2026-04-28T07:28:14.317Z
Learnt from: Astro-Han
Repo: Astro-Han/pawwork PR: 270
File: packages/opencode/src/tool/lsp.ts:23-32
Timestamp: 2026-04-28T07:28:14.317Z
Learning: In `packages/opencode/src/tool/lsp.ts` (Astro-Han/pawwork, PR `#270`), the `Parameters` schema requires `line` and `character` for all operations, including `workspaceSymbol` and `documentSymbol` which never use coordinates. This matches the upstream `dev:packages/opencode/src/tool/lsp.ts:23-32` baseline exactly — both fields are declared as required `Schema.Number` with `>= 1` checks. The fix (per-operation schema split, or making `line`/`character` optional with handler-side presence validation for operations that need them like `goToDefinition`/`findReferences`) is deferred to a follow-up PR or upstream report to avoid mixing refactor + bug-fix intents and drifting the diff from the upstream baseline. Do NOT re-flag the required coordinates on `workspaceSymbol`/`documentSymbol` in upstream-sync PRs; flag it only in a PawWork-authored PR that directly touches `lsp.ts` parameter validation.
Applied to files:
packages/opencode/src/session/overflow.tspackages/opencode/src/session/message-v2.tspackages/opencode/src/tool/read.tspackages/opencode/src/session/compaction.ts
📚 Learning: 2026-04-28T04:56:21.528Z
Learnt from: Astro-Han
Repo: Astro-Han/pawwork PR: 270
File: packages/opencode/src/tool/tool.ts:34-42
Timestamp: 2026-04-28T04:56:21.528Z
Learning: In `packages/opencode/src/tool/tool.ts` (Astro-Han/pawwork, PR `#270`), the `Def.execute` signature uses `ctx: Context` (without the `M` generic) instead of `ctx: Context<M>`, meaning `ctx.metadata(...)` is not checked against the same `M` as `ExecuteResult<M>`. This is inherited upstream behavior adopted via the graft strategy in PR `#270` (an upstream-sync PR per `project_upstream_strategy.md`). Fixing it here would mix refactor + bugfix intents and drift the diff from the upstream baseline. The fix is deferred to a dedicated follow-up PR or upstream report. Do NOT re-flag the missing `Context<M>` generic in `Def.execute` during upstream-sync PRs; only flag it in a PawWork-authored PR that directly touches `tool.ts` type signatures.
Applied to files:
packages/opencode/src/session/overflow.tspackages/opencode/src/session/message-v2.tspackages/opencode/src/session/compaction.ts
📚 Learning: 2026-04-20T14:21:56.373Z
Learnt from: Astro-Han
Repo: Astro-Han/pawwork PR: 71
File: packages/app/src/components/session/session-status-connections.tsx:146-147
Timestamp: 2026-04-20T14:21:56.373Z
Learning: In the Astro-Han/pawwork repository (SolidJS app), `sync.data.config` is always initialized to `{}` at `packages/app/src/context/global-sync.tsx` line 71 and is never `undefined` at runtime. Non-optional property access like `sync.data.config.plugin` is intentional and consistent with the pattern used in `packages/app/src/components/status-popover-body.tsx` line 243. Do not flag `sync.data.config.plugin` as needing optional chaining.
Applied to files:
packages/opencode/src/session/overflow.ts
📚 Learning: 2026-04-28T04:56:13.350Z
Learnt from: Astro-Han
Repo: Astro-Han/pawwork PR: 270
File: packages/opencode/src/tool/tool.ts:8-10
Timestamp: 2026-04-28T04:56:13.350Z
Learning: In `packages/opencode/src/tool/tool.ts` (Astro-Han/pawwork, PR `#270`), the `Metadata` interface uses `[key: string]: any` as its index signature. This is upstream-inherited code adopted wholesale via the graft strategy (per `project_upstream_strategy.md`). The fix — tightening to `Record<string, unknown>` and explicitly narrowing framework-owned fields like `truncated` — is intentionally deferred to a follow-up PR or upstream report. Do NOT re-flag the `any` index signature on `Metadata` in PR `#270` or in future upstream-sync PRs that carry the same upstream baseline.
Applied to files:
packages/opencode/src/session/overflow.tspackages/opencode/src/session/message-v2.tspackages/opencode/src/tool/read.tspackages/opencode/src/session/compaction.ts
📚 Learning: 2026-04-26T15:35:36.505Z
Learnt from: Astro-Han
Repo: Astro-Han/pawwork PR: 245
File: packages/opencode/src/question/index.ts:21-24
Timestamp: 2026-04-26T15:35:36.505Z
Learning: In Astro-Han/pawwork (`packages/opencode/src/question/index.ts`), `Question.Option.description` is intentionally **required** (`z.string()`, not `.optional()`). This matches the upstream opencode contract and all current callers (e.g. `PlanExitTool`) always supply a description. The defensive `<Show when={props.description}>` rendering in `session-question-dock.tsx` is a standard guard, not a signal that the field is intended to be optional. Do NOT suggest making `Option.description` optional without a dedicated follow-up that covers schema + tool description + dock copy + tests.
Applied to files:
packages/opencode/src/session/overflow.tspackages/opencode/src/session/message-v2.tspackages/opencode/src/tool/read.ts
📚 Learning: 2026-04-28T04:38:05.946Z
Learnt from: Astro-Han
Repo: Astro-Han/pawwork PR: 270
File: packages/opencode/src/patch/index.ts:337-346
Timestamp: 2026-04-28T04:38:05.946Z
Learning: In Astro-Han/pawwork (`packages/opencode/src/patch/index.ts`), the BOM-transition surfacing gap — where `Bom.split` strips BOM before building `unified_diff`/`new_content` but `Bom.join` later re-attaches BOM on disk write, meaning BOM changes are not reflected in the diff payload — is intentionally deferred. PR `#270` is an upstream-sync graft; fixing the issue here would mix refactor + bugfix intents and drift the diff from the upstream baseline needed for clean future grafts. A dedicated follow-up PR (or upstream report) will address this. Do NOT re-flag the missing BOM-change surfacing in `ApplyPatchFileUpdate`/`ApplyPatchFileChange` as a blocking issue in PR `#270` or in future sync PRs that carry the same upstream baseline.
Applied to files:
packages/opencode/src/session/overflow.tspackages/opencode/src/session/message-v2.tspackages/opencode/src/tool/read.tspackages/opencode/src/session/compaction.ts
📚 Learning: 2026-04-28T08:29:02.858Z
Learnt from: Astro-Han
Repo: Astro-Han/pawwork PR: 270
File: packages/opencode/src/file/ripgrep.ts:469-470
Timestamp: 2026-04-28T08:29:02.858Z
Learning: In `packages/opencode/src/file/ripgrep.ts` (Astro-Han/pawwork, PR `#270`), the `tree()` function filters files with `if (file.includes(".opencode")) continue`, which is a raw substring match that would also drop unrelated paths whose names merely contain `.opencode` (e.g., `.opencode-backup`). The correct fix is `if (file.split(path.sep).includes(".opencode")) continue` to match only the exact path segment. However, upstream `dev:packages/opencode/src/file/ripgrep.ts` carries the identical false-positive filter. PR `#270` is an upstream-sync graft; fixing it here would diverge from the baseline. The fix is deferred to a PawWork ripgrep filter cleanup PR or an upstream-first fix, to land alongside the other ripgrep deferrals (Schema.Class, bytes-union, abort short-circuit, mixed-separator). Do NOT re-flag the `.opencode` substring filter in upstream-sync PRs.
Applied to files:
packages/opencode/src/session/overflow.tspackages/opencode/src/session/message-v2.tspackages/opencode/src/tool/read.tspackages/opencode/src/session/compaction.ts
📚 Learning: 2026-04-28T04:56:18.533Z
Learnt from: Astro-Han
Repo: Astro-Han/pawwork PR: 270
File: packages/opencode/test/file/ripgrep.test.ts:172-175
Timestamp: 2026-04-28T04:56:18.533Z
Learning: In `packages/opencode/test/file/ripgrep.test.ts` (Astro-Han/pawwork, PR `#270`), the `files dies on nonexistent directory` test hardcodes `/tmp/nonexistent-dir-12345` as the missing-directory path. This is upstream-inherited behaviour adopted wholesale via the graft strategy (`project_upstream_strategy.md`). The fix — replacing the hardcoded path with a platform-neutral guaranteed-nonexistent child path derived from the `tmpdir` fixture — is intentionally deferred to a follow-up PR or upstream report, to avoid mixing bugfix + refactor intents and drifting the diff from the upstream baseline future syncs need. Do NOT re-flag the hardcoded `/tmp/nonexistent-dir-12345` path in this file as a blocking or actionable issue until the follow-up PR lands.
Applied to files:
packages/opencode/src/session/overflow.tspackages/opencode/src/session/message-v2.tspackages/opencode/src/session/compaction.ts
📚 Learning: 2026-04-28T04:38:11.727Z
Learnt from: Astro-Han
Repo: Astro-Han/pawwork PR: 270
File: packages/opencode/src/tool/edit.ts:35-45
Timestamp: 2026-04-28T04:38:11.727Z
Learning: In `packages/opencode/src/tool/edit.ts` (Astro-Han/pawwork, PR `#270`), the module-scoped `Map<string, Semaphore.Semaphore>` (`locks`) and `lock(filePath)` helper are intentionally left as a global, never-cleaned registry. PR `#270` is a pure upstream-sync graft and mixing in a bugfix would drift the diff from the upstream baseline. The fix (moving per-file semaphores into `InstanceState`/`ScopedCache` for per-instance lifecycle management) is tracked as a follow-up to land in its own PR or be reported upstream. Do NOT re-flag the absence of InstanceState wiring for the lock map in this file until the follow-up PR is opened.
Applied to files:
packages/opencode/src/session/overflow.tspackages/opencode/src/session/message-v2.tspackages/opencode/src/tool/read.tspackages/opencode/src/session/compaction.ts
📚 Learning: 2026-04-28T04:56:21.338Z
Learnt from: Astro-Han
Repo: Astro-Han/pawwork PR: 270
File: packages/opencode/test/tool/tool-define.test.ts:2-9
Timestamp: 2026-04-28T04:56:21.338Z
Learning: In `packages/opencode/test/tool/tool-define.test.ts` (Astro-Han/pawwork, PR `#270`), the test suite intentionally uses a `ManagedRuntime.make(Layer.mergeAll(Truncate.defaultLayer, Agent.defaultLayer))` wrapper and raw `bun:test` `test(...)` cases rather than the `testEffect(...)` harness. This code was adopted wholesale from upstream via the graft strategy (per `project_upstream_strategy.md`). Migrating to `testEffect` is deferred to a follow-up PR so as not to mix refactor + harness-migration intents and to avoid drifting the diff from the upstream baseline needed for clean future grafts. Do NOT re-flag the `ManagedRuntime.make` usage in this file as needing harness migration until that follow-up lands.
Applied to files:
packages/opencode/src/session/overflow.tspackages/opencode/src/session/message-v2.tspackages/opencode/src/tool/read.tspackages/opencode/src/session/compaction.ts
📚 Learning: 2026-04-28T07:27:59.666Z
Learnt from: Astro-Han
Repo: Astro-Han/pawwork PR: 270
File: packages/opencode/src/file/ripgrep.ts:364-368
Timestamp: 2026-04-28T07:27:59.666Z
Learning: In `packages/opencode/src/file/ripgrep.ts` (Astro-Han/pawwork, PR `#270`), both `files()` and `search()` do not short-circuit on an already-aborted `AbortSignal` before `spawner.spawn(...)`. The `check(input.cwd)` effect before spawn already provides a natural cancellation point for interrupted fibers. Upstream `dev:packages/opencode/src/file/ripgrep.ts` has identical ordering with no pre-spawn `signal.aborted` guard; adding one would diverge from the graft baseline. The fix (explicit pre-spawn `signal.aborted` check) is deferred to a dedicated PawWork ripgrep cancellation pass or upstream PR. Do NOT re-flag the missing pre-spawn cancellation short-circuit in `files()`/`search()` in upstream-sync PRs.
Applied to files:
packages/opencode/src/session/overflow.tspackages/opencode/src/session/message-v2.tspackages/opencode/src/session/compaction.ts
📚 Learning: 2026-04-28T07:27:41.971Z
Learnt from: Astro-Han
Repo: Astro-Han/pawwork PR: 270
File: packages/opencode/src/file/ripgrep.ts:29-118
Timestamp: 2026-04-28T07:27:41.971Z
Learning: In `packages/opencode/src/file/ripgrep.ts` (Astro-Han/pawwork), the internal Effect schemas `TimeStats`, `Stats`, `PathText`, `Begin`, `End`, `Summary`, and the exported `SearchMatch`/`Match`/`Result` union are all declared as `Schema.Struct` — matching upstream `dev:packages/opencode/src/file/ripgrep.ts` lines 60-118. PR `#270` is an upstream-sync graft per `project_upstream_strategy.md`. Converting these to `Schema.Class` would diverge from the upstream baseline and create recurring merge friction on future syncs. The `Schema.Class` convention applies to PawWork-authored schemas, not to upstream-grafted shapes in this file. Do NOT re-flag the use of `Schema.Struct` for these schemas in upstream-sync PRs; flag it only in a PR that simultaneously converts the upstream side, or in a dedicated PawWork carve-out PR for ripgrep schemas.
Applied to files:
packages/opencode/src/session/overflow.tspackages/opencode/src/tool/read.tspackages/opencode/src/session/compaction.ts
📚 Learning: 2026-04-27T10:32:59.274Z
Learnt from: Astro-Han
Repo: Astro-Han/pawwork PR: 264
File: packages/opencode/src/session/diagnostics.ts:245-252
Timestamp: 2026-04-27T10:32:59.274Z
Learning: In pawwork’s session diagnostics/loop error handling (e.g., packages/opencode/src/session/diagnostics.ts), keep `loopLastError` stored as raw/unscrubbed error text in loop metadata. The intended trust boundary for sensitive-data scrubbing is the renderer: `LoopRenderer.render` must apply `scrubErrorText` before any user/model-facing output. Do not recommend scrubbing `loopLastError` at the `observeToolError` call site, because failed tool parts already store raw errors in `state.error` (pre-existing leak) and scrubbing only `loopLastError` would be lossy without closing the gap. If export-side hardening is required, implement a single sanitizer pass in the export layer over both `state.error` and the loop/metadata fields, rather than sanitizing only `loopLastError` earlier in the storage path.
Applied to files:
packages/opencode/src/session/overflow.tspackages/opencode/src/session/message-v2.tspackages/opencode/src/session/compaction.ts
📚 Learning: 2026-04-28T06:47:20.342Z
Learnt from: Astro-Han
Repo: Astro-Han/pawwork PR: 270
File: packages/opencode/src/tool/question.ts:6-16
Timestamp: 2026-04-28T06:47:20.342Z
Learning: In Astro-Han/pawwork, `packages/opencode/src/tool/question.ts` is a permanent PawWork carve-out: `Question.Prompt` (defined in `packages/opencode/src/question/index.ts`) remains a Zod schema, NOT an Effect Schema. The `ts-expect-error` boundary in `tool/registry.ts` marks the Zod ↔ Effect Schema seam. The dropped `packages/opencode/test/tool/question.test.ts` covered the old Zod-mixed surface. Schema-bound regression tests for this tool are intentionally deferred until `question/index.ts` migrates to Effect Schema in its own dedicated PR. Do NOT request restoring question tool tests in upstream-sync PRs; flag it only in the PR that migrates `question/index.ts` to Effect Schema.
Applied to files:
packages/opencode/src/session/overflow.tspackages/opencode/src/session/message-v2.tspackages/opencode/src/tool/read.tspackages/opencode/src/session/compaction.ts
📚 Learning: 2026-04-28T06:51:54.812Z
Learnt from: Astro-Han
Repo: Astro-Han/pawwork PR: 270
File: packages/opencode/src/tool/todo.ts:9-18
Timestamp: 2026-04-28T06:51:54.812Z
Learning: In `packages/opencode/src/tool/todo.ts` (Astro-Han/pawwork), `TodoItem.status` and `TodoItem.priority` are intentionally declared as plain `Schema.String` rather than closed literal unions. This matches the upstream opencode baseline (`dev:packages/opencode/src/tool/todo.ts`). The tightening — `Schema.Literals(["pending","in_progress","completed","cancelled"])` for `status` and `Schema.Literals(["high","medium","low"])` for `priority` — is tracked as a follow-up under the harness/tool-set-v1 series (issue `#129`) to land either as part of a tool-schema tightening sweep or upstream-first. Do NOT re-flag the free-form strings for these fields in upstream-sync PRs; flag it only in a PawWork-authored PR or the dedicated sweep that touches `TodoItem` schema.
Applied to files:
packages/opencode/src/session/overflow.tspackages/opencode/src/session/message-v2.tspackages/opencode/src/tool/read.ts
📚 Learning: 2026-04-27T08:27:43.672Z
Learnt from: Astro-Han
Repo: Astro-Han/pawwork PR: 264
File: packages/opencode/src/session/diagnostics.ts:255-279
Timestamp: 2026-04-27T08:27:43.672Z
Learning: In `packages/opencode/src/session/diagnostics.ts` (PR `#264`, issue `#229`), target-level loop detection is intentionally tool-scoped. The `real` filter includes `r.tool === input.tool` and target signature keys are formatted as `target:${tool}:${targetHash}`. Cross-tool retries on the same URL/path/query are considered legitimate exploration (e.g., webfetch failed → switch to a different tool), NOT a stuck loop. Only same-tool + same-target accumulation counts toward block/stop. Do NOT suggest removing the tool scope from target signature keys or the `real` filter.
Applied to files:
packages/opencode/src/session/overflow.tspackages/opencode/src/session/message-v2.tspackages/opencode/src/session/compaction.ts
📚 Learning: 2026-04-27T08:58:00.665Z
Learnt from: Astro-Han
Repo: Astro-Han/pawwork PR: 264
File: packages/opencode/src/session/prompt.ts:531-538
Timestamp: 2026-04-27T08:58:00.665Z
Learning: When using Effect (e.g., `yield*` with `Effect`-style generator yielding), only use `yield* new SomeErrorClass(...)` if `SomeErrorClass` extends `Schema.TaggedErrorClass` (i.e., it implements Effect’s Yieldable interface). For plain `Error` subclasses (like `BlockedLoopError` / `LoopStopError`) or inline `new Error(...)` values, they are not yieldable and must be wrapped as `yield* Effect.fail(new PlainError(...))`. Do not recommend changing `yield* Effect.fail(new SomePlainError(...))` to `yield* new SomePlainError(...)` unless the error class extends `Schema.TaggedErrorClass`.
Applied to files:
packages/opencode/src/session/overflow.tspackages/opencode/src/session/message-v2.tspackages/opencode/src/tool/read.tspackages/opencode/src/session/compaction.ts
📚 Learning: 2026-04-28T05:36:22.128Z
Learnt from: Astro-Han
Repo: Astro-Han/pawwork PR: 270
File: packages/opencode/src/session/compaction.ts:169-191
Timestamp: 2026-04-28T05:36:22.128Z
Learning: In `packages/opencode/src/session/compaction.ts` (Astro-Han/pawwork), the internal helper `splitTurn` intentionally returns a raw `Effect.gen(...)` rather than being wrapped with `Effect.fnUntraced`. Converting `splitTurn` (and similar plain-Effect.gen internal helpers in this file, e.g. `turns`, `completedCompactions`, `buildPrompt`) to `Effect.fnUntraced` is a repo-wide convention sweep deferred to a dedicated follow-up PR. The same sweep covers `tool/tool.ts` and other helpers flagged in the same sync. Do NOT re-flag the absence of `Effect.fnUntraced` on `splitTurn` or other internal helpers in `compaction.ts` during upstream-sync PRs; flag it only in the dedicated convention-sweep PR.
Applied to files:
packages/opencode/src/session/message-v2.tspackages/opencode/src/session/compaction.ts
📚 Learning: 2026-04-28T08:14:31.436Z
Learnt from: Astro-Han
Repo: Astro-Han/pawwork PR: 270
File: packages/opencode/src/tool/edit.ts:48-48
Timestamp: 2026-04-28T08:14:31.436Z
Learning: In `packages/opencode/src/tool/edit.ts` (Astro-Han/pawwork), the `filePath` schema description (`"The absolute path to the file to modify"`) is upstream-inherited from `dev:packages/opencode/src/tool/edit.ts:48`. The runtime actually accepts relative paths (resolved via `Instance.directory` at lines 79-81), but the description fix is intentionally deferred to a single PawWork-authored description-cleanup PR that will also cover the identical mismatch in `packages/opencode/src/tool/write.ts:24`. Do NOT re-flag the too-narrow `filePath` description in upstream-sync PRs; flag it only in the dedicated description-cleanup PR.
Applied to files:
packages/opencode/src/session/message-v2.tspackages/opencode/src/tool/read.tspackages/opencode/src/session/compaction.ts
📚 Learning: 2026-04-28T05:36:25.456Z
Learnt from: Astro-Han
Repo: Astro-Han/pawwork PR: 270
File: packages/opencode/test/tool/grep.test.ts:16-22
Timestamp: 2026-04-28T05:36:25.456Z
Learning: In `packages/opencode/test/tool/grep.test.ts` (Astro-Han/pawwork, PR `#270`), the test suite intentionally uses a manual `ManagedRuntime.make(Layer.mergeAll(...))` setup and raw `bun:test` test cases rather than the `testEffect(...)` harness. The migration to `testEffect` + `it.live(...)` + `provideTmpdirInstance(...)` is a whole-file rewrite deferred to a dedicated sweep PR that will migrate all tool tests still on the manual ManagedRuntime pattern together. Do NOT re-flag the ManagedRuntime setup in this file as needing harness migration until that sweep PR lands.
Applied to files:
packages/opencode/src/session/message-v2.tspackages/opencode/src/tool/read.tspackages/opencode/src/session/compaction.ts
📚 Learning: 2026-04-28T04:38:21.935Z
Learnt from: Astro-Han
Repo: Astro-Han/pawwork PR: 270
File: packages/opencode/test/file/ripgrep.test.ts:9-10
Timestamp: 2026-04-28T04:38:21.935Z
Learning: In `packages/opencode/test/file/ripgrep.test.ts` (Astro-Han/pawwork, PR `#270`), the tests intentionally use a local `run` helper (`effect.pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromise)`) rather than the `testEffect(...)` harness. This file was adopted from upstream wholesale as part of an upstream-sync graft; migrating to `testEffect` + `it.live(...)` was deferred to a separate follow-up PR to avoid mixing refactor and harness-migration intents within the sync. Do NOT re-flag the local `run` wrapper as needing harness migration until that follow-up lands.
Applied to files:
packages/opencode/src/session/message-v2.tspackages/opencode/src/tool/read.tspackages/opencode/src/session/compaction.ts
📚 Learning: 2026-04-20T14:36:31.032Z
Learnt from: CR
Repo: Astro-Han/pawwork PR: 0
File: packages/opencode/test/AGENTS.md:0-0
Timestamp: 2026-04-20T14:36:31.032Z
Learning: Applies to packages/opencode/test/**/*.test.{ts,tsx} : Define `const it = testEffect(...)` near the top of the test file and keep the test body inside `Effect.gen(function* () { ... })`. Yield services directly with `yield* MyService.Service` or `yield* MyTool`.
Applied to files:
packages/opencode/src/session/message-v2.tspackages/opencode/src/tool/read.ts
📚 Learning: 2026-04-28T02:24:27.738Z
Learnt from: Astro-Han
Repo: Astro-Han/pawwork PR: 280
File: packages/opencode/src/tool/todo.ts:7-22
Timestamp: 2026-04-28T02:24:27.738Z
Learning: In `packages/opencode/src/tool/todo.ts` (Astro-Han/pawwork, PR `#280`), the `parameters` constant for `TodoWriteTool` intentionally duplicates the `Todo.Info` fields inline rather than referencing `Todo.Info` or `zodObject(Todo.Info)`. After the Effect Schema migration in this PR, `Todo.Info` is an Effect Schema, so `zodObject(Todo.Info)` returns `z.ZodObject<any>` — which erases per-field types that `Tool.define`'s `execute()` inference depends on. The inline Zod schema is a deliberate temporary measure; tool schemas will migrate to Effect Schema as a separate slice per `specs/effect/schema.md`. Do NOT suggest replacing the inline schema with `z.array(Todo.Info)` or `zodObject(Todo.Info)` until that migration lands.
Applied to files:
packages/opencode/src/tool/read.tspackages/opencode/src/session/compaction.ts
📚 Learning: 2026-04-28T04:37:56.001Z
Learnt from: Astro-Han
Repo: Astro-Han/pawwork PR: 270
File: packages/opencode/src/tool/webfetch.ts:12-20
Timestamp: 2026-04-28T04:37:56.001Z
Learning: In `webfetch.ts` and `read.ts`, keep numeric parameter bounds (e.g., `timeout > 0 && timeout <= 120`) enforced via runtime guards inside `execute()`. Do not refactor these validations into `Parameters` schema refinements (e.g., avoid `Schema.Number.pipe(Schema.greaterThan(0), Schema.lessThanOrEqualTo(120))`) because the tool’s advertised JSON Schema is intentionally kept as a simple `
Applied to files:
packages/opencode/src/tool/read.ts
📚 Learning: 2026-04-20T14:36:21.288Z
Learnt from: CR
Repo: Astro-Han/pawwork PR: 0
File: packages/opencode/AGENTS.md:0-0
Timestamp: 2026-04-20T14:36:21.288Z
Learning: Applies to packages/opencode/**/*.ts : Use branded schemas (`Schema.brand`) for single-value types in Effect
Applied to files:
packages/opencode/src/tool/read.ts
📚 Learning: 2026-04-20T14:36:21.288Z
Learnt from: CR
Repo: Astro-Han/pawwork PR: 0
File: packages/opencode/AGENTS.md:0-0
Timestamp: 2026-04-20T14:36:21.288Z
Learning: Applies to packages/opencode/**/*.ts : Use `Schema.Defect` instead of `unknown` for defect-like causes in Effect code
Applied to files:
packages/opencode/src/tool/read.ts
📚 Learning: 2026-04-28T03:46:07.505Z
Learnt from: Astro-Han
Repo: Astro-Han/pawwork PR: 280
File: packages/opencode/src/config/config.ts:148-155
Timestamp: 2026-04-28T03:46:07.505Z
Learning: In `packages/opencode/src/config/config.ts` (Astro-Han/pawwork), `Config.Info` is an Effect Schema with a `.zod` accessor derived via `withStatics`. There are zero callers of `Config.Info.parse`, `Config.Info.safeParse`, `Config.parse`, or `Config.safeParse` in the codebase. The `.zod` accessor pattern (`Config.Info.zod`) is the documented PawWork convention for all Hono validators and Zod-style decoders. Do NOT suggest adding `.parse`/`.safeParse` compatibility shims to `Config.Info` — they would be dead surface area with no consumer.
Applied to files:
packages/opencode/src/tool/read.ts
📚 Learning: 2026-04-20T14:36:21.288Z
Learnt from: CR
Repo: Astro-Han/pawwork PR: 0
File: packages/opencode/AGENTS.md:0-0
Timestamp: 2026-04-20T14:36:21.288Z
Learning: Applies to packages/opencode/**/*.ts : Use `Schema.Class` for multi-field data in Effect schemas
Applied to files:
packages/opencode/src/tool/read.ts
📚 Learning: 2026-04-28T07:27:49.810Z
Learnt from: Astro-Han
Repo: Astro-Han/pawwork PR: 270
File: packages/opencode/src/file/ripgrep.ts:45-72
Timestamp: 2026-04-28T07:27:49.810Z
Learning: In `packages/opencode/src/file/ripgrep.ts` (Astro-Han/pawwork), `PathText`, the `lines` field inside `SearchMatch`, and `submatches[].match` accept only `{text: string}` and do NOT handle ripgrep's `{bytes: base64}` JSON variant (emitted for non-UTF-8 paths/match text). This matches the upstream `dev:packages/opencode/src/file/ripgrep.ts` baseline exactly. The fix — adding `Schema.Union([Schema.Struct({text: Schema.String}), Schema.Struct({bytes: Schema.String})])` for each location plus base64-decode in downstream consumers — is intentionally deferred to either a dedicated PawWork PR or an upstream-first fix inherited via the next sync. Do NOT re-flag the missing `{bytes: ...}` union variants in upstream-sync PRs; flag it only in a PawWork-authored PR that directly touches these schema definitions or their downstream consumers.
Applied to files:
packages/opencode/src/tool/read.ts
📚 Learning: 2026-04-28T05:36:18.200Z
Learnt from: Astro-Han
Repo: Astro-Han/pawwork PR: 270
File: packages/opencode/src/file/ripgrep.ts:311-328
Timestamp: 2026-04-28T05:36:18.200Z
Learning: In `packages/opencode/src/file/ripgrep.ts` (Astro-Han/pawwork, PR `#270`), the ripgrep binary download flow (`HttpClientRequest.get` → `fs.writeWithDirs` → `extract`) does NOT verify a pinned SHA-256 checksum. Adding per-platform hash verification (15.1.0 × 6 platforms) requires a dedicated manifest + `crypto.subtle.digest` step and is intentionally deferred to a follow-up PR. Existing mitigations: (1) `VERSION` is pinned to `"15.1.0"`, (2) downloads are over HTTPS from GitHub Releases, (3) PawWork production builds ship a bundled `rg` binary so this download path is a last-resort fallback (system PATH → cached → download). Do NOT re-flag the missing checksum verification in this file until the dedicated follow-up PR lands.
Applied to files:
packages/opencode/src/tool/read.ts
📚 Learning: 2026-04-27T10:33:12.228Z
Learnt from: Astro-Han
Repo: Astro-Han/pawwork PR: 264
File: packages/opencode/src/session/prompt.ts:108-169
Timestamp: 2026-04-27T10:33:12.228Z
Learning: In Astro-Han/pawwork (`packages/opencode/src/session/prompt.ts` and `packages/opencode/src/session/processor.ts`, PR `#264`), the loop-gate race condition between `buildLoopContext()` and `recordSyntheticBlock`/`recordSyntheticStop` is intentionally handled via idempotence guards (re-check sigKey presence / `hasStopped` inside the record helpers) rather than a full per-parent `Effect.Mutex`. Threading a `Map<MessageID, Mutex>` through the processor was considered too large a surface change for this edge case; the residual TOCTOU window only produces extra synthetic parts with no behavioral drift on the "turn ends" contract. A code comment documents the trade-off and points to a full-mutex follow-up if the race is observed in practice. Do NOT re-flag the absence of a per-parent mutex as a blocking issue in future reviews.
Applied to files:
packages/opencode/src/tool/read.ts
📚 Learning: 2026-04-20T14:36:21.288Z
Learnt from: CR
Repo: Astro-Han/pawwork PR: 0
File: packages/opencode/AGENTS.md:0-0
Timestamp: 2026-04-20T14:36:21.288Z
Learning: Applies to packages/opencode/**/*.ts : Prefer `FileSystem.FileSystem` instead of raw `fs/promises` for effectful file I/O in Effect services
Applied to files:
packages/opencode/src/tool/read.ts
📚 Learning: 2026-04-25T12:52:37.850Z
Learnt from: Astro-Han
Repo: Astro-Han/pawwork PR: 234
File: packages/opencode/src/session/export.ts:24-31
Timestamp: 2026-04-25T12:52:37.850Z
Learning: In `packages/opencode/src/session/export.ts`, the `hashFile` async helper intentionally uses raw `node:fs/promises` (`fs.readFile`) rather than `FileSystem.FileSystem`. It is a small, self-contained helper called inside `Effect.promise(...)` with broad failure tolerance (try/catch returning `undefined`). Using `FileSystem.FileSystem` here would add unnecessary layers without payoff. The `FileSystem.FileSystem` preference applies to Effect-service-style operations, not thin async boundary helpers like `hashFile`.
Applied to files:
packages/opencode/src/tool/read.ts
📚 Learning: 2026-04-20T14:36:31.032Z
Learnt from: CR
Repo: Astro-Han/pawwork PR: 0
File: packages/opencode/test/AGENTS.md:0-0
Timestamp: 2026-04-20T14:36:31.032Z
Learning: Applies to packages/opencode/test/**/*.test.{ts,tsx} : Prefer Effect-aware helpers from `fixture/fixture.ts` over building manual runtimes in tests: use `tmpdirScoped()` for scoped temp directories, `provideInstance(dir)(effect)` for low-level binding without directory creation, `provideTmpdirInstance(...)` for single temp instance binding, or `provideTmpdirServer(...)` for tests that also need the test LLM server.
Applied to files:
packages/opencode/src/tool/read.ts
📚 Learning: 2026-04-27T11:18:47.332Z
Learnt from: Astro-Han
Repo: Astro-Han/pawwork PR: 271
File: packages/opencode/test/tool/mcp-exa.test.ts:1-186
Timestamp: 2026-04-27T11:18:47.332Z
Learning: In `packages/opencode/test/tool/mcp-exa.test.ts` (Astro-Han/pawwork), the tests intentionally use raw `bun:test` async cases with `Effect.runPromise(...)` and per-case `HttpClient.make(...)` fakes rather than the `testEffect(...)` harness. The maintainer has explicitly decided not to migrate, because the HttpClient fake wiring is itself the behavior under test and switching to `testEffect` would be style churn without changing risk coverage. Do not flag these tests as needing harness migration.
Applied to files:
packages/opencode/src/tool/read.tspackages/opencode/src/session/compaction.ts
📚 Learning: 2026-04-27T11:19:24.963Z
Learnt from: Astro-Han
Repo: Astro-Han/pawwork PR: 271
File: packages/opencode/test/tool/websearch-auth.test.ts:0-0
Timestamp: 2026-04-27T11:19:24.963Z
Learning: In `packages/opencode/test/tool/websearch-auth.test.ts` (Astro-Han/pawwork), the tests intentionally use a small local `runWith` runner with raw `bun:test` and `Effect.runPromise` rather than the `testEffect` harness. Each test case injects a custom in-memory `Auth.Service` layer; switching to `testEffect` would be style-only churn without changing risk coverage. Do not flag these tests as needing harness migration.
Applied to files:
packages/opencode/src/tool/read.tspackages/opencode/src/session/compaction.ts
📚 Learning: 2026-04-20T14:36:21.288Z
Learnt from: CR
Repo: Astro-Han/pawwork PR: 0
File: packages/opencode/AGENTS.md:0-0
Timestamp: 2026-04-20T14:36:21.288Z
Learning: Applies to packages/opencode/**/*.ts : Use `Effect.fn("Domain.method")` for named/traced effects and `Effect.fnUntraced` for internal helpers; these accept pipeable operators as extra arguments to avoid unnecessary outer `.pipe()` wrappers
Applied to files:
packages/opencode/src/session/compaction.ts
📚 Learning: 2026-04-20T14:36:21.288Z
Learnt from: CR
Repo: Astro-Han/pawwork PR: 0
File: packages/opencode/AGENTS.md:0-0
Timestamp: 2026-04-20T14:36:21.288Z
Learning: Applies to packages/opencode/**/*.ts : Use `Effect.gen(function* () { ... })` for Effect composition
Applied to files:
packages/opencode/src/session/compaction.ts
📚 Learning: 2026-04-20T14:36:21.288Z
Learnt from: CR
Repo: Astro-Han/pawwork PR: 0
File: packages/opencode/AGENTS.md:0-0
Timestamp: 2026-04-20T14:36:21.288Z
Learning: Applies to packages/opencode/**/*.ts : In `Effect.gen` / `Effect.fn`, prefer `yield* new MyError(...)` over `yield* Effect.fail(new MyError(...))` for direct early-failure branches
Applied to files:
packages/opencode/src/session/compaction.ts
📚 Learning: 2026-04-20T14:36:21.288Z
Learnt from: CR
Repo: Astro-Han/pawwork PR: 0
File: packages/opencode/AGENTS.md:0-0
Timestamp: 2026-04-20T14:36:21.288Z
Learning: Applies to packages/opencode/**/*.ts : Use `Effect.cached` when multiple concurrent callers should share a single in-flight computation rather than storing `Fiber | undefined` or `Promise | undefined` manually
Applied to files:
packages/opencode/src/session/compaction.ts
📚 Learning: 2026-04-20T14:36:21.288Z
Learnt from: CR
Repo: Astro-Han/pawwork PR: 0
File: packages/opencode/AGENTS.md:0-0
Timestamp: 2026-04-20T14:36:21.288Z
Learning: Applies to packages/opencode/**/*.ts : Prefer `ChildProcessSpawner.ChildProcessSpawner` with `ChildProcess.make(...)` instead of custom process wrappers in Effect services
Applied to files:
packages/opencode/src/session/compaction.ts
📚 Learning: 2026-04-22T09:32:54.556Z
Learnt from: Astro-Han
Repo: Astro-Han/pawwork PR: 126
File: packages/opencode/test/provider/provider.test.ts:64-85
Timestamp: 2026-04-22T09:32:54.556Z
Learning: In `packages/opencode/test/provider/provider.test.ts`, the file intentionally uses AppRuntime-based async helpers (`run`, `list`, `getProvider`, etc.) rather than `testEffect(...)` for all tests. Converting individual tests to `testEffect` while leaving the rest on the async pattern would create internal inconsistency. A full harness migration of this file is the right approach if the pattern needs to change, but that should be a separate PR.
Applied to files:
packages/opencode/src/session/compaction.ts
📚 Learning: 2026-04-20T14:36:21.288Z
Learnt from: CR
Repo: Astro-Han/pawwork PR: 0
File: packages/opencode/AGENTS.md:0-0
Timestamp: 2026-04-20T14:36:21.288Z
Learning: Applies to packages/opencode/**/*.ts : Use `makeRuntime` from `src/effect/run-service.ts` for all services; it returns `{ runPromise, runFork, runCallback }` backed by a shared `memoMap` that deduplicates layers
Applied to files:
packages/opencode/src/session/compaction.ts
🔇 Additional comments (4)
packages/opencode/src/agent/prompt/compaction.txt (1)
1-9: LGTM: the prompt now scopes the compaction model to anchored-summary updates.It clearly tells the model to preserve a prior summary, keep the requested structure, and avoid answering the conversation itself.
packages/opencode/src/session/overflow.ts (1)
8-20: LGTM:usable()cleanly centralizes the effective input-budget calculation.Having both overflow detection and compaction budgeting depend on the same reserved-cap math removes an easy source of drift.
Also applies to: 23-29
packages/opencode/src/session/message-v2.ts (1)
22-42: Nice: tool-output truncation now respects the cap in both completed and interrupted paths.The helper reserves suffix space correctly, and the interrupted-output branch now mirrors the same cap instead of bypassing it with raw
metadata.output.Also applies to: 748-750, 780-789
packages/opencode/src/session/compaction.ts (1)
108-129: Nice: compaction now measures and persists against the same boundary it later reuses.Estimating with the same
stripMedia/toolOutputMaxCharsoptions, hiding history already covered by the latest summary, and persistingtail_start_idclears should keep repeated compactions from drifting or re-expanding the prompt.Also applies to: 246-257, 404-446, 507-517
Outside-diff comment reply (
|
…Schema Combines two changes that arrived together during rebase onto the upstream tool-framework-Schema migration (PR #270 / #23244): Review fixes (originally a separate fix(agent_output) commit, folded in because the same files needed Schema migration in the same hunk): - agent_output: switches the tool_call_id lookup branch to readByToolCallID so the call survives a host restart by falling back to persisted parts. - agent_output: formatTranscript now mirrors formatResult's fallback chain (result_text then partial_result), so canceled_by_user rows still surface their preserved partial under detail=transcript. Schema migration: - Both tools port their parameters from z.object(...) to Schema.Struct + Schema.Literals + Schema.optional + Schema.withDecodingDefault, matching codesearch.ts / webfetch.ts. - agent_output: the XOR validation (subagent_session_id ⊕ tool_call_id) moves from Zod's .refine(...) to an imperative check at the entry of execute() — Effect Schema 4 beta does not yet expose a friendly filter combinator and the imperative form keeps the error message clear.
* feat(session): add createdByAgentTool and subagentType columns Lays the groundwork for subagent lifecycle tracking (#283). Adds two nullable Session columns plus the corresponding Info/CreateInput/Interface fields so the agent tool can mark child sessions as agent-tool-created and record the requested subagent type. * feat(agent): mark child sessions with createdByAgentTool and subagentType Wires the new Session lifecycle fields (#283) through the agent tool's sessions.create call so dispatched subagents are identifiable as agent-tool-created and tagged with the requested subagent type. * feat(message): extend SubtaskPart with subagent lifecycle fields Adds the SubtaskEvent discriminated union and extends SubtaskPart with identity (tool_call_id / parent_session_id / parent_message_id / subagent_session_id), lifecycle (status / started_at / updated_at / ended_at / consumed_at), progress (last_activity / recent_events), and result (result_summary / result_text / partial_result / error) fields required by #283. Every new field is optional or defaulted so legacy SubtaskPart rows decode as terminal-completed without rewrites. Existing literal writers in prompt.ts and the prompt-effect test get explicit status/recent_events defaults to satisfy the inferred output shape. * feat(session): add SubagentRun service with cap and per-row mutex Introduces the SubagentRun service that owns subagent lifecycle writes (#283). Slot reservation is gated by a per-parent semaphore at MAX_ACTIVE=5; each tool_call_id has its own row-level semaphore for serialized lifecycle mutations. start/finalize/patchSession/recordActivity/setConsumed are implemented; recordEvent/recordRejected/findLatestBySessionID/list remain explicit Effect.die stubs filled in by Tasks 5/11/12/13. * feat(prompt): add wasInterrupted query to AgentPromptOps Wires per-Instance interruptedSessions tracking through SessionPrompt's ops factory so AgentTool.execute can deterministically distinguish a child runner abort from a natural completion or model failure (#283). The set is reset at the start of every prompt() call and added to inside loop()'s onInterrupt branch, so the read happens after ops.prompt resolves without racing ctx.abort.aborted. Test stubOps gains an optional interruptedSessions hook for tests that need to simulate abort. * feat(subagent-run): pin lifecycle events in recent_events ring Implements recordEvent with append-and-evict semantics: the ring is capped at 20 entries; when full, the oldest non-lifecycle (progress) event is removed. Lifecycle events (started, completed, completed_empty, canceled_by_user, failed, consumed) stay pinned so the rendered timeline always shows the run's beginning and outcome regardless of intermediate tool churn (#283). * feat(session): single-writer guard for SubtaskPart lifecycle fields Adds a Context.Reference (SubagentRunWriterContext) that is true only inside SubagentRun service writers. Session.updatePart consults it on SubtaskPart writes and dies with SubagentRunGuardViolation when an outsider tries to mutate lifecycle fields. The reference lives in its own module so both writers and the guard can observe it without a circular Layer dep, deviating from the spec's Layer-wrapper sketch but preserving the API-level enforcement intent (#283). * refactor(agent): scoped 8-step flow with slot finalizer and idempotent error catch Restructures AgentTool.execute around the SubagentRun service (#283): - Cap-rejection short-circuits before reserveSlot, returning a synthesized failed output via recordRejected (Task 11 will fill in that writer). - Effect.scoped wraps the rest of the body so releaseSlot and the parent abort listener detach via Effect.addFinalizer on every exit path, closing the prior reserveSlot/acquireUseRelease interrupt window. - start/patchSession/finalize replace ad-hoc lifecycle bookkeeping; an outer Effect.catch finalizes a started SubtaskPart to `failed` so pre-prompt errors can never strand the row in `running`. - A pre-aborted check finalizes canceled_by_user without invoking ops.prompt. - finalizeAfter latches `interrupted = wasInterrupted || ctx.abort.aborted` synchronously at handler entry so a late parent abort during cleanup of a real model failure cannot misclassify it. ToolRegistry layer requirements grow to include SubagentRun.Service; the test layer composers in prompt-effect.test.ts and snapshot-tool-race.test.ts provide it. Three older agent.test.ts cases (resume + canAgent shaping) are skipped with `it.live.skip` and updated by Tasks 8/9. synthesizeOutput, truncateHead, and readLastCompletedAssistantText are minimal stubs; Task 10 replaces them with the structured-output + partial result implementation. * feat(agent): tighten nested-deny gates to be unconditional The agent permission deny rule is now unconditional regardless of whether the dispatching subagent has canAgent permission. Combined with the already-unconditional `agent: false` entry in the prompt-time tools map, this prevents the v1 non-goal of nested subagent dispatch (#283). Removes the unused canAgent local; updates the matching agent.test.ts permission and tools assertions and rescues the previously skipped case. * test(agent): cover subagent_session_id resume validation paths Replaces the skipped resume tests with three positive/negative cases that match the new agent.ts contract (#283): a SubagentRun-created child resumes successfully, a missing session id fails fast, a plain child session (no createdByAgentTool) is rejected, and a subagent_type mismatch fails. The "creates a child anyway when missing" legacy case is left skipped — Task 13's agent_output covers the lookup semantics for the remaining paths. * feat(agent): structured output with status header and error sanitization Replaces the Task 7 stubs with the production implementation (#283): synthesizeOutput preserves the exact 5-line `<subagent_result>` format on the success path so existing prompt teaching still parses, while non-completed statuses surface a `status: <status>` header (and a sanitized `error: ...` line for failed runs) before the wrapper. Sanitization strips stack traces, host paths, and JSON envelopes and caps the message at 200 chars. readLastCompletedAssistantText reads the most recent stable assistant text from the child session for partial_result on cancel and returns null when no completed text exists, avoiding leaked half-tokens. TextPart completion is keyed off `time.end !== undefined`. * feat(subagent-run): implement recordRejected for cap rejection When the parent already has 5 active subagents, AgentTool.execute now delegates to SubagentRun.recordRejected which writes a fully-formed SubtaskPart in `failed` state with `error.kind = too_many_active` and recent_events seeded with [started, failed]. The model sees a synthesized output via synthesizeOutput's failed branch instead of a thrown error, keeping the cap path inside the lifecycle vocabulary (#283). * feat(agent_list): tool that lists subagents launched by parent session Adds the agent_list model-callable tool plus the SubagentRun.list and collectSubtaskParts helpers it relies on. List filters by lifecycle status (running / completed / completed_empty / failed / canceled_by_user) and the two synthetic filters all_active (running plus unread terminal) and all. Rows are returned newest-first; legacy SubtaskParts decoded without tool_call_id stay visible with elapsed=- and label=(legacy) so older runs remain reachable (#283). * feat(agent_output): tool that reads subagent result or transcript Adds the agent_output model-callable tool. Accepts exactly one of subagent_session_id or tool_call_id (XOR enforced via Zod refine), verifies the row's parent_session_id matches the caller and that the child session was created by the agent tool, then formats either the result text (status: result) or a transcript preview (status: transcript) plus event count and last_activity. Reading a row in any terminal status calls SubagentRun.setConsumed once so future agent_list runs in all_active mode skip it. SubagentRun.findLatestBySessionID powers the subagent_session_id lookup branch (#283). * feat(registry): register agent_list and agent_output tools Wires the two new model-callable inspection tools into the builtin tool list right after `agent`, so they are reachable from any agent that already has agent dispatch enabled (#283). * test(subagent-run): integration coverage for all five terminal states Drives the SubagentRun service through completed, completed_empty, failed (execution_error), failed (too_many_active via recordRejected), and canceled_by_user terminal states end-to-end (#283), and exercises findLatestBySessionID + list filters across running/completed/all. * test(subagent-run): assert slot/row lock ordering does not deadlock Drives 5 reservations + 5 row writes through the service, asserts the 6th reservation hits TooManyActive, then interleaves reads with releases. If the per-row mutex (start, finalize, etc.) ever called back into reserveSlot the loop would hang on iteration 6, so passing this test confirms the documented lock-ordering invariant (slot acquired before row, never the reverse) holds in code (#283). * fix(test): widen integration setup body error type Allow setup() body Effect to surface NotFound from svc.read; runner uses Effect.orDie to bubble unexpected failures so tests still see them as test failures instead of swallowing them as undefined behavior. * fix(subagent-run): cap recent_events at 20, add readByToolCallID, narrow catch Three review fixes to SubagentRun: - recent_events ring now enforces a hard cap of 20 after the lifecycle-priority eviction pass. Previously, if all 20 entries were lifecycle events the inner while loop would break (no progress event to evict) and the array kept growing past 20, which violates SubtaskEvent.array().max(20) on next decode. - New readByToolCallID(parentID, toolCallID) falls back to scanning persisted SubtaskParts when the in-memory tool_call_id index misses, so agent_output { tool_call_id } keeps working across host restarts; on a hit it backfills the cache so subsequent reads stay fast. - The shared read-or-skip shell is extracted as withExistingPart and the readPart catch is narrowed from Effect.catch to the typed Effect.catchTag("NotFound", ...) so storage failures surface as defects instead of being swallowed as missing rows. Also drops the unused Bus.Service requirement from layer. * fix(agent): rely on wasInterrupted alone, isolate success-path finalize Two review fixes to AgentTool.execute: - finalizeAfter no longer ORs ctx.abort.aborted into interrupted. A parent abort that lands AFTER ops.prompt's natural success but BEFORE the tap callback runs would otherwise relabel a completed run as canceled_by_user and lose the result_text. ops.wasInterrupted is the authoritative signal because it is set by SessionRunState.onInterrupt firing on the child runner; the pre-aborted short-circuit upstream already handles aborts that landed before ops.prompt was invoked at all. - The success-path tap now wraps finalizeAfter in Effect.catchCause so a transient finalize failure cannot propagate to the next pipe step and re-finalize as kind: err, rebranding a completed run as failed. finalize is already idempotent against repeat calls. Also narrows the outer catch of Session.read to the typed NotFound tag so storage failures surface as defects instead of being swallowed. * fix(agent_output,agent_list): apply review fixes + migrate to Effect Schema Combines two changes that arrived together during rebase onto the upstream tool-framework-Schema migration (PR #270 / #23244): Review fixes (originally a separate fix(agent_output) commit, folded in because the same files needed Schema migration in the same hunk): - agent_output: switches the tool_call_id lookup branch to readByToolCallID so the call survives a host restart by falling back to persisted parts. - agent_output: formatTranscript now mirrors formatResult's fallback chain (result_text then partial_result), so canceled_by_user rows still surface their preserved partial under detail=transcript. Schema migration: - Both tools port their parameters from z.object(...) to Schema.Struct + Schema.Literals + Schema.optional + Schema.withDecodingDefault, matching codesearch.ts / webfetch.ts. - agent_output: the XOR validation (subagent_session_id ⊕ tool_call_id) moves from Zod's .refine(...) to an imperative check at the entry of execute() — Effect Schema 4 beta does not yet expose a friendly filter combinator and the imperative form keeps the error message clear. * fix(agent_output): narrow catch tags and report total length on truncate - Replace broad Effect.catch with catchTag("NotFound") on read paths so storage / decode defects surface via orDie instead of being silently reported as "subagent not found". - Switch sessions.get cleanup to catchCause since NotFoundError is thrown (lifted to Cause.Die), not a typed failure. - Append "chars total" hint to formatTranscript truncation so the model knows how much was cut. * fix(agent_list): surface clock skew with "-" for negative elapsed formatElapsed returned "0s" for ms <= 0, hiding the case where started_at is in the future. Negative durations now render as "-" so the anomaly is visible. * fix(subagent-run): tighten correctness, drop dead code, document cap Correctness: - recordEvent now sorts with a secondary insertion-index key, so events sharing Date.now() (e.g. recordRejected's started+failed pair) keep deterministic order. - releaseSlot dies on underflow instead of clipping at 0, so a missing reserve/release pair surfaces as a defect. - start and recordRejected populate partsByToolCall only after session.updatePart succeeds, preventing orphan cache entries on persistence failure. Cleanup: - Drop recordActivity from the interface and runtime; v1 has no caller. - Document MAX_ACTIVE = 5 rationale and v1.1 follow-up. * fix(agent): sanitize at errorMessage entry, preserve cause, trim comment - Move sanitizeErrorMessage call inside errorMessage so persisted SubtaskPart.error.message never contains raw stacks, paths, or JSON envelopes — agent_output reads the field directly without re-sanitizing. - Switch the outer error path from Effect.catch + Effect.fail to Effect.catchCause + Effect.failCause so stack annotations and parallel errors survive instead of being flattened. - Trim the verbose finalizeAfter explanation that read like a commit message embedded in source. * fix(session): strengthen subtask lifecycle guard - updatePart guard no longer swallows getPart failures: the typed error channel is never, so dropping the catch lets any defect surface instead of silently allowing an unauthorized write through. - SubagentRunGuardViolation now extends Error so Effect.die preserves a real stack trace at the violation point. - Document the JSON.stringify-based comparison's limitation (order-sensitive, drops undefined) in lifecycleFieldsChanged so the trade-off is explicit. * fix(prompt): self-clean interruptedSessions on read wasInterrupted now deletes the entry after observing it. Each subagent dispatch reads exactly once, so eviction-on-read keeps the Set bounded by concurrent inflight subagents instead of growing unboundedly across the whole runtime lifetime. * fix(session): allow first-write SubtaskParts so Session.fork still works The lifecycle guard policed the very first persistence of a SubtaskPart, which blocked Session.fork() — fork replays historical parts via updatePart() outside any SubagentRunWriterContext, and a forked session that contained a completed/failed/canceled subagent now died on SubagentRunGuardViolation. Trust on creation: only mutations of an existing row are policed. lifecycleFieldsChanged loses its first-write branch (callers no longer pass undefined). New regression test asserts a non-writer can persist a SubtaskPart with full lifecycle values when no row exists yet. * fix(agent_output): prefix canceled_by_user with status header in result mode formatResult fell through to the generic body for canceled_by_user rows, returning only partial_result. The model could not distinguish a cancellation from a normal completion. Canceled rows now lead with "status: canceled_by_user" and surface the partial body (or "(no partial output)") so the lifecycle state is explicit. * fix(agent): scrub Windows paths and tighten resume lookup catch - PATH_RE now also covers Windows drive paths (C:\Users\...) and UNC paths (\\server\share\...) so error.message persisted in SubtaskPart.error never leaks usernames or local layout on Windows. - Resume lookup uses Cause.squash to swallow only NotFoundError; other defects (storage I/O, schema decode) re-raise via failCause instead of being masked as "subagent_session_id not found". - Export sanitizeErrorMessage and add invariant-style tests so any future leak vector (including Cygwin / WSL / new envelopes) trips the suite. Assertions verify the output never contains the original sensitive substrings, not the literal replacement format. * fix(subagent-run): make patchSession single-assignment Re-pointing subagent_session_id on a row that already names a different child would corrupt findLatestBySessionID and ownership-based lookups. patchSession now no-ops when the same id is provided again and dies when a different id is already stored, so any retry / re-entrant path that tries to overwrite the binding surfaces as a defect instead of silently mutating ownership. * fix(session): guard immutable subtask linkage fields LIFECYCLE_KEYS now also covers tool_call_id, parent_session_id, parent_message_id, and subagent_session_id. Without these, a non-writer path that already holds an existing SubtaskPart could re-point the row to a different child or parent via Session.updatePart(), bypassing the single-writer guarantee. SubagentRun's own writers stay unaffected: start() / recordRejected() set the fields on first write (always allowed) and patchSession() runs inside SubagentRunWriterContext. * fix(subagent-run): refresh tool_call_id index on findLatestBySessionID After a host restart, agent_output can still locate a row by subagent_session_id via the persisted-part scan, but subsequent mutators (setConsumed, recordEvent, finalize) hit withExistingPart's NotFound no-op because partsByToolCall has no entry. The advertised "consumed on first read" behavior therefore silently failed in the subagent_session_id lookup mode. findLatestBySessionID now repopulates partsByToolCall before returning, mirroring readByToolCallID's fallback. * refactor(subagent-run): drop dead subagent_type from StartInput / RejectedInput Neither start() nor recordRejected() reads input.subagent_type — the SubtaskPart schema does not carry it, and child-session tagging is already done by sessions.create({ subagentType }) in agent.ts. The field made the SubagentRun API imply responsibility for tagging that this layer never performed. Remove it from the DTOs and from the two agent.ts call sites; tests no longer pass it. * fix(subagent-run): persist lifecycle events on finalize and setConsumed recent_events used to stop at "started" for any row not produced by recordRejected — finalize and setConsumed updated only the status / consumed_at fields without appending a matching event. That left the ring useless for reconstructing the actual transition sequence (started → completed / failed / canceled_by_user → consumed). Hoist the eviction + stable-sort logic into a shared appendLifecycleEvent helper and use it from recordEvent, finalize, and setConsumed. finalize maps TerminalStatus to the matching SubtaskEvent variant (failed carries the error.kind); setConsumed appends a "consumed" event when the consumed_at flip lands. * fix(agent): scan full child history for cancellation partial_result and split finalize paths - makeReadLastCompletedAssistantText drops the limit:5 backscan and scans the full child message history. Tool-heavy children can push the last completed assistant text past five messages, which used to surface as partial_result: null for canceled_by_user even though stable text existed. Cancellation is a rare path so the extra read cost is acceptable. - Replace the tap+catch finalize handling with Effect.matchCauseEffect. The previous wrapper swallowed ok-finalize failures via catchCause, which let read() see a stale "running" row and synthesizeOutput render a defective success. Each path now runs its matching finalize exactly once: success → ok-finalize (failures propagate to outer catch); failure → err-finalize as best-effort cleanup, then re-raise the original cause via failCause. * fix(subagent-run): hydrate subtask defaults for legacy rows MessageV2.parts() and Session.getPart() return raw row.data with a type cast — they never run the SubtaskPart zod schema, so its .default("completed") / .default([]) annotations are not applied to rows persisted before this PR. Legacy subtask rows therefore reached agent_list / agent_output as `status === undefined`, rendering as "undefined (unread)" and breaking the advertised backward-compat path. Add a hydrateSubtask coercion helper and apply it in collectSubtaskParts (used by list / findLatestBySessionID / readByToolCallID) and in readPart (used by every per-row mutator), so old rows surface with status: "completed" and recent_events: [] like new ones. * refactor: drop unused last_activity, tighten agent_output cause swallow last_activity: - Nothing in v1 ever wrote last_activity (recordActivity was already removed earlier; no caller wires child tool/model events). agent_list and agent_output were reading last_activity?.label as the "what is it doing" label, which always fell back to "-" / result_summary in practice. YAGNI: remove the schema field, the readers, and its mention in LIFECYCLE_KEYS. Progress reporting comes back as a v1.1 feature with a real writer. - agent_output's running-status header surfaces result_summary instead of an empty placeholder; agent_list's row activity falls back to result_summary or "(legacy)". agent_output sessions.get cleanup: - Switch the catchCause to suppress only NotFoundError (squash the cause + instanceof check), re-raising any other cause via failCause. Same selective-catch pattern that 6bf8a1f applied to the resume lookup; storage / schema-decode failures are no longer masked as a clean "subagent inaccessible" response. * chore(agent): document why err-path keeps cause-rethrow Note that ctx.metadata (set in step 4) keeps subagent_session_id visible to the parent for resume even when the tool result lands as \`error\` — answers a recurring review question about why model failure does not surface a structured status: failed via synthesizeOutput. The existing test contract (b50815f) deliberately expects tool state = error + metadata.sessionId on child failure. * fix(subagent-run): tie-break newest-first sort on insertion index findLatestBySessionID and list both sorted only on started_at. Two rows started in the same millisecond — possible under burst dispatch of multiple subagents from one parent — kept whichever the scan saw first, so agent_output could surface a stale row and agent_list could return the older one as "latest". Pair each part with its scan index and break the started_at tie on index descending, mirroring the deterministic ordering recordEvent uses for SubtaskEvents that share Date.now(). * fix(agent): squash cause before persisting outer-catch error message errorMessage(cause) fell through to JSON.stringify on the raw Cause object, producing {"_tag":"Fail",...} which JSON_ENVELOPE_RE then collapsed to "<json>" — uninformative for the persisted SubtaskPart.error.message. Use Cause.squash to extract the underlying error before sanitizing, matching what 6bf8a1f and the err-path finalize already do. * test(subagent-run): pin guard test to SubagentRunGuardViolation defect The guard test only checked exit._tag === "Failure", which would still pass if updatePart started failing for an unrelated reason in a future refactor. Inspect the Cause's Die reasons, find the SubagentRunGuardViolation defect, and assert its tool_call_id matches the row being mutated. The test now actually pins the contract this PR introduces.
Summary
Pulls upstream PR #23244 (tool framework Effect Schema migration) and applies the same pattern to PawWork's renamed
agent.ts(was upstreamtask.ts) plus PawWork-only tools (trash.ts,mcp-exa.ts).Why
Stage E of the foundation sync (#209). Tool framework migration is core runtime work — adopting upstream's Effect Schema framework keeps PawWork's tool layer aligned with where upstream is going. Tool descriptions (
*.txt) remain a permanent PawWork carve-out perproject_tool_description_carveout_strategic.md; only the framework, schema definitions, and tool source files migrate.Value to PawWork
Beyond keeping the foundation aligned, this PR brings concrete correctness and performance improvements that users will feel:
Tool layer extensibility.
Tool.define+ Effect Schema unifies registration/validation across upstream tools and PawWork-only tools (agent,trash,mcp-exa). Future PawWork-specific tools no longer need a parallel injection path. Prerequisite for landing the tool-set v1 decisions in #129.File operation correctness (user-visible).
write/edit/patchno longer silently strip or add a UTF-8 BOM. Editing Windows toolchain artifacts (.bat, some.json, legacy PowerShell scripts) keeps shebang and first-token parsing intact.editandwriterecompute the diff after the formatter runs, so the diff shown in the UI matches what's on disk — no more "diff looks right but saved file behaves differently."editrejects directory paths up front instead of failing silently downstream.Search / grep stability. Ripgrep is now a streaming Service with
partialReason:greponly fails on a genuinely invalid pattern; unreadable paths return matches with a "(some paths skipped)" note instead of failing the whole call.Long-session token cost. Compaction now tracks
tail_start_id:estimate()uses the samestripMedia/toolOutputMaxCharsoptions as the real compaction path, so the predicted token count matches reality and compaction triggers on time.Subagent / question input validation.
agenttool validatessubagent_session_idshape — a malformed ID from the model fails loudly instead of silently no-op'ing downstream.questiontool rejects duplicate option labels and out-of-range replies — the UI can't end up in a confused state because the model invented a label or replied to a non-existent option.What landed (35 files)
packages/opencode/src/tool/tool.ts— framework migrated to useSchema.Decoder<unknown>for parameters withInit<P, M>indirection.bash,edit,glob,grep,invalid,lsp,plan,read,schema,skill,todo,truncate,truncation-dir,webfetch,websearch,write,external-directory, plusapply_patchandcodesearch(PawWork mirrors that upstream's #23244 also migrates).agent.tsmanually ported to Effect Schema following thebash.tspattern (upstream'stask.tsdropped).trash.ts,mcp-exa.ts.packages/opencode/src/util/bom.tsrestored from upstream HEAD (used by the newedit.ts).Truncate.Service/Agent.Servicetype re-exports added so the new framework type signatures work.PawWork carve-outs preserved
packages/opencode/src/tool/*.txtkept HEAD (tool description carve-out, all 18+ files).packages/opencode/src/tool/registry.tskept PawWork pre-D structure: customTool.initpattern,AgentTool/trash/mcp-exaregistration. Three@ts-expect-errordirectives mark the Zod-vs-Effect-Schema boundary where PawWork'sQuestionTool/GrepTool/ plugin tools still use Zod params.packages/opencode/src/tool/question.tsandgrep.tskept PawWork pre-D Zod params:Question.PromptandRipgrep.Interfaceremain Zod in PawWork's divergentquestion/index.tsandfile/ripgrep.ts. Migrating those namespaces is out of scope for this PR.packages/opencode/src/tool/edit.tsported to PawWork's format/lsp API surface (format.filereturnsvoid,lsp.touchFiletakesboolean) rather than upstream HEAD's signature.packages/opencode/test/tool/question.test.tsremoved: tested the old Zod-Schema mixed surface; rewrite when the question namespace is migrated.Mechanical changes
import { Tool } from "./tool"→import * as Tool from "./tool"across ~13 source/test files:tool/tool.tsmoved from namespace to flat exports.import { ToolRegistry } from "./registry"stays as named import: PawWork's registry kept its namespace export.Related Issue
#209
How To Verify
All 5 packages typecheck clean.
Stacked on
Base is
claude/upstream-sync-209-pr3-effect-schema(#268). Will auto-rebase todevafter #265 → #266 → #268 chain merges.Checklist
packages/opencode/src/util/bom.tsrestored; no new deps; one PawWork test removeddev(via stacked PR), Conventional Commits English titleSummary by CodeRabbit
New Features
Improvements