#[allow(unused_imports)]
use crate::sync_util::LockExt;
use std::sync::{Arc, Mutex};
use crate::plugin::{PluginManager, escape_janet_string};
use super::hooks::{
AfterToolCallContext, AfterToolCallFn, BeforeToolCallContext, BeforeToolCallFn,
BeforeToolCallReturn, GetFollowupMessagesFn, GetSteeringMessagesFn, PrepareNextTurnFn,
ShouldStopAfterTurnFn,
};
use super::message::{LoopMessage, UserMessage};
use super::result::{AfterToolCallResult, BeforeToolCallResult};
use super::types::{ThinkingLevel, TurnUpdate};
pub fn before_hook_from_plugin_manager(pm: Arc<Mutex<PluginManager>>) -> BeforeToolCallFn {
Arc::new(move |ctx: BeforeToolCallContext| {
let pm = pm.clone();
Box::pin(async move {
let args_json = match serde_json::to_string(&ctx.args) {
Ok(s) => s,
Err(_) => {
return BeforeToolCallReturn {
result: None,
args: ctx.args,
};
}
};
let janet_ctx = format!(
"@{{:tool \"{}\" :args \"{}\"}}",
escape_janet_string(&ctx.tool_call_name),
escape_janet_string(&args_json),
);
let pm_for_dispatch = pm.clone();
let janet_ctx_clone = janet_ctx.clone();
let tool_name = ctx.tool_call_name.clone();
let dispatch_result = match tokio::task::spawn_blocking(move || {
let mut mgr = pm_for_dispatch.lock_ignore_poison();
mgr.dispatch_tool_hook("on-tool-start", &janet_ctx_clone)
})
.await
{
Ok(r) => r,
Err(join_err) => {
tracing::warn!(
target: "dirge::agent_loop::plugin_hooks",
tool = %tool_name,
error = %join_err,
"on-tool-start hook panicked; proceeding without hook",
);
return BeforeToolCallReturn {
result: None,
args: ctx.args,
};
}
};
let hook_result = match dispatch_result {
Ok(r) => r,
Err(_) => {
return BeforeToolCallReturn {
result: None,
args: ctx.args,
};
}
};
if let Some(reason) = hook_result.block {
return BeforeToolCallReturn {
result: Some(BeforeToolCallResult {
block: Some(true),
reason: Some(reason),
}),
args: ctx.args,
};
}
let final_args = if let Some(mutated_json) = hook_result.mutate_input {
match serde_json::from_str::<serde_json::Value>(&mutated_json) {
Ok(v) => v,
Err(e) => {
tracing::warn!(
target: "dirge::agent_loop::plugin_hooks",
tool = %ctx.tool_call_name,
error = %e,
"harness/mutate-input returned invalid JSON; ignoring",
);
ctx.args
}
}
} else {
ctx.args
};
BeforeToolCallReturn {
result: None,
args: final_args,
}
})
})
}
pub fn after_hook_from_plugin_manager(pm: Arc<Mutex<PluginManager>>) -> AfterToolCallFn {
Arc::new(move |ctx: AfterToolCallContext| {
let pm = pm.clone();
Box::pin(async move {
let output_text = flatten_text(&ctx.result.content);
let command_str = ctx
.args
.get("command")
.and_then(|v| v.as_str())
.unwrap_or("");
let janet_ctx = format!(
"@{{:tool \"{}\" :output \"{}\" :command \"{}\"}}",
escape_janet_string(&ctx.tool_call_name),
escape_janet_string(&output_text),
escape_janet_string(command_str),
);
let pm_for_dispatch = pm.clone();
let janet_ctx_clone = janet_ctx.clone();
let tool_name = ctx.tool_call_name.clone();
let dispatch_result = match tokio::task::spawn_blocking(move || {
let mut mgr = pm_for_dispatch.lock_ignore_poison();
mgr.dispatch_tool_hook("on-tool-end", &janet_ctx_clone)
})
.await
{
Ok(r) => r,
Err(join_err) => {
tracing::warn!(
target: "dirge::agent_loop::plugin_hooks",
tool = %tool_name,
error = %join_err,
"on-tool-end hook panicked; proceeding without hook",
);
return None;
}
};
let hook_result = match dispatch_result {
Ok(r) => r,
Err(_) => return None,
};
hook_result
.replace_result
.map(|new_output| AfterToolCallResult {
content: Some(vec![serde_json::json!({
"type": "text",
"text": new_output,
})]),
details: None,
is_error: None,
terminate: None,
})
})
})
}
fn flatten_text(content: &[serde_json::Value]) -> String {
let mut out = String::new();
for block in content {
if let Some(obj) = block.as_object()
&& obj.get("type").and_then(|t| t.as_str()) == Some("text")
&& let Some(text) = obj.get("text").and_then(|t| t.as_str())
{
if !out.is_empty() {
out.push('\n');
}
out.push_str(text);
} else {
if !out.is_empty() {
out.push('\n');
}
out.push_str(&block.to_string());
}
}
out
}
pub fn prepare_next_turn_from_plugin_manager(pm: Arc<Mutex<PluginManager>>) -> PrepareNextTurnFn {
Arc::new(move |_ctx| {
let pm = pm.clone();
Box::pin(async move {
let thinking = {
let mut mgr = pm.lock_ignore_poison();
mgr.take_pending_next_thinking_level()
};
let thinking_level = thinking.and_then(parse_thinking_level)?;
Some(TurnUpdate {
context: None,
model: None,
thinking_level: Some(thinking_level),
})
})
})
}
pub fn should_stop_after_turn_from_plugin_manager(
pm: Arc<Mutex<PluginManager>>,
) -> ShouldStopAfterTurnFn {
Arc::new(move |_ctx| {
let pm = pm.clone();
Box::pin(async move {
let mut mgr = pm.lock_ignore_poison();
mgr.take_pending_stop_after_turn()
})
})
}
pub fn get_steering_messages_from_plugin_manager(
pm: Arc<Mutex<PluginManager>>,
) -> GetSteeringMessagesFn {
Arc::new(move || {
let pm = pm.clone();
Box::pin(async move {
let (steering, custom) = {
let mut mgr = pm.lock_ignore_poison();
(mgr.drain_steering_messages(), mgr.drain_custom_messages())
};
let mut out: Vec<LoopMessage> = Vec::with_capacity(steering.len() + custom.len());
for content in steering {
out.push(LoopMessage::User(UserMessage { content }));
}
for entry in custom {
out.push(LoopMessage::Custom(serde_json::json!({
"role": "custom",
"customType": entry.custom_type,
"content": entry.content,
"display": entry.display,
})));
}
out
})
})
}
pub fn get_followup_messages_from_plugin_manager(
pm: Arc<Mutex<PluginManager>>,
) -> GetFollowupMessagesFn {
Arc::new(move || {
let pm = pm.clone();
Box::pin(async move {
let drained: Vec<String> = {
let mut mgr = pm.lock_ignore_poison();
mgr.drain_followup_messages()
};
drained
.into_iter()
.map(|content| LoopMessage::User(UserMessage { content }))
.collect()
})
})
}
pub fn transform_context_from_plugin_manager(
pm: Arc<Mutex<PluginManager>>,
) -> super::types::TransformContextFn {
Arc::new(move |messages: Vec<serde_json::Value>| {
let pm = pm.clone();
Box::pin(async move {
let Ok(messages_json) = serde_json::to_string(&messages) else {
return messages; };
let ctx = format!(
"@{{:messages \"{}\"}}",
crate::plugin::escape_janet_string(&messages_json)
);
const HOOK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
let pm_for_dispatch = pm.clone();
let fut = tokio::task::spawn_blocking(move || {
let mut mgr = pm_for_dispatch.lock_ignore_poison();
match mgr.dispatch("transform-context", &ctx) {
Ok(_) => Ok(mgr.take_replace_context()),
Err(e) => Err(e.to_string()),
}
});
let replaced: Option<String> = match tokio::time::timeout(HOOK_TIMEOUT, fut).await {
Ok(Ok(Ok(v))) => v,
Ok(Ok(Err(e))) => {
tracing::warn!(
target: "dirge::plugin",
error = %e,
"transform-context hook error — context unchanged",
);
None
}
Ok(Err(join_err)) => {
tracing::warn!(
target: "dirge::plugin",
error = %join_err,
"transform-context hook panicked — context unchanged",
);
None
}
Err(_) => {
tracing::warn!(
target: "dirge::plugin",
timeout_ms = HOOK_TIMEOUT.as_millis() as u64,
"transform-context hook timed out — context unchanged",
);
None
}
};
match replaced {
Some(json) => match serde_json::from_str::<Vec<serde_json::Value>>(&json) {
Ok(new_messages) => new_messages,
Err(e) => {
tracing::warn!(
target: "dirge::plugin",
error = %e,
"transform-context returned malformed JSON — context unchanged",
);
messages
}
},
None => messages,
}
})
})
}
pub fn compaction_hooks_from_plugin_manager(
pm: Arc<Mutex<PluginManager>>,
) -> super::types::CompactionHooks {
let pm_before = pm.clone();
const COMPACT_HOOK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
let on_before: super::types::OnBeforeCompactFn = Arc::new(move |count: usize, tokens: u64| {
let pm = pm_before.clone();
Box::pin(async move {
let ctx = format!("@{{:message-count {count} :tokens {tokens}}}");
let fut = tokio::task::spawn_blocking(move || {
pm.lock_ignore_poison()
.dispatch("on-before-compact", &ctx)
.map(|_| ())
.map_err(|e| e.to_string())
});
match tokio::time::timeout(COMPACT_HOOK_TIMEOUT, fut).await {
Ok(Ok(Ok(()))) => {}
Ok(Ok(Err(e))) => tracing::warn!(
target: "dirge::plugin",
error = %e,
"on-before-compact hook error (observe-only; fold proceeds)",
),
Ok(Err(join_err)) => tracing::warn!(
target: "dirge::plugin", error = %join_err,
"on-before-compact hook panicked (observe-only; fold proceeds)",
),
Err(_) => tracing::warn!(
target: "dirge::plugin",
"on-before-compact hook timed out (observe-only; fold proceeds)",
),
}
})
});
let on_compact: super::types::OnCompactFn = Arc::new(move |middle: Vec<serde_json::Value>| {
let pm = pm.clone();
Box::pin(async move {
let Ok(middle_json) = serde_json::to_string(&middle) else {
return None;
};
let ctx = format!(
"@{{:messages \"{}\"}}",
crate::plugin::escape_janet_string(&middle_json)
);
let fut = tokio::task::spawn_blocking(move || {
let mut mgr = pm.lock_ignore_poison();
match mgr.dispatch("on-compact", &ctx) {
Ok(_) => Ok(mgr.take_compact_summary()),
Err(e) => Err(e.to_string()),
}
});
match tokio::time::timeout(COMPACT_HOOK_TIMEOUT, fut).await {
Ok(Ok(Ok(v))) => v,
Ok(Ok(Err(e))) => {
tracing::warn!(
target: "dirge::plugin", error = %e,
"on-compact hook error — falling back to LLM summarizer",
);
None
}
Ok(Err(join_err)) => {
tracing::warn!(
target: "dirge::plugin", error = %join_err,
"on-compact hook panicked — falling back to LLM summarizer",
);
None
}
Err(_) => {
tracing::warn!(
target: "dirge::plugin",
"on-compact hook timed out — falling back to LLM summarizer",
);
None
}
}
})
});
super::types::CompactionHooks {
on_before,
on_compact,
}
}
fn parse_thinking_level(s: String) -> Option<ThinkingLevel> {
match s.as_str() {
"off" => Some(ThinkingLevel::Off),
"minimal" => Some(ThinkingLevel::Minimal),
"low" => Some(ThinkingLevel::Low),
"medium" => Some(ThinkingLevel::Medium),
"high" => Some(ThinkingLevel::High),
"xhigh" => Some(ThinkingLevel::Xhigh),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agent::agent_loop::message::{AssistantMessage, ContentBlock, StopReason};
use crate::agent::agent_loop::result::LoopToolResult;
use serde_json::json;
fn try_pm() -> Option<Arc<Mutex<PluginManager>>> {
match PluginManager::try_new() {
Ok(mgr) => Some(Arc::new(Mutex::new(mgr))),
Err(_) => None,
}
}
fn before_ctx(args: serde_json::Value) -> BeforeToolCallContext {
BeforeToolCallContext {
assistant_message: AssistantMessage::new(
vec![ContentBlock::ToolCall {
id: "call-1".to_string(),
name: "echo".to_string(),
arguments: args.clone(),
}],
StopReason::ToolUse,
),
tool_call_id: "call-1".to_string(),
tool_call_name: "echo".to_string(),
args,
}
}
fn after_ctx(result: LoopToolResult, is_error: bool) -> AfterToolCallContext {
AfterToolCallContext {
assistant_message: AssistantMessage::new(vec![], StopReason::ToolUse),
tool_call_id: "call-1".to_string(),
tool_call_name: "echo".to_string(),
args: json!({}),
result,
is_error,
}
}
#[tokio::test]
async fn before_hook_blocks_when_plugin_calls_block() {
let Some(pm) = try_pm() else {
eprintln!("[skipped] PluginManager::try_new failed (Janet not available)");
return;
};
{
let mut mgr = pm.lock().unwrap();
mgr.eval(r#"(defn deny [_ctx] (harness/block "policy violation"))"#)
.expect("install deny");
mgr.register("on-tool-start", "deny");
}
let hook = before_hook_from_plugin_manager(pm);
let result = hook(before_ctx(json!({"v": 1}))).await;
assert!(
result.result.is_some(),
"block hook should return a BeforeToolCallResult"
);
let inner = result.result.unwrap();
assert_eq!(inner.block, Some(true));
assert_eq!(inner.reason.as_deref(), Some("policy violation"));
assert_eq!(result.args, json!({"v": 1}));
}
#[tokio::test]
async fn before_hook_mutates_args_when_plugin_calls_mutate_input() {
let Some(pm) = try_pm() else {
eprintln!("[skipped] PluginManager::try_new failed (Janet not available)");
return;
};
{
let mut mgr = pm.lock().unwrap();
mgr.eval(
r#"(defn rewrite [_ctx] (harness/mutate-input "{\"v\":42,\"extra\":\"added\"}"))"#,
)
.expect("install rewrite");
mgr.register("on-tool-start", "rewrite");
}
let hook = before_hook_from_plugin_manager(pm);
let result = hook(before_ctx(json!({"v": 1}))).await;
assert!(
result.result.is_none(),
"mutate-only hook should not produce a block result"
);
assert_eq!(result.args, json!({"v": 42, "extra": "added"}));
}
#[tokio::test]
async fn before_hook_noop_when_plugin_does_nothing() {
let Some(pm) = try_pm() else {
eprintln!("[skipped] PluginManager::try_new failed (Janet not available)");
return;
};
{
let mut mgr = pm.lock().unwrap();
mgr.eval(r#"(defn observer [_ctx] nil)"#)
.expect("install observer");
mgr.register("on-tool-start", "observer");
}
let hook = before_hook_from_plugin_manager(pm);
let result = hook(before_ctx(json!({"v": 1}))).await;
assert!(result.result.is_none());
assert_eq!(result.args, json!({"v": 1}));
}
#[tokio::test]
async fn before_hook_falls_back_on_malformed_mutate_input() {
let Some(pm) = try_pm() else {
eprintln!("[skipped] PluginManager::try_new failed (Janet not available)");
return;
};
{
let mut mgr = pm.lock().unwrap();
mgr.eval(r#"(defn bad [_ctx] (harness/mutate-input "not-json{"))"#)
.expect("install bad");
mgr.register("on-tool-start", "bad");
}
let hook = before_hook_from_plugin_manager(pm);
let result = hook(before_ctx(json!({"v": 1}))).await;
assert!(result.result.is_none());
assert_eq!(result.args, json!({"v": 1}));
}
#[tokio::test]
async fn after_hook_replaces_result_when_plugin_calls_replace() {
let Some(pm) = try_pm() else {
eprintln!("[skipped] PluginManager::try_new failed (Janet not available)");
return;
};
{
let mut mgr = pm.lock().unwrap();
mgr.eval(r#"(defn replace [_ctx] (harness/replace-result "rewritten output"))"#)
.expect("install replace");
mgr.register("on-tool-end", "replace");
}
let hook = after_hook_from_plugin_manager(pm);
let result = hook(after_ctx(
LoopToolResult {
content: vec![json!({"type": "text", "text": "original"})],
details: json!({}),
terminate: None,
},
false,
))
.await;
assert!(result.is_some(), "replace-result should produce override");
let inner = result.unwrap();
let content = inner.content.expect("content overridden");
assert_eq!(content.len(), 1);
assert_eq!(content[0]["type"], "text");
assert_eq!(content[0]["text"], "rewritten output");
assert!(inner.details.is_none());
assert!(inner.is_error.is_none());
assert!(inner.terminate.is_none());
}
#[tokio::test]
async fn after_hook_returns_none_when_plugin_does_nothing() {
let Some(pm) = try_pm() else {
eprintln!("[skipped] PluginManager::try_new failed (Janet not available)");
return;
};
{
let mut mgr = pm.lock().unwrap();
mgr.eval(r#"(defn observer [_ctx] nil)"#)
.expect("install observer");
mgr.register("on-tool-end", "observer");
}
let hook = after_hook_from_plugin_manager(pm);
let result = hook(after_ctx(
LoopToolResult {
content: vec![json!({"type": "text", "text": "original"})],
details: json!({}),
terminate: None,
},
false,
))
.await;
assert!(result.is_none());
}
#[test]
fn flatten_text_joins_blocks() {
let blocks = vec![
json!({"type": "text", "text": "line 1"}),
json!({"type": "text", "text": "line 2"}),
];
assert_eq!(flatten_text(&blocks), "line 1\nline 2");
}
#[test]
fn flatten_text_stringifies_unknown_blocks() {
let blocks = vec![json!({"type": "image", "url": "x.png"})];
let out = flatten_text(&blocks);
assert!(out.contains("image"));
}
use crate::agent::agent_loop::hooks::TurnHookContext;
use crate::agent::agent_loop::message::AssistantMessage as AM;
fn turn_ctx() -> TurnHookContext {
TurnHookContext {
message: AM::new(vec![], super::super::message::StopReason::Stop),
tool_results: Vec::new(),
context: crate::agent::agent_loop::types::Context::default(),
new_messages: Vec::new(),
}
}
#[tokio::test]
async fn prepare_next_turn_reads_thinking_level() {
let Some(pm) = try_pm() else { return };
{
let mut mgr = pm.lock().unwrap();
mgr.eval(r#"(defn bump [_ctx] (harness/set-next-thinking-level "high"))"#)
.unwrap();
mgr.register("on-tool-end", "bump");
mgr.dispatch_tool_hook("on-tool-end", "@{:tool \"t\" :output \"x\"}")
.unwrap();
}
let hook = prepare_next_turn_from_plugin_manager(pm);
let out = hook(turn_ctx()).await;
assert!(out.is_some(), "expected TurnUpdate");
let upd = out.unwrap();
assert_eq!(upd.thinking_level, Some(ThinkingLevel::High));
assert!(upd.model.is_none());
}
#[tokio::test]
async fn prepare_next_turn_returns_none_when_no_slot_set() {
let Some(pm) = try_pm() else { return };
let hook = prepare_next_turn_from_plugin_manager(pm);
assert!(hook(turn_ctx()).await.is_none());
}
#[tokio::test]
async fn should_stop_after_turn_drains_slot() {
let Some(pm) = try_pm() else { return };
{
let mut mgr = pm.lock().unwrap();
mgr.eval(r#"(defn stop [_ctx] (harness/request-stop-after-turn))"#)
.unwrap();
mgr.register("on-tool-end", "stop");
mgr.dispatch_tool_hook("on-tool-end", "@{:tool \"t\" :output \"x\"}")
.unwrap();
}
let hook = should_stop_after_turn_from_plugin_manager(pm);
assert!(hook(turn_ctx()).await, "first read should return true");
assert!(
!hook(turn_ctx()).await,
"second read should be false (slot drained)"
);
}
#[tokio::test]
async fn get_steering_messages_drains_queue() {
let Some(pm) = try_pm() else { return };
{
let mut mgr = pm.lock().unwrap();
mgr.eval(
r#"(defn add [_ctx] (harness/add-steering "first") (harness/add-steering "second"))"#,
)
.unwrap();
mgr.register("on-tool-end", "add");
mgr.dispatch_tool_hook("on-tool-end", "@{:tool \"t\" :output \"x\"}")
.unwrap();
}
let hook = get_steering_messages_from_plugin_manager(pm.clone());
let messages = hook().await;
assert_eq!(messages.len(), 2);
let texts: Vec<String> = messages
.iter()
.filter_map(|m| match m {
LoopMessage::User(u) => Some(u.content.clone()),
_ => None,
})
.collect();
assert_eq!(texts, vec!["first", "second"]);
assert!(hook().await.is_empty());
}
#[tokio::test]
async fn get_followup_messages_drains_queue() {
let Some(pm) = try_pm() else { return };
{
let mut mgr = pm.lock().unwrap();
mgr.eval(r#"(defn add [_ctx] (harness/add-followup "next turn"))"#)
.unwrap();
mgr.register("on-tool-end", "add");
mgr.dispatch_tool_hook("on-tool-end", "@{:tool \"t\" :output \"x\"}")
.unwrap();
}
let hook = get_followup_messages_from_plugin_manager(pm);
let messages = hook().await;
assert_eq!(messages.len(), 1);
match &messages[0] {
LoopMessage::User(u) => assert_eq!(u.content, "next turn"),
_ => panic!("expected User"),
}
}
#[tokio::test]
async fn prepare_next_turn_does_not_drain_next_model_slot() {
let Some(pm) = try_pm() else { return };
{
let mut mgr = pm.lock().unwrap();
mgr.eval(r#"(defn swap [_ctx] (harness/set-next-model "gpt-5"))"#)
.unwrap();
mgr.register("on-tool-end", "swap");
mgr.dispatch_tool_hook("on-tool-end", "@{:tool \"t\" :output \"x\"}")
.unwrap();
}
let hook = prepare_next_turn_from_plugin_manager(pm.clone());
let result = hook(turn_ctx()).await;
assert!(
result.is_none(),
"prepare_next_turn should ignore model slot",
);
let pending = pm.lock().unwrap().take_pending_next_model();
assert_eq!(pending, Some("gpt-5".to_string()));
}
#[tokio::test]
async fn get_steering_messages_separates_user_and_custom() {
let Some(pm) = try_pm() else { return };
{
let mut mgr = pm.lock().unwrap();
mgr.eval(
r#"(defn add [_ctx]
(harness/add-steering "real user input")
(harness/add-custom-message "build started"))"#,
)
.unwrap();
mgr.register("on-tool-end", "add");
mgr.dispatch_tool_hook("on-tool-end", "@{:tool \"t\" :output \"x\"}")
.unwrap();
}
let hook = get_steering_messages_from_plugin_manager(pm);
let messages = hook().await;
assert_eq!(messages.len(), 2);
match &messages[0] {
LoopMessage::User(u) => assert_eq!(u.content, "real user input"),
other => panic!("expected User; got {other:?}"),
}
match &messages[1] {
LoopMessage::Custom(v) => {
assert_eq!(v.get("role").and_then(|r| r.as_str()), Some("custom"));
assert_eq!(
v.get("content").and_then(|c| c.as_str()),
Some("build started"),
);
assert_eq!(v.get("customType").and_then(|c| c.as_str()), Some(""));
assert_eq!(v.get("display").and_then(|d| d.as_bool()), Some(true));
}
other => panic!("expected Custom; got {other:?}"),
}
}
#[tokio::test]
async fn get_steering_messages_carries_customtype_at_top_level() {
let Some(pm) = try_pm() else { return };
{
let mut mgr = pm.lock().unwrap();
mgr.eval(
r#"(defn add [_ctx]
(harness/add-custom-message "status" "build started"))"#,
)
.unwrap();
mgr.register("on-tool-end", "add");
mgr.dispatch_tool_hook("on-tool-end", "@{:tool \"t\" :output \"x\"}")
.unwrap();
}
let hook = get_steering_messages_from_plugin_manager(pm);
let messages = hook().await;
assert_eq!(messages.len(), 1);
match &messages[0] {
LoopMessage::Custom(v) => {
assert_eq!(v.get("customType").and_then(|c| c.as_str()), Some("status"),);
assert_eq!(
v.get("content").and_then(|c| c.as_str()),
Some("build started"),
);
assert_eq!(v.get("display").and_then(|d| d.as_bool()), Some(true));
}
other => panic!("expected Custom; got {other:?}"),
}
}
#[tokio::test]
async fn prepare_next_turn_ignores_unknown_thinking_level() {
let Some(pm) = try_pm() else { return };
{
let mut mgr = pm.lock().unwrap();
mgr.eval(r#"(defn bad [_ctx] (harness/set-next-thinking-level "supercritical"))"#)
.unwrap();
mgr.register("on-tool-end", "bad");
mgr.dispatch_tool_hook("on-tool-end", "@{:tool \"t\" :output \"x\"}")
.unwrap();
}
let hook = prepare_next_turn_from_plugin_manager(pm);
assert!(hook(turn_ctx()).await.is_none());
}
}