skald/vendor/clawdforge/tests/sessions.rs
Kayos f71b533e52 v0.2 scaffold: vendor clawdforge SDK + forge module + Whisper plan
The Rust SDK already existed at Sulkta-Coop/clawdforge clients/rust/ — async,
reqwest-based, bearer-auth, exposes Client::run() + Session for multi-turn.
Vendoring it into vendor/clawdforge so skald is self-contained: no
git-submodule + no needing the clawdforge repo cloned next to skald.
Trade-off accepted: updates require manual re-copy until both sides
stabilize and we publish to a private cargo registry.

What landed:

- vendor/clawdforge/ — full SDK source from Sulkta-Coop/clawdforge HEAD.
  Pinned in skald-core/Cargo.toml as a path dep.
- skald-core/src/forge.rs — three-pass orchestration shell. Forge wraps
  clawdforge::Client; generate() / cleanup() / audit() each build a
  RunRequest with the right system prompt + model alias (always opus),
  call client.run(), return a PassOutput.
  Prompt templates are TODO stubs (SYSTEM_GEN_TODO etc) — filling in the
  actual prose-craft prompts is its own deep session.
- skald-core/src/config.rs — ForgeConfig { base_url, app_token, model }.
  Resolved by the binary from env (CLAWDFORGE_URL + CLAWDFORGE_TOKEN);
  lib stays env-agnostic.
- skald-core::AuditFinding + AuditResponse — parse shape for what the
  third-Opus canon audit returns, ready to map onto audit_findings rows.
- docs/tts-pipeline.md — full plan for v0.2 narration + post-TTS audit
  chain. Whisper-large-v3 STT does text-to-text verification on every
  render; an optional Gemini Flash audio pass catches subjective issues
  (prosody, tone) Whisper can't see. Reroll loop on crit findings.

What's still stubbed:

- Prompt templates in forge.rs (gen / cleanup / audit) — placeholders
  that describe the role but don't constrain output shape yet.
- context.rs (assemble the LLM context blob from DB rows) — entire module
  TBD.
- No CLI subcommand yet for invoking forge — that comes after context.rs.

Naming note: in Rust 2024 'gen' is a reserved keyword (for generators),
so the method is Forge::generate(), not Forge::gen().
2026-05-13 10:18:56 -07:00

425 lines
14 KiB
Rust

//! Integration tests for the v0.2 multi-turn Session API.
//!
//! All tests run against an in-process `wiremock` server — no live clawdforge
//! required.
use std::time::Duration;
use clawdforge::{Client, Error, SessionOptions, TurnEvent, TurnResult};
use serde_json::json;
use wiremock::matchers::{body_json, header, method, path, path_regex};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn make_client(server: &MockServer) -> Client {
Client::builder()
.base_url(server.uri())
.token("cf_test_token")
.timeout(Duration::from_secs(5))
.build()
.expect("client builds")
}
fn mock_create(session_id: &str) -> Mock {
Mock::given(method("POST"))
.and(path("/sessions"))
.and(header("authorization", "Bearer cf_test_token"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"session_id": session_id,
"agent": "claude",
"created_at": 1_700_000_000_i64,
})))
}
fn mock_delete_ok(session_id: &str) -> Mock {
Mock::given(method("DELETE"))
.and(path(format!("/sessions/{session_id}")))
.and(header("authorization", "Bearer cf_test_token"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true})))
}
#[tokio::test]
async fn test_new_session_and_close() {
let server = MockServer::start().await;
mock_create("sess_abc").expect(1).mount(&server).await;
mock_delete_ok("sess_abc").expect(1).mount(&server).await;
let c = make_client(&server);
let s = c
.new_session(SessionOptions::default())
.await
.expect("new_session");
assert_eq!(s.id(), "sess_abc");
assert_eq!(s.agent(), "claude");
assert_eq!(s.created_at(), 1_700_000_000);
assert!(!s.is_closed());
s.close().await.expect("close");
// wiremock verifies expectations on Drop of the server.
}
#[tokio::test]
async fn test_turn_round_trip() {
let server = MockServer::start().await;
mock_create("sess_t1").mount(&server).await;
Mock::given(method("POST"))
.and(path("/sessions/sess_t1/turn"))
.and(header("authorization", "Bearer cf_test_token"))
.and(body_json(json!({"prompt": "hello"})))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"ok": true,
"session_id": "sess_t1",
"turn_index": 1,
"events": [
{"type": "thinking", "content": "..."},
{"type": "text", "content": "hi back"}
],
"stop_reason": "end_turn",
"duration_ms": 250
})))
.expect(1)
.mount(&server)
.await;
// Allow drop-close to land without failing other assertions.
Mock::given(method("DELETE"))
.and(path("/sessions/sess_t1"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true})))
.mount(&server)
.await;
let c = make_client(&server);
let mut s = c.new_session(SessionOptions::default()).await.unwrap();
let r: TurnResult = s.turn("hello").await.unwrap();
assert!(r.ok);
assert_eq!(r.session_id, "sess_t1");
assert_eq!(r.turn_index, 1);
assert_eq!(r.events.len(), 2);
assert_eq!(r.stop_reason, "end_turn");
assert_eq!(r.duration_ms, 250);
assert_eq!(r.text(), "hi back");
// Drive the turn_with_files path too.
Mock::given(method("POST"))
.and(path("/sessions/sess_t1/turn"))
.and(body_json(
json!({"prompt": "next", "files": ["ff_one", "ff_two"]}),
))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"ok": true,
"session_id": "sess_t1",
"turn_index": 2,
"events": [{"type": "text", "content": "ok"}],
"stop_reason": "end_turn",
"duration_ms": 10
})))
.expect(1)
.mount(&server)
.await;
let r2 = s
.turn_with_files("next", &["ff_one".into(), "ff_two".into()])
.await
.unwrap();
assert_eq!(r2.turn_index, 2);
assert_eq!(r2.text(), "ok");
s.close().await.unwrap();
}
#[tokio::test]
async fn test_close_idempotent_short_circuits() {
let server = MockServer::start().await;
mock_create("sess_idem").mount(&server).await;
// Expect EXACTLY ONE delete — second close() is in-memory.
mock_delete_ok("sess_idem").expect(1).mount(&server).await;
let c = make_client(&server);
let s = c.new_session(SessionOptions::default()).await.unwrap();
let id = s.id().to_string();
s.close().await.unwrap();
// Second close-equivalent: rebuild a Session-shape via reconstructing the
// closed state would require private constructors. Instead, drive the
// semantic check via close_session_internal contract: a fresh Session from
// a *new* create that we close twice. But since `close` consumes self,
// "second close" semantically means a Drop after an explicit close — and
// that path is covered by `test_drop_after_explicit_close_no_double_call`.
//
// What this test asserts is the wiremock `expect(1)` on the DELETE: one
// close => one DELETE => idempotency at the network layer holds.
let _ = id;
}
#[tokio::test]
async fn test_drop_fires_async_close() {
let server = MockServer::start().await;
mock_create("sess_drop").mount(&server).await;
mock_delete_ok("sess_drop").expect(1).mount(&server).await;
let c = make_client(&server);
{
let _s = c.new_session(SessionOptions::default()).await.unwrap();
// _s drops here — Drop spawns the async close.
}
// Yield repeatedly so the spawned future has a chance to run + the HTTP
// request lands at wiremock. wiremock asserts `expect(1)` on Drop of the
// server.
for _ in 0..50 {
tokio::task::yield_now().await;
}
tokio::time::sleep(Duration::from_millis(200)).await;
}
#[tokio::test]
async fn test_drop_after_explicit_close_no_double_call() {
let server = MockServer::start().await;
mock_create("sess_once").mount(&server).await;
// Exactly ONE delete — explicit close fires it, Drop should short-circuit.
mock_delete_ok("sess_once").expect(1).mount(&server).await;
let c = make_client(&server);
let s = c.new_session(SessionOptions::default()).await.unwrap();
s.close().await.unwrap();
// Yield to give any erroneous spawn a chance to land — if Drop spawned a
// second DELETE, wiremock's expect(1) would fail.
for _ in 0..20 {
tokio::task::yield_now().await;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
#[tokio::test]
async fn test_list_sessions() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/sessions"))
.and(header("authorization", "Bearer cf_test_token"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"sessions": [
{
"session_id": "sess_a",
"agent": "claude",
"app_name": "cauldron",
"created_at": 1_700_000_000_i64,
"last_turn_at": 1_700_000_500_i64,
"turn_count": 3,
"closed_at": null
},
{
"session_id": "sess_b",
"agent": "claude",
"app_name": "cauldron",
"created_at": 1_700_000_100_i64,
"last_turn_at": null,
"turn_count": 0,
"closed_at": 1_700_001_000_i64
}
]
})))
.mount(&server)
.await;
let c = make_client(&server);
let list = c.list_sessions().await.unwrap();
assert_eq!(list.sessions.len(), 2);
assert_eq!(list.sessions[0].session_id, "sess_a");
assert_eq!(list.sessions[0].turn_count, 3);
assert_eq!(list.sessions[0].last_turn_at, Some(1_700_000_500));
assert_eq!(list.sessions[1].closed_at, Some(1_700_001_000));
}
#[tokio::test]
async fn test_get_session() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/sessions/sess_q"))
.and(header("authorization", "Bearer cf_test_token"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"session_id": "sess_q",
"agent": "claude",
"app_name": "cauldron",
"created_at": 1_700_000_000_i64,
"last_turn_at": 1_700_000_900_i64,
"turn_count": 7,
"closed_at": null
})))
.mount(&server)
.await;
let c = make_client(&server);
let st = c.get_session("sess_q").await.unwrap();
assert_eq!(st.session_id, "sess_q");
assert_eq!(st.agent, "claude");
assert_eq!(st.app_name, "cauldron");
assert_eq!(st.turn_count, 7);
assert!(st.closed_at.is_none());
}
#[tokio::test]
async fn test_cross_token_404() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path_regex(r"^/sessions/sess_other"))
.respond_with(ResponseTemplate::new(404).set_body_json(json!({
"detail": "session not found"
})))
.mount(&server)
.await;
let c = make_client(&server);
let err = c
.get_session("sess_other")
.await
.expect_err("cross-token must 404");
match err {
Error::Api { status, body } => {
assert_eq!(status, 404);
assert!(body.contains("session not found"), "body was {body}");
}
other => panic!("expected Error::Api {{ status: 404, .. }}, got {other:?}"),
}
}
#[tokio::test]
async fn test_turn_result_text_concat() {
let r = TurnResult {
ok: true,
session_id: "sess".into(),
turn_index: 1,
events: vec![
TurnEvent {
event_type: "thinking".into(),
content: Some("ignored".into()),
name: None,
args: None,
result: None,
},
TurnEvent {
event_type: "text".into(),
content: Some("hello ".into()),
name: None,
args: None,
result: None,
},
TurnEvent {
event_type: "tool_call".into(),
content: None,
name: Some("Read".into()),
args: Some(json!({"path": "/x"})),
result: Some(json!({"ok": true})),
},
TurnEvent {
event_type: "text".into(),
content: Some("world".into()),
name: None,
args: None,
result: None,
},
TurnEvent {
// text event with None content — must contribute the empty
// string, not panic, not stringify "None".
event_type: "text".into(),
content: None,
name: None,
args: None,
result: None,
},
],
stop_reason: "end_turn".into(),
duration_ms: 99,
};
assert_eq!(r.text(), "hello world");
}
#[tokio::test]
async fn test_session_debug_does_not_leak_token() {
let server = MockServer::start().await;
let secret = "cf_super_secret_session_bearer";
Mock::given(method("POST"))
.and(path("/sessions"))
.and(header("authorization", format!("Bearer {secret}").as_str()))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"session_id": "sess_dbg",
"agent": "claude",
"created_at": 1_700_000_000_i64,
})))
.mount(&server)
.await;
Mock::given(method("DELETE"))
.and(path("/sessions/sess_dbg"))
.and(header("authorization", format!("Bearer {secret}").as_str()))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true})))
.mount(&server)
.await;
let c = Client::builder()
.base_url(server.uri())
.token(secret)
.build()
.unwrap();
let s = c.new_session(SessionOptions::default()).await.unwrap();
let dbg = format!("{s:?}");
assert!(
!dbg.contains(secret),
"token leaked through Session Debug: {dbg}"
);
// The Session Debug should print these visible bits.
assert!(dbg.contains("sess_dbg"), "session_id missing: {dbg}");
assert!(dbg.contains("agent"), "agent field missing: {dbg}");
assert!(dbg.contains("closed"), "closed field missing: {dbg}");
s.close().await.unwrap();
}
/// Regression: `new_session` with options serializes the `agent` and `meta`
/// fields when set, omits them when None.
#[tokio::test]
async fn test_new_session_options_serialize() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/sessions"))
.and(body_json(json!({
"agent": "claude",
"meta": {"trace_id": "t-123"}
})))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"session_id": "sess_opts",
"agent": "claude",
"created_at": 1
})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("DELETE"))
.and(path("/sessions/sess_opts"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true})))
.mount(&server)
.await;
let c = make_client(&server);
let s = c
.new_session(SessionOptions {
agent: Some("claude".into()),
meta: Some(json!({"trace_id": "t-123"})),
})
.await
.unwrap();
assert_eq!(s.id(), "sess_opts");
s.close().await.unwrap();
}
/// `get_session` / `close` / `turn` must reject path-traversal session ids
/// before issuing any HTTP request — same defense-in-depth pattern as
/// `revoke_token`.
#[tokio::test]
async fn test_session_id_rejects_path_traversal() {
let server = MockServer::start().await;
let c = make_client(&server);
for bad in ["", "../foo", "..", "foo/bar", "foo?x=1", "foo#frag"] {
let err = c
.get_session(bad)
.await
.expect_err(&format!("get_session({bad:?}) should reject"));
assert!(
matches!(err, Error::Config(_)),
"{bad:?} produced wrong variant: {err:?}"
);
}
}