Skip to content

refactor: sync upstream tool framework and service refactors#270

Merged
Astro-Han merged 6 commits into
devfrom
claude/upstream-sync-209-pr4-tool-framework
Apr 28, 2026
Merged

refactor: sync upstream tool framework and service refactors#270
Astro-Han merged 6 commits into
devfrom
claude/upstream-sync-209-pr4-tool-framework

Conversation

@Astro-Han

@Astro-Han Astro-Han commented Apr 27, 2026

Copy link
Copy Markdown
Owner

Summary

Pulls upstream PR #23244 (tool framework Effect Schema migration) and applies the same pattern to PawWork's renamed agent.ts (was upstream task.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 per project_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).

  • BOM-aware write / edit / patch no 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.
  • edit and write recompute 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."
  • edit rejects directory paths up front instead of failing silently downstream.

Search / grep stability. Ripgrep is now a streaming Service with partialReason:

  • Invalid regex vs unreadable paths are no longer conflated. grep only fails on a genuinely invalid pattern; unreadable paths return matches with a "(some paths skipped)" note instead of failing the whole call.
  • Streaming output means large-repo file enumeration no longer materialises the full list in memory.

Long-session token cost. Compaction now tracks tail_start_id:

  • Subsequent compactions skip messages already covered by a prior summary instead of re-feeding them to the model.
  • estimate() uses the same stripMedia / toolOutputMaxChars options as the real compaction path, so the predicted token count matches reality and compaction triggers on time.

Subagent / question input validation.

  • agent tool validates subagent_session_id shape — a malformed ID from the model fails loudly instead of silently no-op'ing downstream.
  • question tool 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 use Schema.Decoder<unknown> for parameters with Init<P, M> indirection.
  • 18 upstream-tracked tools migrated: bash, edit, glob, grep, invalid, lsp, plan, read, schema, skill, todo, truncate, truncation-dir, webfetch, websearch, write, external-directory, plus apply_patch and codesearch (PawWork mirrors that upstream's #23244 also migrates).
  • PawWork's renamed agent.ts manually ported to Effect Schema following the bash.ts pattern (upstream's task.ts dropped).
  • PawWork-only tools migrated: trash.ts, mcp-exa.ts.
  • packages/opencode/src/util/bom.ts restored from upstream HEAD (used by the new edit.ts).
  • Truncate.Service / Agent.Service type re-exports added so the new framework type signatures work.

PawWork carve-outs preserved

  • packages/opencode/src/tool/*.txt kept HEAD (tool description carve-out, all 18+ files).
  • packages/opencode/src/tool/registry.ts kept PawWork pre-D structure: custom Tool.init pattern, AgentTool/trash/mcp-exa registration. Three @ts-expect-error directives mark the Zod-vs-Effect-Schema boundary where PawWork's QuestionTool / GrepTool / plugin tools still use Zod params.
  • packages/opencode/src/tool/question.ts and grep.ts kept PawWork pre-D Zod params: Question.Prompt and Ripgrep.Interface remain Zod in PawWork's divergent question/index.ts and file/ripgrep.ts. Migrating those namespaces is out of scope for this PR.
  • packages/opencode/src/tool/edit.ts ported to PawWork's format/lsp API surface (format.file returns void, lsp.touchFile takes boolean) rather than upstream HEAD's signature.
  • packages/opencode/test/tool/question.test.ts removed: 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.ts moved 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

bun install
bun run --cwd packages/core typecheck
bun run --cwd packages/opencode typecheck
bun run --cwd packages/util typecheck
bun run --cwd packages/ui typecheck
bun run --cwd packages/desktop-electron typecheck

All 5 packages typecheck clean.

Stacked on

Base is claude/upstream-sync-209-pr3-effect-schema (#268). Will auto-rebase to dev after #265#266#268 chain merges.

Checklist

  • I linked the related issue ([Feature] Track selective upstream backports from opencode v1.14.17-v1.14.22 #209)
  • This PR has type, scope, and priority labels (upstream / harness / P3)
  • I listed the relevant verification steps
  • No visible UI changes — N/A
  • No desktop/packaging/updater impact — N/A (runtime tool framework only)
  • I considered docs/dependencies — packages/opencode/src/util/bom.ts restored; no new deps; one PawWork test removed
  • Targeting dev (via stacked PR), Conventional Commits English title

Summary by CodeRabbit

  • New Features

    • Runtime-configurable tool-output truncation limits.
    • BOM-aware file I/O that preserves and syncs UTF‑8 BOMs.
    • Faster streamed file search/tree with clearer partial-result indicators.
  • Improvements

    • Session compaction anchored to prior summaries with improved tail budgeting and tool-output capping.
    • Formatter now reports whether it changed a file.
    • Unified/stricter tool parameter validation for more consistent error handling and defaults.

@Astro-Han Astro-Han added P3 Low priority upstream Tracked upstream or vendor behavior harness Model harness, prompts, tool descriptions, and session mechanics labels Apr 27, 2026
@coderabbitai

coderabbitai Bot commented Apr 27, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

Replaces 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

Cohort / File(s) Summary
Filesystem import migration & tests
packages/opencode/src/*/... (many), packages/opencode/test/**
Repointed many imports from the local filesystem module to @opencode-ai/core/filesystem; tests updated to use the external AppFileSystem layers.
Deleted local filesystem module
packages/opencode/src/filesystem/index.ts
Removed the in-repo filesystem module and its public types/helpers; consumers now rely on the external package.
Ripgrep service & callers
packages/opencode/src/file/ripgrep.ts, packages/opencode/src/file/index.ts, packages/opencode/src/cli/cmd/debug/..., packages/opencode/src/server/instance/file.ts, packages/opencode/test/file/ripgrep.test.ts
Rewrote ripgrep as an Effect/Schema-based service with JSON-line parsing, streaming APIs, platform-specific installer layer; callers updated to use Ripgrep.Service via runtime effects and streams.
Tool schema migration (Zod → effect/Schema)
packages/opencode/src/tool/*.ts, packages/opencode/src/tool/registry.ts, packages/opencode/src/tool/tool.ts, packages/opencode/test/tool/**
Converted many tool parameter schemas to exported effect/Schema structs, changed tool typing/decoding/execute flow, and added per-invocation tracing; tests adapted to new schemas.
BOM utilities & integrations
packages/opencode/src/util/bom.ts, packages/opencode/src/patch/index.ts, packages/opencode/src/tool/{apply_patch,edit,write}.ts
Added BOM helpers (split, join, readFile, syncFile) and integrated BOM round-tripping into patch/edit/write flows; diffs recomputed after formatting.
Session compaction & message handling
packages/opencode/src/session/{compaction,overflow,message-v2}.ts
Rewrote tail-selection (recent-turns strategy), added tool-output truncation caps, changed anchored-summary prompt flow, exposed isOverflow/prune exports, and fixed compaction boundary persistence.
Config & truncation service
packages/opencode/src/config/config.ts, packages/opencode/src/tool/truncate.ts
Added optional tool_output config (max_lines, max_bytes); Truncate service reads config via limits() with safe fallbacks and uses resolved limits during truncation.
Format behavior change
packages/opencode/src/format/index.ts
Format.file now returns Effect<boolean> indicating whether any formatter succeeded; individual formatter failures are logged and do not abort the chain.
Effect↔Zod tooling & prompts
packages/opencode/src/util/effect-zod.ts, packages/opencode/src/session/prompt.ts, packages/opencode/src/agent/prompt/compaction.txt
Added effect→Zod JSON-schema helper; updated prompt compaction text to an anchored summarizer with stricter output formatting rules.
Question model & runtime tightening
packages/opencode/src/question/index.ts, packages/opencode/src/server/instance/question.ts, packages/opencode/test/question/**
Migrated Question models to Schema.Class, tightened ask/reply validation/constraints, cloned payloads on publish, and updated server validation and tests.
Other imports, typings, and smaller changes
packages/opencode/src/{auth,agent,cli,provider,project,vcs,skill,snapshot,storage,worktree,...} and many tests
Numerous focused edits: import path updates to core filesystem, signature/type adjustments for Schema, ripgrep caller updates, and assorted refactors across modules and tests.

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
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

Possibly related PRs

Suggested labels

enhancement, P2

"I hopped through patches, schemas, and streams,
BOM snug in files, ripgrep humming in dreams,
Tokens tidied, compactions held tight,
Tools learned new shapes and ran just right,
A rabbit's cheer for refactor nights!" 🐇✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 2.27% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The PR title 'refactor: sync upstream tool framework and service refactors' clearly describes the main change: syncing/migrating the tool framework from the upstream codebase, following a conventional commit format.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed The PR description is detailed and comprehensive, covering summary, rationale, objectives, file changes, carve-outs, verification steps, and related issues.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/upstream-sync-209-pr4-tool-framework

Comment @coderabbitai help to get the list of available commands and usage tips.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/opencode/src/tool/read.ts
Comment thread packages/opencode/src/tool/edit.ts
Comment thread packages/opencode/src/tool/edit.ts Outdated
@Astro-Han Astro-Han force-pushed the claude/upstream-sync-209-pr3-effect-schema branch 5 times, most recently from ebd1167 to 42557db Compare April 28, 2026 03:38
@Astro-Han Astro-Han changed the title refactor(tool): migrate tool framework to Effect Schema (#23244) refactor: sync upstream tool framework and service refactors Apr 28, 2026
@Astro-Han Astro-Han changed the base branch from claude/upstream-sync-209-pr3-effect-schema to dev April 28, 2026 03:57
@Astro-Han Astro-Han force-pushed the claude/upstream-sync-209-pr4-tool-framework branch from 37c79c1 to 2229f47 Compare April 28, 2026 03:57

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 | 🟠 Major

Recompute the returned diff after formatter rewrites newly created files.

In the oldString === "" branch, contentNew is updated after format.file() / Bom.syncFile(), but diff is 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 | 🟠 Major

Size recent turns with the same serialization options used for compaction.

select() budgets recent turns with full toModelMessagesEffect(...), but processCompaction() 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

📥 Commits

Reviewing files that changed from the base of the PR and between b7f2482 and 2229f47.

⛔ Files ignored due to path filters (1)
  • packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap is excluded by !**/*.snap
📒 Files selected for processing (78)
  • packages/opencode/src/agent/prompt/compaction.txt
  • packages/opencode/src/auth/index.ts
  • packages/opencode/src/cli/cmd/debug/file.ts
  • packages/opencode/src/cli/cmd/debug/ripgrep.ts
  • packages/opencode/src/cli/cmd/run.ts
  • packages/opencode/src/config/config.ts
  • packages/opencode/src/effect/app-runtime.ts
  • packages/opencode/src/file/index.ts
  • packages/opencode/src/file/ripgrep.ts
  • packages/opencode/src/filesystem/index.ts
  • packages/opencode/src/format/index.ts
  • packages/opencode/src/mcp/auth.ts
  • packages/opencode/src/mcp/index.ts
  • packages/opencode/src/patch/index.ts
  • packages/opencode/src/project/project.ts
  • packages/opencode/src/project/vcs.ts
  • packages/opencode/src/provider/provider.ts
  • packages/opencode/src/question/index.ts
  • packages/opencode/src/server/instance/file.ts
  • packages/opencode/src/server/instance/question.ts
  • packages/opencode/src/session/compaction.ts
  • packages/opencode/src/session/instruction.ts
  • packages/opencode/src/session/message-v2.ts
  • packages/opencode/src/session/overflow.ts
  • packages/opencode/src/session/prompt.ts
  • packages/opencode/src/skill/discovery.ts
  • packages/opencode/src/skill/index.ts
  • packages/opencode/src/snapshot/index.ts
  • packages/opencode/src/storage/storage.ts
  • packages/opencode/src/tool/agent.ts
  • packages/opencode/src/tool/apply_patch.ts
  • packages/opencode/src/tool/bash.ts
  • packages/opencode/src/tool/codesearch.ts
  • packages/opencode/src/tool/edit.ts
  • packages/opencode/src/tool/external-directory.ts
  • packages/opencode/src/tool/glob.ts
  • packages/opencode/src/tool/grep.ts
  • packages/opencode/src/tool/invalid.ts
  • packages/opencode/src/tool/lsp.ts
  • packages/opencode/src/tool/plan.ts
  • packages/opencode/src/tool/question.ts
  • packages/opencode/src/tool/read.ts
  • packages/opencode/src/tool/registry.ts
  • packages/opencode/src/tool/skill.ts
  • packages/opencode/src/tool/todo.ts
  • packages/opencode/src/tool/tool.ts
  • packages/opencode/src/tool/trash.ts
  • packages/opencode/src/tool/truncate.ts
  • packages/opencode/src/tool/webfetch.ts
  • packages/opencode/src/tool/websearch.ts
  • packages/opencode/src/tool/write.ts
  • packages/opencode/src/util/bom.ts
  • packages/opencode/src/util/effect-zod.ts
  • packages/opencode/src/worktree/index.ts
  • packages/opencode/test/config/config.test.ts
  • packages/opencode/test/config/pawwork-global-config.test.ts
  • packages/opencode/test/file/ripgrep.test.ts
  • packages/opencode/test/filesystem/filesystem.test.ts
  • packages/opencode/test/project/project.test.ts
  • packages/opencode/test/question/question.test.ts
  • packages/opencode/test/question/schema.test.ts
  • packages/opencode/test/session/compaction.test.ts
  • packages/opencode/test/session/prompt-effect.test.ts
  • packages/opencode/test/session/snapshot-tool-race.test.ts
  • packages/opencode/test/storage/storage.test.ts
  • packages/opencode/test/tool/agent-rename.test.ts
  • packages/opencode/test/tool/apply_patch.test.ts
  • packages/opencode/test/tool/bash.test.ts
  • packages/opencode/test/tool/edit.test.ts
  • packages/opencode/test/tool/external-directory.test.ts
  • packages/opencode/test/tool/glob.test.ts
  • packages/opencode/test/tool/grep.test.ts
  • packages/opencode/test/tool/question.test.ts
  • packages/opencode/test/tool/read.test.ts
  • packages/opencode/test/tool/skill.test.ts
  • packages/opencode/test/tool/tool-define.test.ts
  • packages/opencode/test/tool/trash.test.ts
  • packages/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

Comment thread packages/opencode/src/cli/cmd/debug/ripgrep.ts Outdated
Comment thread packages/opencode/src/patch/index.ts Outdated
Comment thread packages/opencode/src/session/compaction.ts
Comment thread packages/opencode/src/session/message-v2.ts Outdated
Comment thread packages/opencode/src/tool/agent.ts
Comment thread packages/opencode/src/tool/registry.ts
Comment thread packages/opencode/src/tool/webfetch.ts
Comment thread packages/opencode/src/tool/write.ts Outdated
Comment thread packages/opencode/test/file/ripgrep.test.ts
Comment thread packages/opencode/test/tool/agent-rename.test.ts Outdated
@Astro-Han Astro-Han force-pushed the claude/upstream-sync-209-pr4-tool-framework branch from 2229f47 to 5781bb7 Compare April 28, 2026 04:36
@Astro-Han

Copy link
Copy Markdown
Owner Author

Outside-diff comment replies

tool/edit.ts:89-109 — Recompute diff after formatter rewrites newly created files

Major

Deferring on graft scope. The oldString === "" create-file branch is upstream code adopted as part of refactor(tool): re-sync tool ts files to upstream. The diff/metadata-vs-disk skew when formatting rewrites a file is a real correctness concern, but it predates the sync — it is not introduced by this PR. Tracking as a follow-up: the fix is mechanical (recompute diff after the format.file() / Bom.syncFile() block before publishing File.Event.Edited) and worth landing as its own commit alongside the sibling edit-replace branch which already has the same shape.

session/compaction.ts:236-241 (and 405-408) — Match select() serialization to processCompaction()

Major

Deferring on graft scope. compaction.ts is the upstream refactor(compaction): adopt upstream structured-template summarization slice; the budget-vs-actual mismatch between select()'s toModelMessagesEffect(...) and processCompaction()'s media-strip + tool-output-truncate path is a behavior gap inherited from upstream's structured-template design. The fix is intrusive — passing stripMedia / toolOutputMaxChars options through toModelMessagesEffect (which doesn't currently accept them) — so it belongs in its own PR with focused tests rather than mixed into this graft sync.

Both items will be tracked as follow-ups; this PR stays focused on the upstream graft to keep the diff alignable for future syncs.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 | 🟡 Minor

Use graceful fallback for cache exists() probes instead of hard-failing.

In download(), the early cache check at line 39 uses Effect.orDie on fs.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) use fs.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 | 🟡 Minor

Add URL validation to pull() to prevent SSRF attacks.

The pull() function constructs and fetches URLs from user-configurable sources without validating the destination. Since url originates from cfg.skills?.urls in 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

ran is reported as true even when formatting fails.

ran is set before checking process exit code, so callers can get true after 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 | 🟠 Major

Validate 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 | 🟡 Minor

Reject timeout = 0 or document it explicitly.

This guard only rejects negatives, but the error text says the timeout must be positive. Right now 0 slips through and becomes an effective ~100 ms timeout because run() races against Effect.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 | 🟠 Major

Estimate preserved-tail size with the same serialization options used for compaction.

select() budgets turns through estimate(), but estimate() 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 | 🟠 Major

Reject directories before entering the create/overwrite path.

When oldString === "" and filePath already points to a directory, existsSafe() is true and this branch calls Bom.readFile(...) instead of returning the explicit "Path is a directory" error used below. Inside this Effect.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 | 🟡 Minor

Strengthen the removed-key assertion to avoid false positives.

toBeUndefined() does not prove the key is absent; it also passes when the key exists with an undefined value. 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 | 🟠 Major

Plugin 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) before def.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 | 🟠 Major

BOM 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 | 🟠 Major

Reserve room for the truncation suffix.

This keeps maxChars characters 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 | 🟠 Major

Don't treat every empty partial result as an invalid regex.

result.partial is also the signal used later for inaccessible/skipped paths. With this branch, a search that hits unreadable paths and finds no matches now throws likely invalid pattern instead 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 | 🟡 Minor

Advertise the configured truncation limits.

run() now honors yield* trunc.limits(), but the description still interpolates Truncate.MAX_LINES / Truncate.MAX_BYTES. When tool_output.max_lines or tool_output.max_bytes is 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 | 🟠 Major

Keep 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

📥 Commits

Reviewing files that changed from the base of the PR and between 2229f47 and 5781bb7.

⛔ Files ignored due to path filters (1)
  • packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap is excluded by !**/*.snap
📒 Files selected for processing (78)
  • packages/opencode/src/agent/prompt/compaction.txt
  • packages/opencode/src/auth/index.ts
  • packages/opencode/src/cli/cmd/debug/file.ts
  • packages/opencode/src/cli/cmd/debug/ripgrep.ts
  • packages/opencode/src/cli/cmd/run.ts
  • packages/opencode/src/config/config.ts
  • packages/opencode/src/effect/app-runtime.ts
  • packages/opencode/src/file/index.ts
  • packages/opencode/src/file/ripgrep.ts
  • packages/opencode/src/filesystem/index.ts
  • packages/opencode/src/format/index.ts
  • packages/opencode/src/mcp/auth.ts
  • packages/opencode/src/mcp/index.ts
  • packages/opencode/src/patch/index.ts
  • packages/opencode/src/project/project.ts
  • packages/opencode/src/project/vcs.ts
  • packages/opencode/src/provider/provider.ts
  • packages/opencode/src/question/index.ts
  • packages/opencode/src/server/instance/file.ts
  • packages/opencode/src/server/instance/question.ts
  • packages/opencode/src/session/compaction.ts
  • packages/opencode/src/session/instruction.ts
  • packages/opencode/src/session/message-v2.ts
  • packages/opencode/src/session/overflow.ts
  • packages/opencode/src/session/prompt.ts
  • packages/opencode/src/skill/discovery.ts
  • packages/opencode/src/skill/index.ts
  • packages/opencode/src/snapshot/index.ts
  • packages/opencode/src/storage/storage.ts
  • packages/opencode/src/tool/agent.ts
  • packages/opencode/src/tool/apply_patch.ts
  • packages/opencode/src/tool/bash.ts
  • packages/opencode/src/tool/codesearch.ts
  • packages/opencode/src/tool/edit.ts
  • packages/opencode/src/tool/external-directory.ts
  • packages/opencode/src/tool/glob.ts
  • packages/opencode/src/tool/grep.ts
  • packages/opencode/src/tool/invalid.ts
  • packages/opencode/src/tool/lsp.ts
  • packages/opencode/src/tool/plan.ts
  • packages/opencode/src/tool/question.ts
  • packages/opencode/src/tool/read.ts
  • packages/opencode/src/tool/registry.ts
  • packages/opencode/src/tool/skill.ts
  • packages/opencode/src/tool/todo.ts
  • packages/opencode/src/tool/tool.ts
  • packages/opencode/src/tool/trash.ts
  • packages/opencode/src/tool/truncate.ts
  • packages/opencode/src/tool/webfetch.ts
  • packages/opencode/src/tool/websearch.ts
  • packages/opencode/src/tool/write.ts
  • packages/opencode/src/util/bom.ts
  • packages/opencode/src/util/effect-zod.ts
  • packages/opencode/src/worktree/index.ts
  • packages/opencode/test/config/config.test.ts
  • packages/opencode/test/config/pawwork-global-config.test.ts
  • packages/opencode/test/file/ripgrep.test.ts
  • packages/opencode/test/filesystem/filesystem.test.ts
  • packages/opencode/test/project/project.test.ts
  • packages/opencode/test/question/question.test.ts
  • packages/opencode/test/question/schema.test.ts
  • packages/opencode/test/session/compaction.test.ts
  • packages/opencode/test/session/prompt-effect.test.ts
  • packages/opencode/test/session/snapshot-tool-race.test.ts
  • packages/opencode/test/storage/storage.test.ts
  • packages/opencode/test/tool/agent-rename.test.ts
  • packages/opencode/test/tool/apply_patch.test.ts
  • packages/opencode/test/tool/bash.test.ts
  • packages/opencode/test/tool/edit.test.ts
  • packages/opencode/test/tool/external-directory.test.ts
  • packages/opencode/test/tool/glob.test.ts
  • packages/opencode/test/tool/grep.test.ts
  • packages/opencode/test/tool/question.test.ts
  • packages/opencode/test/tool/read.test.ts
  • packages/opencode/test/tool/skill.test.ts
  • packages/opencode/test/tool/tool-define.test.ts
  • packages/opencode/test/tool/trash.test.ts
  • packages/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

Comment thread packages/opencode/src/question/index.ts
Comment thread packages/opencode/src/session/overflow.ts Outdated
Comment thread packages/opencode/src/tool/tool.ts
Comment thread packages/opencode/src/tool/tool.ts
Comment thread packages/opencode/src/tool/tool.ts
Comment thread packages/opencode/test/file/ripgrep.test.ts
Comment thread packages/opencode/test/tool/tool-define.test.ts

@Astro-Han Astro-Han left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/opencode/src/session/compaction.ts
@Astro-Han Astro-Han force-pushed the claude/upstream-sync-209-pr4-tool-framework branch from 5781bb7 to e8215a3 Compare April 28, 2026 04:55
@Astro-Han

Copy link
Copy Markdown
Owner Author

Outside-diff comment replies (round 2)

Same disposition as the inline replies — this PR is refactor: sync upstream tool framework and service refactors, a graft-style upstream sync. All 7 outside-diff findings are on upstream code being adopted wholesale; none were authored by PawWork.

File Issue Disposition
skill/discovery.ts:38-40 Effect.orDie on cache exists() probe → use existsSafe Defer (upstream)
skill/discovery.ts:55-105 pull() URL not validated for SSRF on user-config URLs Defer (upstream); SSRF is real but config is local-trust today
format/index.ts:120-157 ran set true before exit-code check Defer (upstream)
question/index.ts:226-243 reply() doesn't validate against pending question schema Defer (upstream)
tool/bash.ts:596-598 timeout = 0 slips through Fixed (<= instead of <) — same shape as the webfetch fix from the previous round
session/compaction.ts:236-241 estimate() budgets without stripMedia/toolOutputMaxChars Defer (upstream) — same item as previous round, reposted by CodeRabbit on the new commit
tool/edit.ts:89-115 Directory not rejected before Bom.readFile() in create path Defer (upstream)

Bash timeout is the only one fixed in this round because it's a 1-character change (<<=) directly parallel to the webfetch fix already landed earlier — keeping consistency was cheap. The other 6 require substantive design decisions (SSRF policy, schema validation, format error semantics, compaction budgeting) that belong in dedicated follow-ups, not bundled into a graft sync.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 | 🟠 Major

Formatting can make the approved diff stale.

The permission prompt is built from contentOld/contentNew, but format.file(filepath) rewrites the file afterward and Bom.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 | 🟠 Major

Use Effect.fn* for these tool constructors.

wrap is the internal helper here, and define / init are exported effect constructors, but all three still build raw Effect.gen(...) values. Please align them with the repo convention: Effect.fnUntraced for wrap, named Effect.fn(...) for define and init.

As per coding guidelines, "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".

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

📥 Commits

Reviewing files that changed from the base of the PR and between 5781bb7 and e8215a3.

📒 Files selected for processing (34)
  • packages/opencode/src/agent/prompt/compaction.txt
  • packages/opencode/src/cli/cmd/debug/file.ts
  • packages/opencode/src/cli/cmd/debug/ripgrep.ts
  • packages/opencode/src/config/config.ts
  • packages/opencode/src/file/index.ts
  • packages/opencode/src/file/ripgrep.ts
  • packages/opencode/src/format/index.ts
  • packages/opencode/src/patch/index.ts
  • packages/opencode/src/question/index.ts
  • packages/opencode/src/server/instance/file.ts
  • packages/opencode/src/server/instance/question.ts
  • packages/opencode/src/session/compaction.ts
  • packages/opencode/src/session/message-v2.ts
  • packages/opencode/src/session/overflow.ts
  • packages/opencode/src/tool/apply_patch.ts
  • packages/opencode/src/tool/bash.ts
  • packages/opencode/src/tool/edit.ts
  • packages/opencode/src/tool/glob.ts
  • packages/opencode/src/tool/grep.ts
  • packages/opencode/src/tool/lsp.ts
  • packages/opencode/src/tool/question.ts
  • packages/opencode/src/tool/read.ts
  • packages/opencode/src/tool/registry.ts
  • packages/opencode/src/tool/skill.ts
  • packages/opencode/src/tool/todo.ts
  • packages/opencode/src/tool/tool.ts
  • packages/opencode/src/tool/truncate.ts
  • packages/opencode/src/tool/webfetch.ts
  • packages/opencode/src/tool/write.ts
  • packages/opencode/test/file/ripgrep.test.ts
  • packages/opencode/test/question/question.test.ts
  • packages/opencode/test/question/schema.test.ts
  • packages/opencode/test/session/compaction.test.ts
  • packages/opencode/test/tool/grep.test.ts
💤 Files with no reviewable changes (1)
  • packages/opencode/test/session/compaction.test.ts

Comment thread packages/opencode/src/file/ripgrep.ts
Comment thread packages/opencode/src/file/ripgrep.ts
Comment thread packages/opencode/src/format/index.ts Outdated
Comment thread packages/opencode/src/session/compaction.ts
Comment thread packages/opencode/src/tool/grep.ts Outdated
Comment thread packages/opencode/src/tool/lsp.ts
Comment thread packages/opencode/test/file/ripgrep.test.ts
Comment thread packages/opencode/test/file/ripgrep.test.ts Outdated
Comment thread packages/opencode/test/tool/grep.test.ts
@Astro-Han Astro-Han force-pushed the claude/upstream-sync-209-pr4-tool-framework branch from e8215a3 to 19ba464 Compare April 28, 2026 05:19
@Astro-Han

Copy link
Copy Markdown
Owner Author

Re-evaluated per P0/P1/P2/P3 grading

After 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 19ba4642a rather than tracked as follow-ups. The "graft scope" defer was over-applied — most of these were small-to-medium changes well worth landing now.

Fixed in this PR (16)

Thread File:Line Severity Fix
3151506460 patch/index.ts:346 P1 Only preserve original BOM; patch content can no longer introduce/strip BOM silently
3151506463 compaction.ts:181 Still deferred (P2, design-level)
3151506466 message-v2.ts:25 P2 truncateToolOutput now reserves room for the suffix so output respects maxChars
3151506471 tool/agent.ts:27 P1 Decode subagent_session_id against SessionID before lookup; surface clear error on typo
3151506479 tool/grep.ts:79 P1 Ripgrep.Service now distinguishes invalid_pattern vs partial_io via stderr sniff; grep only throws on the former
3151506492 tool/write.ts:52 P1 Only preserve original file's BOM; surface bomDiscarded flag when input would have changed BOM state
3151506498 test/tool/agent-rename.test.ts P2 Switched to "key" in obj assertions to catch explicit-undefined values
3151634827 question/index.ts:61 P1 ask() rejects duplicate option labels per question
3151634830 session/overflow.ts:18 P2 ||?? so explicit input cap of 0 is preserved
3151634849 test/file/ripgrep.test.ts:175 P2 Replaced hardcoded /tmp/... with path under fresh tmpdir()
3151652138 compaction.ts:392 P1 completedCompactions now tracks tail_start_id; hidden set excludes everything before the latest summary's tail, preventing repeated re-summarisation
edit.ts:89-109 (R1 outside-diff) tool/edit.ts:108 P1 Recompute diff after format.file()/Bom.syncFile() so metadata matches on-disk content
compaction.ts:236 (outside-diff) compaction.ts:240 P1 estimate() now passes {stripMedia, toolOutputMaxChars} matching processCompaction() so select() budgets reflect post-compaction size
skill/discovery.ts orDie (R2 outside-diff) skill/discovery.ts:39,99 P2 Switched both fs.exists().pipe(Effect.orDie) to fs.existsSafe()
format/index.ts ran=true (R2 outside-diff) format/index.ts:120-157 P2 ran = true only after exit code 0; failed formatters no longer mark the run successful
question reply validation (R2 outside-diff) question/index.ts:226 P1 reply() validates answer count, single-select cardinality, and label membership (when custom: false); falls to Deferred.fail on mismatch

Genuinely deferred to follow-up (6)

Thread Reason
tool/edit.ts:45 locks Map leak P3, requires InstanceState migration with disposal path
tool/bash.ts:426 description vs configured limits P2, needs runtime template (description currently captures static constants at module load)
compaction.ts:181 splitTurn mid-turn P2 design-level — needs to define turn-boundary semantics correctly
skill/discovery.ts:55-105 SSRF P2-3 — config is local-trust today; SSRF policy belongs in its own PR with clear allowlist semantics
tool.ts:10/42/128 Metadata any/M generic/Effect.fn naming P3 type/tracing ergonomics with multi-callsite blast radius
test/file/ripgrep.test.ts:10 + test/tool/tool-define.test.ts:9 testEffect harness P3 pure test style

tool/registry.ts:152 was self-noted by CodeRabbit as "Addressed in commits 241b462 to 5781bb7" — already covered by the original PR commits.

Force-pushed 19ba4642a. Targeted tests: bun test test/{tool,session,file,format}/**/*.test.ts → 410 pass / 0 fail. CI is rerunning.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

♻️ Duplicate comments (10)
packages/opencode/src/question/index.ts (1)

204-227: ⚠️ Potential issue | 🟠 Major

Normalize option labels before checking uniqueness.

TrimmedString trims labels during Request decoding, but this guard runs against the raw input.questions. "A" and " A " pass here, then collapse to the same stored label in info.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 | 🔵 Trivial

Use schema-derived type for execute to prevent drift.

The execute parameter still uses an inline type rather than Schema.Schema.Type<typeof Parameters>. If Parameters evolves (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 | 🟠 Major

Don’t silently fork a new subagent on resume lookup failures.

The new SessionID validation 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 | 🟡 Minor

Advertise the same truncation limits that run() enforces.

run() now honors yield* trunc.limits(), but the tool description still interpolates Truncate.MAX_LINES / Truncate.MAX_BYTES. When tool_output.max_lines or tool_output.max_bytes is 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 | 🟠 Major

Derive execute params from Parameters.

The inline object type can drift from the schema and silently bypass the compile-time guarantee this migration is supposed to add. Type params as Schema.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 | 🟡 Minor

Make the glob-filter case fail when globbing regresses.

skip.txt never contains "needle", so this still passes if the implementation ignores glob and searches every file. Put a matching line in the non-*.ts file 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 | 🟡 Minor

Exercise 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 | 🟠 Major

Let check() return filesystem errors instead of defecting.

The Effect.orDie on Line 335 turns permission/stat failures into defects, so files, search, and tree can no longer surface their declared PlatformError | Error failures 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 | 🟠 Major

Verify 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 | 🟠 Major

Persist a full-summary boundary when no raw tail is kept.

This only hides prior history when latestPrior.tailStartId is present. If a compaction summarizes the entire filtered history, selected.tail_start_id is undefined, so the next round has no boundary to hide that already-summarized head; and Line 497 also leaves any older compactionPart.tail_start_id in place. Repeated compactions can still re-feed summarized messages or use a stale boundary. Use the completed-summary boundary when tail_start_id is 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

📥 Commits

Reviewing files that changed from the base of the PR and between e8215a3 and 19ba464.

⛔ Files ignored due to path filters (1)
  • packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap is excluded by !**/*.snap
📒 Files selected for processing (78)
  • packages/opencode/src/agent/prompt/compaction.txt
  • packages/opencode/src/auth/index.ts
  • packages/opencode/src/cli/cmd/debug/file.ts
  • packages/opencode/src/cli/cmd/debug/ripgrep.ts
  • packages/opencode/src/cli/cmd/run.ts
  • packages/opencode/src/config/config.ts
  • packages/opencode/src/effect/app-runtime.ts
  • packages/opencode/src/file/index.ts
  • packages/opencode/src/file/ripgrep.ts
  • packages/opencode/src/filesystem/index.ts
  • packages/opencode/src/format/index.ts
  • packages/opencode/src/mcp/auth.ts
  • packages/opencode/src/mcp/index.ts
  • packages/opencode/src/patch/index.ts
  • packages/opencode/src/project/project.ts
  • packages/opencode/src/project/vcs.ts
  • packages/opencode/src/provider/provider.ts
  • packages/opencode/src/question/index.ts
  • packages/opencode/src/server/instance/file.ts
  • packages/opencode/src/server/instance/question.ts
  • packages/opencode/src/session/compaction.ts
  • packages/opencode/src/session/instruction.ts
  • packages/opencode/src/session/message-v2.ts
  • packages/opencode/src/session/overflow.ts
  • packages/opencode/src/session/prompt.ts
  • packages/opencode/src/skill/discovery.ts
  • packages/opencode/src/skill/index.ts
  • packages/opencode/src/snapshot/index.ts
  • packages/opencode/src/storage/storage.ts
  • packages/opencode/src/tool/agent.ts
  • packages/opencode/src/tool/apply_patch.ts
  • packages/opencode/src/tool/bash.ts
  • packages/opencode/src/tool/codesearch.ts
  • packages/opencode/src/tool/edit.ts
  • packages/opencode/src/tool/external-directory.ts
  • packages/opencode/src/tool/glob.ts
  • packages/opencode/src/tool/grep.ts
  • packages/opencode/src/tool/invalid.ts
  • packages/opencode/src/tool/lsp.ts
  • packages/opencode/src/tool/plan.ts
  • packages/opencode/src/tool/question.ts
  • packages/opencode/src/tool/read.ts
  • packages/opencode/src/tool/registry.ts
  • packages/opencode/src/tool/skill.ts
  • packages/opencode/src/tool/todo.ts
  • packages/opencode/src/tool/tool.ts
  • packages/opencode/src/tool/trash.ts
  • packages/opencode/src/tool/truncate.ts
  • packages/opencode/src/tool/webfetch.ts
  • packages/opencode/src/tool/websearch.ts
  • packages/opencode/src/tool/write.ts
  • packages/opencode/src/util/bom.ts
  • packages/opencode/src/util/effect-zod.ts
  • packages/opencode/src/worktree/index.ts
  • packages/opencode/test/config/config.test.ts
  • packages/opencode/test/config/pawwork-global-config.test.ts
  • packages/opencode/test/file/ripgrep.test.ts
  • packages/opencode/test/filesystem/filesystem.test.ts
  • packages/opencode/test/project/project.test.ts
  • packages/opencode/test/question/question.test.ts
  • packages/opencode/test/question/schema.test.ts
  • packages/opencode/test/session/compaction.test.ts
  • packages/opencode/test/session/prompt-effect.test.ts
  • packages/opencode/test/session/snapshot-tool-race.test.ts
  • packages/opencode/test/storage/storage.test.ts
  • packages/opencode/test/tool/agent-rename.test.ts
  • packages/opencode/test/tool/apply_patch.test.ts
  • packages/opencode/test/tool/bash.test.ts
  • packages/opencode/test/tool/edit.test.ts
  • packages/opencode/test/tool/external-directory.test.ts
  • packages/opencode/test/tool/glob.test.ts
  • packages/opencode/test/tool/grep.test.ts
  • packages/opencode/test/tool/question.test.ts
  • packages/opencode/test/tool/read.test.ts
  • packages/opencode/test/tool/skill.test.ts
  • packages/opencode/test/tool/tool-define.test.ts
  • packages/opencode/test/tool/trash.test.ts
  • packages/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

Comment thread packages/opencode/src/question/index.ts
Comment thread packages/opencode/src/tool/edit.ts Outdated
Comment thread packages/opencode/src/tool/question.ts
Comment thread packages/opencode/src/tool/truncate.ts
Comment thread packages/opencode/src/tool/write.ts Outdated
Comment thread packages/opencode/src/util/effect-zod.ts Outdated
@Astro-Han Astro-Han force-pushed the claude/upstream-sync-209-pr4-tool-framework branch from 19ba464 to 25a27dd Compare April 28, 2026 05:35
@Astro-Han

Copy link
Copy Markdown
Owner Author

Outside-diff comment reply (tool/write.ts:52-66)

MajorFormatting can make the approved diff stale.

Fixed by mirroring the same recompute-after-format pattern landed for tool/edit.ts in the prior round. After format.file() + Bom.syncFile() returns, diff is rebuilt from (contentOld, finalContent) so the bus event / snapshot reflects the post-format on-disk content rather than the pre-format approved diff. Caller-facing flow:

  1. Build initial diff from (contentOld, contentNew) and ask permission as before.
  2. Write file, then run formatter; if formatter ran, Bom.syncFile returns the on-disk text and we recompute diff against that text.
  3. Publish File.Event.Edited with the recomputed (post-format) diff.

The deeper architectural question of "user approves pre-format bytes" applies equally to edit.ts, apply_patch.ts, and write.ts — all of which run the formatter after permission grant. Genuinely fixing it requires either an in-memory format pass or a two-step approval flow, which is a cross-tool design change worth its own PR. Keeping behavior consistent across edit/write for now and tracking the deeper fix separately.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

♻️ Duplicate comments (13)
packages/opencode/src/tool/edit.ts (2)

99-104: ⚠️ Potential issue | 🟠 Major

Don't let newString change BOM state invisibly.

desiredBom = source.bom || next.bom still 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.ts now avoids this by preserving source.bom only; 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 | 🟡 Minor

Only translate NotFound into “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 | 🟠 Major

Bind execute to Schema.Schema.Type<typeof Parameters>.

This handler still duplicates the decoded shape manually, so the implementation can drift from Parameters again 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 | 🟠 Major

The recomputed diff is unused, so the approval preview still doesn't reflect formatter rewrites.

diff is only consumed by ctx.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 | 🟡 Minor

Make the glob-filter test fail if globbing regresses.

skip.txt still has no "needle", so this stays green even if glob: ["*.ts"] is ignored and both files are searched. Put a match in the non-*.ts file 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 | 🔴 Critical

Let ripgrep bootstrap I/O fail through the declared error channel.

The cached binary-resolution path wraps fs.isFile(...) and fs.ensureDir(...) with Effect.orDie at lines 300, 303, and 316. This converts filesystem errors to defects, but the public interface declares files(), search(), and tree() return errors as PlatformError | 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.orDie from 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 | 🟠 Major

This 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 | 🟠 Major

Propagate 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.Service is 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 maxChars is smaller than the suffix itself, Lines 30-32 return the suffix alone, which is still longer than maxChars. That still skews compaction budgeting for small toolOutputMaxChars values.

♻️ 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 | 🟠 Major

Surface 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 as partial, 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 | 🟠 Major

Reject empty selections in reply().

answer.length === 0 still resolves the deferred for both single- and multi-select questions, so a buggy client can dismiss a prompt without going through reject().

♻️ 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 | 🟠 Major

Normalize labels before enforcing uniqueness.

This check runs before Schema.decodeUnknownSync(Request) applies TrimmedString, 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 | 🟠 Major

Hide the previous summarized head even when tailStartId is missing.

If the last compaction kept no raw tail, selected.tail_start_id was stored as undefined, but that summary still covered the entire pre-summary history. This branch skips hiding anything in that case, so the next compaction sends previousSummary and 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

📥 Commits

Reviewing files that changed from the base of the PR and between 19ba464 and 25a27dd.

📒 Files selected for processing (34)
  • packages/opencode/src/agent/prompt/compaction.txt
  • packages/opencode/src/cli/cmd/debug/file.ts
  • packages/opencode/src/cli/cmd/debug/ripgrep.ts
  • packages/opencode/src/config/config.ts
  • packages/opencode/src/file/index.ts
  • packages/opencode/src/file/ripgrep.ts
  • packages/opencode/src/format/index.ts
  • packages/opencode/src/patch/index.ts
  • packages/opencode/src/question/index.ts
  • packages/opencode/src/server/instance/file.ts
  • packages/opencode/src/server/instance/question.ts
  • packages/opencode/src/session/compaction.ts
  • packages/opencode/src/session/message-v2.ts
  • packages/opencode/src/session/overflow.ts
  • packages/opencode/src/tool/apply_patch.ts
  • packages/opencode/src/tool/bash.ts
  • packages/opencode/src/tool/edit.ts
  • packages/opencode/src/tool/glob.ts
  • packages/opencode/src/tool/grep.ts
  • packages/opencode/src/tool/lsp.ts
  • packages/opencode/src/tool/question.ts
  • packages/opencode/src/tool/read.ts
  • packages/opencode/src/tool/registry.ts
  • packages/opencode/src/tool/skill.ts
  • packages/opencode/src/tool/todo.ts
  • packages/opencode/src/tool/tool.ts
  • packages/opencode/src/tool/truncate.ts
  • packages/opencode/src/tool/webfetch.ts
  • packages/opencode/src/tool/write.ts
  • packages/opencode/test/file/ripgrep.test.ts
  • packages/opencode/test/question/question.test.ts
  • packages/opencode/test/question/schema.test.ts
  • packages/opencode/test/session/compaction.test.ts
  • packages/opencode/test/tool/grep.test.ts
💤 Files with no reviewable changes (1)
  • packages/opencode/test/session/compaction.test.ts

Comment thread packages/opencode/src/cli/cmd/debug/ripgrep.ts Outdated
Comment thread packages/opencode/src/file/ripgrep.ts Outdated
Comment thread packages/opencode/src/server/instance/file.ts
Comment thread packages/opencode/src/tool/todo.ts
Comment thread packages/opencode/src/tool/write.ts

@Astro-Han Astro-Han left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fresh-eye review pass: one additional P2 finding.

Comment thread packages/opencode/src/tool/edit.ts Outdated
…-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
@Astro-Han Astro-Han force-pushed the claude/upstream-sync-209-pr4-tool-framework branch 2 times, most recently from 630d3f6 to e42987c Compare April 28, 2026 07:49
@Astro-Han

Copy link
Copy Markdown
Owner Author

Outside-diff comment reply (tool/apply_patch.ts:136-149, 199-209)

🔴 CriticalAuthorize move_path targets too.

Fixed in e42987cb4 (autosquashed onto e54d1759c). The permission patterns at src/tool/apply_patch.ts:198-211 now include both source and destination paths for move hunks:

const relativePaths = [
  ...new Set(
    fileChanges.flatMap((c) => [
      path.relative(Instance.worktree, c.filePath).replaceAll("\\", "/"),
      ...(c.movePath ? [path.relative(Instance.worktree, c.movePath).replaceAll("\\", "/")] : []),
    ]),
  ),
]

This closes the gap where a move src/regular.ts -> .opencode/secret.ts patch could have slipped past an edit policy that denied .opencode/** because the permission ask only carried src/regular.ts as a pattern. The existing assertExternalDirectoryEffect(ctx, movePath) at apply_patch.ts:137 already covered the cross-worktree case, so the remaining gap was the in-worktree permission denial path — that's what this fix addresses.

Set deduplicates so move src/x.ts -> src/x.ts (no-op move) doesn't double-pattern.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 | 🔵 Trivial

Make Parameters the single source of truth.

The new multi-field tool input is declared with Schema.Struct, then re-described manually in the execute handler. Converting it to Schema.Class and deriving the handler arg type from typeof Parameters aligns with the coding guideline to use Schema.Class for 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 | 🟠 Major

Remove the conversation-language override.

packages/opencode/src/session/compaction.ts appends 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 | 🟠 Major

Clear tail_start_id when the new selection has no tail.

This update only runs when selected.tail_start_id is truthy. If the same compaction part is retried and the latest selection returns undefined, 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 | 🟠 Major

Bind execute() to Schema.Schema.Type<typeof Parameters>.

The handler is back to a handwritten object shape, so Parameters can 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.ts is 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 | 🟡 Minor

Make the glob-filter test fail when globbing regresses.

skip.txt still does not contain "needle", so this test passes even if glob is 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 | 🟠 Major

Post-format diff is currently dropped on the floor.

diff is only consumed when the metadata object is created for ctx.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

📥 Commits

Reviewing files that changed from the base of the PR and between 6bdce4d and e42987c.

📒 Files selected for processing (34)
  • packages/opencode/src/agent/prompt/compaction.txt
  • packages/opencode/src/cli/cmd/debug/file.ts
  • packages/opencode/src/cli/cmd/debug/ripgrep.ts
  • packages/opencode/src/config/config.ts
  • packages/opencode/src/file/index.ts
  • packages/opencode/src/file/ripgrep.ts
  • packages/opencode/src/format/index.ts
  • packages/opencode/src/patch/index.ts
  • packages/opencode/src/question/index.ts
  • packages/opencode/src/server/instance/file.ts
  • packages/opencode/src/server/instance/question.ts
  • packages/opencode/src/session/compaction.ts
  • packages/opencode/src/session/message-v2.ts
  • packages/opencode/src/session/overflow.ts
  • packages/opencode/src/tool/apply_patch.ts
  • packages/opencode/src/tool/bash.ts
  • packages/opencode/src/tool/edit.ts
  • packages/opencode/src/tool/glob.ts
  • packages/opencode/src/tool/grep.ts
  • packages/opencode/src/tool/lsp.ts
  • packages/opencode/src/tool/question.ts
  • packages/opencode/src/tool/read.ts
  • packages/opencode/src/tool/registry.ts
  • packages/opencode/src/tool/skill.ts
  • packages/opencode/src/tool/todo.ts
  • packages/opencode/src/tool/tool.ts
  • packages/opencode/src/tool/truncate.ts
  • packages/opencode/src/tool/webfetch.ts
  • packages/opencode/src/tool/write.ts
  • packages/opencode/test/file/ripgrep.test.ts
  • packages/opencode/test/question/question.test.ts
  • packages/opencode/test/question/schema.test.ts
  • packages/opencode/test/session/compaction.test.ts
  • packages/opencode/test/tool/grep.test.ts
💤 Files with no reviewable changes (1)
  • packages/opencode/test/session/compaction.test.ts

Comment thread packages/opencode/src/cli/cmd/debug/file.ts Outdated
Comment thread packages/opencode/src/session/message-v2.ts
Comment thread packages/opencode/src/tool/edit.ts
Comment thread packages/opencode/src/tool/webfetch.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
@Astro-Han Astro-Han force-pushed the claude/upstream-sync-209-pr4-tool-framework branch from e42987c to 3155c4d Compare April 28, 2026 08:13
@Astro-Han

Copy link
Copy Markdown
Owner Author

Outside-diff comment replies (round 6)

tool/glob.ts:11-27 — Make Parameters single source of truth via Schema.Class

🔵 Trivial Nitpick

Deferring on graft scope. Verified that upstream dev:packages/opencode/src/tool/glob.ts:11-27 declares Parameters with Schema.Struct and the handler takes params: { pattern: string; path?: string } — identical to PawWork's. Same upstream-baseline pattern already deferred for ripgrep schemas in this PR. Tracked as a follow-up to land alongside the broader Schema.Class convention sweep (or upstream-first), to keep PR #270's diff aligned with the baseline future syncs need.

agent/prompt/compaction.txt:9-10 — Remove "Respond in same language" override

🟠 Major

Deferring on graft scope. Verified that upstream dev:packages/opencode/src/agent/prompt/compaction.txt:9 carries the identical "Respond in the same language as the conversation" sentence, and upstream's dev:packages/opencode/src/session/compaction.ts:40-67 declares the same SUMMARY_TEMPLATE with fixed Markdown headings (## Goal, ## Progress, ### Done, ...). The contradiction CodeRabbit identified exists upstream too — PawWork inherited both pieces unchanged.

In practice the Output exactly this Markdown structure and keep the section order unchanged directive from SUMMARY_TEMPLATE overrides the language hint for capable models (we have not seen heading translation in real Chinese sessions), but the cleaner fix is to remove the language line. That belongs upstream first (or in a PawWork compaction-prompt carve-out PR), not bundled into this graft sync.

session/compaction.ts:497-501 — Clear tail_start_id when selection has no tail

🟠 Major

Fixed in 3155c4db7 (autosquashed onto fe2d6e9ab). The conditional at src/session/compaction.ts:497-510 now updates whenever compactionPart.tail_start_id !== selected.tail_start_id, including the clear-back-to-undefined case. This was PawWork-introduced logic (the tail_start_id boundary tracking added during the compaction migration), so the fix lands here cleanly without graft conflict. Stale boundaries can no longer hide unsummarised history from later compactions that completedCompactions() reads.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 | 🟡 Minor

Normalize derived directory keys before appending /.

On Windows, path.dirname(current) returns values like src\\nested, so dir + "/" 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 | 🟠 Major

Hide fully summarized history even when tailStartId is undefined.

A completed compaction can legitimately clear tail_start_id when it summarized all prior history. In that case previousSummary is 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

📥 Commits

Reviewing files that changed from the base of the PR and between e42987c and 3155c4d.

📒 Files selected for processing (14)
  • packages/opencode/src/agent/prompt/compaction.txt
  • packages/opencode/src/cli/cmd/debug/file.ts
  • packages/opencode/src/cli/cmd/debug/ripgrep.ts
  • packages/opencode/src/file/index.ts
  • packages/opencode/src/file/ripgrep.ts
  • packages/opencode/src/server/instance/file.ts
  • packages/opencode/src/session/compaction.ts
  • packages/opencode/src/session/message-v2.ts
  • packages/opencode/src/session/overflow.ts
  • packages/opencode/src/tool/grep.ts
  • packages/opencode/src/tool/read.ts
  • packages/opencode/test/file/ripgrep.test.ts
  • packages/opencode/test/session/compaction.test.ts
  • packages/opencode/test/tool/grep.test.ts
💤 Files with no reviewable changes (1)
  • packages/opencode/test/session/compaction.test.ts

Comment thread packages/opencode/src/file/ripgrep.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
@Astro-Han Astro-Han force-pushed the claude/upstream-sync-209-pr4-tool-framework branch from 3155c4d to 2b69f7f Compare April 28, 2026 08:28
@Astro-Han

Copy link
Copy Markdown
Owner Author

Outside-diff comment replies (round 7)

file/index.ts:392-403 — Normalize derived directory keys before appending /

🟡 Minor

Deferring on graft scope. Verified that upstream dev:packages/opencode/src/file/index.ts:395-398 carries the identical pattern (next.dirs.push(dir + "/") after path.dirname(current)) — same Windows mixed-separator risk. PawWork inherited unchanged.

The fix is small (dir.split(path.sep).join("/") + "/" or a POSIX-normalizer) but lands cleaner upstream first or in a PawWork-authored Windows-path cleanup PR alongside the ripgrep .opencode substring filter just deferred.

session/compaction.ts:405-418 — Hide fully-summarized history when tailStartId === undefined

🟠 Major

Fixed in 2b69f7f8a (autosquashed onto 83271d07a). The branch at src/session/compaction.ts:411-422 now hides on latestPrior?.summary and splits on tailStartId:

  • If tailStartId is set → hide everything before that boundary (existing behaviour).
  • If tailStartId is undefined (the new "completed compaction summarised everything" state introduced by the just-landed clear-tail fix) → hide everything before latestPrior.userIndex.

Without the second branch, a fully-summarising compaction would silently re-feed all prior turns to the next round. PawWork-introduced logic, fix lands cleanly here.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
packages/opencode/src/tool/read.ts (1)

28-34: ⚠️ Potential issue | 🟡 Minor

Broaden the parameter descriptions to match runtime behavior.

filePath is described as absolute-only, but Lines 165-168 resolve relative paths too. Likewise, offset and limit now 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

📥 Commits

Reviewing files that changed from the base of the PR and between 3155c4d and 2b69f7f.

📒 Files selected for processing (6)
  • packages/opencode/src/agent/prompt/compaction.txt
  • packages/opencode/src/session/compaction.ts
  • packages/opencode/src/session/message-v2.ts
  • packages/opencode/src/session/overflow.ts
  • packages/opencode/src/tool/read.ts
  • packages/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: Use Effect.gen(function* () { ... }) for Effect composition
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
Use Effect.callback for callback-based APIs
Prefer DateTime.nowAsDate over new Date(yield* Clock.currentTimeMillis) when you need a Date in Effect code
Use Schema.Class for multi-field data in Effect schemas
Use branded schemas (Schema.brand) for single-value types in Effect
Use Schema.TaggedErrorClass for typed errors in Effect schemas
Use Schema.Defect instead of unknown for defect-like causes in Effect code
In Effect.gen / Effect.fn, prefer yield* new MyError(...) over yield* Effect.fail(new MyError(...)) for direct early-failure branches
Use makeRuntime from src/effect/run-service.ts for all services; it returns { runPromise, runFork, runCallback } backed by a shared memoMap that deduplicates layers
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
Use Effect.addFinalizer or Effect.acquireRelease inside the InstanceState.make closure for cleanup (subscriptions, process teardown, etc.)
Use Effect.forkScoped inside the InstanceState.make closure for background stream consumers — the fiber is interrupted when the instance is disposed
Prefer FileSystem.FileSystem instead of raw fs/promises for effectful file I/O in Effect services
Prefer ChildProcessSpawner.ChildProcessSpawner with ChildProcess.make(...) instead of custom process wrappers in Effect services
Prefer HttpClient.HttpClient instead of raw fetch in Effect services
Prefer Path.Path, Config, Clock, and DateTime services when those concerns are already inside Effect code
For backgroun...

Files:

  • packages/opencode/src/session/overflow.ts
  • packages/opencode/src/session/message-v2.ts
  • packages/opencode/src/tool/read.ts
  • packages/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.txt
  • packages/opencode/src/session/overflow.ts
  • packages/opencode/src/session/message-v2.ts
  • packages/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.ts
  • packages/opencode/src/session/message-v2.ts
  • packages/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.ts
  • packages/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.ts
  • packages/opencode/src/session/message-v2.ts
  • packages/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.ts
  • packages/opencode/src/session/message-v2.ts
  • packages/opencode/src/tool/read.ts
  • packages/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.ts
  • packages/opencode/src/session/message-v2.ts
  • packages/opencode/src/tool/read.ts
  • packages/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.ts
  • packages/opencode/src/session/message-v2.ts
  • packages/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.ts
  • packages/opencode/src/session/message-v2.ts
  • packages/opencode/src/tool/read.ts
  • packages/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.ts
  • packages/opencode/src/session/message-v2.ts
  • packages/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.ts
  • packages/opencode/src/session/message-v2.ts
  • packages/opencode/src/tool/read.ts
  • packages/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.ts
  • packages/opencode/src/session/message-v2.ts
  • packages/opencode/src/tool/read.ts
  • packages/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.ts
  • packages/opencode/src/session/message-v2.ts
  • packages/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.ts
  • packages/opencode/src/session/message-v2.ts
  • packages/opencode/src/tool/read.ts
  • packages/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.ts
  • packages/opencode/src/session/message-v2.ts
  • packages/opencode/src/tool/read.ts
  • packages/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.ts
  • packages/opencode/src/session/message-v2.ts
  • packages/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.ts
  • packages/opencode/src/tool/read.ts
  • packages/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.ts
  • packages/opencode/src/session/message-v2.ts
  • packages/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.ts
  • packages/opencode/src/session/message-v2.ts
  • packages/opencode/src/tool/read.ts
  • packages/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.ts
  • packages/opencode/src/session/message-v2.ts
  • packages/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.ts
  • packages/opencode/src/session/message-v2.ts
  • packages/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.ts
  • packages/opencode/src/session/message-v2.ts
  • packages/opencode/src/tool/read.ts
  • packages/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.ts
  • packages/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.ts
  • packages/opencode/src/tool/read.ts
  • packages/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.ts
  • packages/opencode/src/tool/read.ts
  • packages/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.ts
  • packages/opencode/src/tool/read.ts
  • packages/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.ts
  • packages/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.ts
  • packages/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.ts
  • packages/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.ts
  • 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.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/toolOutputMaxChars options, hiding history already covered by the latest summary, and persisting tail_start_id clears should keep repeated compactions from drifting or re-expanding the prompt.

Also applies to: 246-257, 404-446, 507-517

Comment thread packages/opencode/src/tool/read.ts
@Astro-Han

Copy link
Copy Markdown
Owner Author

Outside-diff comment reply (tool/read.ts:28-34)

🟡 MinorBroaden the parameter descriptions to match runtime behavior.

Deferring on graft scope. Verified that upstream dev:packages/opencode/src/tool/read.ts:27-35 carries the identical descriptions ("The absolute path to the file or directory to read", "The line number to start reading from (1-indexed)", "The maximum number of lines to read (defaults to 2000)"), and upstream's dev:packages/opencode/src/tool/read.ts:195-213 also paginates directory entries the same way — so all three description mismatches exist upstream too. PawWork inherited unchanged.

This is the same pattern already deferred in this PR for edit.ts:48, write.ts:24, and webfetch.ts:19. Tracking under the same description-cleanup follow-up to land all of them in one diff (PawWork-authored sweep or upstream-first), so the consistency comes through in a single commit instead of scattered partial fixes across graft syncs.

@Astro-Han Astro-Han merged commit b030132 into dev Apr 28, 2026
25 of 27 checks passed
@Astro-Han Astro-Han deleted the claude/upstream-sync-209-pr4-tool-framework branch April 28, 2026 08:56
Astro-Han added a commit that referenced this pull request Apr 28, 2026
…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.
Astro-Han added a commit that referenced this pull request Apr 28, 2026
* 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

harness Model harness, prompts, tool descriptions, and session mechanics P3 Low priority upstream Tracked upstream or vendor behavior

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants