feat(TASK-035): wire turn/session/compression seams + top-level agent bracket
Turn bracket (repl::TurnBracket): turn.started on entry and exactly one of
turn.{completed,interrupted,failed} on exit, with the interrupted-vs-failed
discrimination (abort_signal.aborted_ctrlc()) in one place. Wired at the
three outer boundaries: ask(), start_directive, and shell_execute — never at
the per-LLM-round before/after_chat_completion seams.
Session seams: session.started fires for newly created sessions only
(use_session's new_from_ctx paths; resume fires nothing), session.ended in
exit_session while the session is still attached, session.compressed on the
success path of both compress_session call sites (auto-compression and the
.compress command).
Session-held role hooks: Session snapshots the role's HooksMap on set_role,
clears it on clear_role/sync_agent, and re-reads it from the role file on
load; resolved_hooks gains a session leg so role hooks keep firing when the
role lives in the session (fresh set_role and resumed sessions alike).
Top-level agent bracket: agent.started/completed/failed once per agent RUN —
headless --agent dispatch and the interactive agent-context lifetime (enter
via .agent or startup; exit via switch-away or REPL teardown), never per
message. COYOTE_AGENT_ID is synthesized as top-<agent-name>-<nonce>.
Exit-path seams call hooks::drain_pending with a 2s bound so exit hooks
finish spawning before process teardown; interactive mid-session events
never drain.
This commit is contained in:
5 files changed
+778
-32
No files matched your search
@@ -3,6 +3,7 @@ use super::todo::TodoList;
|
||||
use super::*;
|
||||
|
||||
use crate::client::{Message, MessageContent, MessageRole, TokenUsage};
|
||||
use crate::hooks::HooksMap;
|
||||
use crate::render::MarkdownRender;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
@@ -113,6 +114,8 @@ pub struct Session {
|
||||
#[serde(skip)]
|
||||
role_prompt: String,
|
||||
#[serde(skip)]
|
||||
role_hooks: Option<HooksMap>,
|
||||
#[serde(skip)]
|
||||
name: String,
|
||||
#[serde(skip)]
|
||||
path: Option<String>,
|
||||
@@ -193,6 +196,7 @@ impl Session {
|
||||
&& let Ok(role) = ctx.retrieve_role(app, role_name)
|
||||
{
|
||||
session.role_prompt = role.prompt().to_string();
|
||||
session.role_hooks = role.hooks().cloned();
|
||||
}
|
||||
|
||||
session.update_tokens();
|
||||
@@ -254,6 +258,13 @@ impl Session {
|
||||
self.role_name.as_deref()
|
||||
}
|
||||
|
||||
/// Hooks of the role this session holds. `set_role` snapshots them
|
||||
/// because only the frontmatter-stripped role prompt is persisted;
|
||||
/// `load_from_ctx` restores them by re-reading the role file on resume.
|
||||
pub fn role_hooks(&self) -> Option<&HooksMap> {
|
||||
self.role_hooks.as_ref()
|
||||
}
|
||||
|
||||
pub fn dirty(&self) -> bool {
|
||||
self.dirty
|
||||
}
|
||||
@@ -561,6 +572,7 @@ impl Session {
|
||||
self.model = role.model().clone();
|
||||
self.role_name = convert_option_string(role.name());
|
||||
self.role_prompt = role.prompt().to_string();
|
||||
self.role_hooks = role.hooks().cloned();
|
||||
self.dirty = true;
|
||||
self.update_tokens();
|
||||
}
|
||||
@@ -568,11 +580,13 @@ impl Session {
|
||||
pub fn clear_role(&mut self) {
|
||||
self.role_name = None;
|
||||
self.role_prompt.clear();
|
||||
self.role_hooks = None;
|
||||
}
|
||||
|
||||
pub fn sync_agent(&mut self, agent: &Agent) {
|
||||
self.role_name = None;
|
||||
self.role_prompt = agent.interpolated_instructions();
|
||||
self.role_hooks = None;
|
||||
self.agent_variables = agent.variables().clone();
|
||||
self.agent_instructions = self.role_prompt.clone();
|
||||
if let Some(threshold) = agent.compression_threshold() {
|
||||
@@ -1111,6 +1125,25 @@ mod tests {
|
||||
assert!(!session.dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_role_captures_and_clear_role_drops_role_hooks() {
|
||||
let content = "---\nhooks:\n turn.completed:\n - name: role-probe\n command: role-cmd\n---\nPrompt";
|
||||
let mut session = Session::default();
|
||||
|
||||
session.set_role(Role::new("hooked", content));
|
||||
let hooks = session.role_hooks().expect("set_role must snapshot hooks");
|
||||
assert_eq!(hooks["turn.completed"][0].name, "role-probe");
|
||||
|
||||
session.clear_role();
|
||||
assert!(session.role_hooks().is_none());
|
||||
|
||||
session.set_role(Role::new("plain", "No frontmatter"));
|
||||
assert!(
|
||||
session.role_hooks().is_none(),
|
||||
"a role without hooks must not leave a stale snapshot"
|
||||
);
|
||||
}
|
||||
|
||||
fn push_interrupted_turn(session: &mut Session) {
|
||||
session.messages.push(Message::new(
|
||||
MessageRole::User,
|
||||
|
||||
Reference in new issue
Block a user