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 returnsTurnPreparation::Ready(send now) orPending(future). The future waits for the barrier, copies its environment into theprompt.submitenvelope, dispatches, and resolves toPromptDecision::{Proceed, Halted, Cancelled}. A halt iscontinue: false, adeny, or anask(no confirmation exists for a prompt; the notice says so). ADeniednotice carries the reason.finish_turn_preparationsettles 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::Userstarts a new follow-up chain; a queued row's provenance comes from the queue.app/src/ai/blocklist/hooks/turn.rs:hold_turnholds a completed turn's success when aturn.stopbinding matches;start_turn_stopdispatches once the turn's stream is out of the way, bounded by the longest matching binding timeout;resolve_turn_stopqueues the follow-up (QueuedQuery::new_hook_follow_upwithPromptProvenance::HookFollowUp { binding, chain_id }) if the binding'sloop_limitand the host cap allow, and returnsFinalize,FollowUpQueued, orDropped(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 andhalt_turnon stop are ignored with a diagnostic.cancel_held_turndrops 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, firessession.startonce 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'senv, 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_returneddecidestool.resultagainsttool.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 behindDENIED_BY_HOOK_PREFIX), a busy terminal, and a refusedrun_agentsare failures.ShellCompletionsremembers aLongRunningCommandSnapshotby block id and, when a later read, write, or handoff result reportsCommandFinishedorShellRecoveredfor that block, fires a secondtool.resulton the command's own action with aCompletedresult 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.completeexactly once on the first update withfinished_duration, and a late update to an earlier tool call not ending the text.stream.rsitself did not need to change.app/src/ai/blocklist/hooks/mod.rs:HookRuntimegains the per-conversation state the boundaries need (turn_ids,session_starts,turn_stops,pending_prompts,shell_completions),turn_id_for,session_env_for,forget_conversationonRemoveConversationandDeletedConversation, andforget_cleared_conversation(everything but a held turn) onClearedConversationsForTerminalSurface(the history subscription is now exhaustive).longest_matching_timeout(snapshot, envelope)is the bound both held steps use. Thetestingmodule gains a controller harness (controller_harness,mock_stream,finish_turn_with_text,record_statuses, and friends) that the boundary tests andcontroller_tests.rsshare.app/src/ai/blocklist/hooks/envelope.rs:EnvelopeSite { conversation_id, active_session }replaces the two constructors;buildfillsturn_idandsession_envfrom the runtime.app/src/ai/blocklist/controller.rs:send_queryis 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 aPreparedQueryandprepare_turneither sends at once (send_prepared_query) or spawns the decision on the model's executor and finishes inconclude_prepared_query, which puts the dirty events back when the prompt is not sent.hold_or_finalize_turnreplaces the two places that setConversationStatus::Successat the end of a turn (handle_response_stream_finished'sDonearm, through the returnedturn_ended, and theFinishedActionsubscription);start_held_turn_stopruns aftercleanup_streamin theAfterStreamFinishedarm and after theFinishedActionbranch;settle_turn_after_stop,finalize_held_turn, andfinalize_turnapply the hooks' answer, and the first two askturn_in_flight(a waiting prompt, an active stream, or actions still pending or running) before marking anything or sending.cancel_conversation_progress_routedcancels 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_requestskips a hook's row, andsend_querysends a hook's text as written (no slash command, no mode prefix). PR 1'sturn_completedblock inAfterStreamFinishedand itsnotify_turn_stopcall in theFinishedActionbranch are gone. Controller diff: 283 lines added, 79 removed.app/src/ai/blocklist/history_model.rsandapp/src/ai/agent/conversation.rs:mark_response_stream_completed_successfullyandmark_request_completedreturn whether the turn ended instead of settingSuccessthemselves; thenotify_session_startcall instart_new_conversationis removed.app/src/ai/blocklist/queued_query.rs:QueuedQueryOrigin::HookFollowUp, aprovenancefield on every row (Userfor everything else),QueuedQuery::new_hook_follow_up, andQueuedQueryModel::provenance_for; a telemetry variant to match.crates/ai/src/hooks/dispatch.rs: the invocation environment is built fromenvelope.session_envinstead 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_querystarts a turn and allocates a turn id, including inputs Warp builds (a passive suggestion result, an agent wake, and theStartFromAmbientRunPromptinput an Oz run sends throughsend_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
StartFromAmbientRunPromptinput carries no text, so that turn starts (turn id, barrier) but fires noprompt.submit.beforeSubmitPromptcannot see it in Phase 1; PR 8 may supply the text if the run metadata has it. - A halt covers
continue: false, adenyverdict, and anask(§9.2). The prompt is not sent, aDeniednotice 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.askhalts because no confirmation step exists for a prompt. Both verdict readings are also recorded inquestions.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 asession.startwait produce one request, the newer one, and a prompt cancelled during that wait is never sent. The second prompt'scancel_conversation_progressruns 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_querydoes 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.submithook 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::Completedfires (§9.2): the hold is taken where the turn would be markedSuccessand nowhere else. A cancelled or failed turn is not a stop event in Phase 1, soTurnStopStatus::{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_completedandmark_response_stream_completed_successfullytherefore return "the turn ended" instead of settingSuccessthemselves. - The Oz idle-exit race (§11):
execute_runends a run when it seesSuccess. While the stop hooks run the conversation staysInProgress(success_is_not_shown_while_the_stop_hooks_runrecords every status event), and a queued follow-up keeps itInProgressthrough its own request (a_follow_up_keeps_the_conversation_in_progress_until_its_own_request).Successis 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
QueuedQueryModelby conversation id (never to the active conversation), sent bydispatch_queued_warp_agent_prompt, which respectsis_dispatch_blockedand an active subagent, and so throughsend_query, where it firesprompt.submitwithHookFollowUpprovenance 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 finalizedSuccess; recorded inquestions.md. - A held turn's follow-up is dispatched from
settle_turn_after_stoponly 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 forloop_limitfollow-ups per chain (the Cursor adapter supplies 5 when the definition sets none, and turnsnullor 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 exceedsHOOK_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 turnSuccesswith 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 leftInProgresson account of a hook. Recorded inquestions.md. - A person's prompt during the hold takes the status (the held turn's
Successis 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 inquestions.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, orSuccessfor 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_turnon 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::HookFollowUpis a new origin rather than a reuse ofUserQueued, 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 everysend_queryreaches, 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 asession.startfired 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, sinceowns_turn_lifecycleis false). - Fire once per conversation id: the state goes
InFlightthenDone; 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 findsDoneand 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
envof a successful run is stored on the runtime, copied onto every later envelope for the conversation (HookEnvelope::session_env), and the runner'sinvocation_environmentapplies it under the scrub policy. Recorded inquestions.md. is_background_agentisAppExecutionMode::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 atool.failure. Concretely: shellCompletedat 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 carriesDENIED_BY_HOOK_PREFIX(checked only on failed results) are failures. The adapter needed no change because it reads both payloads the same way. Recorded inquestions.md. afterShellExecutionobservability: the real exit of an interact-mode command is only seen by whichever later poll, write, or handoff result reportsCommandFinishedorShellRecovered, and those results carry no command text. The funnel remembers the snapshot by block id and fires a secondtool.resulton the command's own action with a synthesizedRequestCommandOutputResult::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 inquestions.md.- A
read_shell_command_outputpoll is no longer itself a finished shell command for the Cursor adapter (amendment to PR 3, below), soafterShellExecutionfires 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
afterAgentResponsesees 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.completefires on the first update of a message that carriesfinished_duration, keyed by message id; later replacements and appends of that message are ignored. A split does not fireturn.stopfor 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
RemoveConversationandDeletedConversation, 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 asCancelled; 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_eventskeeps the newer edit). - The controller test helpers that build a mock stream and finish a turn moved into
hooks::testingso the boundary tests andcontroller_tests.rsshare one harness. - The PR 5 flake (
prompt_submit_reaches_the_adapter_with_the_text_and_conversationasserting the runner count without settling) is fixed: the test awaits the decision and settles oninvocation_count.
Departures from architecture.md
- §9.2 and §5 name
BlocklistAIHistoryModel::start_new_conversationas the single owner ofsession.start. The owner is now the barrier inprepare_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 instart_new_conversationis removed so it cannot fire twice. - Unowned files changed:
app/src/ai/agent/conversation.rs(mark_request_completedreturnsResult<bool>and no longer setsSuccess; the hold must own that status),app/src/server/telemetry/events.rs(TelemetryQueuedQueryOrigin::HookFollowUp, required by the exhaustiveFrom),crates/ai/src/hooks/dispatch.rs(PR 6's file; one line passesenvelope.session_envtoinvocation_environment),app/src/ai/document/ai_document_model.rs(set_dirty_orchestration_eventsinserts withor_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 addpub modlines only) gains the runtime state, cleanup,longest_matching_timeout, and the shared test harness, because the boundaries' state lives onHookRuntime.app/src/ai/blocklist/hooks/envelope.rsandenvelope_tests.rs(PR 1 amendments) change on this branch rather than PR 1's becausebuildnow readsturn_id_forandsession_env_for, which exist only here.- Amendment to PR 1 (
35faca3a5, formatting inb7e173404):crates/ai/src/hooks/model.rsgainsHOOK_FOLLOW_UP_CAP: u32 = 8,FollowUpChainId,PromptProvenance { User, HookFollowUp { binding, chain_id } }, aprovenancefield onHookPayload::PromptSubmit,HookEnvelope::session_env, andMergedEffects::continuation_binding(filled byreduce_effects);hooks/mod.rsre-exports them;test_util/mod.rssetssession_envon the test envelope;recording_adapter.rsdecodes"env": [[k, v]]intosession_envand gives a continuation the binding'sloop_limit; PR 1'senvelope.rs,prompt.rs, andprompt_tests.rspassPromptProvenance::User. - Amendment to PR 3 (
a779ec806):encode/mod.rs,encode/mod_test.rs, andmatcher_tests.rscarry the newprovenancefield;facets::finished_shell_commandreturnsNoneforReadShellCommandOutput, soafterShellExecutionfires once, on the command's own action, through the funnel's completion report, and not for a polled user command; a matcher test covers aShellReadpoll.
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-specorready-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_turnwas run with the steering check removed and failed at the firstis_none()assertion;a_newer_turn_whose_actions_still_run_is_not_finished_under_themwas 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 warningsandcargo 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
8792d583This pull request is a draft
Merging is unavailable until it is marked ready for review.
masterThis stack of pull requests is managed by Graphite. Learn more about stacking.