#16593

hooks: prompt, turn, session, and shell completion boundaries

Draftvorporealwants to merge intofrom

Description

Summary

Seventh PR of the agent hooks Phase 1 stack. It gives the boundaries that are not tool calls their real behavior. prompt.submit becomes a step inside send_query that can stop a prompt and starts the turn whose id every later envelope carries; turn.stop holds a completed turn's success until the stop hooks answer and can queue a follow-up prompt, with provenance and loop limits, without racing Oz's idle exit; session.start fires once per conversation from one place, just before the first request, which waits for it and carries its environment to every later hook; response.complete and reasoning.complete keep their heuristic and are now tested across splits, errors, and late updates; and afterShellExecution fires on the real exit of an interact-mode command instead of on its snapshot. With hooks off, or with no binding for a boundary, every path is the synchronous one it was before: send_query sends on the same call, finalization is immediate, and the existing controller, startup-queue, and queued-query tests pass unchanged.

Turn lifecycle, end to end: send_query builds the request as before and calls prepare_turn, which pins the run, starts the conversation's session.start barrier if this is its first request, allocates the turn id, and runs the prompt.submit hooks when one applies; with nothing to wait for the request goes out on the same call (send_prepared_query), otherwise conclude_prepared_query sends it, or does not, when the wait ends. The stream runs; tool calls run and their results go out as further requests of the same turn. When the stream ends with no actions (or the last action finishes with no follow-up), hold_or_finalize_turn either marks Success or, if a turn.stop binding matches, holds it; after cleanup_stream, start_held_turn_stop dispatches the stop hooks; resolve_turn_stop queues the follow-up they asked for and says whether the controller may finish the turn, and settle_turn_after_stop or finalize_held_turn do so unless a newer turn is under way.

  • app/src/ai/blocklist/hooks/prompt.rs: prepare_turn(prompt, conversation_id, terminal_surface_id, active_session, ctx) runs the session barrier first (which pins the run), allocates the turn id, and returns TurnPreparation::Ready (send now) or Pending(future). The future waits for the barrier, copies its environment into the prompt.submit envelope, dispatches, and resolves to PromptDecision::{Proceed, Halted, Cancelled}. A halt is continue: false, a deny, or an ask (no confirmation exists for a prompt; the notice says so). A Denied notice carries the reason. finish_turn_preparation settles the bookkeeping and says whether to send and whether an earlier turn's finalization was left to this prompt. Every waiting prompt, whether it waits on its own hooks or only on the session barrier, is the conversation's one pending prompt: a newer prompt for the same conversation replaces it, and cancelling the conversation drops it, in both cases before it is sent. PromptProvenance::User starts a new follow-up chain; a queued row's provenance comes from the queue.
  • app/src/ai/blocklist/hooks/turn.rs: hold_turn holds a completed turn's success when a turn.stop binding matches; start_turn_stop dispatches once the turn's stream is out of the way, bounded by the longest matching binding timeout; resolve_turn_stop queues the follow-up (QueuedQuery::new_hook_follow_up with PromptProvenance::HookFollowUp { binding, chain_id }) if the binding's loop_limit and the host cap allow, and returns Finalize, FollowUpQueued, or Dropped (the hold was cancelled or replaced, or a prompt whose own hooks are still running will own the status). Whether a newer turn owns the conversation is read from live state when the hooks answer (a pending prompt here, a request in flight in the controller), never from a flag set earlier. Verdicts and halt_turn on stop are ignored with a diagnostic. cancel_held_turn drops the hold for the cancel path.
  • app/src/ai/blocklist/hooks/session.rs: barrier(conversation_id, terminal_surface_id, active_session, ctx) pins the run, fires session.start once for a conversation this client drives that has no exchanges yet and a matching binding, and returns what the first request must wait for: the hook's env, or nothing after the longest matching timeout. A second request while the hooks run waits on the same firing. Restored and resumed conversations have exchanges and never fire it.
  • app/src/ai/blocklist/hooks/tool_result.rs: ran_and_returned decides tool.result against tool.failure: a tool that ran and returned is a result whatever it returned (any exit code, ShellRecovered, an MCP error); a cancellation, CancelledBeforeExecution, a denylist hit, a hook denial (including the families that carry it in their error text behind DENIED_BY_HOOK_PREFIX), a busy terminal, and a refused run_agents are failures. ShellCompletions remembers a LongRunningCommandSnapshot by block id and, when a later read, write, or handoff result reports CommandFinished or ShellRecovered for that block, fires a second tool.result on the command's own action with a Completed result and the duration from the timestamps.
  • app/src/ai/blocklist/hooks/stream_tests.rs: tests for a split mid-stream (the old conversation's text is finished under the old id, later text under the new one), a stream that errors (the text streamed so far is reported), reasoning.complete exactly once on the first update with finished_duration, and a late update to an earlier tool call not ending the text. stream.rs itself did not need to change.
  • app/src/ai/blocklist/hooks/mod.rs: HookRuntime gains the per-conversation state the boundaries need (turn_ids, session_starts, turn_stops, pending_prompts, shell_completions), turn_id_for, session_env_for, forget_conversation on RemoveConversation and DeletedConversation, and forget_cleared_conversation (everything but a held turn) on ClearedConversationsForTerminalSurface (the history subscription is now exhaustive). longest_matching_timeout(snapshot, envelope) is the bound both held steps use. The testing module gains a controller harness (controller_harness, mock_stream, finish_turn_with_text, record_statuses, and friends) that the boundary tests and controller_tests.rs share.
  • app/src/ai/blocklist/hooks/envelope.rs: EnvelopeSite { conversation_id, active_session } replaces the two constructors; build fills turn_id and session_env from the runtime.
  • app/src/ai/blocklist/controller.rs: send_query is split at the point the request would go out. Everything before it (conversation and task resolution, cancelling the active conversation's progress, draining finished results and passive suggestion results once, building inputs, taking dirty orchestration events) is unchanged and still runs synchronously; the inputs are packed into a PreparedQuery and prepare_turn either sends at once (send_prepared_query) or spawns the decision on the model's executor and finishes in conclude_prepared_query, which puts the dirty events back when the prompt is not sent. hold_or_finalize_turn replaces the two places that set ConversationStatus::Success at the end of a turn (handle_response_stream_finished's Done arm, through the returned turn_ended, and the FinishedAction subscription); start_held_turn_stop runs after cleanup_stream in the AfterStreamFinished arm and after the FinishedAction branch; settle_turn_after_stop, finalize_held_turn, and finalize_turn apply the hooks' answer, and the first two ask turn_in_flight (a waiting prompt, an active stream, or actions still pending or running) before marking anything or sending. cancel_conversation_progress_routed cancels a waiting prompt and, unless the cancellation keeps the conversation in progress, drops a held turn and settles the status it owed. steer_head_prompt_for_request skips a hook's row, and send_query sends a hook's text as written (no slash command, no mode prefix). PR 1's turn_completed block in AfterStreamFinished and its notify_turn_stop call in the FinishedAction branch are gone. Controller diff: 283 lines added, 79 removed.
  • app/src/ai/blocklist/history_model.rs and app/src/ai/agent/conversation.rs: mark_response_stream_completed_successfully and mark_request_completed return whether the turn ended instead of setting Success themselves; the notify_session_start call in start_new_conversation is removed.
  • app/src/ai/blocklist/queued_query.rs: QueuedQueryOrigin::HookFollowUp, a provenance field on every row (User for everything else), QueuedQuery::new_hook_follow_up, and QueuedQueryModel::provenance_for; a telemetry variant to match.
  • crates/ai/src/hooks/dispatch.rs: the invocation environment is built from envelope.session_env instead of an empty slice.
  • Amendments to PR 1 and PR 3 are listed under Departures.

Decisions

prompt.submit and turn ids (§4.2, §5, §9.2)

  • Every send_query starts a turn and allocates a turn id, including inputs Warp builds (a passive suggestion result, an agent wake, and the StartFromAmbientRunPrompt input an Oz run sends through send_custom_ai_input_query). Tool-result follow-ups (send_follow_up_for_conversation) continue the current turn. So every envelope after the first prompt carries a turn id, and two prompts in one conversation carry two.
  • The Oz first prompt is server side (§10 item 8): the StartFromAmbientRunPrompt input carries no text, so that turn starts (turn id, barrier) but fires no prompt.submit. beforeSubmitPrompt cannot see it in Phase 1; PR 8 may supply the text if the run metadata has it.
  • A halt covers continue: false, a deny verdict, and an ask (§9.2). The prompt is not sent, a Denied notice carries the hook's reason (or a default), the conversation's status is unchanged, and the dirty orchestration events the query took are put back. ask halts because no confirmation step exists for a prompt. Both verdict readings are also recorded in questions.md.
  • A newer prompt for the same conversation replaces one still waiting, whether on its own hooks (its dispatch is told to stop) or only on the session barrier, and its decision is dropped as Cancelled; cancelling the conversation drops a waiting prompt the same way. So two prompts sent during a session.start wait produce one request, the newer one, and a prompt cancelled during that wait is never sent. The second prompt's cancel_conversation_progress runs as it always did; nothing is drained twice because the first prompt's inputs were built before it yielded.
  • A prompt a stop hook asked for is sent as written: send_query does not read it for a slash command (/compact, /init) or a mode prefix (/plan, /orchestrate), so a hook cannot turn its follow-up into a Warp command and skip the prompt boundary. The person's queued rows are read as before.
  • The hooks see the prompt as typed, including a mode prefix, and the copy is made only when the runtime is active.
  • No notice is shown while a slow prompt.submit hook runs; the bound is the longest matching binding timeout (30 s default), after which the dispatcher's failure policy applies. Phase 1.5's UI can add a waiting indicator.

turn.stop and follow-ups (§4.5, §5, §9.2, §11)

  • Only TurnStopStatus::Completed fires (§9.2): the hold is taken where the turn would be marked Success and nowhere else. A cancelled or failed turn is not a stop event in Phase 1, so TurnStopStatus::{Cancelled, Error} are never sent.
  • The hold is taken when the turn ends and the dispatch starts once the turn's own stream has been cleaned up, so a follow-up's request never collides with the stream that ended the turn. mark_request_completed and mark_response_stream_completed_successfully therefore return "the turn ended" instead of setting Success themselves.
  • The Oz idle-exit race (§11): execute_run ends a run when it sees Success. While the stop hooks run the conversation stays InProgress (success_is_not_shown_while_the_stop_hooks_run records every status event), and a queued follow-up keeps it InProgress through its own request (a_follow_up_keeps_the_conversation_in_progress_until_its_own_request). Success is set only when the hooks answered with no follow-up, when the follow-up could not be sent, or when the hold timed out.
  • A follow-up goes through the normal prompt path and no other: appended to QueuedQueryModel by conversation id (never to the active conversation), sent by dispatch_queued_warp_agent_prompt, which respects is_dispatch_blocked and an active subagent, and so through send_query, where it fires prompt.submit with HookFollowUp provenance and gets its own turn id. The steering step an Oz-bound conversation uses to fold the queue's head into its next tool-result request (steer_head_prompt_for_request) skips a hook's row, so a follow-up is never sent mid-turn without its prompt hooks and turn id. A follow-up that cannot be sent yet stays queued (with a warning in the log) and the turn is finalized Success; recorded in questions.md.
  • A held turn's follow-up is dispatched from settle_turn_after_stop only for the surface's own conversation: the Oz-bound one, or the surface's active one. For any other conversation the row stays queued for that conversation's next drain and the turn is finalized, because the dispatch goes through the surface's prompt path, which would cancel the conversation the person is in and lend it their pending context. Inert in Phase 1 (the GUI and TUI do not activate hooks).
  • Loop accounting (§4.5): a chain starts at a person's prompt (PromptProvenance::User) and every follow-up stays in it. Each binding may ask for loop_limit follow-ups per chain (the Cursor adapter supplies 5 when the definition sets none, and turns null or a value above 8 into 8 with a load-time warning); a continuation that arrives with no limit at all (only a non-Cursor adapter or a test binding can send one) gets the host cap; and the chain as a whole never exceeds HOOK_FOLLOW_UP_CAP (8), so two bindings feeding each other stop at 8 total (the_host_cap_applies_to_the_whole_chain_across_bindings). A refused follow-up ends the turn Success with a warning diagnostic naming the limit that was hit.
  • The hold's bound is the longest matching binding's timeout (default 60 s). On timeout the hooks are told to stop, their answer is ignored, and the turn is finalized Success; a conversation is never left InProgress on account of a hook. Recorded in questions.md.
  • A person's prompt during the hold takes the status (the held turn's Success is never set under it) and the follow-up, when it arrives, queues behind the person's turn; if that prompt is then halted or cancelled before it goes out, the earlier turn is finished then. Recorded in questions.md. This is decided from live state when the stop hooks answer, not from flags set when the prompt arrived: a prompt still waiting (pending_prompts) takes the finalization with it and hands it back only if it is the last waiting prompt and does not go out (including when its send fails); a request sent since the hold (the conversation's exchange count moved, whichever path sent it: a typed prompt, a resume, an orchestration injection) owns the conversation and the hooks' answer only queues its follow-up; and a turn under way in the controller's view (turn_in_flight: a waiting prompt, an active stream, or actions pending or running between a stream's end and the request that carries their results) stops both the finalization and the follow-up dispatch. The exchange count covers the one window the action model cannot see, an action still being preprocessed. A turn that came and went during the hold therefore leaves nothing behind for a later cancellation to act on, and a replaced prompt cannot hand the status to the hooks while its successor still waits (a_turn_that_came_and_went_during_the_hold_leaves_nothing_behind, success_is_not_shown_while_a_replaced_prompts_successor_still_waits, a_newer_turn_whose_actions_still_run_is_not_finished_under_them).
  • Cancelling the conversation while a turn is held (manual cancel, a new prompt in another conversation) drops the hold and settles the status the hold owed (Cancelled, or Success for a cancellation that counts as success); a cancellation that keeps the conversation in progress leaves the hold alone. A late answer for a dropped hold is discarded (an_answer_for_a_cancelled_hold_is_dropped).
  • Verdicts and halt_turn on stop are ignored with a warning diagnostic that says a stop hook can only ask for a follow-up (§9.2).
  • Follow-ups are ordinary prompts for billing purposes until §12's question is decided: nothing marks them for quota, and the queued row is visible like any other.
  • QueuedQueryOrigin::HookFollowUp is a new origin rather than a reuse of UserQueued, so the row is distinguishable in the UI and telemetry.

session.start (§5, §6.4, §9.2, §10 item 6)

  • The barrier is at the start of prepare_turn, which every send_query reaches, so it covers the Oz first prompt as well as a typed one. It pins the run first (pin_for_run), because PR 5 reads the posture and activates project files at the first request, and a session.start fired any earlier would not see them. This is also the first point where the conversation's driver is known, which closes PR 1's S4 gap (a conversation an external harness later drives fires nothing, since owns_turn_lifecycle is false).
  • Fire once per conversation id: the state goes InFlight then Done; a second request while the hooks run waits on the same firing (at most 5 s longer, since the first request holds the real deadline); a later request finds Done and waits for nothing.
  • A conversation is new when it has no exchanges, so a restored or resumed conversation never fires it (§10 item 6), and neither does a shared-session viewer or a CLI transcript (owns_turn_lifecycle).
  • The first request waits at most the longest matching binding timeout (§6.4); a hook that fails or times out adds no environment and the request goes out. The env of a successful run is stored on the runtime, copied onto every later envelope for the conversation (HookEnvelope::session_env), and the runner's invocation_environment applies it under the scrub policy. Recorded in questions.md.
  • is_background_agent is AppExecutionMode::is_autonomous().

tool.result, tool.failure, and afterShellExecution (§5, §9.2; PR 3 review S3)

  • Routing rule: a tool that ran and returned is a tool.result, whatever it returned; a tool that never ran is a tool.failure. Concretely: shell Completed at any exit code (including 130), ShellRecovered, snapshots, an MCP tool's error result, and a failed file edit are results; any cancellation (including one during a running command), CancelledBeforeExecution, Denylisted, HookDenied, TerminalBusy, RunAgentsResult::Denied, and an error whose text carries DENIED_BY_HOOK_PREFIX (checked only on failed results) are failures. The adapter needed no change because it reads both payloads the same way. Recorded in questions.md.
  • afterShellExecution observability: the real exit of an interact-mode command is only seen by whichever later poll, write, or handoff result reports CommandFinished or ShellRecovered, and those results carry no command text. The funnel remembers the snapshot by block id and fires a second tool.result on the command's own action with a synthesized RequestCommandOutputResult::Completed (command, output, exit code, timestamps) and the duration from the timestamps, so a hook watching finished commands sees each command once, with its exit code, in the command's conversation. Not observable and so never fired: a command the model stops polling, a poll cancelled before the exit, and a command handed to the user whose exit no later poll reports. Pending entries are dropped with their conversation and are bounded by the commands a conversation started. Recorded in questions.md.
  • A read_shell_command_output poll is no longer itself a finished shell command for the Cursor adapter (amendment to PR 3, below), so afterShellExecution fires once per agent-started command and not for a user-started command the agent only polls.

Stream observers (§5, §9.2)

  • A split mid-stream finishes the text streamed before it under the old conversation id and attributes text after it to the new conversation once its id is known; Cursor's afterAgentResponse sees each message in the conversation it belongs to.
  • A stream that errors reports the text it streamed so far (the heuristic's "or the stream ends"); a cancelled stream reports nothing, as before.
  • reasoning.complete fires on the first update of a message that carries finished_duration, keyed by message id; later replacements and appends of that message are ignored. A split does not fire turn.stop for the old conversation, since the old conversation's turn did not complete.

Housekeeping

  • Turn ids, session state, holds, chains, pending prompts, and pending shell completions are dropped on RemoveConversation and DeletedConversation, so a restored conversation gets a fresh turn id at its next prompt. A conversation cleared from its surface (ClearedConversationsForTerminalSurface) drops the same except its held turn: the conversation still exists, and the surface cancels its active conversation right after, which settles the held turn as Cancelled; a held turn of a cleared conversation that was not the active one is finished by its hooks' answer.
  • Dirty orchestration events put back after a prompt is halted or fails to send do not overwrite a plan edited again during the wait (set_dirty_orchestration_events keeps the newer edit).
  • The controller test helpers that build a mock stream and finish a turn moved into hooks::testing so the boundary tests and controller_tests.rs share one harness.
  • The PR 5 flake (prompt_submit_reaches_the_adapter_with_the_text_and_conversation asserting the runner count without settling) is fixed: the test awaits the decision and settles on invocation_count.

Departures from architecture.md

  • §9.2 and §5 name BlocklistAIHistoryModel::start_new_conversation as the single owner of session.start. The owner is now the barrier in prepare_turn, before the conversation's first request. Reason: PR 5 pins the active set and reads the posture at the first request, so a firing at creation time cannot see project hooks and cannot carry the run's environment; and at creation time the driver is not yet known (PR 1 review S4). The call in start_new_conversation is removed so it cannot fire twice.
  • Unowned files changed: app/src/ai/agent/conversation.rs (mark_request_completed returns Result<bool> and no longer sets Success; the hold must own that status), app/src/server/telemetry/events.rs (TelemetryQueuedQueryOrigin::HookFollowUp, required by the exhaustive From), crates/ai/src/hooks/dispatch.rs (PR 6's file; one line passes envelope.session_env to invocation_environment), app/src/ai/document/ai_document_model.rs (set_dirty_orchestration_events inserts with or_insert, so a restored event does not overwrite a newer edit; the only other caller is the controller's own failed-send path, which had no window for a newer edit).
  • app/src/ai/blocklist/hooks/mod.rs (PR 1's; others add pub mod lines only) gains the runtime state, cleanup, longest_matching_timeout, and the shared test harness, because the boundaries' state lives on HookRuntime. app/src/ai/blocklist/hooks/envelope.rs and envelope_tests.rs (PR 1 amendments) change on this branch rather than PR 1's because build now reads turn_id_for and session_env_for, which exist only here.
  • Amendment to PR 1 (35faca3a5, formatting in b7e173404): crates/ai/src/hooks/model.rs gains HOOK_FOLLOW_UP_CAP: u32 = 8, FollowUpChainId, PromptProvenance { User, HookFollowUp { binding, chain_id } }, a provenance field on HookPayload::PromptSubmit, HookEnvelope::session_env, and MergedEffects::continuation_binding (filled by reduce_effects); hooks/mod.rs re-exports them; test_util/mod.rs sets session_env on the test envelope; recording_adapter.rs decodes "env": [[k, v]] into session_env and gives a continuation the binding's loop_limit; PR 1's envelope.rs, prompt.rs, and prompt_tests.rs pass PromptProvenance::User.
  • Amendment to PR 3 (a779ec806): encode/mod.rs, encode/mod_test.rs, and matcher_tests.rs carry the new provenance field; facets::finished_shell_command returns None for ReadShellCommandOutput, so afterShellExecution fires once, on the command's own action, through the funnel's completion report, and not for a polled user command; a matcher test covers a ShellRead poll.

Linked Issue

No linked issue. Part of the agent hooks Phase 1 stack; design is in specs/agent-hooks/architecture.md.

  • The linked issue is labeled ready-to-spec or ready-to-implement.
  • Where appropriate, screenshots or a short video of the implementation are included below (especially for user-visible or UI changes).

Testing

  • cargo check -p ai — passed.
  • cargo nextest run -p ai --features local_fs,test-util -E 'test(hooks)' — passed, 266 tests.
  • WARP_LOCAL_SKIP_METAL=1 cargo check -p warp --tests — passed.
  • WARP_LOCAL_SKIP_METAL=1 cargo nextest run -p warp -E 'test(hooks)' --no-fail-fast — passed, 116 tests (prompt: 9, turn: 16, session: 9, tool_result: 11, stream: 15, controller hooks: 4, plus envelope, pre-tool, denied-result, and runtime tests).
  • WARP_LOCAL_SKIP_METAL=1 cargo nextest run -p warp -E 'test(ai::blocklist::controller) | test(startup_queue) | test(queued_query) | test(ai::blocklist::history_model) | test(ai::blocklist::action_model) | test(ai::agent::conversation) | test(queued_prompts) | test(agent_sdk::driver) | test(request_metadata) | test(ai_document_model)' --no-fail-fast — 1394 tests with the hook tests included, 1393 passed (the existing controller, startup-queue, and queued-query tests pass unchanged with hooks inactive); the one failure, agent_sdk::driver::cloud_provider::gcp::tests::best_effort_command_is_killed_on_timeout, is a timing test unrelated to hooks that passes when run alone.
  • Review rounds: a_hook_follow_up_at_the_queue_head_is_not_steered_into_a_running_turn was run with the steering check removed and failed at the first is_none() assertion; a_newer_turn_whose_actions_still_run_is_not_finished_under_them was run with both in-flight guards disabled and failed at the status assertion; both pass with the guards in place.
  • cargo clippy -p ai --all-targets --tests -- -D warnings and cargo clippy -p ai --features local_fs,test-util --all-targets --tests -- -D warnings — passed.
  • WARP_LOCAL_SKIP_METAL=1 cargo clippy -p warp --all-targets --tests -- -D warnings — passed.
  • ./script/format — run last; the formatting commit is on the branch.

Acceptance criteria: 1 and 2 in prompt_tests.rs; 3, 4, and 5 in turn_tests.rs; 6 in session_tests.rs; 7 in stream_tests.rs; 8 in tool_result_tests.rs; the provenance round trip in queued_query_tests.rs.

  • I have manually tested my changes locally with ./script/run

Manual testing is deferred to the PR 9 instructions; the feature flag is off.

Agent Mode

  • Warp Agent Mode - This PR was created via Warp's AI Agent Mode

CHANGELOG-NONE

Activity

8
vorporeal9d ago

WARNING

This pull request is not mergeable via GitHub because a downstack PR is open. Once all requirements are satisfied, merge this PR as a stack on Graphite. Learn more

  • #16605
  • #16602
  • #16593 👈 (View in Graphite)
  • #16590
  • #16588
  • #16583
  • #16582
  • #16579
  • #16577
  • #16558
  • master

This stack of pull requests is managed by Graphite. Learn more about stacking.

D
5c4089b
D
9b593e1

This pull request is a draft

Merging is unavailable until it is marked ready for review.