Skip to content

app-server: Replay pending item requests on thread/resume#12560

Merged
euroelessar merged 14 commits intomainfrom
ruslan/thread-resume-requests
Feb 27, 2026
Merged

app-server: Replay pending item requests on thread/resume#12560
euroelessar merged 14 commits intomainfrom
ruslan/thread-resume-requests

Conversation

@euroelessar
Copy link
Contributor

@euroelessar euroelessar commented Feb 23, 2026

Replay pending client requests after thread/resume and emit resolved notifications when those requests clear so approval/input UI state stays in sync after reconnects and across subscribed clients.

Affected RPCs:

  • item/commandExecution/requestApproval
  • item/fileChange/requestApproval
  • item/tool/requestUserInput

Motivation:

  • Resumed clients need to see pending approval/input requests that were already outstanding before the reconnect.
  • Clients also need an explicit signal when a pending request resolves or is cleared so stale UI can be removed on turn start, completion, or interruption.

Implementation notes:

  • Use pending client requests from OutgoingMessageSender in order to replay them after thread/resume attaches the connection, using original request ids.
  • Emit item/commandExecution/approvalResolved, item/fileChange/approvalResolved, and item/tool/requestUserInputResolved when pending requests are answered or cleared by lifecycle cleanup.
  • Update the app-server protocol schema, generated TypeScript bindings, and README docs for the replay/resolution flow.

High-level test plan:

  • Added automated coverage for replaying pending command execution and file change approval requests on thread/resume.
  • Added automated coverage for resolved notifications in command approval, file change approval, request_user_input, turn start, and turn interrupt flows.
  • Verified schema/docs updates in the relevant protocol and app-server tests.

Manual testing:

  • Tested reconnect/resume with multiple connections.
  • Confirmed state stayed in sync between connections.

@etraut-openai etraut-openai added the oai PRs contributed by OpenAI employees label Feb 23, 2026
@euroelessar euroelessar force-pushed the ruslan/thread-resume-requests branch from d0a1dc7 to 593d7d9 Compare February 26, 2026 02:06
Replay pending client requests after `thread/resume` and emit resolved notifications when those requests clear so approval/input UI state stays in sync after reconnects and across subscribed clients.

Affected RPCs:
- `item/commandExecution/requestApproval`
- `item/fileChange/requestApproval`
- `item/tool/requestUserInput`

Motivation:
- Resumed clients need to see pending approval/input requests that were already outstanding before the reconnect.
- Clients also need an explicit signal when a pending request resolves or is cleared so stale UI can be removed on turn start, completion, or interruption.

Implementation notes:
- Track pending client requests in `ThreadState` and replay them after `thread/resume` attaches the connection.
- Reuse the original JSON-RPC request id for replays and resend prerequisite notifications like `item/started` for pending file change approvals.
- Emit `item/commandExecution/approvalResolved`, `item/fileChange/approvalResolved`, and `item/tool/requestUserInputResolved` when pending requests are answered or cleared by lifecycle cleanup.
- Update the app-server protocol schema, generated TypeScript bindings, and README docs for the replay/resolution flow.

High-level test plan:
- Added automated coverage for replaying pending command execution and file change approval requests on `thread/resume`.
- Added automated coverage for resolved notifications in command approval, file change approval, request_user_input, turn start, and turn interrupt flows.
- Verified schema/docs updates in the relevant protocol and app-server tests.

Manual testing:
- Not run.
@euroelessar euroelessar force-pushed the ruslan/thread-resume-requests branch from 593d7d9 to 60ee51e Compare February 26, 2026 05:32
CommandExecutionApprovalResolved => "item/commandExecution/approvalResolved" (v2::CommandExecutionApprovalResolvedNotification),
FileChangeOutputDelta => "item/fileChange/outputDelta" (v2::FileChangeOutputDeltaNotification),
FileChangeApprovalResolved => "item/fileChange/approvalResolved" (v2::FileChangeApprovalResolvedNotification),
ToolRequestUserInputResolved => "item/tool/requestUserInputResolved" (v2::ToolRequestUserInputResolvedNotification),
Copy link
Collaborator

Choose a reason for hiding this comment

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

ooc, why do we need these? can't clients resolve things based on receiving item/completed?

Copy link
Collaborator

Choose a reason for hiding this comment

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

i.e. server will send:

  • item/started notification for file change / command execution / etc.
  • item/commandExecution/requestApproval request
  • a client responds
  • item/completed notification

Copy link
Contributor Author

Choose a reason for hiding this comment

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

  1. we don't have item/started/item/completed events for user input at all, as there is no corresponding item, iiuc
  2. in case of file changes, they are synthesized, but not always? e.g. I don't think we never emit item/completed in cases like turn interruption and such (which could be a bug); also harness is allowed to send multiple requests for the same item, so there is no one-to-one mapping between requests & items for file changes approvals in particular, it's many-to-one

due to points above it's just easier (from protocol perspective & client-side consumption) and more consistent to have dedicated events

as a side note, it should be fine to merge these events into just single one if you prefer, I've only split them to mirror having multiple independent rpcs

Copy link
Collaborator

@owenlin0 owenlin0 Feb 26, 2026

Choose a reason for hiding this comment

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

ah ok, we might want to add an item for representing the user input tool call. I had to do that recently for dynamic tool calls: #12732

but you're right, we're going to move to a world soon where there are multiple approvals for a CommandExecution so we can't rely on item/completed for that either. proceed :)

Copy link
Collaborator

Choose a reason for hiding this comment

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

naming nit: since our requests are like item/commandExecution/requestApproval should we name these notifications item/commandExecution/requestApproval/resolved?

Copy link
Collaborator

Choose a reason for hiding this comment

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

basically just appending /resolved to the corresponding server method name

notifications_before_request: &[ServerNotification],
) -> bool {
// Hold the callback map lock until the replay is queued so an already-resolved
// request cannot be replayed to a resumed client.
Copy link
Contributor

Choose a reason for hiding this comment

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

stresses me out - prefer to make this not needed for correctness, or use try_send
I don't fully understand what race this prevents - even if we send an already-resolved request event that should be ok right? since the client will observe the resolution as well right after (due to ordering guarantee provided by the thread listener task)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

yeah, I agree, we already serialize resolution properly so should be fine, simplified

Copy link
Contributor

@maxj-oai maxj-oai left a comment

Choose a reason for hiding this comment

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

awesome, ty!

Copy link
Collaborator

@owenlin0 owenlin0 left a comment

Choose a reason for hiding this comment

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

trying my best to review but it's getting quite hard to follow :(

connection_ids: Option<&[ConnectionId]>,
request: ServerRequestPayload,
thread_id: Option<ThreadId>,
tracked_request: bool,
Copy link
Collaborator

Choose a reason for hiding this comment

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

what makes a request "tracked" or not?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

it's essentially a workaround to avoid closing rpcs for dynamic tool & v1, as I wanted to maintain status quo in their behavior
alternatively could check for the type (but that feels more hackier), or spend more type drilling into them to make sure they are not broken by the change

Copy link
Contributor Author

Choose a reason for hiding this comment

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

bit the bullet and gone through all the remaining server->client requests to add proper handling of abortion into them, so no need for tracked requests anymore

.await
}

pub(crate) async fn send_request_with_server_request(
Copy link
Collaborator

Choose a reason for hiding this comment

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

what's the difference between this and send_request?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

this one was returning a request id as well, merged them into one & updated all callsites for simplicity

outgoing.send_server_notification(notification).await;
}

pub(crate) async fn abort_pending_client_requests(outgoing: &ThreadScopedOutgoingMessageSender) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

can this be a method on ThreadScopedOutgoingMessageSender?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

yep, updated

Copy link
Collaborator

@owenlin0 owenlin0 left a comment

Choose a reason for hiding this comment

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

nice, much cleaner!!

} = event;
match msg {
EventMsg::TurnStarted(_) => {
outgoing.abort_pending_client_requests().await;
Copy link
Collaborator

Choose a reason for hiding this comment

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

nit: maybe worth a comment on why we need this, still not super clear to me 🤷

@@ -29,13 +31,24 @@ pub(crate) struct PendingThreadResumeRequest {
}

pub(crate) enum ThreadListenerCommand {
Copy link
Collaborator

Choose a reason for hiding this comment

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

prob worth a comment on what this (and the variants) are used for

#[derive(Default, Clone)]
pub(crate) struct TurnSummary {
pub(crate) file_change_started: HashSet<String>,
pub(crate) file_change_started: HashMap<String, Vec<FileUpdateChange>>,
Copy link
Collaborator

Choose a reason for hiding this comment

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

curious, why did we need to change it to a hashmap here?

code: INTERNAL_ERROR_CODE,
message: "client request resolved because the turn state was changed"
.to_string(),
data: Some(serde_json::json!({ "reason": "turnTransition" })),
Copy link
Collaborator

Choose a reason for hiding this comment

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

can import the const you defined: TURN_TRANSITION_PENDING_REQUEST_ERROR_REASON

@euroelessar euroelessar merged commit 69d7a45 into main Feb 27, 2026
85 of 95 checks passed
@euroelessar euroelessar deleted the ruslan/thread-resume-requests branch February 27, 2026 20:46
@github-actions github-actions bot locked and limited conversation to collaborators Feb 27, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

oai PRs contributed by OpenAI employees

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants