feat: discover subject session history

This commit is contained in:
2026-10-02 16:41:29 +09:00
parent 43e3b42055
commit ee6aa9de41
34 changed files with 5167 additions and 12 deletions
+9
View File
@@ -1307,6 +1307,15 @@ where
{
feature_registry.add_module(subjektiv_memory);
}
if let Some(subjektiv_sessions) =
crate::feature::builtin::subjektiv_session::SubjektivSessionFeature::from_resolved_config(
&worker.manifest().feature.subjektiv,
worker.committed_session_capture_handle(),
worker.workspace_client_handle(),
)?
{
feature_registry.add_module(subjektiv_sessions);
}
if let Some(memory_config) = memory_lifecycle_config
&& let Some(memory_lifecycle) =
crate::feature::builtin::memory_lifecycle::MemoryLifecycleFeature::from_resolved_config(
+1
View File
@@ -19,6 +19,7 @@ pub mod orchestration;
mod resource_projection;
pub mod session_explore;
pub(crate) mod subjektiv_memory;
pub(crate) mod subjektiv_session;
pub mod task;
pub mod ticket;
pub mod worker_observation;
@@ -2078,6 +2078,7 @@ permission = "write"
segment_id: "segment-1".to_string(),
session_revision: history_len.try_into().unwrap(),
entry_count: history_len,
has_committed_run: true,
run_exit: CommittedRunExit::Finished,
history: (0..history_len)
.map(|index| HistoryEntry {
@@ -1101,6 +1101,7 @@ mod tests {
segment_id: "segment-1".into(),
session_revision: 1,
entry_count: 0,
has_committed_run: true,
run_exit: CommittedRunExit::Finished,
history: Vec::new(),
usage_history: Vec::new(),
@@ -1351,6 +1352,7 @@ mod tests {
segment_id: "segment-1".into(),
session_revision: 1,
entry_count: 3,
has_committed_run: true,
run_exit: CommittedRunExit::Interrupted,
history: vec![
crate::session_history::history_entry(
@@ -1410,6 +1412,7 @@ mod tests {
segment_id: "segment-1".into(),
session_revision: 2,
entry_count: 2,
has_committed_run: true,
run_exit: CommittedRunExit::Finished,
history: vec![
crate::session_history::history_entry(
@@ -1487,6 +1490,7 @@ mod tests {
segment_id: "segment-1".into(),
session_revision: 2,
entry_count: 2,
has_committed_run: true,
run_exit: CommittedRunExit::Finished,
history: vec![
crate::session_history::history_entry(
@@ -0,0 +1,817 @@
//! Host-scoped tools for exploring subject-attributed committed Sessions.
//!
//! Subject, Runtime, Worker, archive, and filesystem authority stays bound by
//! the Workspace host. Model input contains only the selectors and filters from
//! the public `server_api` request types.
use std::sync::Arc;
use agen::tool::{Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
use async_trait::async_trait;
use schemars::JsonSchema;
use server_api::{
SUBJEKTIV_SESSION_MAX_TOOL_CONTENT_BYTES, SubjektivRecordSessionRequest,
SubjektivRecordSessionResponse, SubjektivSessionBackendOperation,
SubjektivSessionBackendRequest, SubjektivSessionBackendResponse, SubjektivSessionBackendResult,
SubjektivSessionDiagnosticCode, SubjektivSessionErrorResponse, SubjektivSessionListRequest,
SubjektivSessionReadRequest, SubjektivSessionSearchRequest,
};
use crate::feature::session::CommittedSessionCaptureHandle;
use crate::feature::{
FeatureDescriptor, FeatureHookPoint, FeatureInstallContext, FeatureInstallError, FeatureModule,
HookDeclaration, ToolContribution, ToolDeclaration,
};
use crate::hook::{
Hook, HookError, HookErrorCategory, HookExecutionPolicy, HookFailurePolicy,
HookPreRequestAction, PreLlmRequest, PreRequestContext, RunCommitted, RunCommittedContext,
};
use crate::worker::{WorkspaceClient, WorkspaceServerOperation};
const LIST_TOOL: &str = "SubjektivSessionList";
const SEARCH_TOOL: &str = "SubjektivSessionSearch";
const READ_TOOL: &str = "SubjektivSessionRead";
const COMMIT_HOOK: &str = "record-subjektiv-session-after-commit";
const RETRY_HOOK: &str = "retry-subjektiv-session-attribution";
const LIST_DESCRIPTION: &str = "List committed retained or archived Sessions attributed by the Host to the connected subject. Filter by exact session_id or storage and continue with the returned opaque cursor; subject, Runtime, Worker, archive, and filesystem authority are never model inputs.";
const SEARCH_DESCRIPTION: &str = "Search committed public entries across Host-authorized Sessions attributed to the connected subject, or list one Session's public entries when session_id is supplied and query is omitted. Search is case-insensitive literal matching; system prompts, hidden reasoning, traces, and attachment bodies are never exposed.";
const READ_DESCRIPTION: &str = "Read one exact committed public Session entry selected by session_id, segment_id, and entry_ref. Compact mode is the default; full mode remains bounded, and partial content must be continued with the returned opaque cursor.";
#[derive(Clone)]
pub(crate) struct SubjektivSessionFeature {
state: SubjektivSessionState,
}
#[derive(Clone)]
struct SubjektivSessionState {
client: Arc<dyn WorkspaceClient>,
capture: CommittedSessionCaptureHandle,
}
impl SubjektivSessionFeature {
pub(crate) fn from_resolved_config(
config: &manifest::ResolvedSubjektivFeatureConfig,
capture: CommittedSessionCaptureHandle,
client: Arc<dyn WorkspaceClient>,
) -> std::io::Result<Option<Self>> {
if !config.profile.enabled {
return Ok(None);
}
config
.validate_execution()
.map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?;
if !client.is_available() || client.workspace_id().is_none() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"subjektiv Session tools require Backend Workspace API authority",
));
}
Ok(Some(Self {
state: SubjektivSessionState { client, capture },
}))
}
}
impl FeatureModule for SubjektivSessionFeature {
fn descriptor(&self) -> FeatureDescriptor {
FeatureDescriptor::builtin("subjektiv-session-tools", "subjektiv Session Tools")
.with_description(
"Read-only discovery, search, and reading of Host-authorized subject Sessions.",
)
.with_tool(ToolDeclaration::new(LIST_TOOL, LIST_DESCRIPTION))
.with_tool(ToolDeclaration::new(SEARCH_TOOL, SEARCH_DESCRIPTION))
.with_tool(ToolDeclaration::new(READ_TOOL, READ_DESCRIPTION))
.with_hook(HookDeclaration::new(
COMMIT_HOOK,
FeatureHookPoint::RunCommitted,
))
.with_hook(HookDeclaration::new(
RETRY_HOOK,
FeatureHookPoint::PreLlmRequest,
))
}
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
for (name, description, operation) in [
(LIST_TOOL, LIST_DESCRIPTION, SessionOperation::List),
(SEARCH_TOOL, SEARCH_DESCRIPTION, SessionOperation::Search),
(READ_TOOL, READ_DESCRIPTION, SessionOperation::Read),
] {
context.tools().register(ToolContribution::new(
name,
tool_definition(name, description, self.state.clone(), operation),
))?;
}
context.hooks().add_run_committed(
COMMIT_HOOK,
HookExecutionPolicy::new(HookFailurePolicy::AttentionRequired, 30_000),
RecordCommittedSessionHook {
state: self.state.clone(),
},
)?;
context.hooks().add_pre_request(
RETRY_HOOK,
RetryCommittedSessionHook {
state: self.state.clone(),
},
)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum SessionOperation {
List,
Search,
Read,
}
fn tool_definition(
name: &'static str,
description: &'static str,
state: SubjektivSessionState,
operation: SessionOperation,
) -> ToolDefinition {
Arc::new(move || {
let schema = match operation {
SessionOperation::List => schema_for::<SubjektivSessionListRequest>(),
SessionOperation::Search => schema_for::<SubjektivSessionSearchRequest>(),
SessionOperation::Read => schema_for::<SubjektivSessionReadRequest>(),
};
let tool: Arc<dyn Tool> = Arc::new(SubjektivSessionTool {
state: state.clone(),
operation,
});
(
ToolMeta::new(name)
.description(description)
.input_schema(schema),
tool,
)
})
}
struct SubjektivSessionTool {
state: SubjektivSessionState,
operation: SessionOperation,
}
#[async_trait]
impl Tool for SubjektivSessionTool {
async fn execute(
&self,
input_json: &str,
_context: ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
let operation = match self.operation {
SessionOperation::List => {
SubjektivSessionBackendOperation::List(parse(input_json, LIST_TOOL)?)
}
SessionOperation::Search => {
SubjektivSessionBackendOperation::Search(parse(input_json, SEARCH_TOOL)?)
}
SessionOperation::Read => {
SubjektivSessionBackendOperation::Read(parse(input_json, READ_TOOL)?)
}
};
let response = self.state.execute(operation)?;
model_visible_response(response)
}
}
impl SubjektivSessionState {
fn execute(
&self,
operation: SubjektivSessionBackendOperation,
) -> Result<SubjektivSessionBackendResponse, ToolError> {
let response = self
.client
.execute_server_operation(WorkspaceServerOperation::SubjektivSession(
SubjektivSessionBackendRequest { operation },
))
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
if let Ok(response) =
serde_json::from_str::<SubjektivSessionBackendResponse>(&response.body)
{
return Ok(response);
}
let detail = serde_json::from_str::<server_api::RepositoryApiError>(&response.body)
.map(|error| error.message)
.unwrap_or(response.body);
Err(ToolError::ExecutionFailed(format!(
"subjektiv Session backend returned HTTP {}: {detail}",
response.status
)))
}
fn record_current_committed_session(&self) -> Result<(), HookError> {
let capture = self
.capture
.capture()
.map_err(|error| HookError::new(HookErrorCategory::Dependency, error.to_string()))?;
if !capture.has_committed_run {
return Err(HookError::new(
HookErrorCategory::Dependency,
"committed Session capture is not available yet",
));
}
self.record_session(&capture.session_id)
}
fn retry_current_committed_session(&self) -> Result<(), HookError> {
let capture = self
.capture
.capture()
.map_err(|error| HookError::new(HookErrorCategory::Dependency, error.to_string()))?;
if !capture.has_committed_run {
return Ok(());
}
self.record_session(&capture.session_id)
}
fn record_session(&self, session_id: &str) -> Result<(), HookError> {
let response = self
.client
.execute_server_operation(WorkspaceServerOperation::SubjektivRecordSession(
SubjektivRecordSessionRequest {
session_id: session_id.to_string(),
},
))
.map_err(|error| HookError::new(HookErrorCategory::Dependency, error.to_string()))?;
if !response.is_success() {
return Err(HookError::new(
HookErrorCategory::Dependency,
format!(
"record subjektiv Session attribution returned HTTP {}",
response.status
),
));
}
let output: SubjektivRecordSessionResponse =
serde_json::from_str(&response.body).map_err(|error| {
HookError::new(
HookErrorCategory::Dependency,
format!("decode subjektiv Session attribution response: {error}"),
)
})?;
if output.session_id != session_id {
return Err(HookError::new(
HookErrorCategory::Dependency,
"subjektiv Session attribution response changed session identity",
));
}
Ok(())
}
}
struct RecordCommittedSessionHook {
state: SubjektivSessionState,
}
#[async_trait]
impl Hook<RunCommitted> for RecordCommittedSessionHook {
async fn call(&self, _input: &RunCommittedContext) -> Result<(), HookError> {
self.state.record_current_committed_session()
}
}
struct RetryCommittedSessionHook {
state: SubjektivSessionState,
}
#[async_trait]
impl Hook<PreLlmRequest> for RetryCommittedSessionHook {
async fn call(&self, _input: &PreRequestContext) -> Result<HookPreRequestAction, HookError> {
self.state.retry_current_committed_session()?;
Ok(HookPreRequestAction::Continue)
}
}
fn parse<T: serde::de::DeserializeOwned>(input: &str, tool: &str) -> Result<T, ToolError> {
serde_json::from_str(input)
.map_err(|error| ToolError::InvalidArgument(format!("invalid {tool} input: {error}")))
}
fn schema_for<T: JsonSchema>() -> serde_json::Value {
serde_json::to_value(schemars::schema_for!(T)).unwrap_or_else(|_| serde_json::json!({}))
}
fn model_visible_response(
response: SubjektivSessionBackendResponse,
) -> Result<ToolOutput, ToolError> {
let summary = response_summary(&response);
let content = serde_json::to_string(&response)
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
if content.len() <= SUBJEKTIV_SESSION_MAX_TOOL_CONTENT_BYTES {
return Ok(ToolOutput {
summary,
content: Some(content),
attachments: Vec::new(),
});
}
let bounded = SubjektivSessionBackendResponse::Error {
error: SubjektivSessionErrorResponse {
code: SubjektivSessionDiagnosticCode::ResourceLimit,
message: "subjektiv Session response exceeded the 56 KiB model-visible JSON budget"
.to_string(),
},
};
let content = serde_json::to_string(&bounded)
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
debug_assert!(content.len() <= SUBJEKTIV_SESSION_MAX_TOOL_CONTENT_BYTES);
Ok(ToolOutput {
summary: "Subjektiv Session operation reached its output limit.".to_string(),
content: Some(content),
attachments: Vec::new(),
})
}
fn response_summary(response: &SubjektivSessionBackendResponse) -> String {
match response {
SubjektivSessionBackendResponse::Ok {
result: SubjektivSessionBackendResult::List(value),
} => format!("Listed {} subject Session(s).", value.items.len()),
SubjektivSessionBackendResponse::Ok {
result: SubjektivSessionBackendResult::Search(value),
} => format!("Found {} subject Session entries.", value.items.len()),
SubjektivSessionBackendResponse::Ok {
result: SubjektivSessionBackendResult::Read(value),
} => format!("Read subject Session entry {}.", value.entry_ref),
SubjektivSessionBackendResponse::Error { error } => {
format!("Subjektiv Session operation returned {:?}.", error.code)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
use std::sync::Mutex;
use std::sync::atomic::{AtomicUsize, Ordering};
use crate::feature::session::{CommittedRunExit, CommittedSessionCapture};
use crate::hook::{HookHistoryRange, HookInvocationContext, PreRequestInfo, RunCommittedExit};
use crate::worker::{
WorkspaceClientError, WorkspaceRequest, WorkspaceRequestMethod, WorkspaceResponse,
};
#[derive(Debug)]
struct SessionToolClient {
response: Mutex<SubjektivSessionBackendResponse>,
requests: Mutex<Vec<WorkspaceRequest>>,
}
impl SessionToolClient {
fn new(response: SubjektivSessionBackendResponse) -> Self {
Self {
response: Mutex::new(response),
requests: Mutex::new(Vec::new()),
}
}
}
impl WorkspaceClient for SessionToolClient {
fn workspace_id(&self) -> Option<&str> {
Some("workspace-test")
}
fn kind(&self) -> &str {
"subjektiv-session-test"
}
fn is_available(&self) -> bool {
true
}
fn execute(
&self,
request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
self.requests.lock().unwrap().push(request);
Ok(WorkspaceResponse {
status: 200,
body: serde_json::to_string(&*self.response.lock().unwrap()).unwrap(),
})
}
}
#[derive(Debug, Default)]
struct AttributionClient {
requests: Mutex<Vec<WorkspaceRequest>>,
recorded: Mutex<HashSet<String>>,
insertions: AtomicUsize,
failures: AtomicUsize,
}
impl WorkspaceClient for AttributionClient {
fn workspace_id(&self) -> Option<&str> {
Some("workspace-test")
}
fn kind(&self) -> &str {
"subjektiv-attribution-test"
}
fn is_available(&self) -> bool {
true
}
fn execute(
&self,
request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
self.requests.lock().unwrap().push(request.clone());
if self
.failures
.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |remaining| {
remaining.checked_sub(1)
})
.is_ok()
{
return Ok(WorkspaceResponse {
status: 503,
body: "temporarily unavailable".into(),
});
}
let input: SubjektivRecordSessionRequest =
serde_json::from_str(request.body.as_deref().unwrap()).unwrap();
if self
.recorded
.lock()
.unwrap()
.insert(input.session_id.clone())
{
self.insertions.fetch_add(1, Ordering::SeqCst);
}
Ok(WorkspaceResponse {
status: 200,
body: serde_json::to_string(&SubjektivRecordSessionResponse {
subject_id: "subject-1".into(),
session_id: input.session_id,
})
.unwrap(),
})
}
}
fn capture(has_committed_run: bool) -> CommittedSessionCaptureHandle {
CommittedSessionCaptureHandle::new(move || {
Ok(CommittedSessionCapture {
session_id: "session-1".into(),
segment_id: "segment-1".into(),
session_revision: usize::from(has_committed_run) as u64,
entry_count: usize::from(has_committed_run),
has_committed_run,
run_exit: CommittedRunExit::Finished,
history: Vec::new(),
usage_history: Vec::new(),
extensions: Vec::new(),
})
})
}
fn list_response() -> SubjektivSessionBackendResponse {
SubjektivSessionBackendResponse::Ok {
result: SubjektivSessionBackendResult::List(server_api::SubjektivSessionListResponse {
items: Vec::new(),
next_cursor: None,
has_more: false,
}),
}
}
fn state(client: Arc<dyn WorkspaceClient>, has_committed_run: bool) -> SubjektivSessionState {
SubjektivSessionState {
client,
capture: capture(has_committed_run),
}
}
fn feature(client: Arc<dyn WorkspaceClient>) -> SubjektivSessionFeature {
SubjektivSessionFeature {
state: state(client, true),
}
}
fn run_committed_context() -> RunCommittedContext {
RunCommittedContext {
invocation: HookInvocationContext {
workspace_id: Some("workspace-test".into()),
worker_id: "worker-1".into(),
session_id: "session-1".into(),
session_revision: 1,
run_id: Some("run-1".into()),
turn_index: Some(0),
call_id: None,
},
exit: RunCommittedExit::Finished,
committed_history: HookHistoryRange::default(),
}
}
fn pre_request_context() -> PreRequestContext {
PreRequestContext::new(
PreRequestInfo {
item_count: 0,
estimated_tokens: None,
turn_index: 0,
tool_calls_this_turn: 0,
},
None,
)
}
#[test]
fn descriptor_and_definitions_register_exact_session_tools_and_typed_schemas() {
let feature = feature(Arc::new(SessionToolClient::new(list_response())));
let descriptor = feature.descriptor();
assert_eq!(
descriptor
.tools
.iter()
.map(|tool| (tool.name.as_str(), tool.description.as_str()))
.collect::<Vec<_>>(),
vec![
(LIST_TOOL, LIST_DESCRIPTION),
(SEARCH_TOOL, SEARCH_DESCRIPTION),
(READ_TOOL, READ_DESCRIPTION),
]
);
assert_eq!(
descriptor
.hooks
.iter()
.map(|hook| (hook.name.as_str(), hook.point.clone()))
.collect::<Vec<_>>(),
vec![
(COMMIT_HOOK, FeatureHookPoint::RunCommitted),
(RETRY_HOOK, FeatureHookPoint::PreLlmRequest),
]
);
for (name, description, operation, expected_schema) in [
(
LIST_TOOL,
LIST_DESCRIPTION,
SessionOperation::List,
schema_for::<SubjektivSessionListRequest>(),
),
(
SEARCH_TOOL,
SEARCH_DESCRIPTION,
SessionOperation::Search,
schema_for::<SubjektivSessionSearchRequest>(),
),
(
READ_TOOL,
READ_DESCRIPTION,
SessionOperation::Read,
schema_for::<SubjektivSessionReadRequest>(),
),
] {
let (meta, _) = tool_definition(name, description, feature.state.clone(), operation)();
assert_eq!(meta.name, name);
assert_eq!(meta.description, description);
assert_eq!(meta.input_schema, expected_schema);
let properties = meta.input_schema["properties"].as_object().unwrap();
for forbidden in [
"subject_id",
"runtime_id",
"worker_id",
"archive_id",
"archive_path",
"path",
] {
assert!(!properties.contains_key(forbidden));
}
}
}
#[test]
fn disabled_subjektiv_feature_registers_nothing() {
let config = manifest::ResolvedSubjektivFeatureConfig::default();
let result = SubjektivSessionFeature::from_resolved_config(
&config,
capture(false),
Arc::new(SessionToolClient::new(list_response())),
)
.unwrap();
assert!(result.is_none());
}
#[tokio::test]
async fn tools_reject_model_supplied_host_scope_and_storage_paths() {
let client = Arc::new(SessionToolClient::new(list_response()));
for (operation, input) in [
(
SessionOperation::List,
r#"{"subject_id":"subject-1","archive_path":"/tmp/archive"}"#,
),
(
SessionOperation::Search,
r#"{"query":"needle","runtime_id":"runtime-1","worker_id":"worker-1"}"#,
),
(
SessionOperation::Read,
r#"{"session_id":"session-1","segment_id":"segment-1","entry_ref":"E1","path":"session.jsonl"}"#,
),
] {
let error = SubjektivSessionTool {
state: state(client.clone(), true),
operation,
}
.execute(input, ToolExecutionContext::new("call", "batch", 0))
.await
.unwrap_err();
assert!(matches!(error, ToolError::InvalidArgument(_)));
}
assert!(client.requests.lock().unwrap().is_empty());
}
#[tokio::test]
async fn tool_routes_one_host_bound_operation_to_session_history() {
let client = Arc::new(SessionToolClient::new(list_response()));
let tool = SubjektivSessionTool {
state: state(client.clone(), true),
operation: SessionOperation::List,
};
let output = tool
.execute(
r#"{"storage":"retained","limit":20}"#,
ToolExecutionContext::new("call-1", "batch-1", 0),
)
.await
.unwrap();
let requests = client.requests.lock().unwrap();
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].method, WorkspaceRequestMethod::Post);
assert_eq!(
requests[0].path,
"/api/w/workspace-test/subjektiv/session-history"
);
let body: SubjektivSessionBackendRequest =
serde_json::from_str(requests[0].body.as_deref().unwrap()).unwrap();
assert!(matches!(
body.operation,
SubjektivSessionBackendOperation::List(SubjektivSessionListRequest {
session_id: None,
storage: server_api::SubjektivSessionStorageFilter::Retained,
limit: Some(20),
cursor: None,
})
));
let visible: SubjektivSessionBackendResponse =
serde_json::from_str(output.content.as_deref().unwrap()).unwrap();
assert!(matches!(
visible,
SubjektivSessionBackendResponse::Ok {
result: SubjektivSessionBackendResult::List(_)
}
));
}
#[tokio::test]
async fn typed_errors_are_visible_and_oversized_json_becomes_bounded_resource_limit() {
let typed_error = SubjektivSessionBackendResponse::Error {
error: SubjektivSessionErrorResponse {
code: SubjektivSessionDiagnosticCode::StaleCursor,
message: "restart the search".into(),
},
};
let error_tool = SubjektivSessionTool {
state: state(Arc::new(SessionToolClient::new(typed_error.clone())), true),
operation: SessionOperation::Search,
};
let output = error_tool
.execute(
r#"{"query":"needle"}"#,
ToolExecutionContext::new("call-error", "batch", 0),
)
.await
.unwrap();
let visible: SubjektivSessionBackendResponse =
serde_json::from_str(output.content.as_deref().unwrap()).unwrap();
assert!(matches!(
visible,
SubjektivSessionBackendResponse::Error {
error: SubjektivSessionErrorResponse {
code: SubjektivSessionDiagnosticCode::StaleCursor,
..
}
}
));
let oversized = SubjektivSessionBackendResponse::Ok {
result: SubjektivSessionBackendResult::List(server_api::SubjektivSessionListResponse {
items: vec![server_api::SubjektivSessionListItem {
session_id: "session-1".into(),
attributed_at: "2026-01-01T00:00:00Z".into(),
storage: server_api::SubjektivSessionStorage::Retained,
availability: server_api::SubjektivSessionAvailability::Unavailable,
reason: Some("\\\u{0000}".repeat(SUBJEKTIV_SESSION_MAX_TOOL_CONTENT_BYTES)),
}],
next_cursor: Some("cursor".into()),
has_more: true,
}),
};
let output = model_visible_response(oversized).unwrap();
let content = output.content.unwrap();
assert!(content.len() <= SUBJEKTIV_SESSION_MAX_TOOL_CONTENT_BYTES);
let visible: SubjektivSessionBackendResponse = serde_json::from_str(&content).unwrap();
assert!(matches!(
visible,
SubjektivSessionBackendResponse::Error {
error: SubjektivSessionErrorResponse {
code: SubjektivSessionDiagnosticCode::ResourceLimit,
..
}
}
));
}
#[tokio::test]
async fn candidate_free_commit_records_session_and_pre_request_waits_for_committed_capture() {
let client = Arc::new(AttributionClient::default());
let commit_hook = RecordCommittedSessionHook {
state: state(client.clone(), true),
};
commit_hook.call(&run_committed_context()).await.unwrap();
let requests = client.requests.lock().unwrap();
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].path, "/api/w/workspace-test/subjektiv/sessions");
assert!(!requests[0].path.contains("staging"));
drop(requests);
let before_commit = Arc::new(AttributionClient::default());
let premature_commit_hook = RecordCommittedSessionHook {
state: state(before_commit.clone(), false),
};
assert!(
premature_commit_hook
.call(&run_committed_context())
.await
.is_err()
);
let retry_hook = RetryCommittedSessionHook {
state: state(before_commit.clone(), false),
};
assert_eq!(
retry_hook.call(&pre_request_context()).await.unwrap(),
HookPreRequestAction::Continue
);
assert!(before_commit.requests.lock().unwrap().is_empty());
}
#[tokio::test]
async fn transient_attribution_failure_is_a_hook_failure_then_retries_exactly() {
let client = Arc::new(AttributionClient::default());
client.failures.store(1, Ordering::SeqCst);
let state = state(client.clone(), true);
let commit_hook = RecordCommittedSessionHook {
state: state.clone(),
};
let retry_hook = RetryCommittedSessionHook { state };
let error = commit_hook
.call(&run_committed_context())
.await
.unwrap_err();
assert_eq!(error.category, HookErrorCategory::Dependency);
assert_eq!(
retry_hook.call(&pre_request_context()).await.unwrap(),
HookPreRequestAction::Continue
);
let requests = client.requests.lock().unwrap();
assert_eq!(requests.len(), 2);
assert_eq!(requests[0].body, requests[1].body);
assert_eq!(client.insertions.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn repeated_commit_and_pre_request_use_exact_request_and_backend_dedupes() {
let client = Arc::new(AttributionClient::default());
let state = state(client.clone(), true);
let commit_hook = RecordCommittedSessionHook {
state: state.clone(),
};
let retry_hook = RetryCommittedSessionHook { state };
commit_hook.call(&run_committed_context()).await.unwrap();
commit_hook.call(&run_committed_context()).await.unwrap();
retry_hook.call(&pre_request_context()).await.unwrap();
let requests = client.requests.lock().unwrap();
assert_eq!(requests.len(), 3);
assert!(
requests
.iter()
.all(|request| request.body == requests[0].body)
);
assert_eq!(client.recorded.lock().unwrap().len(), 1);
assert_eq!(client.insertions.load(Ordering::SeqCst), 1);
}
}
+3
View File
@@ -25,6 +25,9 @@ pub(crate) struct CommittedSessionCapture {
/// Monotonic committed-log revision for the captured Segment.
pub(crate) session_revision: u64,
pub(crate) entry_count: usize,
/// Whether the durable capture contains a committed run boundary. This is
/// false for a newly materialized Session before its first Run commits.
pub(crate) has_committed_run: bool,
pub(crate) run_exit: CommittedRunExit,
pub(crate) history: Vec<HistoryEntry<SessionHistoryMetadata>>,
pub(crate) usage_history: Vec<UsageRecord>,
+11
View File
@@ -629,6 +629,7 @@ pub enum WorkspaceServerOperation {
SubjektivStageCandidate(server_api::SubjektivStageCandidateRequest),
SubjektivRecordSession(server_api::SubjektivRecordSessionRequest),
SubjektivMemory(server_api::SubjektivMemoryBackendRequest),
SubjektivSession(server_api::SubjektivSessionBackendRequest),
}
fn workspace_server_json_request<T: serde::Serialize>(
@@ -716,6 +717,9 @@ fn workspace_server_operation_request(
WorkspaceServerOperation::SubjektivMemory(request) => {
workspace_server_json_request(format!("{base}/subjektiv/memory"), &request)
}
WorkspaceServerOperation::SubjektivSession(request) => {
workspace_server_json_request(format!("{base}/subjektiv/session-history"), &request)
}
}
}
@@ -3488,6 +3492,12 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
let entries = store
.read_all(location.session_id, location.segment_id)
.map_err(|error| FeatureSessionError::Capture(error.to_string()))?;
let has_committed_run = entries.iter().any(|entry| {
matches!(
entry,
LogEntry::RunCompleted { .. } | LogEntry::RunYielded { .. }
)
});
let run_exit = entries
.iter()
.rev()
@@ -3514,6 +3524,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
segment_id: location.segment_id.to_string(),
session_revision: entries.len().try_into().unwrap_or(u64::MAX),
entry_count: entries.len(),
has_committed_run,
run_exit,
history,
usage_history: restored.usage_history,