Skip to content

refactor(chat): give tool replay reconciliation its own module - #3439

Merged
kwakayama merged 9 commits into
mainfrom
refactor/chat-tool-replay-extraction
Aug 7, 2026
Merged

kwakayama merged 9 commits into
mainfrom
refactor/chat-tool-replay-extraction

Conversation

@kojiwakayama

@kojiwakayama kojiwakayama commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Pure extraction, zero behaviour change. Splits src/chat/conversation.ts along its real seams and gives the tool-replay algorithm a name.

Why

conversation.ts was a 1563-line grab-bag hiding a genuinely deep algorithm: ~260 lines that reconcile tool-call/result history by part object identity (WeakSet/WeakMap, single pass) to decide which occurrences are authoritative for provider conversion. It had no name and no home. The same file also exported generic value guards that had nothing to do with conversations.

What changed

A three-layer ladder, each importing only downward:

Module Lines Owns
part-field-access.ts 66 reading one field off an untrusted unknown
message-part-parsing.ts 264 interpreting one message part — tool, text, reasoning, or file
tool-replay-reconciliation.ts 294 deciding which occurrences in a history of parts are authoritative
conversation.ts 1018 was 1563

Net +80 lines across src/chat/. This is deliberately not a line-reduction change — the gain is that three named single-purpose modules replace one grab-bag, and CONTEXT.md now records two new domain terms.

Also adds LOC ceilings for these five files to ban-chat-antipatterns.ts. src/chat/ previously had no ceiling at all, which is how conversation.ts reached 1563 lines. Precedent: modules/server/module-server.ts was refactored once and then regrew 1138 → 1806 lines across six follow-on PRs, ending larger than before. Extraction without a ceiling regresses.

Evidence

  • deno task test:unit: 3804 passed / 27918 steps / 0 failed / 1 ignored — identical to the pre-refactor baseline of 3801/27907 plus exactly the 11 new test steps. Held at every commit.
  • deno task verify:quick: exit 0.
  • deno task dupes: 232 groups, unchanged from branch start — the refactor introduced no duplication.
  • Whole-branch diff of conversation.ts adds exactly 22 lines, all imports and re-exports; every other line is a deletion. No logic in that file was edited anywhere on the branch.
  • The full set of top-level declarations at base was diffed against the union of the four files today: byte-identical sorted sets. Nothing lost, nothing invented.
  • Object identity traced end to end through the matcher — no part object is cloned, re-wrapped, or reconstructed. Notably toRecord, the one constructing function in part-field-access.ts, is not imported by the reconciliation module.
  • The new ceiling was proven to fire (86 LOC exceeds ceiling 66) and then clear.

Every task was implemented and reviewed in separate passes, with a fix round where review found a problem.

Notes for reviewers

  • conversation.ts keeps re-exporting getStringField, isRecord, stringifyUnknownisRecord alone has ~8 importers across src/agent/. Consolidating those is a separate increment (there are ~76 private isRecord copies repo-wide); this branch gives the canonical one a home first.
  • tool-replay-reconciliation.ts imports ChatProviderModelInputMessage from conversation.ts as import type. It must stay type-only — a value import would create a real cycle. deno check would not catch that (types erase at emit) but lint:module-boundaries will; there is a comment at the import saying so.

Known follow-ups (deferred, not blocking)

  • findProviderVisibleToolReplayMatches — the algorithm this branch exists to expose — has only a smoke test. A direct table-driven test is the highest-value follow-up and is now easy to write against the extracted interface.
  • 8 of 10 exports in message-part-parsing.ts lack JSDoc (all were private and undocumented before; the verbatim-move rule preserved that).
  • conversation.ts's ~423-line provider-conversion block is the obvious next seam, leaving ~600 lines that finally match the file's name. Sequencing matters: extract the ChatProviderModelInput* types first, or the type-only back-edge simply relocates instead of disappearing.

Summary by CodeRabbit

  • Improvements

    • Improved chat message handling across text, reasoning, files, images, tool calls, and tool results.
    • More reliably interprets alternate message formats and safely handles incomplete or invalid content.
    • Improved replay behavior by preserving relevant transient tool activity and preventing duplicate or outdated tool results.
    • Tool outputs are now represented more consistently, including clearer handling of pending and failed operations.
  • Quality

    • Added automated coverage for message parsing, field handling, and tool replay scenarios.
    • Added safeguards to maintain consistent source-file quality.

Fix round 1 on the tool-replay-reconciliation extraction: replace the
private duplicated isTextPart/isProviderVisibleReasoningPart/getFilePart
in tool-replay-reconciliation.ts (which had already drifted from
conversation.ts's isTextPart) with a real move of TextPartLike,
ReasoningPartLike, isTextPart, isReasoningPart,
isProviderVisibleReasoningPart, and getFilePart into
tool-part-parsing.ts. conversation.ts re-exports the public predicate
surface (isReasoningPart, isTextPart) so external callers are
unaffected.
…exports

Zero behavior change. Renames tool-part-parsing.ts to
message-part-parsing.ts since it owns text/reasoning/file predicates
alongside tool parts, not just tool parts; drops the unused
ReasoningPartLike/TextPartLike re-export from conversation.ts; documents
why the ChatProviderModelInputMessage import in tool-replay-reconciliation.ts
must stay type-only; and inlines isTransientToolState as a direct export
instead of a trailing export block. Re-pins the FILE_SIZE_CEILINGS in
ban-chat-antipatterns.ts to match.
… lint

The comment claimed lint:module-boundaries never scans src/chat/, so
a type-only import slip would not be caught. This is false. The lint
script scans all of src/ and runs findCyclicEdges on the entire graph,
which correctly detects cycles including this import.

Updated comment to accurately state: the import must stay type-only
because a value import creates a real cycle with conversation.ts.
deno check won't catch this (types erase at emit), but
lint:module-boundaries will.
@kojiwakayama
kojiwakayama requested a review from kwakayama as a code owner August 6, 2026 20:33
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@kwakayama, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 6 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 1bc62d8e-6475-4971-a0ca-ab738f9a6e42

📥 Commits

Reviewing files that changed from the base of the PR and between 1eba258 and 67ffa89.

📒 Files selected for processing (4)
  • src/chat/message-part-parsing.test.ts
  • src/chat/message-part-parsing.ts
  • src/chat/part-field-access.test.ts
  • src/chat/part-field-access.ts
📝 Walkthrough

Walkthrough

The change centralizes chat field access and message-part parsing, adds tool replay reconciliation, and updates conversation and message preparation to use the shared modules. Tests cover parsing, field access, and reconciliation behavior.

Changes

Chat processing

Layer / File(s) Summary
Message part interpretation
src/chat/part-field-access.ts, src/chat/message-part-parsing.ts, src/chat/*test.ts, CONTEXT.md
Shared utilities validate and normalize unknown values, tool parts, text, reasoning, and file parts. Tests cover field access and message-part output handling.
Tool replay reconciliation
src/chat/tool-replay-reconciliation.ts, src/chat/tool-replay-reconciliation.test.ts, CONTEXT.md
Replay processing matches compatible tool calls and results, preserves transient parts, identifies new batches, and marks superseded parts.
Provider conversion wiring
src/chat/conversation.ts, src/chat/message-prep.ts, scripts/lint/ban-chat-antipatterns.ts
Conversation conversion uses shared parsing and reconciliation modules. Message preparation imports reconciliation directly. File-size ceilings cover the chat processing modules.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ProviderMessages
  participant ToolReplayReconciliation
  participant MessagePartParsing
  participant Conversation
  ProviderMessages->>ToolReplayReconciliation: Supply replayed messages
  ToolReplayReconciliation->>MessagePartParsing: Parse tool calls and results
  MessagePartParsing-->>ToolReplayReconciliation: Return normalized parts
  ToolReplayReconciliation-->>Conversation: Return match and supersession collections
  Conversation->>MessagePartParsing: Convert provider-visible parts
  MessagePartParsing-->>Conversation: Return normalized provider content
Loading

Possibly related PRs

Suggested reviewers: kwakayama

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the extraction of tool replay reconciliation into its own module, which is a primary change in the pull request.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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.
✨ 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 refactor/chat-tool-replay-extraction

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

@coderabbitai coderabbitai 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.

🧹 Nitpick comments (6)
src/chat/part-field-access.test.ts (1)

39-43: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Cover the JSON fallback branch of stringifyUnknown.

The current cases exercise the string, undefined, and bigint branches. The final branch delegates to stringifyChatJson, which is the only branch with an external dependency. Add a record or array case, and a case for toJsonValue, so a change in json-value.ts fails here rather than in a provider conversion test.

♻️ Proposed additional cases
   it("returns strings unchanged and stringifies other primitives", () => {
     assertEquals(stringifyUnknown("already"), "already");
     assertEquals(stringifyUnknown(undefined), "undefined");
     assertEquals(stringifyUnknown(10n), "10");
+    assertEquals(stringifyUnknown({ a: 1 }), '{"a":1}');
+    assertEquals(stringifyUnknown(null), "null");
   });
+
+  it("converts values into JSON-safe values", () => {
+    assertEquals(toJsonValue({ a: [1, "b"] }), { a: [1, "b"] });
+  });

Add toJsonValue to the import list at lines 4-11.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/chat/part-field-access.test.ts` around lines 39 - 43, Extend the
stringifyUnknown test in the existing test case to cover the JSON fallback via a
record or array input, and add a separate assertion using toJsonValue after
importing it. Verify both cases produce the expected stringified output,
ensuring changes to the JSON conversion path are caught here.
src/chat/tool-replay-reconciliation.test.ts (1)

17-23: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Test the matching pass, not the shape of its return value.

Lines 19-20 assert that a WeakSet has has and a WeakMap has get. Those assertions hold for every implementation that returns the declared type, so they cannot fail and give no signal.

The module's contract is the identity-based pass: a result matches the nearest pending call with the same id, a later call with the same id supersedes the earlier call and its matched result, a call after content from an earlier message starts a new batch, and a transient call with a matching result is preserved. CONTEXT.md now names this module the single owner of those decisions. None of the four behaviors is tested here. A regression in supersession would surface only in a provider-conversion assertion far from the cause.

♻️ Proposed direct contract test
+  it("matches a result to its call and supersedes an earlier duplicate call", () => {
+    const firstCall = {
+      type: "tool-search",
+      toolCallId: "c1",
+      toolName: "search",
+      state: "input-available",
+      input: {},
+    };
+    const secondCall = {
+      type: "tool-search",
+      toolCallId: "c1",
+      toolName: "search",
+      state: "input-available",
+      input: {},
+    };
+    const result = { type: "tool_result", toolCallId: "c1", output: { ok: true } };
+
+    const matches = findProviderVisibleToolReplayMatches([
+      { id: "m1", role: "assistant", parts: [firstCall] },
+      { id: "m2", role: "assistant", parts: [secondCall] },
+      { id: "m3", role: "tool", parts: [result] },
+    ]);
+
+    assertEquals(matches.matchedToolCallParts.has(secondCall), true);
+    assertEquals(matches.matchedToolResultParts.has(result), true);
+    assertEquals(matches.matchedToolResultNames.get(result), "search");
+    assertEquals(matches.supersededToolCallParts.has(firstCall), true);
+    assertEquals(matches.preservedTransientToolParts.has(secondCall), true);
+  });

Adjust the message literals to match the ChatProviderModelInputMessage fields required by the repository's type.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/chat/tool-replay-reconciliation.test.ts` around lines 17 - 23, Replace
the nonfunctional WeakSet/WeakMap shape assertions in the empty-history test
with contract-focused cases for findProviderVisibleToolReplayMatches: verify
nearest same-id result matching, later-call supersession, batch reset after
intervening content, and preservation of transient calls with matching results.
Use message literals containing the repository-required
ChatProviderModelInputMessage fields, and assert identities and match sets
rather than collection method types.
src/chat/message-part-parsing.test.ts (1)

10-46: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add cases for the non-obvious tool-call predicates.

The module exports 10 symbols. This suite covers 3. The highest-consequence gap is hasSelfContainedRawToolCallResult. It special-cases state === "error" with no output and no errorText and returns false, while buildToolResultOutput alone would return a non-null error-text result for that state. That predicate feeds selfContainedResult in tool-replay-reconciliation.ts, which drives supersession and therefore which provider messages are dropped. A future edit can remove that guard without failing any test in this file.

Also consider getFilePart, which returns null unless a url field is present.

Separately, the name at line 39 describes only the first assertion. Lines 41-44 assert the output-error mapping. Split the block or widen the name.

♻️ Proposed additional cases
+  it("treats a bare error state as carrying no self-contained result", () => {
+    const call = getRawToolCallPart({
+      type: "tool_call",
+      toolCallId: "c1",
+      toolName: "search",
+      state: "error",
+    });
+    assertEquals(call?.toolCallId, "c1");
+    assertEquals(hasSelfContainedRawToolCallResult(call!), false);
+  });
+
+  it("treats an error state with errorText as self-contained", () => {
+    const call = getRawToolCallPart({
+      type: "tool_call",
+      toolCallId: "c1",
+      toolName: "search",
+      state: "error",
+      errorText: "bad",
+    });
+    assertEquals(hasSelfContainedRawToolCallResult(call!), true);
+  });
+
+  it("requires a url on file parts", () => {
+    assertEquals(getFilePart({ type: "file", mediaType: "text/plain" }), null);
+    assertEquals(
+      getFilePart({ type: "image", mediaType: "image/png", url: "u" })?.data,
+      "u",
+    );
+  });

Add getFilePart, getRawToolCallPart, and hasSelfContainedRawToolCallResult to the import list at lines 4-8.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/chat/message-part-parsing.test.ts` around lines 10 - 46, Add focused
tests in the message-part-parsing suite for getFilePart, getRawToolCallPart, and
especially hasSelfContainedRawToolCallResult, covering the state "error" case
with neither output nor errorText and asserting false, plus representative valid
cases. Rename or split the existing "returns null output..." test so its name
also accurately describes the output-error mapping, and include the new symbols
in the import list.
src/chat/part-field-access.ts (1)

10-12: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Alias ChatJsonValue as JsonValue. json-value.ts exports an equivalent type. Preserve the existing JsonValue export for callers while using the shared definition.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/chat/part-field-access.ts` around lines 10 - 12, Replace the local
JsonValue definition in part-field-access.ts with an alias or re-export of the
shared JsonValue type from json-value.ts, while preserving the existing
JsonValue export for current callers.
src/chat/tool-replay-reconciliation.ts (1)

21-25: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚖️ Poor tradeoff

Move the shared provider input types to a lower-level module, or remove the lint safeguard claim. conversation.ts imports tool-replay-reconciliation.ts, which imports ChatProviderModelInputMessage back as a type. lint:module-boundaries recognizes type-only imports but intentionally excludes them from cycle detection, so it will not report this cycle. Moving only the interface is insufficient because it depends on the related input-part types.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/chat/tool-replay-reconciliation.ts` around lines 21 - 25, Resolve the
circular dependency between conversation.ts and tool-replay-reconciliation.ts by
moving ChatProviderModelInputMessage together with all related input-part types
it depends on into a lower-level shared module, then update both consumers to
import them from that module; alternatively, remove the inaccurate lint
safeguard comment. Ensure the type-only import no longer points back through
conversation.ts.
src/chat/message-part-parsing.ts (1)

242-259: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Rename the local data variable to url. getFilePart reads only part.url and returns that value as both data and url, matching the pre-refactor implementation. ChatUiMessagePart requires url for file parts, so do not add a data fallback here.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/chat/message-part-parsing.ts` around lines 242 - 259, Rename the local
data variable to url in getFilePart, keeping it sourced only from part.url with
no data fallback. Use url for the required-value check and return both data and
url fields from that same value, preserving ChatUiMessagePart compatibility.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/chat/message-part-parsing.test.ts`:
- Around line 10-46: Add focused tests in the message-part-parsing suite for
getFilePart, getRawToolCallPart, and especially
hasSelfContainedRawToolCallResult, covering the state "error" case with neither
output nor errorText and asserting false, plus representative valid cases.
Rename or split the existing "returns null output..." test so its name also
accurately describes the output-error mapping, and include the new symbols in
the import list.

In `@src/chat/message-part-parsing.ts`:
- Around line 242-259: Rename the local data variable to url in getFilePart,
keeping it sourced only from part.url with no data fallback. Use url for the
required-value check and return both data and url fields from that same value,
preserving ChatUiMessagePart compatibility.

In `@src/chat/part-field-access.test.ts`:
- Around line 39-43: Extend the stringifyUnknown test in the existing test case
to cover the JSON fallback via a record or array input, and add a separate
assertion using toJsonValue after importing it. Verify both cases produce the
expected stringified output, ensuring changes to the JSON conversion path are
caught here.

In `@src/chat/part-field-access.ts`:
- Around line 10-12: Replace the local JsonValue definition in
part-field-access.ts with an alias or re-export of the shared JsonValue type
from json-value.ts, while preserving the existing JsonValue export for current
callers.

In `@src/chat/tool-replay-reconciliation.test.ts`:
- Around line 17-23: Replace the nonfunctional WeakSet/WeakMap shape assertions
in the empty-history test with contract-focused cases for
findProviderVisibleToolReplayMatches: verify nearest same-id result matching,
later-call supersession, batch reset after intervening content, and preservation
of transient calls with matching results. Use message literals containing the
repository-required ChatProviderModelInputMessage fields, and assert identities
and match sets rather than collection method types.

In `@src/chat/tool-replay-reconciliation.ts`:
- Around line 21-25: Resolve the circular dependency between conversation.ts and
tool-replay-reconciliation.ts by moving ChatProviderModelInputMessage together
with all related input-part types it depends on into a lower-level shared
module, then update both consumers to import them from that module;
alternatively, remove the inaccurate lint safeguard comment. Ensure the
type-only import no longer points back through conversation.ts.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 8b1e3794-f46a-4cf2-8844-f380845a4ae8

📥 Commits

Reviewing files that changed from the base of the PR and between 36c073f and f14619d.

📒 Files selected for processing (10)
  • CONTEXT.md
  • scripts/lint/ban-chat-antipatterns.ts
  • src/chat/conversation.ts
  • src/chat/message-part-parsing.test.ts
  • src/chat/message-part-parsing.ts
  • src/chat/message-prep.ts
  • src/chat/part-field-access.test.ts
  • src/chat/part-field-access.ts
  • src/chat/tool-replay-reconciliation.test.ts
  • src/chat/tool-replay-reconciliation.ts

kojiwakayama and others added 2 commits August 7, 2026 04:42
…3443)

* test(chat): cover findProviderVisibleToolReplayMatches directly

Add 9 direct cases for the replay reconciliation matcher: identity vs
structural equality, supersession via both the pending-call-match path
and the self-contained-call path, transient preservation timing, batch
starts, name-mismatch rejection, and unresolved pending calls. Only the
two prior smoke cases previously exercised this module directly; the
rest of its coverage came indirectly through conversation.test.ts.

* test(chat): fix false fence in tool-replay-reconciliation case 9

Case 9 was titled as pinning that a user-message boundary drops stale
pending tool calls, but its staleCall/freshCall shared a toolCallId, so
id-based eviction (removePendingCallsWithId) and toolCallsById-based
supersession masked the boundary logic entirely — freshCall was also
self-contained, so no result ever needed to match against pendingCalls.
Mutants deleting the boundary flush or the user-visible-content check left
the whole suite green.

Kept the old fixture under an honest title (it does correctly pin
toolCallsById supersession of an already-evicted call) and added a new
case with a fixture that actually needs pendingCalls to retain a boundary-
crossing entry: staleCall and its late result share a toolCallId used
nowhere else, so same-id eviction can't do the work for it.

Also added a one-line positive control to the isCompatibleToolResultName
mismatch case so it can't pass vacuously against a rotted fixture.
Cover the JSON fallback in stringifyUnknown and toJsonValue, the
hasSelfContainedRawToolCallResult error guard that drives supersession
in tool-replay-reconciliation, and the getFilePart url requirement.

Alias JsonValue to ChatJsonValue instead of re-declaring the same
recursive shape, and name the getFilePart local after the field it
actually reads.
@kwakayama

Copy link
Copy Markdown
Contributor

All six nitpicks from the review body triaged. Four applied in 67ffa89, one was already fixed, one is rejected on evidence.

Applied

  • part-field-access.test.ts — added the JSON fallback cases for stringifyUnknown (record, array, null) plus toJsonValue, so a change in json-value.ts fails here rather than in a provider conversion test.
  • message-part-parsing.test.ts — added cases for hasSelfContainedRawToolCallResult. The state === "error" guard with no output and no errorText is the load-bearing one: buildToolResultOutput({state:"error"}) returns {type:"error-text", value:"Tool error"}, so without the guard that call would supersede its paired result. The test asserts both halves so the guard cannot be removed silently. Also covered the stateless case and the getFilePart url requirement.
  • part-field-access.tsJsonValue is now an alias of ChatJsonValue rather than a second hand-rolled copy of the same recursive shape. The export is preserved for callers.
  • message-part-parsing.ts — renamed the getFilePart local from data to url, since it only ever reads part.url. No data fallback added; ChatUiMessagePart requires url for file parts.

Already fixed

  • tool-replay-reconciliation.test.ts — the WeakSet/WeakMap shape assertions are gone. 1eba258 replaced them with the contract cases (nearest same-id match, supersession, batch reset after intervening content, transient preservation). The review ran against f14619d, one commit earlier.

Rejected

  • tool-replay-reconciliation.ts — the claim that lint:module-boundaries excludes type-only imports from cycle detection, so the comment overstates the safeguard. I tested it. Converting line 25 to a value import and running deno task lint:module-boundaries gives exit 1 and:
New dependency edges that participate in cycles:
  src/chat/conversation.ts -> src/chat/tool-replay-reconciliation.ts
  src/chat/tool-replay-reconciliation.ts -> src/chat/conversation.ts

The exclusion at check-module-boundaries.ts:406 applies to type-only edges, which is precisely why the current import is safe. The comment claims a value import would be caught, and it would be. No change.

Verified: deno task typecheck, lint, fmt:check, lint:module-boundaries, lint:chat-ratchets, lint:chat-composability all exit 0; src/chat/ is 60 passed (184 steps), 0 failed.

@kwakayama
kwakayama added this pull request to the merge queue Aug 7, 2026
Merged via the queue into main with commit 2b09646 Aug 7, 2026
31 checks passed
@kwakayama
kwakayama deleted the refactor/chat-tool-replay-extraction branch August 7, 2026 04:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants