diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index a100ca901..ee8521c16 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -237,6 +237,7 @@ impl_openapi_schema!( MemoryConsolidateStagingRequest, MemoryConsolidationResponse, SubjektivSubjectCreateRequest, + SubjektivSubjectBehaviorUpdateRequest, SubjektivSubjectListQuery, SubjektivSubjectListResponse, SubjektivSubjectResponse, @@ -1163,6 +1164,20 @@ pub trait ServerApi { #[path] workspace_id: String, #[path] subject_id: String, ) -> Result; + #[patch( + "/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/behavior", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 500], + bearer_auth = true, + browser_auth = true, + )] + async fn subjektiv_subject_behavior_update( + &self, + #[path] workspace_id: String, + #[path] subject_id: String, + #[body] request: SubjektivSubjectBehaviorUpdateRequest, + ) -> Result; #[get( "/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/surface", status = 200, @@ -4579,9 +4594,20 @@ pub enum SubjektivSubjectState { #[serde(deny_unknown_fields)] pub struct SubjektivSubjectCreateRequest { pub role: String, + #[serde(default)] + pub behavior_md: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivSubjectBehaviorUpdateRequest { + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] + pub expected_behavior_revision: u64, + pub behavior_md: String, } pub const SUBJEKTIV_BROWSER_MAX_LIST_LIMIT: usize = 100; +pub const SUBJEKTIV_MAX_BEHAVIOR_BYTES: usize = 16 * 1024; #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[serde(deny_unknown_fields)] @@ -4609,6 +4635,9 @@ pub struct SubjektivSubjectListResponse { pub struct SubjektivSubjectResponse { pub id: String, pub role: String, + pub behavior_md: String, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] + pub behavior_revision: u64, pub state: SubjektivSubjectState, #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub store_revision: u64, @@ -5442,10 +5471,22 @@ pub struct SubjektivSurfaceFailureResponse { pub status: String, } +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivResidentContextOutput { + /// Exact user-managed document. Empty means explicitly unset. + pub behavior_md: String, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] + pub behavior_revision: u64, + /// Independently-fresh generated Memory projection. + pub memory_surface: memory::backend::MemoryResidentSummaryOutput, +} + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(tag = "operation", content = "input", rename_all = "snake_case")] pub enum SubjektivMemoryBackendOperation { ResidentSummary(memory::backend::MemoryResidentSummaryOperation), + ResidentContext(memory::backend::MemoryResidentSummaryOperation), Query(SubjektivMemoryQueryRequest), Read(SubjektivMemoryReadRequest), ListRevisions(SubjektivMemoryListRevisionsRequest), @@ -5618,6 +5659,7 @@ pub struct SubjektivMemoryStageExplicitResponse { #[serde(tag = "result", content = "data", rename_all = "snake_case")] pub enum SubjektivMemoryBackendResponse { ResidentSummary(memory::backend::MemoryResidentSummaryOutput), + ResidentContext(SubjektivResidentContextOutput), Query(SubjektivMemoryQueryResponse), Read(SubjektivMemoryReadResponse), ListRevisions(SubjektivMemoryListRevisionsResponse), @@ -9861,9 +9903,11 @@ pub fn memory_api_typescript() -> String { // `ts-rs` solely for this read-only projection. let subjektiv_browser_declarations = r#"export type SubjektivSubjectState = "active" | "retired"; -export type SubjektivSubjectCreateRequest = { role: string, }; +export type SubjektivSubjectCreateRequest = { role: string, behavior_md?: string, }; -export type SubjektivSubjectResponse = { id: string, role: string, state: SubjektivSubjectState, store_revision: number, created_at: string, updated_at: string, current_worker?: | import("./worker-launch-api").WorkerLaunchWorkerSummary | null, }; +export type SubjektivSubjectBehaviorUpdateRequest = { expected_behavior_revision: number, behavior_md: string, }; + +export type SubjektivSubjectResponse = { id: string, role: string, behavior_md: string, behavior_revision: number, state: SubjektivSubjectState, store_revision: number, created_at: string, updated_at: string, current_worker?: | import("./worker-launch-api").WorkerLaunchWorkerSummary | null, }; export type SubjektivSubjectListResponse = { limit: number, items: Array, next_cursor?: string | null, has_more: boolean, }; @@ -9896,8 +9940,11 @@ export type SubjektivMemoryListRevisionsResponse = { memory_id: string, current_ let limits = format!( "export const MEMORY_API_LIMITS = {{\n maxResponseBytes: {MEMORY_API_MAX_RESPONSE_BYTES},\n maxDocumentBytes: {MEMORY_API_MAX_DOCUMENT_BYTES},\n maxCollectionItems: {MEMORY_API_MAX_COLLECTION_ITEMS},\n maxStringBytes: {MEMORY_API_MAX_STRING_BYTES},\n maxIdentifierBytes: {MEMORY_API_MAX_IDENTIFIER_BYTES},\n}} as const;" ); + let subjektiv_limits = format!( + "export const SUBJEKTIV_API_LIMITS = {{\n maxBehaviorBytes: {SUBJEKTIV_MAX_BEHAVIOR_BYTES},\n}} as const;" + ); format!( - "// Generated from server-api. Do not edit by hand.\n// Regenerate: cargo run -q -p server-api --features typescript --example generate_memory_api_types > web/workspace/src/lib/generated/memory-api.ts\n\n{limits}\n\n{}\n\n{subjektiv_browser_declarations}\n", + "// Generated from server-api. Do not edit by hand.\n// Regenerate: cargo run -q -p server-api --features typescript --example generate_memory_api_types > web/workspace/src/lib/generated/memory-api.ts\n\n{limits}\n\n{subjektiv_limits}\n\n{}\n\n{subjektiv_browser_declarations}\n", declarations .into_iter() .map(|declaration| format!("export {declaration}")) diff --git a/crates/session-store/src/system_item.rs b/crates/session-store/src/system_item.rs index 4d4a28b6a..4bbc56075 100644 --- a/crates/session-store/src/system_item.rs +++ b/crates/session-store/src/system_item.rs @@ -167,6 +167,15 @@ pub enum SystemItem { prompt_provenance: Option, }, + /// A Host-fetched change to user-managed Subject behavior. Each changed + /// revision is appended rather than rewriting prior prompt/session content. + SubjectBehaviorRefresh { + body: String, + behavior_revision: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + prompt_provenance: Option, + }, + /// Synthetic note inserted after an interrupted turn before the next /// user input. `body` is the exact LLM-context text explaining that the /// previous turn was cut short. @@ -192,6 +201,7 @@ impl SystemItem { } SystemItem::TaskReminder { body, .. } => body.clone(), SystemItem::ResidentSummaryRefresh { body, .. } => body.clone(), + SystemItem::SubjectBehaviorRefresh { body, .. } => body.clone(), SystemItem::Interrupt { body, .. } => body.clone(), } } @@ -214,6 +224,7 @@ impl SystemItem { SystemItem::LegacyIgnored { .. } => "legacy_ignored", SystemItem::TaskReminder { .. } => "task_reminder", SystemItem::ResidentSummaryRefresh { .. } => "resident_summary_refresh", + SystemItem::SubjectBehaviorRefresh { .. } => "subject_behavior_refresh", SystemItem::Interrupt { .. } => "interrupt", } } @@ -270,6 +281,25 @@ mod tests { )); } + #[test] + fn subject_behavior_refresh_round_trips_with_revision_and_exact_body() { + let item = SystemItem::SubjectBehaviorRefresh { + body: "current Subject context".to_string(), + behavior_revision: 7, + prompt_provenance: None, + }; + let raw = serde_json::to_string(&item).unwrap(); + let parsed: SystemItem = serde_json::from_str(&raw).unwrap(); + assert_eq!(parsed.history_text(), "current Subject context"); + assert!(matches!( + parsed, + SystemItem::SubjectBehaviorRefresh { + behavior_revision: 7, + .. + } + )); + } + #[test] fn notification_history_text_returns_stored_body() { let item = SystemItem::Notification { diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index 1a1f7f156..5871884ef 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -2618,6 +2618,7 @@ impl App { session_store::SystemItem::FileAttachment { body, .. } | session_store::SystemItem::SkillActivation { body, .. } | session_store::SystemItem::ResidentSummaryRefresh { body, .. } + | session_store::SystemItem::SubjectBehaviorRefresh { body, .. } | session_store::SystemItem::Interrupt { body, .. } => { self.task_store.apply_system_message_text(&body); self.blocks.push(Block::SystemMessage { text: body }); diff --git a/crates/worker/src/controller.rs b/crates/worker/src/controller.rs index 9b959f8b6..006ccd718 100644 --- a/crates/worker/src/controller.rs +++ b/crates/worker/src/controller.rs @@ -1375,6 +1375,7 @@ where crate::feature::builtin::memory::ordinary_subjektiv_resident_summary_source( worker.manifest(), worker.workspace_client_handle(), + worker.prompts(), )? { let workspace_id = worker diff --git a/crates/worker/src/feature/builtin/memory.rs b/crates/worker/src/feature/builtin/memory.rs index d1ed3689d..3565e8fcf 100644 --- a/crates/worker/src/feature/builtin/memory.rs +++ b/crates/worker/src/feature/builtin/memory.rs @@ -4,7 +4,8 @@ //! authority. In that case model-visible Memory tools must go through the //! workspace backend instead of resolving `.yoi/memory` from a Worker workdir. -use std::sync::Arc; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; use std::time::Duration; use agen::tool::{Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput}; @@ -24,11 +25,15 @@ use crate::feature::{ FeatureDescriptor, FeatureHookPoint, FeatureInstallContext, FeatureInstallError, FeatureModule, HookDeclaration, ToolContribution, ToolDeclaration, }; -use crate::hook::{Hook, HookError, HookErrorCategory, HookExecutionPolicy, WorkerRestored}; +use crate::hook::{ + BeforeSessionRewrite, BeforeSessionRewriteAction, BeforeSessionRewriteContext, Hook, HookError, + HookErrorCategory, HookExecutionPolicy, HookPreRequestAction, PreLlmRequest, PreRequestContext, + WorkerRestored, +}; use crate::prompt::catalog::{PromptCatalog, WorkerPrompt}; use crate::worker::{ - SystemPromptContribution, SystemPromptContributionSource, WorkspaceClient, - WorkspaceClientError, WorkspaceRequest, WorkspaceRequestMethod, + ResidentContextRefresh, SystemPromptContribution, SystemPromptContributionSource, + WorkspaceClient, WorkspaceClientError, WorkspaceRequest, WorkspaceRequestMethod, }; #[derive(Clone, Debug)] @@ -74,6 +79,8 @@ pub enum WorkspaceMemoryBackendError { Decode(#[from] serde_json::Error), #[error("workspace memory backend rejected operation: {0}")] Backend(String), + #[error("workspace memory backend returned an invalid response: {0}")] + InvalidResponse(String), } impl dyn WorkspaceClient + '_ { @@ -672,12 +679,17 @@ struct WorkspaceResidentSummarySource { client: Arc, } -struct WorkspaceSubjektivResidentSummarySource { - client: Arc, +#[derive(Default)] +struct SubjectBehaviorRefreshState { + applied: Option<(u64, String)>, + pending: HashMap, } -const READY_EMPTY_RESIDENT_SURFACE: &str = - "The current ready resident memory surface is intentionally empty."; +struct WorkspaceSubjektivResidentSummarySource { + client: Arc, + prompts: Arc>, + behavior_state: Mutex, +} fn resident_summary_contribution( output: memory::backend::MemoryResidentSummaryOutput, @@ -715,37 +727,133 @@ impl SystemPromptContributionSource for WorkspaceResidentSummarySource { } } -#[async_trait] -impl SystemPromptContributionSource for WorkspaceSubjektivResidentSummarySource { - async fn load(&self) -> SystemPromptContribution { +impl WorkspaceSubjektivResidentSummarySource { + async fn fetch( + &self, + ) -> Result { match execute_subjektiv_memory_operation_with( self.client.as_ref(), - server_api::SubjektivMemoryBackendOperation::ResidentSummary(Default::default()), + server_api::SubjektivMemoryBackendOperation::ResidentContext(Default::default()), |client, request| { client.execute_with_timeout(request, RESIDENT_SUMMARY_REQUEST_TIMEOUT) }, - ) { - Ok(server_api::SubjektivMemoryBackendResponse::ResidentSummary(output)) => { - match resident_summary_contribution(output) { - SystemPromptContribution::Ready(content) if content.trim().is_empty() => { - SystemPromptContribution::Ready(READY_EMPTY_RESIDENT_SURFACE.to_string()) - } - contribution => contribution, + )? { + server_api::SubjektivMemoryBackendResponse::ResidentContext(output) => Ok(output), + other => Err(WorkspaceMemoryBackendError::InvalidResponse(format!( + "unexpected subject resident context response: {other:?}" + ))), + } + } + + fn render( + &self, + output: &server_api::SubjektivResidentContextOutput, + ) -> Result { + use memory::backend::MemoryResidentSummaryAvailability as Availability; + let availability = match output.memory_surface.availability { + Availability::Ready => "ready", + Availability::Ungenerated => "ungenerated", + Availability::Stale => "stale", + Availability::Failed => "failed", + Availability::Unavailable => "unavailable", + }; + self.prompts + .load() + .subjektiv_resident_context( + &output.behavior_md, + availability, + output.memory_surface.content.as_deref(), + ) + .map_err(|error| error.to_string()) + } + + fn remember_behavior(&self, output: &server_api::SubjektivResidentContextOutput) { + let mut state = self + .behavior_state + .lock() + .expect("subject behavior state poisoned"); + state.applied = Some((output.behavior_revision, output.behavior_md.clone())); + state.pending.remove(&output.behavior_revision); + } +} + +#[async_trait] +impl SystemPromptContributionSource for WorkspaceSubjektivResidentSummarySource { + async fn load(&self) -> SystemPromptContribution { + match self.fetch().await { + Ok(output) => match self.render(&output) { + Ok(rendered) => { + self.remember_behavior(&output); + SystemPromptContribution::Ready(rendered) } - } - Ok(other) => { - tracing::debug!(?other, "unexpected subject resident Memory response"); - SystemPromptContribution::Unavailable - } + Err(error) => { + tracing::error!(%error, "subject resident context render failed"); + SystemPromptContribution::Ready( + "The Host could not render the Subject resident context. Do not treat this as unset behavior or infer replacement behavior from Memory." + .to_string(), + ) + } + }, Err(error) => { - tracing::debug!(%error, "subject resident Memory unavailable"); - SystemPromptContribution::Unavailable + tracing::error!(%error, "subject resident context fetch failed"); + SystemPromptContribution::Ready( + "The Host could not fetch the Subject resident context. Do not treat this as unset behavior or infer replacement behavior from Memory." + .to_string(), + ) } } } + + async fn load_changed_resident_context( + &self, + ) -> Result, String> { + let output = self.fetch().await.map_err(|error| error.to_string())?; + let changed = self + .behavior_state + .lock() + .expect("subject behavior state poisoned") + .applied + .as_ref() + .is_none_or(|(revision, body)| { + *revision != output.behavior_revision || body != &output.behavior_md + }); + if !changed { + return Ok(None); + } + let rendered = self.render(&output)?; + let revision = output.behavior_revision; + self.behavior_state + .lock() + .expect("subject behavior state poisoned") + .pending + .insert(revision, output.behavior_md); + Ok(Some(ResidentContextRefresh { + body: rendered, + revision, + })) + } + + fn confirm_resident_context_revision(&self, revision: u64) { + let mut state = self + .behavior_state + .lock() + .expect("subject behavior state poisoned"); + if let Some(body) = state.pending.remove(&revision) { + state.applied = Some((revision, body)); + } + } + + fn invalidate_resident_context_representation(&self) { + self.behavior_state + .lock() + .expect("subject behavior state poisoned") + .applied = None; + } } const SUBJEKTIV_RESTORE_REFRESH_HOOK: &str = "refresh-resident-surface-after-restore"; +const SUBJEKTIV_BEHAVIOR_REFRESH_HOOK: &str = "refresh-subject-behavior-before-request"; +const SUBJEKTIV_BEHAVIOR_REWRITE_HOOK: &str = "invalidate-subject-behavior-before-rewrite"; /// subjektiv-owned restore behavior. Initial prompt contribution remains a /// shared host facility, while loading and rendering a post-restore refresh is @@ -793,6 +901,14 @@ impl FeatureModule for SubjektivResidentRestoreRefreshFeature { SUBJEKTIV_RESTORE_REFRESH_HOOK, FeatureHookPoint::WorkerRestored, )) + .with_hook(HookDeclaration::new( + SUBJEKTIV_BEHAVIOR_REFRESH_HOOK, + FeatureHookPoint::PreLlmRequest, + )) + .with_hook(HookDeclaration::new( + SUBJEKTIV_BEHAVIOR_REWRITE_HOOK, + FeatureHookPoint::BeforeSessionRewrite, + )) } fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> { @@ -804,10 +920,64 @@ impl FeatureModule for SubjektivResidentRestoreRefreshFeature { prompts: Arc::clone(&self.prompts), workspace_id: self.workspace_id.clone(), }, + )?; + context.hooks().add_pre_llm_request( + SUBJEKTIV_BEHAVIOR_REFRESH_HOOK, + HookExecutionPolicy::fail_closed(), + SubjektivBehaviorRefreshHook { + source: Arc::clone(&self.source), + }, + )?; + context.hooks().add_before_session_rewrite( + SUBJEKTIV_BEHAVIOR_REWRITE_HOOK, + HookExecutionPolicy::fail_closed(), + SubjektivBehaviorRewriteHook { + source: Arc::clone(&self.source), + }, ) } } +struct SubjektivBehaviorRewriteHook { + source: Arc, +} + +#[async_trait] +impl Hook for SubjektivBehaviorRewriteHook { + async fn call( + &self, + _context: &BeforeSessionRewriteContext, + ) -> Result { + self.source.invalidate_resident_context_representation(); + Ok(BeforeSessionRewriteAction::Continue) + } +} + +struct SubjektivBehaviorRefreshHook { + source: Arc, +} + +#[async_trait] +impl Hook for SubjektivBehaviorRefreshHook { + async fn call(&self, context: &PreRequestContext) -> Result { + let refresh = self + .source + .load_changed_resident_context() + .await + .map_err(|error| HookError::new(HookErrorCategory::Dependency, error))?; + if let Some(refresh) = refresh { + let system_items = context.system_items().ok_or_else(|| { + HookError::new( + HookErrorCategory::Dependency, + "Subject behavior refresh requires durable session append authority", + ) + })?; + system_items.append_subject_behavior_refresh(refresh.body, refresh.revision); + } + Ok(HookPreRequestAction::Continue) + } +} + struct SubjektivResidentRestoreRefreshHook { source: Arc, prompts: Arc>, @@ -951,6 +1121,7 @@ pub(crate) fn ordinary_subjektiv_features_enabled(manifest: &manifest::WorkerMan pub(crate) fn ordinary_subjektiv_resident_summary_source( manifest: &manifest::WorkerManifest, client: Arc, + prompts: Arc>, ) -> std::io::Result>> { if !ordinary_subjektiv_features_enabled(manifest) { return Ok(None); @@ -988,6 +1159,8 @@ pub(crate) fn ordinary_subjektiv_resident_summary_source( } Ok(Some(Arc::new(WorkspaceSubjektivResidentSummarySource { client, + prompts, + behavior_state: Mutex::new(SubjectBehaviorRefreshState::default()), }))) } @@ -1120,6 +1293,10 @@ mod tests { use agen::tool::ToolDefinition; use std::sync::Mutex; + fn test_prompts() -> Arc> { + Arc::new(ArcSwap::from(PromptCatalog::builtins_only().unwrap())) + } + fn test_client() -> Arc { Arc::new(crate::worker::TestWorkspaceHttpClient::new( "workspace", @@ -1132,6 +1309,7 @@ mod tests { availability: memory::backend::MemoryResidentSummaryAvailability, content: Option, scope_allowed: bool, + behavior: Mutex<(u64, String)>, paths: Mutex>, timeouts: Mutex>, } @@ -1145,6 +1323,7 @@ mod tests { availability, content: content.map(str::to_string), scope_allowed: true, + behavior: Mutex::new((3, "Be deliberate.".to_string())), paths: Mutex::new(Vec::new()), timeouts: Mutex::new(Vec::new()), } @@ -1155,6 +1334,7 @@ mod tests { availability: memory::backend::MemoryResidentSummaryAvailability::Ready, content: Some("must not be injected".into()), scope_allowed: false, + behavior: Mutex::new((3, "must not be injected".to_string())), paths: Mutex::new(Vec::new()), timeouts: Mutex::new(Vec::new()), } @@ -1184,7 +1364,7 @@ mod tests { serde_json::from_str(request.body.as_deref().unwrap_or_default()).unwrap(); assert!(matches!( request.operation, - server_api::SubjektivMemoryBackendOperation::ResidentSummary(_) + server_api::SubjektivMemoryBackendOperation::ResidentContext(_) )); if !self.scope_allowed { return Ok(WorkspaceResponse { @@ -1192,13 +1372,18 @@ mod tests { body: "subject scope unavailable".into(), }); } + let (behavior_revision, behavior_md) = self.behavior.lock().unwrap().clone(); Ok(WorkspaceResponse { status: 200, body: serde_json::to_string( - &server_api::SubjektivMemoryBackendResponse::ResidentSummary( - memory::backend::MemoryResidentSummaryOutput { - availability: self.availability, - content: self.content.clone(), + &server_api::SubjektivMemoryBackendResponse::ResidentContext( + server_api::SubjektivResidentContextOutput { + behavior_md, + behavior_revision, + memory_surface: memory::backend::MemoryResidentSummaryOutput { + availability: self.availability, + content: self.content.clone(), + }, }, ), ) @@ -1511,13 +1696,19 @@ permission = "write" memory::backend::MemoryResidentSummaryAvailability::Ready, Some("current subject surface"), )); - let ready = ordinary_subjektiv_resident_summary_source(&manifest, ready_client.clone()) - .unwrap() - .unwrap(); - assert_eq!( - ready.load().await, - SystemPromptContribution::Ready("current subject surface".into()) - ); + let ready = ordinary_subjektiv_resident_summary_source( + &manifest, + ready_client.clone(), + test_prompts(), + ) + .unwrap() + .unwrap(); + let loaded = ready.load().await; + let SystemPromptContribution::Ready(loaded) = loaded else { + panic!("subject resident context must be present"); + }; + assert!(loaded.contains("Be deliberate.")); + assert!(loaded.contains("current subject surface")); assert_eq!( ready_client.paths.lock().unwrap().clone(), vec!["/api/w/workspace/subjektiv/memory".to_string()] @@ -1531,13 +1722,18 @@ permission = "write" memory::backend::MemoryResidentSummaryAvailability::Ready, None, )); - let ready_empty = ordinary_subjektiv_resident_summary_source(&manifest, ready_empty_client) - .unwrap() - .unwrap(); - assert_eq!( - ready_empty.load().await, - SystemPromptContribution::Ready(READY_EMPTY_RESIDENT_SURFACE.into()) - ); + let ready_empty = ordinary_subjektiv_resident_summary_source( + &manifest, + ready_empty_client, + test_prompts(), + ) + .unwrap() + .unwrap(); + let SystemPromptContribution::Ready(ready_empty) = ready_empty.load().await else { + panic!("empty surface must retain Subject behavior"); + }; + assert!(ready_empty.contains("Be deliberate.")); + assert!(ready_empty.contains("intentionally empty")); for availability in [ memory::backend::MemoryResidentSummaryAvailability::Ungenerated, @@ -1550,22 +1746,107 @@ permission = "write" availability, Some("must not be injected"), )), + test_prompts(), ) .unwrap() .unwrap(); - assert_eq!(source.load().await, SystemPromptContribution::Unavailable); + let SystemPromptContribution::Ready(loaded) = source.load().await else { + panic!("Subject behavior must survive unavailable surfaces"); + }; + assert!(loaded.contains("Be deliberate.")); + assert!(!loaded.contains("must not be injected")); } } + #[tokio::test] + async fn subject_resident_source_emits_only_changed_behavior_revisions() { + let manifest = subject_manifest("workspace"); + let client = Arc::new(SubjectResidentClient::new( + memory::backend::MemoryResidentSummaryAvailability::Ungenerated, + None, + )); + let source = + ordinary_subjektiv_resident_summary_source(&manifest, client.clone(), test_prompts()) + .unwrap() + .unwrap(); + + let initial = source.load().await; + assert!(matches!(initial, SystemPromptContribution::Ready(_))); + assert!( + source + .load_changed_resident_context() + .await + .unwrap() + .is_none() + ); + + *client.behavior.lock().unwrap() = (4, "Ask when uncertain.".to_string()); + let refresh = source + .load_changed_resident_context() + .await + .unwrap() + .expect("changed behavior must produce one refresh"); + assert_eq!(refresh.revision, 4); + assert!(refresh.body.contains("Ask when uncertain.")); + source.confirm_resident_context_revision(refresh.revision); + assert!( + source + .load_changed_resident_context() + .await + .unwrap() + .is_none() + ); + + *client.behavior.lock().unwrap() = (5, String::new()); + let cleared = source + .load_changed_resident_context() + .await + .unwrap() + .expect("cleared behavior must produce one refresh"); + assert_eq!(cleared.revision, 5); + assert!(cleared.body.contains("No user-managed behavior is set")); + source.confirm_resident_context_revision(cleared.revision); + assert!( + source + .load_changed_resident_context() + .await + .unwrap() + .is_none() + ); + + source.invalidate_resident_context_representation(); + let replay_after_rewrite = source + .load_changed_resident_context() + .await + .unwrap() + .expect("history rewrite must require a durable current representation"); + assert_eq!(replay_after_rewrite.revision, 5); + assert!( + replay_after_rewrite + .body + .contains("No user-managed behavior is set") + ); + // Initial load and every live update/clear/replay probe retain the + // target-side bounded resident request contract. + assert_eq!( + client.timeouts.lock().unwrap().as_slice(), + &[RESIDENT_SUMMARY_REQUEST_TIMEOUT; 7] + ); + } + #[tokio::test] async fn subject_resident_source_fails_closed_without_trusted_subject_scope() { let manifest = subject_manifest("workspace"); let client = Arc::new(SubjectResidentClient::denied()); - let source = ordinary_subjektiv_resident_summary_source(&manifest, client.clone()) - .unwrap() - .unwrap(); + let source = + ordinary_subjektiv_resident_summary_source(&manifest, client.clone(), test_prompts()) + .unwrap() + .unwrap(); - assert_eq!(source.load().await, SystemPromptContribution::Unavailable); + let SystemPromptContribution::Ready(failure) = source.load().await else { + panic!("fetch failure must be explicit in resident context"); + }; + assert!(failure.contains("could not fetch")); assert_eq!( client.paths.lock().unwrap().clone(), vec!["/api/w/workspace/subjektiv/memory".to_string()] @@ -1593,7 +1874,7 @@ permission = "write" ) .unwrap(); assert!( - ordinary_subjektiv_resident_summary_source(&disabled, test_client()) + ordinary_subjektiv_resident_summary_source(&disabled, test_client(), test_prompts()) .unwrap() .is_none() ); @@ -1605,22 +1886,30 @@ permission = "write" Some("policy-only surface must not load"), )); assert!( - ordinary_subjektiv_resident_summary_source(&policy_only, unattached_client.clone()) - .unwrap() - .is_none() + ordinary_subjektiv_resident_summary_source( + &policy_only, + unattached_client.clone(), + test_prompts() + ) + .unwrap() + .is_none() ); assert!(unattached_client.paths.lock().unwrap().is_empty()); let foreign = subject_manifest("other-workspace"); - assert!(ordinary_subjektiv_resident_summary_source(&foreign, test_client()).is_err()); + assert!( + ordinary_subjektiv_resident_summary_source(&foreign, test_client(), test_prompts()) + .is_err() + ); } #[test] fn subject_restore_refresh_is_suppressed_for_internal_hosts() { let manifest = subject_manifest("workspace"); - let source = ordinary_subjektiv_resident_summary_source(&manifest, test_client()) - .unwrap() - .unwrap(); + let source = + ordinary_subjektiv_resident_summary_source(&manifest, test_client(), test_prompts()) + .unwrap() + .unwrap(); let prompts = Arc::new(ArcSwap::from( crate::prompt::catalog::PromptCatalog::builtins_only().unwrap(), )); @@ -1634,9 +1923,10 @@ permission = "write" #[test] fn subject_restore_refresh_feature_declares_only_restore_lifecycle() { let manifest = subject_manifest("workspace"); - let source = ordinary_subjektiv_resident_summary_source(&manifest, test_client()) - .unwrap() - .unwrap(); + let source = + ordinary_subjektiv_resident_summary_source(&manifest, test_client(), test_prompts()) + .unwrap() + .unwrap(); let feature = SubjektivResidentRestoreRefreshFeature::new( source, Arc::new(ArcSwap::from( @@ -1647,8 +1937,13 @@ permission = "write" let descriptor = feature.descriptor(); assert!(descriptor.tools.is_empty()); - assert_eq!(descriptor.hooks.len(), 1); + assert_eq!(descriptor.hooks.len(), 3); assert_eq!(descriptor.hooks[0].point, FeatureHookPoint::WorkerRestored); + assert_eq!(descriptor.hooks[1].point, FeatureHookPoint::PreLlmRequest); + assert_eq!( + descriptor.hooks[2].point, + FeatureHookPoint::BeforeSessionRewrite + ); } #[test] diff --git a/crates/worker/src/hook.rs b/crates/worker/src/hook.rs index 6e0445d87..82f93d469 100644 --- a/crates/worker/src/hook.rs +++ b/crates/worker/src/hook.rs @@ -281,6 +281,20 @@ impl SystemItemAppendHandle { .expect("system-item append queue poisoned") .push(item); } + + /// Queue a revisioned Subject behavior refresh. The caller receives this + /// narrow operation only at a pre-request boundary, and the host commits it + /// before exposing it to the model. + pub fn append_subject_behavior_refresh(&self, body: impl Into, behavior_revision: u64) { + self.pending + .lock() + .expect("system-item append queue poisoned") + .push(SystemItem::SubjectBehaviorRefresh { + body: body.into(), + behavior_revision, + prompt_provenance: None, + }); + } } /// Host-created narrow append authority exposed only at a restore lifecycle diff --git a/crates/worker/src/ipc/interceptor.rs b/crates/worker/src/ipc/interceptor.rs index 2b403cd39..e3378f3f2 100644 --- a/crates/worker/src/ipc/interceptor.rs +++ b/crates/worker/src/ipc/interceptor.rs @@ -38,7 +38,7 @@ use crate::hook::{ use crate::ipc::notify_buffer::{NotifyBuffer, build_system_item_with_provenance}; use crate::prompt::catalog::PromptCatalog; use crate::session_history::SessionHistoryMetadata; -use crate::worker::SystemItemCommitter; +use crate::worker::{SystemItemCommitter, SystemPromptContributionSource}; use agen::HistoryEntry; use agen::token_counter::total_tokens; @@ -109,6 +109,7 @@ pub(crate) struct WorkerInterceptor { /// worker. `None` in tests / `Worker::new` paths where no writer is /// attached. log_writer: Option>, + resident_context_source: Option>, pending_committed_history: Arc>>>, /// Next turn index assigned by `on_prompt_submit`. next_turn_index: AtomicUsize, @@ -163,6 +164,7 @@ impl WorkerInterceptor { prompts, prompt_workspace_id: None, log_writer, + resident_context_source: None, pending_committed_history, next_turn_index: AtomicUsize::new(0), tool_calls_this_turn: AtomicUsize::new(0), @@ -179,11 +181,31 @@ impl WorkerInterceptor { self } + pub(crate) fn with_resident_context_source( + mut self, + source: Option>, + ) -> Self { + self.resident_context_source = source; + self + } + pub(crate) fn with_prompt_workspace_id(mut self, workspace_id: Option) -> Self { self.prompt_workspace_id = workspace_id; self } + fn confirm_resident_context_item(&self, item: &SystemItem) { + let Some(source) = self.resident_context_source.as_ref() else { + return; + }; + if let SystemItem::SubjectBehaviorRefresh { + behavior_revision, .. + } = item + { + source.confirm_resident_context_revision(*behavior_revision); + } + } + /// Commit each `SystemItem` as its own `LogEntry::AnnotatedSystemItem` /// entry through the attached writer (no-op when no writer is /// wired). Sync — writes complete before the matching @@ -211,6 +233,7 @@ impl WorkerInterceptor { .lock() .expect("pending committed history poisoned") .push_back(entry); + self.confirm_resident_context_item(item); } Ok(()) } @@ -350,6 +373,11 @@ impl WorkerInterceptor { } if prompt_provenance.is_none() => { *prompt_provenance = Some(provenance("internal.task_reminder")); } + SystemItem::SubjectBehaviorRefresh { + prompt_provenance, .. + } if prompt_provenance.is_none() => { + *prompt_provenance = Some(provenance("internal.subjektiv_resident_context")); + } SystemItem::Interrupt { prompt_provenance, .. } if prompt_provenance.is_none() => { @@ -763,6 +791,7 @@ mod tests { HookTurnEndAction, OnTurnEnd, PostToolCall, PreLlmRequest, PreToolCall, }; use crate::session_history::{WorkerHistoryProvenance, history_entry}; + use crate::worker::SystemPromptContribution; fn test_prompts() -> Arc> { Arc::new(ArcSwap::from(PromptCatalog::builtins_only().unwrap())) @@ -859,6 +888,53 @@ mod tests { } } + struct AppendingSubjectBehaviorHook; + + #[async_trait] + impl Hook for AppendingSubjectBehaviorHook { + async fn call( + &self, + input: &PreRequestContext, + ) -> Result { + input + .system_items() + .expect("test interceptor has durable append authority") + .append_subject_behavior_refresh("current Subject context", 7); + Ok(HookPreRequestAction::Continue) + } + } + + struct ConfirmRecordingResidentSource { + confirmed: Arc>>, + } + + #[async_trait] + impl SystemPromptContributionSource for ConfirmRecordingResidentSource { + async fn load(&self) -> SystemPromptContribution { + SystemPromptContribution::Unavailable + } + + fn confirm_resident_context_revision(&self, revision: u64) { + self.confirmed.lock().unwrap().push(revision); + } + } + + struct FailingSystemItemCommitter; + + impl SystemItemCommitter for FailingSystemItemCommitter { + fn commit_log_entry( + &self, + entry: session_store::LogEntry, + ) -> Result<(), session_store::StoreError> { + if matches!(entry, session_store::LogEntry::AnnotatedSystemItem { .. }) { + return Err(session_store::StoreError::Io(std::io::Error::other( + "synthetic system-item failure", + ))); + } + Ok(()) + } + } + fn task_tool_call_info( name: &str, input: serde_json::Value, @@ -1346,6 +1422,88 @@ mod tests { } } + #[tokio::test] + async fn subject_behavior_revision_is_confirmed_only_after_durable_commit() { + let mut builder = HookRegistryBuilder::new(); + builder.add_pre_llm_request(AppendingSubjectBehaviorHook); + let committed = Arc::new(Mutex::new(Vec::new())); + let confirmed = Arc::new(Mutex::new(Vec::new())); + let source: Arc = + Arc::new(ConfirmRecordingResidentSource { + confirmed: Arc::clone(&confirmed), + }); + let interceptor = WorkerInterceptor::new( + Arc::new(builder.build()), + None, + None, + NotifyBuffer::new(), + Arc::new(Mutex::new(Vec::new())), + test_prompts(), + Some(Arc::new(RecordingSystemItemCommitter { + committed: Arc::clone(&committed), + })), + ) + .with_resident_context_source(Some(source)); + + let mut ctx = Vec::new(); + let action = interceptor + .pre_llm_request(PreLlmRequestContext { + invocation: Default::default(), + items: &mut ctx, + history: &[], + }) + .await + .unwrap(); + + assert!(matches!(action, PreRequestAction::ContinueWith(_))); + assert_eq!(*confirmed.lock().unwrap(), [7]); + assert!(matches!( + committed.lock().unwrap().as_slice(), + [SystemItem::SubjectBehaviorRefresh { + behavior_revision: 7, + prompt_provenance: Some(_), + .. + }] + )); + } + + #[tokio::test] + async fn failed_subject_behavior_persistence_does_not_confirm_revision() { + let mut builder = HookRegistryBuilder::new(); + builder.add_pre_llm_request(AppendingSubjectBehaviorHook); + let confirmed = Arc::new(Mutex::new(Vec::new())); + let source: Arc = + Arc::new(ConfirmRecordingResidentSource { + confirmed: Arc::clone(&confirmed), + }); + let interceptor = WorkerInterceptor::new( + Arc::new(builder.build()), + None, + None, + NotifyBuffer::new(), + Arc::new(Mutex::new(Vec::new())), + test_prompts(), + Some(Arc::new(FailingSystemItemCommitter)), + ) + .with_resident_context_source(Some(source)); + + let mut ctx = Vec::new(); + let action = interceptor + .pre_llm_request(PreLlmRequestContext { + invocation: Default::default(), + items: &mut ctx, + history: &[], + }) + .await + .unwrap(); + + assert!(matches!( + action, + PreRequestAction::Cancel(reason) if reason.contains("session persistence failed") + )); + assert!(confirmed.lock().unwrap().is_empty()); + } + #[tokio::test] async fn pre_llm_request_without_log_writer_does_not_expose_system_item_handle() { let saw_handle = Arc::new(AtomicBool::new(false)); diff --git a/crates/worker/src/prompt/catalog.rs b/crates/worker/src/prompt/catalog.rs index f8c52cc47..9b3e30459 100644 --- a/crates/worker/src/prompt/catalog.rs +++ b/crates/worker/src/prompt/catalog.rs @@ -105,6 +105,7 @@ pub enum WorkerPrompt { AgentsMdSection, ResidentMemorySummarySection, ResidentMemoryRestoreSection, + SubjektivResidentContext, WorkerOrchestrationGuidanceSection, SubWorkerSpawnToolDescription, } @@ -128,6 +129,7 @@ impl WorkerPrompt { Self::AgentsMdSection => "internal.agents_md_section", Self::ResidentMemorySummarySection => "internal.resident_memory_summary_section", Self::ResidentMemoryRestoreSection => "internal.resident_memory_restore_section", + Self::SubjektivResidentContext => "internal.subjektiv_resident_context", Self::WorkerOrchestrationGuidanceSection => { "internal.worker_orchestration_guidance_section" } @@ -150,6 +152,7 @@ impl WorkerPrompt { WorkerPrompt::AgentsMdSection, WorkerPrompt::ResidentMemorySummarySection, WorkerPrompt::ResidentMemoryRestoreSection, + WorkerPrompt::SubjektivResidentContext, WorkerPrompt::WorkerOrchestrationGuidanceSection, WorkerPrompt::SubWorkerSpawnToolDescription, ]; @@ -518,6 +521,21 @@ impl PromptCatalog { }, ) } + pub fn subjektiv_resident_context( + &self, + behavior_md: &str, + surface_availability: &str, + surface_body: Option<&str>, + ) -> Result { + self.render( + WorkerPrompt::SubjektivResidentContext, + minijinja::context! { + behavior_md => behavior_md, + surface_availability => surface_availability, + surface_body => surface_body.unwrap_or_default(), + }, + ) + } pub fn worker_orchestration_guidance_section(&self) -> Result { self.render( WorkerPrompt::WorkerOrchestrationGuidanceSection, diff --git a/crates/worker/src/worker.rs b/crates/worker/src/worker.rs index 44ac187ea..f3820708d 100644 --- a/crates/worker/src/worker.rs +++ b/crates/worker/src/worker.rs @@ -2361,9 +2361,30 @@ pub(crate) enum SystemPromptContribution { Unavailable, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ResidentContextRefresh { + pub body: String, + pub revision: u64, +} + #[async_trait::async_trait] pub(crate) trait SystemPromptContributionSource: Send + Sync { async fn load(&self) -> SystemPromptContribution; + + /// Returns a durable model-visible refresh only when user-managed resident + /// context changed since the last successful load. + async fn load_changed_resident_context( + &self, + ) -> Result, String> { + Ok(None) + } + + /// Acknowledges that the revision's typed refresh was durably committed. + fn confirm_resident_context_revision(&self, _revision: u64) {} + + /// Invalidates the in-memory representation fence before history may be + /// compacted or rewound. + fn invalidate_resident_context_representation(&self) {} } #[derive(Debug, Clone)] @@ -3840,6 +3861,7 @@ impl Worker { ) .with_usage_tracker(self.usage_tracker.clone()) .with_metrics_tracker(self.metrics_tracker.clone()) + .with_resident_context_source(self.feature_resident_summary_source.clone()) .with_prompt_workspace_id( self.workspace_context .workspace_id() @@ -9224,16 +9246,20 @@ mod build_summary_prompt_tests { serde_json::from_str(request.body.as_deref().unwrap_or_default()).unwrap(); assert!(matches!( request.operation, - server_api::SubjektivMemoryBackendOperation::ResidentSummary(_) + server_api::SubjektivMemoryBackendOperation::ResidentContext(_) )); self.load_count.fetch_add(1, Ordering::SeqCst); Ok(WorkspaceResponse { status: 200, body: serde_json::to_string( - &server_api::SubjektivMemoryBackendResponse::ResidentSummary( - memory::backend::MemoryResidentSummaryOutput { - availability: self.availability, - content: self.content.clone(), + &server_api::SubjektivMemoryBackendResponse::ResidentContext( + server_api::SubjektivResidentContextOutput { + behavior_md: "restore behavior".to_string(), + behavior_revision: 1, + memory_surface: memory::backend::MemoryResidentSummaryOutput { + availability: self.availability, + content: self.content.clone(), + }, }, ), ) @@ -9254,7 +9280,9 @@ mod build_summary_prompt_tests { load_count: Arc::clone(&load_count), }); let source = crate::feature::builtin::memory::ordinary_subjektiv_resident_summary_source( - manifest, client, + manifest, + client, + Arc::new(ArcSwap::from(PromptCatalog::builtins_only().unwrap())), ) .unwrap() .unwrap(); @@ -12219,9 +12247,10 @@ permission = "write" .unwrap() .to_string(); assert!( - empty_boundary.contains("current ready resident memory surface is intentionally empty") + empty_boundary + .contains("current generated Memory surface is ready and intentionally empty") ); - assert!(!empty_boundary.contains("No current ready resident memory surface")); + assert!(!empty_boundary.contains("No generated Memory surface exists yet")); drop(empty_restore); for availability in [ @@ -12273,7 +12302,22 @@ permission = "write" .as_text() .unwrap() .to_string(); - assert!(tombstone.contains("No current ready resident memory surface")); + let unavailable_message = match availability { + memory::backend::MemoryResidentSummaryAvailability::Ungenerated => { + "No generated Memory surface exists yet" + } + memory::backend::MemoryResidentSummaryAvailability::Stale => { + "generated Memory surface is stale" + } + memory::backend::MemoryResidentSummaryAvailability::Failed => { + "Generation of the Memory surface failed" + } + memory::backend::MemoryResidentSummaryAvailability::Unavailable => { + "Host could not fetch the Subject resident context" + } + memory::backend::MemoryResidentSummaryAvailability::Ready => unreachable!(), + }; + assert!(tombstone.contains(unavailable_message)); assert!(!tombstone.contains("latest subject surface")); assert!(!tombstone.contains("LEGACY_MEMORY_SENTINEL_MUST_NOT_REFRESH")); let entries = store.read_all(session_id, segment_id).unwrap(); diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index afa94952e..3954d5416 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -5811,6 +5811,7 @@ fn generated_workspace_contract_router(service: ServerApiContractService) -> Rou .merge(server_api::server_api_axum::subjektiv_subject_get( service.clone(), )) + .merge(server_api::server_api_axum::subjektiv_subject_behavior_update(service.clone())) .merge(server_api::server_api_axum::subjektiv_resident_surface( service.clone(), )) @@ -7486,6 +7487,26 @@ impl server_api::ServerApi for ServerApiContractService { .map_err(ApiError::into_repository_api_error) } + async fn subjektiv_subject_behavior_update( + &self, + workspace_id: String, + subject_id: String, + request: server_api::SubjektivSubjectBehaviorUpdateRequest, + ) -> std::result::Result + { + scoped_update_subjektiv_subject_behavior( + State(self.workspace_api()?.clone()), + AxumPath(ScopedSubjektivSubjectPath { + workspace_id, + subject_id, + }), + Json(request), + ) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + async fn subjektiv_resident_surface( &self, workspace_id: String, @@ -17447,6 +17468,8 @@ fn subjektiv_subject_response( server_api::SubjektivSubjectResponse { id: subject.id, role: subject.role.as_str().to_string(), + behavior_md: subject.behavior_md, + behavior_revision: subject.behavior_revision, state: match subject.state { crate::subjektiv::SubjectState::Active => server_api::SubjektivSubjectState::Active, crate::subjektiv::SubjectState::Retired => server_api::SubjektivSubjectState::Retired, @@ -17466,9 +17489,11 @@ async fn scoped_create_subjektiv_subject( validate_workspace_scope(&api, &path.workspace_id)?; let role = crate::subjektiv::SubjectRole::new(request.role) .map_err(|error| Error::InvalidInput(error.to_string()))?; + crate::subjektiv::validate_subject_behavior(&request.behavior_md) + .map_err(|error| Error::InvalidInput(error.to_string()))?; let subject = open_subjektiv_store(&api)? - .create_subject(role) - .map_err(|error| Error::Store(error.to_string()))?; + .create_subject_with_behavior(role, request.behavior_md) + .map_err(subjektiv_store_error)?; Ok(( StatusCode::CREATED, Json(subjektiv_subject_response(&api, subject)), @@ -17560,6 +17585,24 @@ async fn scoped_get_subjektiv_subject( Ok(Json(subjektiv_subject_response(&api, subject))) } +async fn scoped_update_subjektiv_subject_behavior( + State(api): State, + AxumPath(path): AxumPath, + Json(request): Json, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + crate::subjektiv::validate_subject_behavior(&request.behavior_md) + .map_err(|error| Error::InvalidInput(error.to_string()))?; + let subject = open_subjektiv_store(&api)? + .update_subject_behavior( + &path.subject_id, + request.expected_behavior_revision, + request.behavior_md, + ) + .map_err(subjektiv_store_error)?; + Ok(Json(subjektiv_subject_response(&api, subject))) +} + fn require_subjektiv_subject( store: &crate::subjektiv::SubjektivStore, subject_id: &str, @@ -19166,6 +19209,23 @@ async fn scoped_subjektiv_memory_backend( resident, )) } + server_api::SubjektivMemoryBackendOperation::ResidentContext(_) => { + require_subjektiv_worker_authority( + authority, + SubjektivWorkerAuthority::Subject, + "resident subject context read", + )?; + let resident = store + .resident_context(&subject_id) + .map_err(subjektiv_store_error)?; + server_api::SubjektivMemoryBackendResponse::ResidentContext( + server_api::SubjektivResidentContextOutput { + behavior_md: resident.subject.behavior_md, + behavior_revision: resident.subject.behavior_revision, + memory_surface: resident_summary_output(resident.surface), + }, + ) + } server_api::SubjektivMemoryBackendOperation::Query(input) => { server_api::SubjektivMemoryBackendResponse::Query(subjektiv_memory_query( &store, @@ -20540,6 +20600,7 @@ fn subjektiv_store_error(error: crate::subjektiv::SubjektivError) -> Error { Error::WorkspacePermissionDenied(format!("subject_scope_mismatch: {error}")) } crate::subjektiv::SubjektivError::RevisionConflict { .. } + | crate::subjektiv::SubjektivError::SubjectBehaviorConflict { .. } | crate::subjektiv::SubjektivError::SurfaceGenerationConflict(_) => { Error::RepositoryConflict(format!("revision_conflict: {error}")) } @@ -39199,18 +39260,80 @@ mod tests { app.clone(), "POST", &format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects"), - Some(serde_json::json!({ "role": " Browser author " })), + Some(serde_json::json!({ + "role": " Browser author ", + "behavior_md": "Prefer explicit evidence.\nAsk when uncertain." + })), &owner_token, StatusCode::CREATED, ) .await; assert_eq!(created["role"], " Browser author "); + assert_eq!( + created["behavior_md"], + "Prefer explicit evidence.\nAsk when uncertain." + ); + assert_eq!(created["behavior_revision"], 0); assert_eq!(created["state"], "active"); assert_eq!(created["store_revision"], 0); assert!(created.get("current_worker").is_none()); let created_id = created["id"].as_str().unwrap(); let persisted = store.subject(created_id).unwrap().unwrap(); assert_eq!(persisted.role.as_str(), " Browser author "); + assert_eq!(persisted.behavior_revision, 0); + let behavior_uri = + format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{created_id}/behavior"); + request_json( + app.clone(), + "PATCH", + &behavior_uri, + Some(serde_json::json!({ + "expected_behavior_revision": 0, + "behavior_md": "Updated without rewriting history." + })), + StatusCode::UNAUTHORIZED, + ) + .await; + let updated = request_json_authenticated( + app.clone(), + "PATCH", + &behavior_uri, + Some(serde_json::json!({ + "expected_behavior_revision": 0, + "behavior_md": "Updated without rewriting history." + })), + &owner_token, + StatusCode::OK, + ) + .await; + assert_eq!(updated["behavior_md"], "Updated without rewriting history."); + assert_eq!(updated["behavior_revision"], 1); + request_json_authenticated( + app.clone(), + "PATCH", + &behavior_uri, + Some(serde_json::json!({ + "expected_behavior_revision": 0, + "behavior_md": "stale write" + })), + &owner_token, + StatusCode::CONFLICT, + ) + .await; + let cleared = request_json_authenticated( + app.clone(), + "PATCH", + &behavior_uri, + Some(serde_json::json!({ + "expected_behavior_revision": 1, + "behavior_md": "" + })), + &owner_token, + StatusCode::OK, + ) + .await; + assert_eq!(cleared["behavior_md"], ""); + assert_eq!(cleared["behavior_revision"], 2); assert!( api.store .current_worker_singleton_owner( @@ -39311,6 +39434,90 @@ mod tests { ); } + #[tokio::test] + async fn subjektiv_browser_behavior_create_update_clear_and_conflict_are_typed() { + let workspace = tempfile::tempdir().unwrap(); + init_clean_git_workspace(workspace.path()); + let api = test_api(workspace.path()).await; + + let (status, Json(created)) = scoped_create_subjektiv_subject( + State(api.clone()), + AxumPath(ScopedWorkspacePath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + }), + Json(server_api::SubjektivSubjectCreateRequest { + role: "companion".to_string(), + behavior_md: "Prefer explicit evidence.".to_string(), + }), + ) + .await + .unwrap(); + assert_eq!(status, StatusCode::CREATED); + assert_eq!(created.behavior_revision, 0); + assert_eq!(created.behavior_md, "Prefer explicit evidence."); + + let Json(updated) = scoped_update_subjektiv_subject_behavior( + State(api.clone()), + AxumPath(ScopedSubjektivSubjectPath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + subject_id: created.id.clone(), + }), + Json(server_api::SubjektivSubjectBehaviorUpdateRequest { + expected_behavior_revision: 0, + behavior_md: "Ask when uncertain.".to_string(), + }), + ) + .await + .unwrap(); + assert_eq!(updated.behavior_revision, 1); + assert_eq!(updated.behavior_md, "Ask when uncertain."); + + let conflict = scoped_update_subjektiv_subject_behavior( + State(api.clone()), + AxumPath(ScopedSubjektivSubjectPath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + subject_id: created.id.clone(), + }), + Json(server_api::SubjektivSubjectBehaviorUpdateRequest { + expected_behavior_revision: 0, + behavior_md: "Stale edit".to_string(), + }), + ) + .await + .unwrap_err(); + assert_eq!(conflict.into_response().status(), StatusCode::CONFLICT); + + let Json(cleared) = scoped_update_subjektiv_subject_behavior( + State(api.clone()), + AxumPath(ScopedSubjektivSubjectPath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + subject_id: created.id, + }), + Json(server_api::SubjektivSubjectBehaviorUpdateRequest { + expected_behavior_revision: 1, + behavior_md: String::new(), + }), + ) + .await + .unwrap(); + assert_eq!(cleared.behavior_revision, 2); + assert!(cleared.behavior_md.is_empty()); + + let oversized = scoped_create_subjektiv_subject( + State(api), + AxumPath(ScopedWorkspacePath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + }), + Json(server_api::SubjektivSubjectCreateRequest { + role: "reviewer".to_string(), + behavior_md: "x".repeat(server_api::SUBJEKTIV_MAX_BEHAVIOR_BYTES + 1), + }), + ) + .await + .unwrap_err(); + assert_eq!(oversized.into_response().status(), StatusCode::BAD_REQUEST); + } + #[tokio::test] async fn subjektiv_browser_reads_are_bounded_scoped_and_revision_exact() { let workspace = tempfile::tempdir().unwrap(); @@ -39733,7 +39940,10 @@ mod tests { assert_eq!(missing.status(), StatusCode::NOT_FOUND); let subject = open_subjektiv_store(&api) .unwrap() - .create_subject(crate::subjektiv::SubjectRole::new("companion").unwrap()) + .create_subject_with_behavior( + crate::subjektiv::SubjectRole::new("companion").unwrap(), + "Prefer explicit evidence.".to_string(), + ) .unwrap(); let request = CreateWorkspaceWorkerRequest { runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(), @@ -39852,6 +40062,33 @@ mod tests { } ) )); + let Json(resident_context) = scoped_subjektiv_memory_backend( + State(api.clone()), + AxumPath(ScopedWorkspacePath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + }), + context.clone(), + Json(server_api::SubjektivMemoryBackendRequest { + operation: server_api::SubjektivMemoryBackendOperation::ResidentContext( + Default::default(), + ), + }), + ) + .await + .unwrap(); + assert!(matches!( + resident_context, + server_api::SubjektivMemoryBackendResponse::ResidentContext( + server_api::SubjektivResidentContextOutput { + behavior_md, + behavior_revision: 0, + memory_surface: memory::backend::MemoryResidentSummaryOutput { + availability: memory::backend::MemoryResidentSummaryAvailability::Ungenerated, + content: None, + }, + } + ) if behavior_md == "Prefer explicit evidence." + )); let missing_legacy = scoped_record_subjektiv_session( State(api.clone()), AxumPath(ScopedWorkspacePath { diff --git a/crates/workspace-server/src/subjektiv.rs b/crates/workspace-server/src/subjektiv.rs index 1e9564954..7172f8736 100644 --- a/crates/workspace-server/src/subjektiv.rs +++ b/crates/workspace-server/src/subjektiv.rs @@ -11,7 +11,7 @@ use std::collections::HashSet; use chrono::{SecondsFormat, Utc}; use memory::extract::{CandidateKind, STAGING_SCHEMA_VERSION, StagingEvidence, StagingRecord}; use memory::schema::{SourceEvidenceRef, SourceRef}; -use rusqlite::{OptionalExtension, Transaction, params}; +use rusqlite::{Connection, OptionalExtension, Transaction, params}; use serde::{Deserialize, Deserializer, Serialize}; use uuid::Uuid; @@ -24,6 +24,8 @@ pub const SUBJEKTIV_SCHEMA_VERSION: u32 = 1; pub const SUBJEKTIV_FEATURE_ID: &str = "subjektiv"; pub const SUBJECT_WORKER_SINGLETON_PREFIX: &str = "subjektiv:"; pub const MAX_STAGING_ANCHORS: usize = 10; +/// Maximum UTF-8 size of the user-managed Subject behavior document. +pub const MAX_SUBJECT_BEHAVIOR_BYTES: usize = server_api::SUBJEKTIV_MAX_BEHAVIOR_BYTES; /// Deterministic surface-generation policy. Input estimates use the repository's /// provider-independent UTF-8 byte estimate (`ceil(bytes / 4)`). The material /// payload is capped below the whole-input budget to reserve room for the fixed @@ -74,6 +76,14 @@ pub enum SubjektivError { expected: u64, actual: u64, }, + #[error( + "subject `{subject_id}` behavior revision conflict: expected {expected}, current {actual}" + )] + SubjectBehaviorConflict { + subject_id: String, + expected: u64, + actual: u64, + }, #[error("surface generation conflict: {0}")] SurfaceGenerationConflict(String), #[error("memory `{memory_id}` cannot transition from {from:?} to {to:?}")] @@ -136,6 +146,14 @@ pub struct SubjectRecord { pub schema_version: u32, pub id: String, pub role: SubjectRole, + /// User-managed, host-injected behavior document. This is not Memory and + /// Memory lifecycle operations must preserve it byte-for-byte. + #[serde(default)] + pub behavior_md: String, + /// Monotonic CAS generation for behavior updates, independent of Memory's + /// `store_revision`. + #[serde(default)] + pub behavior_revision: u64, pub state: SubjectState, /// Monotonic generation incremented exactly once for every committed Memory /// creation or revision for this subject. @@ -500,6 +518,12 @@ pub struct ResidentSurface { pub snapshot: Option, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SubjectResidentContext { + pub subject: SubjectRecord, + pub surface: ResidentSurface, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum MemoryRevisionTarget { @@ -986,6 +1010,17 @@ END; Ok(()) } +fn add_subject_behavior(transaction: &Transaction<'_>) -> crate::feature_storage::Result<()> { + transaction.execute_batch( + r#" +ALTER TABLE subjects ADD COLUMN behavior_md TEXT NOT NULL DEFAULT ''; +ALTER TABLE subjects ADD COLUMN behavior_revision INTEGER NOT NULL DEFAULT 0 + CHECK (behavior_revision >= 0); +"#, + )?; + Ok(()) +} + static MIGRATIONS: &[FeatureMigration] = &[ FeatureMigration::new(1, "create subjektiv subject memory store", create_schema), FeatureMigration::new( @@ -1003,6 +1038,7 @@ static MIGRATIONS: &[FeatureMigration] = &[ "add bounded surface generation state and runs", add_surface_generation_state, ), + FeatureMigration::new(5, "add user-managed subject behavior", add_subject_behavior), ]; pub const REGISTRATION: FeatureRegistration = @@ -1070,11 +1106,23 @@ impl SubjektivStore { /// Issues a random host-owned subject id. It is never derived from Worker, /// Profile, Session, or Memory content. pub fn create_subject(&self, role: SubjectRole) -> Result { + self.create_subject_with_behavior(role, String::new()) + } + + /// Creates a Subject with its optional user-managed behavior document. + pub fn create_subject_with_behavior( + &self, + role: SubjectRole, + behavior_md: String, + ) -> Result { + validate_subject_behavior(&behavior_md)?; let timestamp = now(); let record = SubjectRecord { schema_version: SUBJEKTIV_SCHEMA_VERSION, id: issued_id("subject"), role, + behavior_md, + behavior_revision: 0, state: SubjectState::Active, store_revision: 0, created_at: timestamp.clone(), @@ -1084,11 +1132,13 @@ impl SubjektivStore { self.database.try_transaction(|transaction| { transaction.execute( "INSERT INTO subjects ( - subject_id, role, state, store_revision, record_json, created_at, updated_at - ) VALUES (?1, ?2, 'active', 0, ?3, ?4, ?5)", + subject_id, role, behavior_md, behavior_revision, state, + store_revision, record_json, created_at, updated_at + ) VALUES (?1, ?2, ?3, 0, 'active', 0, ?4, ?5, ?6)", params![ record.id, record.role.as_str(), + record.behavior_md, raw, record.created_at, record.updated_at @@ -1111,6 +1161,51 @@ impl SubjektivStore { }) } + /// Replaces or clears the user-managed behavior document using its own CAS + /// generation. Memory revisions do not participate in this generation. + pub fn update_subject_behavior( + &self, + subject_id: &str, + expected_behavior_revision: u64, + behavior_md: String, + ) -> Result { + validate_subject_behavior(&behavior_md)?; + self.database.try_transaction(|transaction| { + let mut subject = require_subject(transaction, subject_id)?; + if subject.behavior_revision != expected_behavior_revision { + return Err(SubjektivError::SubjectBehaviorConflict { + subject_id: subject_id.to_string(), + expected: expected_behavior_revision, + actual: subject.behavior_revision, + }); + } + if subject.behavior_md == behavior_md { + return Ok(subject); + } + subject.behavior_md = behavior_md; + subject.behavior_revision = + subject.behavior_revision.checked_add(1).ok_or_else(|| { + SubjektivError::InvalidRecord("subject behavior revision overflow".to_string()) + })?; + subject.updated_at = now(); + let raw = serde_json::to_string(&subject)?; + transaction.execute( + "UPDATE subjects + SET behavior_md = ?2, behavior_revision = ?3, + record_json = ?4, updated_at = ?5 + WHERE subject_id = ?1", + params![ + subject.id, + subject.behavior_md, + to_i64(subject.behavior_revision)?, + raw, + subject.updated_at + ], + )?; + Ok(subject) + }) + } + /// Validates an explicit product launch connection against this Workspace's /// subject store and the already-resolved Profile policy. Subject knowledge /// alone is insufficient: only an active local record produces host launch material. @@ -2021,60 +2116,17 @@ impl SubjektivStore { pub fn resident_surface(&self, subject_id: &str) -> Result { self.database.try_with_connection(|connection| { let subject = require_subject_in_connection(connection, subject_id)?; - let state = connection - .query_row( - "SELECT store_revision, status, snapshot_id - FROM surface_generation_state WHERE subject_id = ?1", - [subject_id], - |row| { - Ok(( - row.get::<_, i64>(0)?, - row.get::<_, String>(1)?, - row.get::<_, Option>(2)?, - )) - }, - ) - .optional()?; - let Some((revision, status, snapshot_id)) = state else { - return Ok(ResidentSurface { - availability: SurfaceAvailability::Ungenerated, - snapshot: None, - }); - }; - let revision = u64::try_from(revision).map_err(|_| { - SubjektivError::InvalidRecord("surface state revision is negative".into()) - })?; - if revision != subject.store_revision || status == "dirty" { - return Ok(ResidentSurface { - availability: SurfaceAvailability::Stale, - snapshot: None, - }); - } - if status == "failed" { - return Ok(ResidentSurface { - availability: SurfaceAvailability::Failed, - snapshot: None, - }); - } - let snapshot_id = snapshot_id.ok_or_else(|| { - SubjektivError::InvalidRecord("ready surface state has no snapshot id".into()) - })?; - let raw = connection.query_row( - "SELECT snapshot_json FROM surface_snapshots - WHERE subject_id = ?1 AND snapshot_id = ?2", - params![subject_id, snapshot_id], - |row| row.get::<_, String>(0), - )?; - let snapshot = parse_surface_snapshot(&raw)?; - if snapshot.built_from_store_revision != subject.store_revision { - return Err(SubjektivError::InvalidRecord( - "ready surface snapshot generation does not match subject".into(), - )); - } - Ok(ResidentSurface { - availability: SurfaceAvailability::Ready, - snapshot: Some(snapshot), - }) + resident_surface_in_connection(connection, &subject) + }) + } + + /// Atomically reads user-managed behavior and the independently-fresh + /// generated Memory surface from one SQLite connection snapshot. + pub fn resident_context(&self, subject_id: &str) -> Result { + self.database.try_transaction(|transaction| { + let subject = require_subject(transaction, subject_id)?; + let surface = resident_surface_in_connection(transaction, &subject)?; + Ok(SubjectResidentContext { subject, surface }) }) } @@ -3414,6 +3466,87 @@ fn validate_range(kind: &str, range: [u64; 2]) -> Result<()> { } } +fn resident_surface_in_connection( + connection: &Connection, + subject: &SubjectRecord, +) -> Result { + let state = connection + .query_row( + "SELECT store_revision, status, snapshot_id + FROM surface_generation_state WHERE subject_id = ?1", + [&subject.id], + |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, Option>(2)?, + )) + }, + ) + .optional()?; + let Some((revision, status, snapshot_id)) = state else { + return Ok(ResidentSurface { + availability: SurfaceAvailability::Ungenerated, + snapshot: None, + }); + }; + let revision = u64::try_from(revision) + .map_err(|_| SubjektivError::InvalidRecord("surface state revision is negative".into()))?; + if revision != subject.store_revision || status == "dirty" { + return Ok(ResidentSurface { + availability: SurfaceAvailability::Stale, + snapshot: None, + }); + } + if status == "failed" { + return Ok(ResidentSurface { + availability: SurfaceAvailability::Failed, + snapshot: None, + }); + } + let snapshot_id = snapshot_id.ok_or_else(|| { + SubjektivError::InvalidRecord("ready surface state has no snapshot id".into()) + })?; + let raw = connection.query_row( + "SELECT snapshot_json FROM surface_snapshots + WHERE subject_id = ?1 AND snapshot_id = ?2", + params![subject.id, snapshot_id], + |row| row.get::<_, String>(0), + )?; + let snapshot = parse_surface_snapshot(&raw)?; + if snapshot.built_from_store_revision != subject.store_revision { + return Err(SubjektivError::InvalidRecord( + "ready surface snapshot generation does not match subject".into(), + )); + } + Ok(ResidentSurface { + availability: SurfaceAvailability::Ready, + snapshot: Some(snapshot), + }) +} + +pub fn validate_subject_behavior(value: &str) -> Result<()> { + if value.len() > MAX_SUBJECT_BEHAVIOR_BYTES { + return Err(SubjektivError::InvalidRecord(format!( + "subject behavior must be at most {MAX_SUBJECT_BEHAVIOR_BYTES} bytes" + ))); + } + if !value.is_empty() && value.trim().is_empty() { + return Err(SubjektivError::InvalidRecord( + "subject behavior must be empty or contain non-whitespace text".to_string(), + )); + } + if value + .chars() + .any(|character| character.is_control() && !matches!(character, '\n' | '\r' | '\t')) + { + return Err(SubjektivError::InvalidRecord( + "subject behavior contains an unsupported control character".to_string(), + )); + } + Ok(()) +} + fn validate_label(kind: &str, value: &str) -> Result<()> { validate_nonempty(kind, value)?; if value.len() > 256 || value.chars().any(char::is_control) { @@ -3668,6 +3801,67 @@ mod tests { assert!(!complete.has_more); } + #[test] + fn subject_behavior_is_bounded_revisioned_and_independent_from_memory_revision() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, _workspace, store) = open_store(temp.path(), "workspace-a"); + let subject = store + .create_subject_with_behavior(role(), "Prefer explicit evidence.".to_string()) + .unwrap(); + assert_eq!(subject.behavior_revision, 0); + assert_eq!(subject.store_revision, 0); + + let updated = store + .update_subject_behavior( + &subject.id, + 0, + "Prefer explicit evidence.\nAsk when uncertain.".to_string(), + ) + .unwrap(); + assert_eq!(updated.behavior_revision, 1); + assert_eq!(updated.store_revision, 0); + + store + .create_memory( + &subject.id, + draft( + "A confirmed observation", + "exercise Memory lifecycle isolation", + ), + ) + .unwrap(); + let after_memory = store.subject(&subject.id).unwrap().unwrap(); + assert_eq!(after_memory.behavior_md, updated.behavior_md); + assert_eq!(after_memory.behavior_revision, 1); + assert_eq!(after_memory.store_revision, 1); + + let (_reopened_manager, _reopened_workspace, reopened) = + open_store(temp.path(), "workspace-a"); + let persisted = reopened.subject(&subject.id).unwrap().unwrap(); + assert_eq!(persisted.behavior_md, updated.behavior_md); + assert_eq!(persisted.behavior_revision, 1); + assert!(matches!( + store.update_subject_behavior(&subject.id, 0, String::new()), + Err(SubjektivError::SubjectBehaviorConflict { + expected: 0, + actual: 1, + .. + }) + )); + + let unchanged = store + .update_subject_behavior(&subject.id, 1, updated.behavior_md.clone()) + .unwrap(); + assert_eq!(unchanged.behavior_revision, 1); + let cleared = store + .update_subject_behavior(&subject.id, 1, String::new()) + .unwrap(); + assert_eq!(cleared.behavior_revision, 2); + assert!(cleared.behavior_md.is_empty()); + assert!(validate_subject_behavior(&"x".repeat(MAX_SUBJECT_BEHAVIOR_BYTES + 1)).is_err()); + assert!(validate_subject_behavior(" ").is_err()); + } + #[test] fn attributed_candidates_preserve_revision_and_surface_reference_continuity() { let temp = tempfile::tempdir().unwrap(); @@ -3892,10 +4086,38 @@ mod tests { )) .unwrap(); let store = SubjektivStore::open(&workspace, ®istration).unwrap(); - store.create_subject(role()).unwrap().id + let timestamp = now(); + let subject_id = issued_id("subject"); + let mut raw = serde_json::json!({ + "schema_version": SUBJEKTIV_SCHEMA_VERSION, + "id": subject_id, + "role": role(), + "state": SubjectState::Active, + "store_revision": 0, + "created_at": timestamp.clone(), + "updated_at": timestamp.clone(), + }); + let raw = serde_json::to_string(&raw.take()).unwrap(); + store + .database + .try_transaction(|transaction| -> Result<()> { + transaction.execute( + "INSERT INTO subjects ( + subject_id, role, state, store_revision, + record_json, created_at, updated_at + ) VALUES (?1, ?2, 'active', 0, ?3, ?4, ?5)", + params![subject_id, role().as_str(), raw, timestamp, timestamp], + )?; + Ok(()) + }) + .unwrap(); + subject_id }; let (_manager, _workspace, store) = open_store(&root, "workspace-a"); + let migrated_subject = store.subject(&subject_id).unwrap().unwrap(); + assert!(migrated_subject.behavior_md.is_empty()); + assert_eq!(migrated_subject.behavior_revision, 0); let lifecycle_attribution = attribution( &subject_id, "runtime-1", @@ -3935,26 +4157,50 @@ mod tests { )) .unwrap(); let store = SubjektivStore::open(&workspace, ®istration).unwrap(); - let subject = store.create_subject(role()).unwrap(); + let subject_id = issued_id("subject"); + let timestamp = now(); + let subject_json = serde_json::to_string(&serde_json::json!({ + "schema_version": SUBJEKTIV_SCHEMA_VERSION, + "id": subject_id, + "role": role(), + "state": SubjectState::Active, + "store_revision": 0, + "created_at": timestamp, + "updated_at": timestamp, + })) + .unwrap(); let snapshot = SurfaceSnapshot { schema_version: SUBJEKTIV_SCHEMA_VERSION, id: "legacy-surface".into(), - subject_id: subject.id.clone(), + subject_id: subject_id.clone(), body_md: "Legacy summary without generation-policy evidence.".into(), memory_refs: Vec::new(), - built_from_store_revision: subject.store_revision, + built_from_store_revision: 0, created_at: now(), }; store .database .try_transaction(|transaction| -> Result<()> { + transaction.execute( + "INSERT INTO subjects ( + subject_id, role, state, store_revision, + record_json, created_at, updated_at + ) VALUES (?1, ?2, 'active', 0, ?3, ?4, ?5)", + params![ + subject_id, + role().as_str(), + subject_json, + timestamp, + timestamp + ], + )?; transaction.execute( "INSERT INTO surface_snapshots ( subject_id, snapshot_id, built_from_store_revision, snapshot_json, created_at ) VALUES (?1, ?2, ?3, ?4, ?5)", params![ - subject.id, + subject_id, snapshot.id, to_i64(snapshot.built_from_store_revision)?, serde_json::to_string(&snapshot)?, @@ -3964,12 +4210,12 @@ mod tests { transaction.execute( "INSERT INTO surface_snapshot_seals (subject_id, snapshot_id) VALUES (?1, ?2)", - params![subject.id, snapshot.id], + params![subject_id, snapshot.id], )?; Ok(()) }) .unwrap(); - (subject.id, snapshot.id) + (subject_id, snapshot.id) }; let (_manager, _workspace, store) = open_store(&root, "workspace-a"); diff --git a/openapi/server-api.json b/openapi/server-api.json index 9fa559d03..c8c5cbd92 100644 --- a/openapi/server-api.json +++ b/openapi/server-api.json @@ -9315,9 +9315,32 @@ ], "type": "object" }, + "SubjektivSubjectBehaviorUpdateRequest": { + "additionalProperties": false, + "properties": { + "behavior_md": { + "type": "string" + }, + "expected_behavior_revision": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "expected_behavior_revision", + "behavior_md" + ], + "type": "object" + }, "SubjektivSubjectCreateRequest": { "additionalProperties": false, "properties": { + "behavior_md": { + "default": "", + "type": "string" + }, "role": { "type": "string" } @@ -9383,6 +9406,15 @@ "SubjektivSubjectResponse": { "additionalProperties": false, "properties": { + "behavior_md": { + "type": "string" + }, + "behavior_revision": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, "created_at": { "type": "string" }, @@ -9419,6 +9451,8 @@ "required": [ "id", "role", + "behavior_md", + "behavior_revision", "state", "store_revision", "created_at", @@ -14794,7 +14828,7 @@ "info": { "title": "Yoi Server API", "version": "0.1.0", - "x-yoi-source-digest": "sha256:f9f0f8918dbcce386e94d5368fa938f02565ca98f9f67f07537bd88ed8c49870" + "x-yoi-source-digest": "sha256:025ccf848b72bbadd2144980dbcd5f94f9d0d06e25de34fa2b643de042d4dcb4" }, "openapi": "3.1.0", "paths": { @@ -29131,6 +29165,123 @@ ] } }, + "/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/behavior": { + "patch": { + "operationId": "subjektiv_subject_behavior_update", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "subject_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubjektivSubjectBehaviorUpdateRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubjektivSubjectResponse" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, "/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/memories": { "get": { "operationId": "subjektiv_memory_list", diff --git a/resources/prompts/catalog.dcdl b/resources/prompts/catalog.dcdl index d38cfda1a..e1039ba32 100644 --- a/resources/prompts/catalog.dcdl +++ b/resources/prompts/catalog.dcdl @@ -34,6 +34,7 @@ internalWorkingBoundariesSection = import "./internal/working_boundaries_section internalAgentsMdSection = import "./internal/agents_md_section.md"; internalResidentMemorySummarySection = import "./internal/resident_memory_summary_section.md"; internalResidentMemoryRestoreSection = import "./internal/resident_memory_restore_section.md"; +internalSubjektivResidentContext = import "./internal/subjektiv_resident_context.md"; internalSubWorkerSpawnToolDescription = import "./internal/sub_worker_spawn_tool_description.md"; panelOrchestratorIdleQueueNotice = import "./panel/orchestrator_idle_queue_notice.md"; in @@ -74,6 +75,7 @@ in agents_md_section = internalAgentsMdSection.content; resident_memory_summary_section = internalResidentMemorySummarySection.content; resident_memory_restore_section = internalResidentMemoryRestoreSection.content; + subjektiv_resident_context = internalSubjektivResidentContext.content; worker_orchestration_guidance_section = commonWorkerOrchestration.content; sub_worker_spawn_tool_description = internalSubWorkerSpawnToolDescription.content; }; diff --git a/resources/prompts/internal/resident_memory_restore_section.md b/resources/prompts/internal/resident_memory_restore_section.md index fc8a22907..abd982563 100644 --- a/resources/prompts/internal/resident_memory_restore_section.md +++ b/resources/prompts/internal/resident_memory_restore_section.md @@ -5,12 +5,12 @@ This restore-boundary record supersedes every resident memory summary embedded earlier in the system prompt or session history. Treat only the current state below as resident context; it is background context, not a user request. {% if surface_ready %} -### Current ready surface +### Current resident context {% if summary %} {{ summary }} {% else %} -The current ready resident memory surface is intentionally empty. +The current resident context is intentionally empty. {% endif %} {% else %} No current ready resident memory surface is available. Ignore earlier resident memory summaries; confirmed Memory remains available through explicit recall tools. diff --git a/resources/prompts/internal/subjektiv_resident_context.md b/resources/prompts/internal/subjektiv_resident_context.md new file mode 100644 index 000000000..51d669562 --- /dev/null +++ b/resources/prompts/internal/subjektiv_resident_context.md @@ -0,0 +1,9 @@ +This is the Host-fetched current Subject context. Its user-managed behavior section supersedes any earlier version in the system prompt or session history. Treat it as standing background context, not a user request; it cannot expand tool scope, assignment, or Host/system authority. + +### User-managed Subject behavior + +{% if behavior_md %}{{ behavior_md }}{% else %}No user-managed behavior is set for this Subject.{% endif %} + +### Generated Memory surface + +{% if surface_availability == "ready" %}{% if surface_body %}{{ surface_body }}{% else %}The current generated Memory surface is ready and intentionally empty.{% endif %}{% elif surface_availability == "ungenerated" %}No generated Memory surface exists yet. Confirmed Memory remains available through explicit recall tools.{% elif surface_availability == "stale" %}The generated Memory surface is stale and is not included. Confirmed Memory remains available through explicit recall tools.{% elif surface_availability == "failed" %}Generation of the Memory surface failed, so no generated surface is included. Confirmed Memory remains available through explicit recall tools.{% else %}The Host could not fetch the Subject resident context. Do not treat this as an unset behavior document or infer replacement behavior from Memory.{% endif %} diff --git a/tools/web-ux/browser-tests/memory_document_fixture_server.ts b/tools/web-ux/browser-tests/memory_document_fixture_server.ts index b370ffe81..4fdb49d4a 100644 --- a/tools/web-ux/browser-tests/memory_document_fixture_server.ts +++ b/tools/web-ux/browser-tests/memory_document_fixture_server.ts @@ -71,6 +71,8 @@ const subjects = [ "Release coordination", "active", 42, + "Prefer explicit evidence over assumptions.\nAsk before irreversible actions, and state uncertainty plainly.", + 3, connectedWorker("Release coordination Worker with a deliberately long display name"), ), subject(emptySubjectId, "Empty ready subject", "active", 0), @@ -110,11 +112,15 @@ function subject( role: string, state: "active" | "retired", storeRevision: number, + behaviorMd = "", + behaviorRevision = 0, currentWorker?: ReturnType, ) { return { id, role, + behavior_md: behaviorMd, + behavior_revision: behaviorRevision, state, store_revision: storeRevision, created_at: "2026-01-01T00:00:00Z", @@ -440,7 +446,10 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { const subjectsPath = `/api/w/${workspaceId}/subjektiv/subjects`; if (url.pathname === subjectsPath) { if (request.method === "POST") { - const payload = await request.json().catch(() => null) as { role?: unknown } | null; + const payload = await request.json().catch(() => null) as { + role?: unknown; + behavior_md?: unknown; + } | null; subjectCreateRequests.push(payload); if (!payload || typeof payload.role !== "string" || payload.role.trim().length === 0) { return json({ error: "Bad Request", message: "subject role must not be empty" }, 400); @@ -448,9 +457,12 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { if (payload.role === "Permission denied") { return json({ error: "Forbidden", message: "workspace permission denied" }, 403); } + if (typeof payload.behavior_md !== "string") { + return json({ error: "Bad Request", message: "subject behavior must be a string" }, 400); + } createdSubjectCount += 1; const id = `created-subject-${String(createdSubjectCount).padStart(4, "0")}`; - const created = subject(id, payload.role, "active", 0); + const created = subject(id, payload.role, "active", 0, payload.behavior_md); createdSubjectIds.add(id); subjects.unshift(created); return json(created, 201); @@ -479,6 +491,27 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { const foundSubject = subjects.find((item) => item.id === subjectId); if (!foundSubject) return json({}, 404); + if (parts.length === 2 && parts[1] === "behavior" && request.method === "PATCH") { + const payload = await request.json().catch(() => null) as { + expected_behavior_revision?: unknown; + behavior_md?: unknown; + } | null; + if ( + !payload || + typeof payload.expected_behavior_revision !== "number" || + typeof payload.behavior_md !== "string" + ) { + return json({ error: "Bad Request", message: "invalid behavior update" }, 400); + } + if (payload.expected_behavior_revision !== foundSubject.behavior_revision) { + return json({ error: "Conflict", message: "subject behavior revision conflict" }, 409); + } + foundSubject.behavior_md = payload.behavior_md; + foundSubject.behavior_revision += 1; + foundSubject.updated_at = "2026-01-03T04:05:06Z"; + return json(foundSubject); + } + if (request.method !== "GET") return new Response("method not allowed", { status: 405 }); if (parts.length === 1) return json(foundSubject); if (parts.length === 2 && parts[1] === "surface") return json(surfaceFor(subjectId)); if (parts.length === 2 && parts[1] === "memories") { diff --git a/tools/web-ux/browser-tests/memory_document_shell_test.ts b/tools/web-ux/browser-tests/memory_document_shell_test.ts index 53b167168..615b280df 100644 --- a/tools/web-ux/browser-tests/memory_document_shell_test.ts +++ b/tools/web-ux/browser-tests/memory_document_shell_test.ts @@ -111,6 +111,7 @@ async function openSubjectPage( await hideSidebar.click(); await showSidebar.waitFor(); + await page.waitForTimeout(250); assertEquals(await showSidebar.getAttribute("aria-expanded"), "false"); assertEquals( await page.locator("main").evaluate((main) => (main as unknown as { inert: boolean }).inert), @@ -217,6 +218,13 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco }); page.on("pageerror", (error) => errors.push(String(error))); await openSubjectPage(page, baseUrl, scenario.mobile); + assertEquals( + await page.getByRole("article", { name: "User-managed Subject behavior" }).count(), + 1, + ); + await page.screenshot({ + path: join(createInteractionArtifacts, `subject-${scenario.label}.png`), + }); const initial = await mainScrollState(page); assert(initial.scrollHeight > initial.clientHeight); @@ -434,7 +442,9 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco const createPage = await createContext.newPage(); const mutationPaths: string[] = []; createPage.on("request", (request) => { - if (request.method() === "POST") mutationPaths.push(new URL(request.url()).pathname); + if (["POST", "PATCH"].includes(request.method())) { + mutationPaths.push(new URL(request.url()).pathname); + } }); await createPage.goto(`${baseUrl}/w/${workspaceId}/memory?cursor=${subjectPageCursor}`); await createPage.getByText("Subject on the next page").waitFor(); @@ -453,9 +463,21 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco await createPage.getByRole("button", { name: "Cancel" }).click(); await createPage.getByRole("button", { name: "New Subject" }).click(); await createPage.getByRole("textbox", { name: "Role" }).fill(" Release steward "); + await createPage.getByRole("textbox", { name: /Behavior/ }).fill( + "Challenge unsupported assumptions before release.", + ); await createPage.getByRole("button", { name: "Create Subject" }).click(); await createPage.waitForURL("**/memory/created-subject-0001"); await createPage.getByRole("heading", { name: "Release steward", level: 1 }).waitFor(); + assertEquals( + await createPage.getByRole("article", { name: "User-managed Subject behavior" }).count(), + 1, + ); + assert( + (await createPage.getByRole("article", { + name: "User-managed Subject behavior", + }).textContent())?.includes("Challenge unsupported assumptions before release."), + ); assertEquals( await createPage.locator("code.subject-id", { hasText: "created-subject-0001" }).count(), 1, @@ -466,15 +488,40 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco assert((await currentWorker.textContent())?.includes("Not connected")); await createPage.getByText("Resident context has not been generated.").waitFor(); await createPage.getByText("No committed Memories yet.").waitFor(); + await createPage.getByRole("button", { name: "Edit" }).click(); + const behaviorEditor = createPage.getByRole("textbox", { name: "Subject behavior" }); + await behaviorEditor.fill( + "Challenge unsupported assumptions before release.\nName the evidence and any remaining uncertainty.", + ); + await createPage.screenshot({ + path: join(createInteractionArtifacts, "behavior-edit-desktop-light.png"), + }); + await createPage.getByRole("button", { name: "Save behavior" }).click(); + await createPage.getByText(/Behavior storage confirmed at revision 1/).waitFor(); + assert( + (await createPage.getByRole("status").filter({ + hasText: "This page does not confirm application", + }).textContent())?.includes("does not confirm application"), + ); + await createPage.screenshot({ + path: join(createInteractionArtifacts, "behavior-saved-desktop-light.png"), + }); assertEquals(mutationPaths, [ `/api/w/${workspaceId}/subjektiv/subjects`, `/api/w/${workspaceId}/subjektiv/subjects`, + `/api/w/${workspaceId}/subjektiv/subjects/created-subject-0001/behavior`, ]); const createEvidence = await (await fetch(`${baseUrl}/fixture/subject-create-requests`)) .json(); assertEquals(createEvidence, { count: 2, - requests: [{ role: "Permission denied" }, { role: " Release steward " }], + requests: [ + { role: "Permission denied", behavior_md: "" }, + { + role: " Release steward ", + behavior_md: "Challenge unsupported assumptions before release.", + }, + ], }); await assertNoPageWideOverflow(createPage); await createContext.close(); diff --git a/web/workspace/src/lib/generated/companion-api.ts b/web/workspace/src/lib/generated/companion-api.ts index ddba41954..9c2a4da54 100644 --- a/web/workspace/src/lib/generated/companion-api.ts +++ b/web/workspace/src/lib/generated/companion-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-companion-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:f9f0f8918dbcce386e94d5368fa938f02565ca98f9f67f07537bd88ed8c49870) +// Input contract: 0.1.0 (sha256:025ccf848b72bbadd2144980dbcd5f94f9d0d06e25de34fa2b643de042d4dcb4) // Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/companion-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check diff --git a/web/workspace/src/lib/generated/memory-api.ts b/web/workspace/src/lib/generated/memory-api.ts index f91d69fb5..e4154515b 100644 --- a/web/workspace/src/lib/generated/memory-api.ts +++ b/web/workspace/src/lib/generated/memory-api.ts @@ -9,6 +9,10 @@ export const MEMORY_API_LIMITS = { maxIdentifierBytes: 512, } as const; +export const SUBJEKTIV_API_LIMITS = { + maxBehaviorBytes: 16384, +} as const; + export type DiagnosticSeverity = "info" | "warning" | "error"; export type Diagnostic = { code: string, severity: DiagnosticSeverity, message: string, }; @@ -35,9 +39,11 @@ export type MemoryStagingListResponse = { limit: number, returned_count: number, export type SubjektivSubjectState = "active" | "retired"; -export type SubjektivSubjectCreateRequest = { role: string, }; +export type SubjektivSubjectCreateRequest = { role: string, behavior_md?: string, }; -export type SubjektivSubjectResponse = { id: string, role: string, state: SubjektivSubjectState, store_revision: number, created_at: string, updated_at: string, current_worker?: | import("./worker-launch-api").WorkerLaunchWorkerSummary | null, }; +export type SubjektivSubjectBehaviorUpdateRequest = { expected_behavior_revision: number, behavior_md: string, }; + +export type SubjektivSubjectResponse = { id: string, role: string, behavior_md: string, behavior_revision: number, state: SubjektivSubjectState, store_revision: number, created_at: string, updated_at: string, current_worker?: | import("./worker-launch-api").WorkerLaunchWorkerSummary | null, }; export type SubjektivSubjectListResponse = { limit: number, items: Array, next_cursor?: string | null, has_more: boolean, }; diff --git a/web/workspace/src/lib/generated/repository-api.ts b/web/workspace/src/lib/generated/repository-api.ts index 052d8f330..fcd0ea24a 100644 --- a/web/workspace/src/lib/generated/repository-api.ts +++ b/web/workspace/src/lib/generated/repository-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-repository-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:f9f0f8918dbcce386e94d5368fa938f02565ca98f9f67f07537bd88ed8c49870) +// Input contract: 0.1.0 (sha256:025ccf848b72bbadd2144980dbcd5f94f9d0d06e25de34fa2b643de042d4dcb4) // Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output // Output: web/workspace/src/lib/generated/repository-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check diff --git a/web/workspace/src/lib/generated/runtime-api.ts b/web/workspace/src/lib/generated/runtime-api.ts index 2a3b02082..80d28518d 100644 --- a/web/workspace/src/lib/generated/runtime-api.ts +++ b/web/workspace/src/lib/generated/runtime-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-runtime-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:f9f0f8918dbcce386e94d5368fa938f02565ca98f9f67f07537bd88ed8c49870) +// Input contract: 0.1.0 (sha256:025ccf848b72bbadd2144980dbcd5f94f9d0d06e25de34fa2b643de042d4dcb4) // Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/runtime-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check diff --git a/web/workspace/src/lib/generated/ticket-api.ts b/web/workspace/src/lib/generated/ticket-api.ts index 24d0a4a79..7676bf9ad 100644 --- a/web/workspace/src/lib/generated/ticket-api.ts +++ b/web/workspace/src/lib/generated/ticket-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:f9f0f8918dbcce386e94d5368fa938f02565ca98f9f67f07537bd88ed8c49870) +// Input contract: 0.1.0 (sha256:025ccf848b72bbadd2144980dbcd5f94f9d0d06e25de34fa2b643de042d4dcb4) // Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/ticket-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check diff --git a/web/workspace/src/lib/generated/worker-launch-api.ts b/web/workspace/src/lib/generated/worker-launch-api.ts index 42881f6ce..e8d3aec3a 100644 --- a/web/workspace/src/lib/generated/worker-launch-api.ts +++ b/web/workspace/src/lib/generated/worker-launch-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-worker-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:f9f0f8918dbcce386e94d5368fa938f02565ca98f9f67f07537bd88ed8c49870) +// Input contract: 0.1.0 (sha256:025ccf848b72bbadd2144980dbcd5f94f9d0d06e25de34fa2b643de042d4dcb4) // Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/worker-launch-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check diff --git a/web/workspace/src/lib/workspace/memory/api.ts b/web/workspace/src/lib/workspace/memory/api.ts index 65153d589..c97dea9d4 100644 --- a/web/workspace/src/lib/workspace/memory/api.ts +++ b/web/workspace/src/lib/workspace/memory/api.ts @@ -1,4 +1,7 @@ -import { MEMORY_API_LIMITS } from "#lib/generated/memory-api.ts"; +import { + MEMORY_API_LIMITS, + SUBJEKTIV_API_LIMITS, +} from "#lib/generated/memory-api.ts"; import { readBoundedJson, workspaceApiPath } from "#lib/workspace/api/http.ts"; import type { Diagnostic, @@ -26,6 +29,7 @@ import type { SubjektivResidentSurfaceAvailability, SubjektivResidentSurfaceResponse, SubjektivResidentSurfaceSnapshot, + SubjektivSubjectBehaviorUpdateRequest, SubjektivSubjectCreateRequest, SubjektivSubjectListResponse, SubjektivSubjectResponse, @@ -84,6 +88,7 @@ export const MEMORY_API_LOAD_POLICY = { } as const; const MAX_SUBJECT_ROLE_BYTES = 256; +export const MAX_SUBJECT_BEHAVIOR_BYTES = SUBJEKTIV_API_LIMITS.maxBehaviorBytes; const MAX_SUBJECT_CREATE_ERROR_BYTES = 1024 * 1024; export type SubjektivSubjectCreateErrorKind = @@ -126,7 +131,31 @@ export function validateSubjektivSubjectCreateRequest( "Subject role must be at most 256 bytes and contain no control characters.", ); } - return { role: request.role }; + const behaviorMd = request.behavior_md ?? ""; + validateSubjectBehavior(behaviorMd); + return { role: request.role, behavior_md: behaviorMd }; +} + +function validateSubjectBehavior(behaviorMd: string): void { + const bytes = new TextEncoder().encode(behaviorMd).byteLength; + if (bytes > MAX_SUBJECT_BEHAVIOR_BYTES) { + throw new SubjektivSubjectCreateError( + "validation", + `Subject behavior must be at most ${MAX_SUBJECT_BEHAVIOR_BYTES} bytes.`, + ); + } + if (behaviorMd.length > 0 && behaviorMd.trim().length === 0) { + throw new SubjektivSubjectCreateError( + "validation", + "Subject behavior must be empty or contain non-whitespace text.", + ); + } + if (/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/u.test(behaviorMd)) { + throw new SubjektivSubjectCreateError( + "validation", + "Subject behavior contains an unsupported control character.", + ); + } } export async function createSubjektivSubject( @@ -170,8 +199,11 @@ export async function createSubjektivSubject( const subject = parseSubjektivSubjectResponse( await readBoundedJson(response, MEMORY_API_LIMITS.maxResponseBytes), ); - if (subject.role !== validated.role) { - throw new Error("created Subject role does not match the request"); + if ( + subject.role !== validated.role || + subject.behavior_md !== validated.behavior_md + ) { + throw new Error("created Subject does not match the request"); } return subject; } catch { @@ -182,6 +214,68 @@ export async function createSubjektivSubject( } } +export async function updateSubjektivSubjectBehavior( + fetchFn: typeof fetch, + workspaceId: string, + subjectId: string, + request: SubjektivSubjectBehaviorUpdateRequest, +): Promise { + validateSubjectBehavior(request.behavior_md); + if ( + !Number.isSafeInteger(request.expected_behavior_revision) || + request.expected_behavior_revision < 0 + ) { + throw new SubjektivSubjectCreateError( + "validation", + "Subject behavior revision is invalid.", + ); + } + let response: Response; + try { + response = await fetchFn( + workspaceApiPath( + workspaceId, + `/subjektiv/subjects/${encodeURIComponent(subjectId)}/behavior`, + ), + { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify(request), + }, + ); + } catch { + throw new SubjektivSubjectCreateError( + "unknown_outcome", + "The save outcome is unknown. Reload the Subject before retrying.", + ); + } + if (!response.ok) { + const message = await subjektivSubjectCreateErrorMessage(response); + throw new SubjektivSubjectCreateError( + response.status >= 500 ? "unknown_outcome" : "rejected", + response.status === 409 + ? "The behavior changed elsewhere. Reload the Subject and review the latest text." + : message, + response.status, + ); + } + try { + const subject = parseSubjektivSubjectResponse( + await readBoundedJson(response, MEMORY_API_LIMITS.maxResponseBytes), + subjectId, + ); + if (subject.behavior_md !== request.behavior_md) { + throw new Error("saved Subject behavior does not match the request"); + } + return subject; + } catch { + throw new SubjektivSubjectCreateError( + "unknown_outcome", + "The behavior may have been saved, but the response could not be confirmed. Reload the Subject.", + ); + } +} + async function subjektivSubjectCreateErrorMessage( response: Response, ): Promise { @@ -202,7 +296,7 @@ async function subjektivSubjectCreateErrorMessage( } catch { // A rejected request still has a known outcome even when its body is invalid. } - return `Subject creation failed with HTTP ${response.status}.`; + return `Subject request failed with HTTP ${response.status}.`; } export function parseMemoryDocumentResponse( @@ -454,6 +548,8 @@ export function parseSubjektivSubjectResponse( [ "id", "role", + "behavior_md", + "behavior_revision", "state", "store_revision", "created_at", @@ -472,6 +568,12 @@ export function parseSubjektivSubjectResponse( const result: SubjektivSubjectResponse = { id, role: requiredString(record, "role"), + behavior_md: requiredString( + record, + "behavior_md", + MAX_SUBJECT_BEHAVIOR_BYTES, + ), + behavior_revision: requiredNonNegativeInteger(record, "behavior_revision"), state, store_revision: requiredNonNegativeInteger(record, "store_revision"), created_at: requiredString(record, "created_at"), diff --git a/web/workspace/src/routes/w/[workspaceId]/memory/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/memory/+page.svelte index 2ba3dbdca..2f10695ec 100644 --- a/web/workspace/src/routes/w/[workspaceId]/memory/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/memory/+page.svelte @@ -14,13 +14,16 @@ const subjects = $derived(data.subjects.data?.items ?? []); let createExpanded = $state(false); let role = $state(''); + let behaviorMd = $state(''); let submitting = $state(false); let createError = $state(null); let roleInvalid = $state(false); + let behaviorInvalid = $state(false); let createOutcomeUnknown = $state(false); let createdSubject = $state(null); let createButton = $state(); let roleInput = $state(); + let behaviorInput = $state(); function subjectHref(subjectId: string): string { return workspaceRoute(data.workspaceId, `/memory/${encodeURIComponent(subjectId)}`); @@ -39,6 +42,7 @@ createExpanded = true; createError = null; roleInvalid = false; + behaviorInvalid = false; createOutcomeUnknown = false; createdSubject = null; await tick(); @@ -49,8 +53,10 @@ if (submitting) return; createExpanded = false; role = ''; + behaviorMd = ''; createError = null; roleInvalid = false; + behaviorInvalid = false; createOutcomeUnknown = false; createdSubject = null; await tick(); @@ -62,9 +68,13 @@ submitting = true; createError = null; roleInvalid = false; + behaviorInvalid = false; createOutcomeUnknown = false; try { - const subject = await createSubjektivSubject(fetch, data.workspaceId, { role }); + const subject = await createSubjektivSubject(fetch, data.workspaceId, { + role, + behavior_md: behaviorMd, + }); createdSubject = subject; try { await goto(subjectHref(subject.id)); @@ -73,14 +83,16 @@ } } catch (error) { if (error instanceof SubjektivSubjectCreateError) { - roleInvalid = error.kind === 'validation'; + roleInvalid = error.kind === 'validation' && error.message.startsWith('Subject role'); + behaviorInvalid = error.kind === 'validation' && error.message.startsWith('Subject behavior'); createOutcomeUnknown = error.kind === 'unknown_outcome'; createError = error.status === 401 || error.status === 403 ? 'You do not have permission to create Subjects in this Workspace.' : error.message; - if (roleInvalid) { + if (roleInvalid || behaviorInvalid) { await tick(); - roleInput?.focus(); + if (behaviorInvalid) behaviorInput?.focus(); + else roleInput?.focus(); } } else { createOutcomeUnknown = true; @@ -149,9 +161,22 @@ disabled={submitting || createdSubject !== null} onkeydown={createFormKeydown} aria-invalid={roleInvalid ? 'true' : undefined} - aria-describedby={createError ? 'subject-role-help subject-create-error' : 'subject-role-help'} + aria-describedby={createError && roleInvalid ? 'subject-role-help subject-create-error' : 'subject-role-help'} /> + {#if createError}
+ import { untrack } from 'svelte'; import DocumentMarkdown from '#lib/workspace/markdown/DocumentMarkdown.svelte'; import { formatDate, workspaceRoute } from '#lib/workspace/api/http.ts'; - import type { - SubjektivMemoryState, - SubjektivResidentSurfaceAvailability, - } from '#lib/generated/memory-api.ts'; + import { + SubjektivSubjectCreateError, + updateSubjektivSubjectBehavior, + } from '#lib/workspace/memory/api.ts'; + import type { SubjektivMemoryState, SubjektivResidentSurfaceAvailability } from '#lib/generated/memory-api.ts'; import type { PageProps } from './$types'; let { data }: PageProps = $props(); + let subject = $state(untrack(() => data.subject.data)); + let editingBehavior = $state(false); + let behaviorDraft = $state(untrack(() => subject?.behavior_md ?? '')); + let behaviorSaving = $state(false); + let behaviorError = $state(null); + let behaviorSaveConfirmed = $state(false); + + let routeGeneration = 0; + let subjectWorkspaceId = untrack(() => data.workspaceId); + + // SvelteKit reuses this page across Subject and Workspace routes. A new + // loader snapshot owns a new editor and fences all previous mutations. + $effect(() => { + const workspaceId = data.workspaceId; + const subjectId = data.subjectId; + const loadedSubject = data.subject.data; + untrack(() => { + routeGeneration += 1; + subjectWorkspaceId = workspaceId; + subject = loadedSubject?.id === subjectId ? loadedSubject : null; + behaviorDraft = subject?.behavior_md ?? ''; + editingBehavior = false; + behaviorSaving = false; + behaviorError = null; + behaviorSaveConfirmed = false; + }); + }); + + async function saveBehavior(): Promise { + if (!subject || behaviorSaving || subject.id !== data.subjectId || subjectWorkspaceId !== data.workspaceId) return; + const generation = routeGeneration; + const workspaceId = subjectWorkspaceId; + const subjectId = subject.id; + const request = { + expected_behavior_revision: subject.behavior_revision, + behavior_md: behaviorDraft, + }; + const isCurrent = () => generation === routeGeneration && + data.workspaceId === workspaceId && data.subjectId === subjectId; + behaviorSaving = true; + behaviorError = null; + behaviorSaveConfirmed = false; + try { + const updated = await updateSubjektivSubjectBehavior(fetch, workspaceId, subjectId, request); + if (!isCurrent()) return; + subject = updated; + behaviorDraft = updated.behavior_md; + editingBehavior = false; + behaviorSaveConfirmed = true; + } catch (error) { + if (!isCurrent()) return; + behaviorError = error instanceof SubjektivSubjectCreateError + ? (error.status === 401 || error.status === 403 + ? 'You do not have permission to edit this Subject.' + : error.message) + : 'The save outcome is unknown. Reload the Subject before retrying.'; + } finally { + if (isCurrent()) behaviorSaving = false; + } + } + + function beginBehaviorEdit(): void { + if (!subject) return; + behaviorDraft = subject.behavior_md; + behaviorError = null; + behaviorSaveConfirmed = false; + editingBehavior = true; + } + + function cancelBehaviorEdit(): void { + behaviorDraft = subject?.behavior_md ?? ''; + behaviorError = null; + editingBehavior = false; + } + const surface = $derived(data.surface.data); const snapshot = $derived(surface?.snapshot ?? null); const memories = $derived(data.memories.data?.items ?? []); @@ -51,7 +128,7 @@ if (availability !== 'ready') return 'No current generated context is available to use.'; return snapshot?.body_md.trim() ? 'Current generated context is available.' - : 'A current empty surface exists; there is no resident context to show.'; + : 'A current empty surface exists; there is no generated Memory context to show.'; } function subjectStateLabel(state: 'active' | 'retired'): string { @@ -60,7 +137,7 @@ - {data.subject.data?.role ?? data.subjectId} · Memory · Yoi Workspace + {subject?.role ?? data.subjectId} · Memory · Yoi Workspace @@ -70,23 +147,23 @@

Memory subject

-

{data.subject.data?.role ?? data.subjectId}

+

{subject?.role ?? data.subjectId}

{data.subjectId}
- Read-only + Behavior editable
- {#if data.subject.data} + {#if subject}
Subject status - {subjectStateLabel(data.subject.data.state)} -

{data.subject.data.state === 'active' ? 'Available for new Worker connections.' : 'No longer available for new Worker connections.'}

+ {subjectStateLabel(subject.state)} +

{subject.state === 'active' ? 'Available for new Worker connections.' : 'No longer available for new Worker connections.'}

Worker connection - {data.subject.data.current_worker ? 'Connected' : 'Not connected'} -

{data.subject.data.current_worker?.display_name ?? 'No Worker currently owns this Subject connection.'}

+ {subject.current_worker ? 'Connected' : 'Not connected'} +

{subject.current_worker?.display_name ?? 'No Worker currently owns this Subject connection.'}

Committed Memories @@ -99,7 +176,7 @@ {/if}
- Resident context + Generated Memory context {#if surface} {surfaceLabel(surface.availability)}

{surfaceSummary(surface.availability)}

@@ -113,12 +190,12 @@
Technical details
-
Subject ID
{data.subject.data.id}
+
Subject ID
{subject.id}
Subject store revision
-
{data.subject.data.store_revision}Internal change number for committed Memories; not a Memory count or content-quality score.
+
{subject.store_revision}Internal change number for committed Memories; not a Memory count or content-quality score.
-
Last changed
+
Last changed
{:else if data.subject.error} @@ -130,6 +207,40 @@

Subject details are unavailable.

{/if} + {#if subject} +
+
+
+

User-managed context

+

Behavior

+
+ {#if !editingBehavior} + + {/if} +
+

The Host injects this text verbatim for connected Workers, separately from generated Memory. Saving confirms storage only. Connected Workers check for the latest revision before later model requests; this page does not report that application.

+ {#if editingBehavior} +
{ event.preventDefault(); void saveBehavior(); }}> + + {#if behaviorError}{/if} +
+ + + +
+
+ {:else if subject.behavior_md.length > 0} +
{subject.behavior_md}
+ {:else} +
No behavior is set.

Connected Workers receive an explicit empty behavior document.

+ {/if} + {#if behaviorSaveConfirmed}

Behavior storage confirmed at revision {subject.behavior_revision}. This page does not confirm application by a connected Worker.

{/if} +
+ {/if} +
@@ -434,6 +545,7 @@ color: var(--danger); } + .behavior-section, .surface-section, .memories-section { display: grid; @@ -442,6 +554,78 @@ padding-top: var(--space-3); } + .behavior-help, + .behavior-saved, + .behavior-error { + margin: 0; + color: var(--text-muted); + font-size: var(--font-size-compact); + } + + .behavior-error { color: var(--danger); } + .behavior-saved { color: var(--success); } + + .behavior-form, + .behavior-form label { + display: grid; + gap: var(--space-3); + } + + .behavior-form textarea { + width: 100%; + min-height: 10rem; + padding: var(--space-3); + color: var(--text); + background: var(--surface-raised); + border: 1px solid var(--line-strong); + border-radius: var(--radius-sm); + font: inherit; + line-height: 1.5; + resize: vertical; + } + + .behavior-actions { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + } + + .behavior-edit, + .behavior-actions button { + padding: var(--space-2) var(--space-3); + color: var(--text); + background: var(--surface-raised); + border: 1px solid var(--line-strong); + border-radius: var(--radius-sm); + cursor: pointer; + } + + .behavior-actions button:disabled { cursor: not-allowed; opacity: 0.55; } + + .behavior-document pre { + margin: 0; + padding: var(--space-4); + overflow-wrap: anywhere; + white-space: pre-wrap; + background: var(--surface-raised); + border: 1px solid var(--line); + border-radius: var(--radius-sm); + font: inherit; + line-height: 1.6; + } + + .sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; + } + .surface-meta, .pill-row { display: flex; diff --git a/web/workspace/src/routes/w/[workspaceId]/memory/memory-page.browser.spec.ts b/web/workspace/src/routes/w/[workspaceId]/memory/memory-page.browser.spec.ts index c8188d9c8..5166dc3b0 100644 --- a/web/workspace/src/routes/w/[workspaceId]/memory/memory-page.browser.spec.ts +++ b/web/workspace/src/routes/w/[workspaceId]/memory/memory-page.browser.spec.ts @@ -34,6 +34,8 @@ function subject() { return { id: "subject-1", role: "Release coordinator", + behavior_md: "Prefer explicit evidence.", + behavior_revision: 2, state: "active" as const, store_revision: 9, created_at: "2026-01-01T00:00:00Z", @@ -99,6 +101,12 @@ test("creates one Subject with the exact role and navigates to its generated det }) as HTMLInputElement; await waitFor(() => expect(document.activeElement).toBe(roleInput)); await fireEvent.input(roleInput, { target: { value: " Review lead " } }); + const behaviorInput = screen.getByRole("textbox", { + name: /Behavior/, + }) as HTMLTextAreaElement; + await fireEvent.input(behaviorInput, { + target: { value: "Challenge unsupported assumptions." }, + }); const submit = screen.getByRole("button", { name: "Create Subject" }); await fireEvent.click(submit); @@ -113,6 +121,8 @@ test("creates one Subject with the exact role and navigates to its generated det resolveRequest(Response.json({ id: "generated-subject-1", role: " Review lead ", + behavior_md: "Challenge unsupported assumptions.", + behavior_revision: 0, state: "active", store_revision: 0, created_at: "2026-01-03T00:00:00Z", @@ -130,7 +140,9 @@ test("creates one Subject with the exact role and navigates to its generated det ]; expect(path).toBe("/api/w/workspace-1/subjektiv/subjects"); expect(init.method).toBe("POST"); - expect(init.body).toBe('{"role":" Review lead "}'); + expect(init.body).toBe( + '{"role":" Review lead ","behavior_md":"Challenge unsupported assumptions."}', + ); }); test("keeps creation available for an empty list and restores focus on cancel", async () => { @@ -149,7 +161,7 @@ test("keeps creation available for an empty list and restores focus on cancel", expect(screen.queryByRole("textbox", { name: "Role" })).toBeNull(); }); -test("preserves invalid and rejected role drafts with accessible errors", async () => { +test("preserves invalid and rejected Subject drafts with accessible field errors", async () => { const fetchMock = vi.fn(() => Promise.resolve(Response.json( { @@ -175,6 +187,20 @@ test("preserves invalid and rejected role drafts with accessible errors", async expect(fetchMock).not.toHaveBeenCalled(); await fireEvent.input(input, { target: { value: "Release reviewer" } }); + const behaviorInput = screen.getByRole("textbox", { + name: /Behavior/, + }) as HTMLTextAreaElement; + await fireEvent.input(behaviorInput, { target: { value: " " } }); + await fireEvent.click(screen.getByRole("button", { name: "Create Subject" })); + expect(screen.getByRole("alert").textContent).toContain( + "empty or contain non-whitespace", + ); + expect(behaviorInput.getAttribute("aria-invalid")).toBe("true"); + expect(fetchMock).not.toHaveBeenCalled(); + + await fireEvent.input(behaviorInput, { + target: { value: "Challenge unsupported assumptions." }, + }); await fireEvent.click(screen.getByRole("button", { name: "Create Subject" })); await waitFor(() => { expect(screen.getByRole("alert").textContent).toContain( @@ -273,6 +299,11 @@ test("renders a ready resident surface and committed Memory lifecycle states", a expect(screen.getByRole("heading", { name: "Resident context", level: 1 })) .not.toBeNull(); }); + expect( + screen.getByRole("article", { name: "User-managed Subject behavior" }) + .textContent, + ) + .toContain("Prefer explicit evidence."); expect(screen.getByRole("article", { name: "Resident context" })).not .toBeNull(); expect(screen.getByText("Connected", { exact: true })).not.toBeNull(); @@ -292,6 +323,272 @@ test("renders a ready resident surface and committed Memory lifecycle states", a .toContain("cursor=memory-next"); }); +test("edits and clears user-managed behavior with CAS while distinguishing save from Worker application", async () => { + const fetchMock = vi.fn(async (_path: string, init?: RequestInit) => { + const request = JSON.parse(String(init?.body)); + return Response.json({ + ...subject(), + behavior_md: request.behavior_md, + behavior_revision: request.expected_behavior_revision + 1, + }); + }); + vi.stubGlobal("fetch", fetchMock); + render(SubjectPage, { + data: { + workspaceId: "workspace-1", + subjectId: "subject-1", + subject: result(subject()), + surface: result({ + subject_id: "subject-1", + availability: "ungenerated" as const, + }), + memories: result({ items: [], has_more: false }), + }, + } as never); + + await fireEvent.click(screen.getByRole("button", { name: "Edit" })); + const textarea = screen.getByRole("textbox", { name: "Subject behavior" }); + await fireEvent.input(textarea, { + target: { value: "Ask before irreversible actions." }, + }); + await fireEvent.click(screen.getByRole("button", { name: "Save behavior" })); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + const [path, init] = fetchMock.mock.calls[0] as unknown as [ + string, + RequestInit, + ]; + expect(path).toBe("/api/w/workspace-1/subjektiv/subjects/subject-1/behavior"); + expect(init.method).toBe("PATCH"); + expect(init.body).toBe( + '{"expected_behavior_revision":2,"behavior_md":"Ask before irreversible actions."}', + ); + await waitFor(() => { + expect( + screen.getByText(/Behavior storage confirmed at revision 3/).textContent, + ) + .toContain("does not confirm application"); + }); + expect( + screen.getByRole("article", { name: "User-managed Subject behavior" }) + .textContent, + ) + .toContain("Ask before irreversible actions."); + + await fireEvent.click(screen.getByRole("button", { name: "Edit" })); + await fireEvent.click(screen.getByRole("button", { name: "Clear" })); + await fireEvent.click(screen.getByRole("button", { name: "Save behavior" })); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); + const secondBody = + (fetchMock.mock.calls[1] as unknown as [string, RequestInit])[1].body; + expect(secondBody).toBe('{"expected_behavior_revision":3,"behavior_md":""}'); + await waitFor(() => + expect(screen.getByText("No behavior is set.")).not.toBeNull() + ); +}); + +function subjectPageData(record = subject(), workspaceId = "workspace-1") { + return { + workspaceId, + subjectId: record.id, + subject: result(record), + surface: result({ + subject_id: record.id, + availability: "ungenerated" as const, + }), + memories: result({ items: [], has_more: false }), + }; +} + +test.each([ + ["subject-2", "workspace-1"], + ["subject-1", "workspace-2"], +])( + "resets reused Subject editor for %s in %s and saves only its own behavior", + async (id, workspaceId) => { + const nextSubject = { ...subject(), id, behavior_md: "Only B behavior." }; + const fetchMock = vi.fn(async (_path: string, init?: RequestInit) => { + const request = JSON.parse(String(init?.body)); + return Response.json({ + ...nextSubject, + behavior_md: request.behavior_md, + behavior_revision: request.expected_behavior_revision + 1, + }); + }); + vi.stubGlobal("fetch", fetchMock); + const view = render(SubjectPage, { data: subjectPageData() } as never); + await fireEvent.click(screen.getByRole("button", { name: "Edit" })); + await fireEvent.input( + screen.getByRole("textbox", { name: "Subject behavior" }), + { + target: { value: "Unsaved A draft" }, + }, + ); + + await view.rerender( + { data: subjectPageData(nextSubject, workspaceId) } as never, + ); + expect(screen.queryByRole("textbox", { name: "Subject behavior" })) + .toBeNull(); + expect( + screen.getByRole("article", { name: "User-managed Subject behavior" }) + .textContent, + ) + .toContain("Only B behavior."); + await fireEvent.click(screen.getByRole("button", { name: "Edit" })); + expect( + (screen.getByRole("textbox", { + name: "Subject behavior", + }) as HTMLTextAreaElement).value, + ) + .toBe("Only B behavior."); + await fireEvent.click( + screen.getByRole("button", { name: "Save behavior" }), + ); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + const [path, init] = fetchMock.mock.calls[0] as unknown as [ + string, + RequestInit, + ]; + expect(path).toBe( + `/api/w/${workspaceId}/subjektiv/subjects/${id}/behavior`, + ); + expect(JSON.parse(String(init.body))).toEqual({ + expected_behavior_revision: 2, + behavior_md: "Only B behavior.", + }); + await waitFor(() => + expect(screen.getByText(/storage confirmed at revision 3/)).not.toBeNull() + ); + }, +); + +test.each(["success", "failure"])( + "fences late %s from a previous Subject without clearing the new save state", + async (outcome) => { + const pending: Array<(response: Response) => void> = []; + const fetchMock = vi.fn(() => + new Promise((resolve) => pending.push(resolve)) + ); + vi.stubGlobal("fetch", fetchMock); + const view = render(SubjectPage, { data: subjectPageData() } as never); + await fireEvent.click(screen.getByRole("button", { name: "Edit" })); + await fireEvent.click( + screen.getByRole("button", { name: "Save behavior" }), + ); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + + const nextSubject = { + ...subject(), + id: "subject-2", + behavior_md: "Only B behavior.", + }; + await view.rerender({ data: subjectPageData(nextSubject) } as never); + await fireEvent.click(screen.getByRole("button", { name: "Edit" })); + await fireEvent.input( + screen.getByRole("textbox", { name: "Subject behavior" }), + { + target: { value: "Updated B behavior." }, + }, + ); + await fireEvent.click( + screen.getByRole("button", { name: "Save behavior" }), + ); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); + pending[0]( + outcome === "success" + ? Response.json({ + ...subject(), + behavior_md: "Late A response", + behavior_revision: 3, + }) + : Response.json({ + error: "Forbidden", + message: "A denied", + diagnostics: [], + }, { status: 403 }), + ); + // Drain the previous response through its success/catch/finally branches. + await new Promise((resolve) => setTimeout(resolve, 0)); + expect( + screen.getByRole("button", { name: "Saving…" }).hasAttribute("disabled"), + ).toBe(true); + expect( + (screen.getByRole("textbox", { + name: "Subject behavior", + }) as HTMLTextAreaElement).value, + ) + .toBe("Updated B behavior."); + expect(screen.queryByRole("alert")).toBeNull(); + expect(screen.queryByText(/storage confirmed/)).toBeNull(); + pending[1]( + Response.json({ + ...nextSubject, + behavior_md: "Updated B behavior.", + behavior_revision: 3, + }), + ); + await waitFor(() => + expect(screen.getByText(/storage confirmed at revision 3/)).not.toBeNull() + ); + expect( + screen.getByRole("article", { name: "User-managed Subject behavior" }) + .textContent, + ) + .toContain("Updated B behavior."); + }, +); + +test("a refreshed loader snapshot fences old saves even after returning to the same Subject", async () => { + let resolveRequest!: (response: Response) => void; + vi.stubGlobal( + "fetch", + vi.fn(() => + new Promise((resolve) => { + resolveRequest = resolve; + }) + ), + ); + const view = render(SubjectPage, { data: subjectPageData() } as never); + await fireEvent.click(screen.getByRole("button", { name: "Edit" })); + await fireEvent.click(screen.getByRole("button", { name: "Save behavior" })); + await view.rerender( + { data: subjectPageData({ ...subject(), id: "subject-2" }) } as never, + ); + const fresh = { + ...subject(), + behavior_md: "Fresh A loader", + behavior_revision: 4, + }; + await view.rerender({ data: subjectPageData(fresh) } as never); + resolveRequest( + Response.json({ + ...subject(), + behavior_md: "Stale A response", + behavior_revision: 3, + }), + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect( + screen.getByRole("article", { name: "User-managed Subject behavior" }) + .textContent, + ) + .toContain("Fresh A loader"); + expect(screen.queryByText(/storage confirmed/)).toBeNull(); + + await view.rerender( + { + data: { + ...subjectPageData(), + subject: result(null, "Subject unavailable"), + }, + } as never, + ); + expect(screen.queryByRole("button", { name: "Edit" })).toBeNull(); + expect( + screen.queryByRole("article", { name: "User-managed Subject behavior" }), + ).toBeNull(); +}); + test("candidate-only Subjects show no committed Memories, not an inferred candidate count", () => { // The store regression candidate_decisions_reject_conflicts_and_roll_back_partial_writes // stages a candidate without committing a Memory. Its public read projection is @@ -320,6 +617,65 @@ test("candidate-only Subjects show no committed Memories, not an inferred candid ).toBeNull(); }); +test.each(["ready", "ungenerated", "stale", "failed"] as const)( + "user behavior and diagnostic disclosures stay independent of %s generated Memory", + async (availability) => { + const record = { ...subject(), store_revision: 0 }; + const view = render(SubjectPage, { + data: { + ...subjectPageData(record), + surface: result({ + subject_id: record.id, + availability, + ...(availability === "ready" + ? { + snapshot: { + snapshot_id: "empty-snapshot", + body_md: "", + memory_refs: [], + built_from_store_revision: 0, + created_at: "2026-01-02T03:04:05Z", + }, + } + : {}), + }), + }, + } as never); + expect( + screen.getByRole("article", { name: "User-managed Subject behavior" }) + .textContent, + ) + .toContain(record.behavior_md); + expect(screen.getByText("Generated Memory context", { exact: true })).not + .toBeNull(); + expect(screen.getByText("No committed Memories yet.")).not.toBeNull(); + expect(screen.getByText("Not connected", { exact: true })).not.toBeNull(); + const technical = view.container.querySelector( + "details.subject-technical-details", + ) as HTMLDetailsElement; + expect(technical.open).toBe(false); + expect(technical.textContent).toContain("Subject store revision"); + if (availability === "ready") { + expect(screen.getByText("Resident context is current but empty.")).not + .toBeNull(); + expect(screen.getByText(/no generated Memory context to show/)).not + .toBeNull(); + expect( + view.container.querySelector("details.surface-details")?.textContent, + ) + .toContain("Generated from Subject revision"); + } + await fireEvent.click(screen.getByRole("button", { name: "Edit" })); + await fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect( + screen.getByRole("article", { name: "User-managed Subject behavior" }) + .textContent, + ) + .toContain(record.behavior_md); + expect(technical.open).toBe(false); + }, +); + test("distinguishes ready-empty, stale, failed, unavailable, and request error surfaces", async () => { const base = { workspaceId: "workspace-1", diff --git a/web/workspace/tests/memory-api.test.ts b/web/workspace/tests/memory-api.test.ts index 52f7c017e..9d5b460d8 100644 --- a/web/workspace/tests/memory-api.test.ts +++ b/web/workspace/tests/memory-api.test.ts @@ -12,6 +12,7 @@ import { parseSubjektivSubjectListResponse, parseSubjektivSubjectResponse, SubjektivSubjectCreateError, + updateSubjektivSubjectBehavior, validateSubjektivSubjectCreateRequest, } from "../src/lib/workspace/memory/api.ts"; @@ -73,6 +74,8 @@ function subject(id = "subject-1") { return { id, role: "Release coordinator", + behavior_md: "Prefer explicit evidence.", + behavior_revision: 2, state: "active", store_revision: 12, created_at: "2026-09-01T00:00:00Z", @@ -271,7 +274,7 @@ Deno.test("Subject list parser preserves the exact bounded response", () => { Deno.test("Subject create request mirrors Backend role validation without rewriting input", () => { assertEquals( validateSubjektivSubjectCreateRequest({ role: " Release coordinator " }), - { role: " Release coordinator " }, + { role: " Release coordinator ", behavior_md: "" }, ); assertThrows( () => validateSubjektivSubjectCreateRequest({ role: " " }), @@ -285,6 +288,70 @@ Deno.test("Subject create request mirrors Backend role validation without rewrit () => validateSubjektivSubjectCreateRequest({ role: "界".repeat(86) }), "at most 256 bytes", ); + assertThrows( + () => + validateSubjektivSubjectCreateRequest({ + role: "Reviewer", + behavior_md: " ".repeat(3), + }), + "empty or contain non-whitespace", + ); + assertThrows( + () => + validateSubjektivSubjectCreateRequest({ + role: "Reviewer", + behavior_md: "x".repeat(16 * 1024 + 1), + }), + "at most 16384 bytes", + ); +}); + +Deno.test("Subject behavior update uses CAS and preserves exact text", async () => { + const requests: Array<{ path: string; init?: RequestInit }> = []; + const fetchFn = (async (path: string | URL | Request, init?: RequestInit) => { + requests.push({ path: String(path), init }); + return Response.json({ + ...subject("subject/one"), + behavior_md: "Line one.\nLine two.", + behavior_revision: 3, + }); + }) as typeof fetch; + + const updated = await updateSubjektivSubjectBehavior( + fetchFn, + "workspace one", + "subject/one", + { expected_behavior_revision: 2, behavior_md: "Line one.\nLine two." }, + ); + assertEquals(updated.behavior_revision, 3); + assertEquals(updated.behavior_md, "Line one.\nLine two."); + assertEquals( + requests[0].path, + "/api/w/workspace%20one/subjektiv/subjects/subject%2Fone/behavior", + ); + assertEquals(requests[0].init?.method, "PATCH"); + assertEquals( + requests[0].init?.body, + '{"expected_behavior_revision":2,"behavior_md":"Line one.\\nLine two."}', + ); +}); + +Deno.test("Subject behavior update reports conflicts without claiming Worker application", async () => { + const conflictFetch = + (async () => + Response.json({ message: "revision conflict" }, { + status: 409, + })) as typeof fetch; + await assertSubjectCreateRejects( + () => + updateSubjektivSubjectBehavior(conflictFetch, "workspace", "subject", { + expected_behavior_revision: 1, + behavior_md: "new", + }), + "rejected", + "changed elsewhere", + 409, + ); }); Deno.test("Subject create client sends the exact typed body once and parses the created Subject", async () => { @@ -294,20 +361,26 @@ Deno.test("Subject create client sends the exact typed body once and parses the return Response.json({ ...subject("subject-created"), role: " Release coordinator ", + behavior_md: "Stay concise.", }, { status: 201 }); }) as typeof fetch; const created = await createSubjektivSubject(fetchFn, "workspace one", { role: " Release coordinator ", + behavior_md: "Stay concise.", }); assertEquals(created, { ...subject("subject-created"), role: " Release coordinator ", + behavior_md: "Stay concise.", }); assertEquals(requests.length, 1); assertEquals(requests[0].path, "/api/w/workspace%20one/subjektiv/subjects"); assertEquals(requests[0].init?.method, "POST"); - assertEquals(requests[0].init?.body, '{"role":" Release coordinator "}'); + assertEquals( + requests[0].init?.body, + '{"role":" Release coordinator ","behavior_md":"Stay concise."}', + ); }); Deno.test("Subject create client distinguishes rejected and unknown outcomes without retrying", async () => {