Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions apps/docs/markdown-pages/blog/handling-late-tool-replies.mdx
Original file line number Diff line number Diff line change
@@ -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.

<CodeTab labels={["ReScript", "Output"]}>

```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
```

</CodeTab>

## 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.
Original file line number Diff line number Diff line change
@@ -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.

<CodeTab labels={["ReScript", "Output"]}>

```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
```

</CodeTab>

## 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.
7 changes: 7 additions & 0 deletions apps/docs/src/markdown/BlogFrontmatter.res
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
2 changes: 2 additions & 0 deletions packages/shared/src/Path.res
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down