From 01fb06dd5447e1395e53c8bfb822a1b95a3b145e Mon Sep 17 00:00:00 2001 From: Tejesh Vaish <188414140+tridev-tej@users.noreply.github.com> Date: Wed, 11 Nov 2026 06:43:05 +0400 Subject: [PATCH] added blog Signed-off-by: Tejesh Vaish <188414140+tridev-tej@users.noreply.github.com> --- .../blog/handling-late-tool-replies.mdx | 93 +++++++++++++++++++ .../modeling-uncertain-tool-call-outcomes.mdx | 69 ++++++++++++++ apps/docs/src/markdown/BlogFrontmatter.res | 7 ++ packages/shared/src/Path.res | 2 + 4 files changed, 171 insertions(+) create mode 100644 apps/docs/markdown-pages/blog/handling-late-tool-replies.mdx create mode 100644 apps/docs/markdown-pages/blog/modeling-uncertain-tool-call-outcomes.mdx diff --git a/apps/docs/markdown-pages/blog/handling-late-tool-replies.mdx b/apps/docs/markdown-pages/blog/handling-late-tool-replies.mdx new file mode 100644 index 000000000..acfc10954 --- /dev/null +++ b/apps/docs/markdown-pages/blog/handling-late-tool-replies.mdx @@ -0,0 +1,93 @@ +--- +author: tejesh-vaish +date: "2026-10-26" +title: Handling late replies with ReScript records and events +badge: community +description: | + Match typed events against the current state and use immutable record updates + to handle deadlines, stale replies, and late completions in ReScript. +--- + +A product-title update can finish after the client stops waiting. If the final reply arrives late, the client still needs to decide whether it belongs to the current attempt and whether it can change the result. + +[Modeling uncertain outcomes with ReScript variants](./modeling-uncertain-tool-call-outcomes.mdx) gives us four states. `Pending` means we're waiting, and `Unresolved` means the deadline passed without a final reply. `Completed(string)` and `Rejected(string)` carry final results. Here those states live in a record alongside an attempt ID. + +## Typed events and a tuple match + +`signal` is a variant for the events we accept. A deadline has no payload, a completion carries output, and a rejection carries a reason. The `event` record pairs each signal with an attempt ID. + +`receive` checks that ID first, then matches the tuple `(call.status, event.signal)`. Each arm specifies the state and signal it accepts. A deadline moves only `Pending` to `Unresolved`. A matching final reply can settle either state. + +Assume replies have already been validated and authenticated, and every new attempt gets a fresh ID that is never reused. The types below describe events after that boundary. + +Open the example with the external-link icon on the ReScript tab and select stable [ReScript 12.3.1](https://github.com/rescript-lang/rescript/releases/tag/v12.3.1). Run it in the playground to compare with the Output tab. + + + +```res example +type status = + | Pending + | Unresolved + | Completed(string) + | Rejected(string) + +type call = {attemptId: string, status: status} +type signal = Deadline | CompletedReply(string) | RejectedReply(string) +type event = {attemptId: string, signal: signal} + +let receive = (call: call, event) => { + if call.attemptId !== event.attemptId { + call + } else { + switch (call.status, event.signal) { + | (Pending, Deadline) => {...call, status: Unresolved} + | (Pending, CompletedReply(output)) + | (Unresolved, CompletedReply(output)) => {...call, status: Completed(output)} + | (Pending, RejectedReply(reason)) + | (Unresolved, RejectedReply(reason)) => {...call, status: Rejected(reason)} + | (Unresolved, Deadline) + | (Completed(_), _) + | (Rejected(_), _) => call + } + } +} + +let describe = call => + switch call.status { + | Pending => "Pending" + | Unresolved => "Unresolved" + | Completed(output) => "Completed: " + output + | Rejected(reason) => "Rejected: " + reason + } + +let pending = {attemptId: "title-1", status: Pending} +let waiting = pending->receive({attemptId: "title-1", signal: Deadline}) +waiting->describe->Console.log +let stale = waiting->receive({attemptId: "title-0", signal: CompletedReply("Old title")}) +stale->describe->Console.log +let saved = stale->receive({attemptId: "title-1", signal: CompletedReply("Title saved")}) +saved->describe->Console.log +saved->receive({attemptId: "title-1", signal: RejectedReply("Too late")}) +->describe->Console.log +``` + +```text +Unresolved +Unresolved +Completed: Title saved +Completed: Title saved +``` + + + +## Record spread preserves earlier values + +ReScript [record fields](../docs/manual/record.mdx) are immutable by default. `{...call, status: Unresolved}` creates a new record with the same attempt ID and a different status. The earlier `pending` value stays `Pending`, even after `waiting` becomes `Unresolved`. + +The stale completion for `title-0` leaves `title-1` unresolved. The next completion matches `title-1` and settles it. The final rejection also matches, but the result stays completed because the terminal-state patterns return `call` unchanged. Repeated deadlines leave an unresolved attempt alone too. + +This is a first accepted final reply policy. It keeps `Completed` and `Rejected` terminal for this client, even if later replies conflict. Conflicting replies still need reconciliation outside this function. + +The tuple match covers every state and signal combination. In the last two patterns, `_` in the signal position matches any signal. That deliberately keeps later events from changing a final result. + +A local timeout can't establish that the write failed, was cancelled, or rolled back. It can't establish that retrying is safe either. Before retrying the title update, check whether the first write took effect and reconcile the result with the tool. diff --git a/apps/docs/markdown-pages/blog/modeling-uncertain-tool-call-outcomes.mdx b/apps/docs/markdown-pages/blog/modeling-uncertain-tool-call-outcomes.mdx new file mode 100644 index 000000000..9b51e2a1a --- /dev/null +++ b/apps/docs/markdown-pages/blog/modeling-uncertain-tool-call-outcomes.mdx @@ -0,0 +1,69 @@ +--- +author: tejesh-vaish +date: "2026-10-26" +title: Modeling uncertain outcomes with ReScript variants +badge: community +description: | + Use payload-carrying variants and exhaustive pattern matching in ReScript + to keep a missing reply separate from a rejected update. +--- + +A seller approves a product-title update. The client sends the request, then its deadline expires before a final reply arrives. The title might already have changed, so showing "Failed" could invite a retry too early. + +I'd give that uncertainty its own ReScript constructor. In this hypothetical tool call, the client knows it stopped waiting but doesn't yet know the outcome. + +## Variants carry the data each state needs + +A [variant](../docs/manual/variant.mdx) gives the attempt four possible shapes. `Pending` means we're waiting. `Unresolved` means the client's deadline passed without a final reply. Neither needs a payload. `Completed(string)` carries the tool's output, and `Rejected(string)` carries the reason from a final refusal. + +The payload belongs to its constructor. When a match reaches `Completed(output)`, `output` is a string. There's no separate optional result field to keep in sync with a status flag. An empty output is still a completion. + +Open the example with the external-link icon on the ReScript tab and select stable [ReScript 12.3.1](https://github.com/rescript-lang/rescript/releases/tag/v12.3.1). Run it in the playground to compare with the Output tab. + + + +```res example +type status = + | Pending + | Unresolved + | Completed(string) + | Rejected(string) + +let describe = status => + switch status { + | Pending => "Pending" + | Unresolved => "Unresolved" + | Completed(output) => `Completed: "${output}"` + | Rejected(reason) => "Rejected: " + reason + } + +let outcomes = [ + Pending, + Unresolved, + Completed("Title saved"), + Completed(""), + Rejected("Title too long"), +] + +outcomes->Array.forEach(status => status->describe->Console.log) +``` + +```text +Pending +Unresolved +Completed: "Title saved" +Completed: "" +Rejected: Title too long +``` + + + +## Exhaustive matches keep the cases visible + +The constructors let ReScript infer that `describe` takes a `status`. [Pattern matching](../docs/manual/pattern-matching-destructuring.mdx) both selects the case and binds its payload. The four arms cover every constructor, so the match is exhaustive. + +If we add a constructor to `status`, ReScript reports the missing case in `describe` because there's no catch-all. Exhaustiveness checks apply to typed ReScript values. Incoming JSON still needs validation before it can become a `status`. + +A network error or a parse error doesn't establish a rejection. Neither does withheld approval. For the title update, `Unresolved` leaves room to check whether the write took effect before deciding whether to retry. + +[Handling late replies with typed events and records](./handling-late-tool-replies.mdx) adds the transitions, including a completion that arrives after the deadline. diff --git a/apps/docs/src/markdown/BlogFrontmatter.res b/apps/docs/src/markdown/BlogFrontmatter.res index 7b910822b..8fccaf098 100644 --- a/apps/docs/src/markdown/BlogFrontmatter.res +++ b/apps/docs/src/markdown/BlogFrontmatter.res @@ -72,6 +72,13 @@ let authors = [ imgUrl: "https://cdn.bsky.app/img/avatar/plain/did:plc:erifxn5qcos2zrxvogse5y5s/bafkreif6v7lrtz24vi5ekumkiwg7n7js55coekszduwhjegfmdopd7tqmi@webp", social: Bluesky("vlkpack.com"), }, + { + username: "tejesh-vaish", + fullname: "Tejesh Vaish", + role: "Community Member", + imgUrl: "https://avatars.githubusercontent.com/u/188414140?v=4", + social: X("its_tejesh_here"), + }, ] module Badge = { diff --git a/packages/shared/src/Path.res b/packages/shared/src/Path.res index 0d63c904f..e235913dd 100644 --- a/packages/shared/src/Path.res +++ b/packages/shared/src/Path.res @@ -60,8 +60,10 @@ type t = [ | #"/blog/editor-support-release-1-0" | #"/blog/enhanced-ergonomics-for-record-types" | #"/blog/first-class-dynamic-import-support" + | #"/blog/handling-late-tool-replies" | #"/blog/improving-interop" | #"/blog/introducing-unified-operators" + | #"/blog/modeling-uncertain-tool-call-outcomes" | #"/blog/new-rescript-logo" | #"/blog/reactive-analysis" | #"/blog/reforging-build-system"