700 lines
26 KiB
Rust
700 lines
26 KiB
Rust
//! Turn lifecycle hook payload types for `HookEvent::Custom`.
|
|
//!
|
|
//! These types ride inside `HookEvent::Custom { kind, payload }` and
|
|
//! provide typed serialization for `before_turn` and `after_turn`
|
|
//! custom hook payloads. They are NOT new `HookEvent` variants.
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
/// Well-known `HookEvent::Custom` kind string for before-turn hooks.
|
|
pub const BEFORE_TURN_KIND: &str = "before_turn";
|
|
|
|
/// Well-known `HookEvent::Custom` kind string for after-turn hooks.
|
|
pub const AFTER_TURN_KIND: &str = "after_turn";
|
|
|
|
/// Default `session_relationship` wire value (mirrors
|
|
/// `xai_file_utils::events::SessionRelationship::Primary`).
|
|
pub const DEFAULT_SESSION_RELATIONSHIP: &str = "primary";
|
|
|
|
/// Default `schema_version` wire value. Bare literal (not the
|
|
/// `xai-file-utils` constant) to avoid a dependency cycle.
|
|
pub const DEFAULT_SCHEMA_VERSION: &str = "1.0";
|
|
|
|
fn default_session_relationship() -> String {
|
|
DEFAULT_SESSION_RELATIONSHIP.to_owned()
|
|
}
|
|
|
|
fn default_schema_version() -> String {
|
|
DEFAULT_SCHEMA_VERSION.to_owned()
|
|
}
|
|
|
|
/// Payload for `before_turn` custom hooks.
|
|
///
|
|
/// Sent by the harness before the agent loop begins a new turn.
|
|
/// Recipients can use this to prepare state (clear caches, initialize
|
|
/// tracking, etc.) but MUST NOT block — hooks are fire-and-forget.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct BeforeTurnPayload {
|
|
/// Monotonically increasing turn counter within the session.
|
|
pub turn_number: u64,
|
|
/// Model being used for this turn (e.g. "grok-3").
|
|
pub model_id: String,
|
|
/// Whether the session is in YOLO / auto-approve mode.
|
|
#[serde(default)]
|
|
pub yolo_mode: bool,
|
|
// ── Extended fields (workspace mirrors these into `events.jsonl`);
|
|
// all `#[serde(default)]` for old-shell / old-workspace interop. ──
|
|
/// Mirrors `Event::TurnStarted::conversation_message_count`.
|
|
#[serde(default)]
|
|
pub conversation_message_count: usize,
|
|
/// Snake-case mirror of `Event::TurnStarted::session_relationship`
|
|
/// (`"primary"` | `"subagent"`). A `String`, not the `xai-file-utils`
|
|
/// enum, to avoid a dependency cycle; decoded by the workspace at emit time.
|
|
#[serde(default = "default_session_relationship")]
|
|
pub session_relationship: String,
|
|
/// Mirrors `Event::TurnStarted::schema_version`.
|
|
#[serde(default = "default_schema_version")]
|
|
pub schema_version: String,
|
|
}
|
|
|
|
impl Default for BeforeTurnPayload {
|
|
/// Mirrors the per-field serde defaults so producers that don't yet track a
|
|
/// field (e.g. the server-side sampler for `conversation_message_count`) can
|
|
/// use `..Default::default()` instead of repeating literal stub values.
|
|
fn default() -> Self {
|
|
Self {
|
|
turn_number: 0,
|
|
model_id: String::new(),
|
|
yolo_mode: false,
|
|
conversation_message_count: 0,
|
|
session_relationship: default_session_relationship(),
|
|
schema_version: default_schema_version(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Payload for `after_turn` custom hooks.
|
|
///
|
|
/// Sent by the harness after the agent loop completes a turn.
|
|
///
|
|
/// **Design note:** This payload carries `tool_call_count` but intentionally
|
|
/// omits per-tool names. The workspace can correlate tool names from its own
|
|
/// `ActivityTracker` per-session state if needed. Keeping the payload small
|
|
/// avoids unbounded growth on tool-heavy turns. `written_repo_paths` is the
|
|
/// exception: bounded by distinct files edited, not tool-call volume.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct AfterTurnPayload {
|
|
/// Same turn counter as the preceding `before_turn`.
|
|
pub turn_number: u64,
|
|
/// High-level outcome of the turn.
|
|
pub outcome: TurnHookOutcome,
|
|
/// Wall-clock duration of the turn in milliseconds.
|
|
pub duration_ms: u64,
|
|
/// Number of tool calls made during the turn.
|
|
/// Tool names are intentionally excluded — the workspace can correlate
|
|
/// from its own `ActivityTracker` if richer data is needed.
|
|
pub tool_call_count: u32,
|
|
/// Model used (may differ from `before_turn` if model was switched mid-turn).
|
|
pub model_id: String,
|
|
/// Repo-relative agent writes, so proxy-mode workspaces can force-include
|
|
/// gitignored edits. Empty in local mode.
|
|
#[serde(default)]
|
|
pub written_repo_paths: Vec<String>,
|
|
/// Snake-case mirror of `Event::TurnEnded::cancellation_category` (e.g.
|
|
/// `"doom_loop_repetition"`). Carried as a `String` for the same
|
|
/// dep-cycle-avoidance reason as `BeforeTurnPayload::session_relationship`;
|
|
/// the workspace decodes it into the `xai-file-utils`
|
|
/// `CancellationCategory` enum at emit time. `None` for non-cancelled turns.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub cancellation_category: Option<String>,
|
|
/// Opaque JSON mirror of `Event::TurnEnded::cancellation_context` (e.g.
|
|
/// `{ "reason": "max_turns_reached", "limit": 50 }`). Passed through
|
|
/// verbatim by the workspace. `None` when there is no context.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub cancellation_context: Option<serde_json::Value>,
|
|
}
|
|
|
|
/// Turn outcome as observed by the sampler.
|
|
///
|
|
/// Named `TurnHookOutcome` (not `TurnOutcome`) to avoid collision with the
|
|
/// shell's existing `TurnOutcome` and the telemetry crate's
|
|
/// `TurnOutcomeLabel`. Module-qualified usage (`turn_hook::TurnHookOutcome`)
|
|
/// is still recommended in shell code.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
#[non_exhaustive]
|
|
pub enum TurnHookOutcome {
|
|
/// Turn completed normally (model finished generating).
|
|
Completed,
|
|
/// Turn was cancelled by the user (Ctrl+C / abort).
|
|
Cancelled,
|
|
/// Turn ended due to an error.
|
|
Error,
|
|
}
|
|
|
|
/// `HookEvent::Custom` kind for the request/response turn hook.
|
|
pub const TURN_HOOK_KIND: &str = "turn_hook";
|
|
|
|
/// Request/response turn hook (sampler → bound workspace), internally tagged on `phase`.
|
|
/// `phase` is a reserved key — `BeforeTurnPayload`/`AfterTurnPayload` must not define a field of that name.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(tag = "phase", rename_all = "snake_case")]
|
|
#[non_exhaustive]
|
|
pub enum TurnHookRequest {
|
|
/// Fired just before the sampler begins a new turn (before inference).
|
|
Before(BeforeTurnPayload),
|
|
/// Fired just after the sampler completes a turn (tool results are in).
|
|
After(AfterTurnPayload),
|
|
}
|
|
|
|
/// Conversation role for a turn the workspace asks the sampler to append.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
#[non_exhaustive]
|
|
pub enum InjectionRole {
|
|
/// Append as a system turn.
|
|
System,
|
|
/// Append as a developer turn.
|
|
Developer,
|
|
/// Append as a user turn (e.g. a `<system-reminder>`-wrapped message).
|
|
User,
|
|
}
|
|
|
|
/// A single turn the workspace asks the sampler to append before the next sampling step.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct HookInjection {
|
|
/// Role to append the content as.
|
|
pub role: InjectionRole,
|
|
/// Verbatim turn content.
|
|
pub content: String,
|
|
}
|
|
|
|
/// Override of the sampler's loop decision at a turn boundary.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
#[non_exhaustive]
|
|
pub enum TurnControl {
|
|
/// No override — the sampler proceeds with its own completion logic.
|
|
#[default]
|
|
Auto,
|
|
/// Force another turn even if the model ended without a tool call.
|
|
ForceContinue,
|
|
/// Force the loop to stop after this turn.
|
|
ForceStop,
|
|
}
|
|
|
|
/// Reply to a [`TurnHookRequest`]: turns to inject plus a loop-control decision; default (`{}`) is a no-op.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct HookReply {
|
|
/// Turns to append before the next sampling step, in order.
|
|
#[serde(default)]
|
|
pub injections: Vec<HookInjection>,
|
|
/// Optional loop-control override.
|
|
#[serde(default)]
|
|
pub control: TurnControl,
|
|
/// Artifact-handling ack for a [`TurnHookRequest::After`] request; `None`
|
|
/// on `Before` replies and from workspaces that predate the ack.
|
|
/// Informational only — the requester never gates its loop on it.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub after_turn_ack: Option<AfterTurnAckPayload>,
|
|
}
|
|
|
|
/// Terminal status of the workspace's per-turn artifact handling, carried in
|
|
/// the [`AfterTurnAckPayload`] the workspace sends back to the shell.
|
|
///
|
|
/// The variants are wire-stable snake_case strings; the shell routes on them
|
|
/// to decide how to record the turn's data-collection outcome. The ack
|
|
/// is informational — the shell never blocks its agent loop on it.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum AfterTurnAckStatus {
|
|
/// Every archive the workspace attempted was durably handed off to its
|
|
/// upload queue (written to the on-disk spill, or an inline-fallback
|
|
/// upload is in flight). The cloud upload then proceeds independently with
|
|
/// the queue's own retry policy. The caller MAY advance.
|
|
Enqueued,
|
|
/// At least one archive could not be handed off (temp file unwritable,
|
|
/// queue worker shut down, or the archive build failed). The workspace has
|
|
/// done what it can — the caller MUST NOT retry.
|
|
Failed,
|
|
/// The workspace skipped uploads before touching disk (no upload queue
|
|
/// configured / not in proxy mode). `error_message` carries the reason.
|
|
Skipped,
|
|
}
|
|
|
|
/// Artifact-handling ack the workspace returns for a
|
|
/// [`TurnHookRequest::After`] request on [`HookReply::after_turn_ack`].
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct AfterTurnAckPayload {
|
|
/// The turn this ack corresponds to (matches `AfterTurnPayload::turn_number`).
|
|
pub turn_number: u64,
|
|
/// Terminal artifact-handling status for the turn.
|
|
pub status: AfterTurnAckStatus,
|
|
/// Failure / skip reason. `Some` only for [`AfterTurnAckStatus::Failed`] or
|
|
/// [`AfterTurnAckStatus::Skipped`]; omitted from the wire when `None`.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub error_message: Option<String>,
|
|
/// Count of archives this turn that landed durably on the queue's on-disk
|
|
/// spill — `0`, `1`, or `2` (before/after repository snapshot archives).
|
|
/// Informational; defaults to `0` for back-compat.
|
|
#[serde(default)]
|
|
pub artifact_count: u32,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use serde_json::json;
|
|
|
|
#[test]
|
|
fn before_turn_round_trip() {
|
|
let payload = BeforeTurnPayload {
|
|
turn_number: 42,
|
|
model_id: "grok-3".to_string(),
|
|
yolo_mode: true,
|
|
conversation_message_count: 9,
|
|
session_relationship: "subagent".to_string(),
|
|
schema_version: "1.0".to_string(),
|
|
};
|
|
|
|
let serialized = serde_json::to_value(&payload).unwrap();
|
|
assert_eq!(
|
|
serialized,
|
|
json!({
|
|
"turn_number": 42,
|
|
"model_id": "grok-3",
|
|
"yolo_mode": true,
|
|
"conversation_message_count": 9,
|
|
"session_relationship": "subagent",
|
|
"schema_version": "1.0",
|
|
})
|
|
);
|
|
|
|
let deserialized: BeforeTurnPayload = serde_json::from_value(serialized).unwrap();
|
|
assert_eq!(deserialized, payload);
|
|
}
|
|
|
|
#[test]
|
|
fn before_turn_yolo_mode_defaults_false() {
|
|
let json = json!({
|
|
"turn_number": 1,
|
|
"model_id": "grok-3",
|
|
});
|
|
let payload: BeforeTurnPayload = serde_json::from_value(json).unwrap();
|
|
assert!(!payload.yolo_mode);
|
|
}
|
|
|
|
#[test]
|
|
fn after_turn_round_trip() {
|
|
// Completed turn: both cancellation fields are `None` and therefore
|
|
// skip serialization — the wire shape is byte-identical to the legacy shape.
|
|
let payload = AfterTurnPayload {
|
|
turn_number: 42,
|
|
outcome: TurnHookOutcome::Completed,
|
|
duration_ms: 1500,
|
|
tool_call_count: 3,
|
|
model_id: "grok-3".to_string(),
|
|
written_repo_paths: vec!["outputs/result.md".to_string()],
|
|
cancellation_category: None,
|
|
cancellation_context: None,
|
|
};
|
|
|
|
let serialized = serde_json::to_value(&payload).unwrap();
|
|
assert_eq!(
|
|
serialized,
|
|
json!({
|
|
"turn_number": 42,
|
|
"outcome": "completed",
|
|
"duration_ms": 1500,
|
|
"tool_call_count": 3,
|
|
"model_id": "grok-3",
|
|
"written_repo_paths": ["outputs/result.md"],
|
|
})
|
|
);
|
|
|
|
let deserialized: AfterTurnPayload = serde_json::from_value(serialized).unwrap();
|
|
assert_eq!(deserialized, payload);
|
|
}
|
|
|
|
#[test]
|
|
fn after_turn_written_repo_paths_defaults_empty() {
|
|
let json = json!({
|
|
"turn_number": 1,
|
|
"outcome": "completed",
|
|
"duration_ms": 10,
|
|
"tool_call_count": 0,
|
|
"model_id": "grok-3",
|
|
});
|
|
let payload: AfterTurnPayload = serde_json::from_value(json).unwrap();
|
|
assert!(payload.written_repo_paths.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn after_turn_round_trip_with_cancellation_fields() {
|
|
let payload = AfterTurnPayload {
|
|
turn_number: 7,
|
|
outcome: TurnHookOutcome::Cancelled,
|
|
duration_ms: 200,
|
|
tool_call_count: 1,
|
|
model_id: "grok-4".to_string(),
|
|
written_repo_paths: vec![],
|
|
cancellation_category: Some("doom_loop_repetition".to_string()),
|
|
cancellation_context: Some(json!({ "reason": "repetition" })),
|
|
};
|
|
|
|
let serialized = serde_json::to_value(&payload).unwrap();
|
|
assert_eq!(
|
|
serialized["cancellation_category"],
|
|
json!("doom_loop_repetition")
|
|
);
|
|
assert_eq!(
|
|
serialized["cancellation_context"],
|
|
json!({ "reason": "repetition" })
|
|
);
|
|
|
|
let deserialized: AfterTurnPayload = serde_json::from_value(serialized).unwrap();
|
|
assert_eq!(deserialized, payload);
|
|
}
|
|
|
|
#[test]
|
|
fn outcome_variants_serialize_snake_case() {
|
|
assert_eq!(
|
|
serde_json::to_value(TurnHookOutcome::Completed).unwrap(),
|
|
json!("completed"),
|
|
);
|
|
assert_eq!(
|
|
serde_json::to_value(TurnHookOutcome::Cancelled).unwrap(),
|
|
json!("cancelled"),
|
|
);
|
|
assert_eq!(
|
|
serde_json::to_value(TurnHookOutcome::Error).unwrap(),
|
|
json!("error"),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn outcome_deserializes_from_snake_case() {
|
|
assert_eq!(
|
|
serde_json::from_value::<TurnHookOutcome>(json!("completed")).unwrap(),
|
|
TurnHookOutcome::Completed,
|
|
);
|
|
assert_eq!(
|
|
serde_json::from_value::<TurnHookOutcome>(json!("cancelled")).unwrap(),
|
|
TurnHookOutcome::Cancelled,
|
|
);
|
|
assert_eq!(
|
|
serde_json::from_value::<TurnHookOutcome>(json!("error")).unwrap(),
|
|
TurnHookOutcome::Error,
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn kind_constants() {
|
|
assert_eq!(BEFORE_TURN_KIND, "before_turn");
|
|
assert_eq!(AFTER_TURN_KIND, "after_turn");
|
|
assert_eq!(DEFAULT_SESSION_RELATIONSHIP, "primary");
|
|
assert_eq!(DEFAULT_SCHEMA_VERSION, "1.0");
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_outcome_variant_rejected() {
|
|
let result = serde_json::from_value::<TurnHookOutcome>(json!("timeout"));
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn after_turn_missing_required_field_rejected() {
|
|
let json = json!({
|
|
"turn_number": 1,
|
|
"duration_ms": 100,
|
|
"tool_call_count": 0,
|
|
"model_id": "grok-3",
|
|
});
|
|
assert!(serde_json::from_value::<AfterTurnPayload>(json).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn extra_fields_ignored() {
|
|
let json = json!({
|
|
"turn_number": 1,
|
|
"model_id": "grok-3",
|
|
"future_field": "should be ignored",
|
|
});
|
|
let payload: BeforeTurnPayload = serde_json::from_value(json).unwrap();
|
|
assert_eq!(payload.turn_number, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn before_turn_yolo_false_serialized() {
|
|
let payload = BeforeTurnPayload {
|
|
turn_number: 1,
|
|
model_id: "grok-3".to_string(),
|
|
yolo_mode: false,
|
|
conversation_message_count: 0,
|
|
session_relationship: "primary".to_string(),
|
|
schema_version: "1.0".to_string(),
|
|
};
|
|
let serialized = serde_json::to_value(&payload).unwrap();
|
|
assert_eq!(serialized["yolo_mode"], json!(false));
|
|
}
|
|
|
|
#[test]
|
|
fn turn_hook_kind_constant() {
|
|
assert_eq!(TURN_HOOK_KIND, "turn_hook");
|
|
}
|
|
|
|
#[test]
|
|
fn turn_hook_request_before_round_trip() {
|
|
let req = TurnHookRequest::Before(BeforeTurnPayload {
|
|
turn_number: 7,
|
|
model_id: "grok-3".to_string(),
|
|
yolo_mode: true,
|
|
conversation_message_count: 0,
|
|
session_relationship: "primary".to_string(),
|
|
schema_version: "1.0".to_string(),
|
|
});
|
|
let serialized = serde_json::to_value(&req).unwrap();
|
|
assert_eq!(
|
|
serialized,
|
|
json!({
|
|
"phase": "before",
|
|
"turn_number": 7,
|
|
"model_id": "grok-3",
|
|
"yolo_mode": true,
|
|
"conversation_message_count": 0,
|
|
"session_relationship": "primary",
|
|
"schema_version": "1.0",
|
|
})
|
|
);
|
|
let deserialized: TurnHookRequest = serde_json::from_value(serialized).unwrap();
|
|
assert_eq!(deserialized, req);
|
|
}
|
|
|
|
#[test]
|
|
fn turn_hook_request_after_round_trip() {
|
|
let req = TurnHookRequest::After(AfterTurnPayload {
|
|
turn_number: 7,
|
|
outcome: TurnHookOutcome::Completed,
|
|
duration_ms: 10,
|
|
tool_call_count: 2,
|
|
model_id: "grok-3".to_string(),
|
|
written_repo_paths: Vec::new(),
|
|
cancellation_category: None,
|
|
cancellation_context: None,
|
|
});
|
|
let serialized = serde_json::to_value(&req).unwrap();
|
|
assert_eq!(serialized["phase"], json!("after"));
|
|
assert_eq!(serialized["tool_call_count"], json!(2));
|
|
let deserialized: TurnHookRequest = serde_json::from_value(serialized).unwrap();
|
|
assert_eq!(deserialized, req);
|
|
}
|
|
|
|
#[test]
|
|
fn hook_reply_default_is_empty_auto() {
|
|
let reply = HookReply::default();
|
|
assert!(reply.injections.is_empty());
|
|
assert_eq!(reply.control, TurnControl::Auto);
|
|
assert_eq!(reply.after_turn_ack, None);
|
|
// `None` must skip serialization so the default reply stays the legacy
|
|
// `{}`-compatible shape (old decoders use `deny_unknown_fields`).
|
|
let serialized = serde_json::to_value(&reply).unwrap();
|
|
assert!(serialized.get("after_turn_ack").is_none());
|
|
}
|
|
|
|
/// An `After` reply carrying the ack round-trips, and a legacy reply
|
|
/// without the field decodes with `after_turn_ack == None`.
|
|
#[test]
|
|
fn hook_reply_after_turn_ack_round_trip_and_legacy_decode() {
|
|
let reply = HookReply {
|
|
injections: vec![],
|
|
control: TurnControl::Auto,
|
|
after_turn_ack: Some(AfterTurnAckPayload {
|
|
turn_number: 7,
|
|
status: AfterTurnAckStatus::Enqueued,
|
|
error_message: None,
|
|
artifact_count: 2,
|
|
}),
|
|
};
|
|
let serialized = serde_json::to_value(&reply).unwrap();
|
|
assert_eq!(serialized["after_turn_ack"]["turn_number"], json!(7));
|
|
assert_eq!(serialized["after_turn_ack"]["status"], json!("enqueued"));
|
|
let deserialized: HookReply = serde_json::from_value(serialized).unwrap();
|
|
assert_eq!(deserialized, reply);
|
|
|
|
let legacy: HookReply =
|
|
serde_json::from_value(json!({"injections": [], "control": "auto"})).unwrap();
|
|
assert_eq!(legacy.after_turn_ack, None);
|
|
}
|
|
|
|
#[test]
|
|
fn hook_reply_deserializes_from_empty_object() {
|
|
let reply: HookReply = serde_json::from_value(json!({})).unwrap();
|
|
assert_eq!(reply, HookReply::default());
|
|
}
|
|
|
|
#[test]
|
|
fn hook_reply_rejects_unknown_field() {
|
|
let result: Result<HookReply, _> =
|
|
serde_json::from_value(json!({"injection": [], "control": "auto"}));
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn hook_reply_round_trip() {
|
|
let reply = HookReply {
|
|
injections: vec![
|
|
HookInjection {
|
|
role: InjectionRole::System,
|
|
content: "Available channels: response".to_string(),
|
|
},
|
|
HookInjection {
|
|
role: InjectionRole::User,
|
|
content: "<system-reminder>\nkeep going\n</system-reminder>".to_string(),
|
|
},
|
|
],
|
|
control: TurnControl::ForceContinue,
|
|
after_turn_ack: None,
|
|
};
|
|
let serialized = serde_json::to_value(&reply).unwrap();
|
|
assert_eq!(
|
|
serialized,
|
|
json!({
|
|
"injections": [
|
|
{ "role": "system", "content": "Available channels: response" },
|
|
{
|
|
"role": "user",
|
|
"content": "<system-reminder>\nkeep going\n</system-reminder>",
|
|
},
|
|
],
|
|
"control": "force_continue",
|
|
})
|
|
);
|
|
let deserialized: HookReply = serde_json::from_value(serialized).unwrap();
|
|
assert_eq!(deserialized, reply);
|
|
}
|
|
|
|
#[test]
|
|
fn turn_control_variants_serialize_snake_case() {
|
|
assert_eq!(
|
|
serde_json::to_value(TurnControl::Auto).unwrap(),
|
|
json!("auto")
|
|
);
|
|
assert_eq!(
|
|
serde_json::to_value(TurnControl::ForceContinue).unwrap(),
|
|
json!("force_continue"),
|
|
);
|
|
assert_eq!(
|
|
serde_json::to_value(TurnControl::ForceStop).unwrap(),
|
|
json!("force_stop"),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn injection_role_serializes_snake_case() {
|
|
assert_eq!(
|
|
serde_json::to_value(InjectionRole::Developer).unwrap(),
|
|
json!("developer"),
|
|
);
|
|
}
|
|
|
|
/// Back-compat: a `before_turn` payload from an OLD shell (without the extended fields)
|
|
/// must still deserialize, with the new fields taking their serde defaults.
|
|
#[test]
|
|
fn before_turn_legacy_payload_defaults_new_fields() {
|
|
let json = json!({
|
|
"turn_number": 3,
|
|
"model_id": "grok-3",
|
|
"yolo_mode": true,
|
|
});
|
|
let payload: BeforeTurnPayload = serde_json::from_value(json).unwrap();
|
|
assert_eq!(payload.conversation_message_count, 0);
|
|
assert_eq!(payload.session_relationship, DEFAULT_SESSION_RELATIONSHIP);
|
|
assert_eq!(payload.schema_version, DEFAULT_SCHEMA_VERSION);
|
|
}
|
|
|
|
/// Back-compat: an `after_turn` payload from an OLD shell (without the
|
|
/// cancellation fields) must still deserialize, defaulting both to `None`.
|
|
#[test]
|
|
fn after_turn_legacy_payload_defaults_new_fields() {
|
|
let json = json!({
|
|
"turn_number": 3,
|
|
"outcome": "completed",
|
|
"duration_ms": 10,
|
|
"tool_call_count": 0,
|
|
"model_id": "grok-3",
|
|
});
|
|
let payload: AfterTurnPayload = serde_json::from_value(json).unwrap();
|
|
assert_eq!(payload.cancellation_category, None);
|
|
assert_eq!(payload.cancellation_context, None);
|
|
}
|
|
|
|
#[test]
|
|
fn after_turn_ack_status_serializes_snake_case() {
|
|
assert_eq!(
|
|
serde_json::to_value(AfterTurnAckStatus::Enqueued).unwrap(),
|
|
json!("enqueued"),
|
|
);
|
|
assert_eq!(
|
|
serde_json::to_value(AfterTurnAckStatus::Failed).unwrap(),
|
|
json!("failed"),
|
|
);
|
|
assert_eq!(
|
|
serde_json::to_value(AfterTurnAckStatus::Skipped).unwrap(),
|
|
json!("skipped"),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn after_turn_ack_payload_round_trip_enqueued() {
|
|
// `Enqueued` ack with no error message: `error_message` skips the wire.
|
|
let payload = AfterTurnAckPayload {
|
|
turn_number: 42,
|
|
status: AfterTurnAckStatus::Enqueued,
|
|
error_message: None,
|
|
artifact_count: 2,
|
|
};
|
|
let serialized = serde_json::to_value(&payload).unwrap();
|
|
assert_eq!(
|
|
serialized,
|
|
json!({
|
|
"turn_number": 42,
|
|
"status": "enqueued",
|
|
"artifact_count": 2,
|
|
})
|
|
);
|
|
let deserialized: AfterTurnAckPayload = serde_json::from_value(serialized).unwrap();
|
|
assert_eq!(deserialized, payload);
|
|
}
|
|
|
|
#[test]
|
|
fn after_turn_ack_payload_round_trip_failed_carries_message() {
|
|
let payload = AfterTurnAckPayload {
|
|
turn_number: 1,
|
|
status: AfterTurnAckStatus::Failed,
|
|
error_message: Some("disk budget exhausted".to_string()),
|
|
artifact_count: 1,
|
|
};
|
|
let serialized = serde_json::to_value(&payload).unwrap();
|
|
assert_eq!(serialized["status"], json!("failed"));
|
|
assert_eq!(serialized["error_message"], json!("disk budget exhausted"));
|
|
assert_eq!(serialized["artifact_count"], json!(1));
|
|
let deserialized: AfterTurnAckPayload = serde_json::from_value(serialized).unwrap();
|
|
assert_eq!(deserialized, payload);
|
|
}
|
|
|
|
/// Back-compat: an ack with only the required fields (old sender) defaults
|
|
/// `artifact_count` to 0 and `error_message` to `None`.
|
|
#[test]
|
|
fn after_turn_ack_payload_minimal_defaults() {
|
|
let json = json!({
|
|
"turn_number": 5,
|
|
"status": "skipped",
|
|
});
|
|
let payload: AfterTurnAckPayload = serde_json::from_value(json).unwrap();
|
|
assert_eq!(payload.status, AfterTurnAckStatus::Skipped);
|
|
assert_eq!(payload.artifact_count, 0);
|
|
assert_eq!(payload.error_message, None);
|
|
}
|
|
}
|