feat: integrate persistent Subject behavior context
This commit is contained in:
@@ -237,6 +237,7 @@ impl_openapi_schema!(
|
|||||||
MemoryConsolidateStagingRequest,
|
MemoryConsolidateStagingRequest,
|
||||||
MemoryConsolidationResponse,
|
MemoryConsolidationResponse,
|
||||||
SubjektivSubjectCreateRequest,
|
SubjektivSubjectCreateRequest,
|
||||||
|
SubjektivSubjectBehaviorUpdateRequest,
|
||||||
SubjektivSubjectListQuery,
|
SubjektivSubjectListQuery,
|
||||||
SubjektivSubjectListResponse,
|
SubjektivSubjectListResponse,
|
||||||
SubjektivSubjectResponse,
|
SubjektivSubjectResponse,
|
||||||
@@ -1163,6 +1164,20 @@ pub trait ServerApi {
|
|||||||
#[path] workspace_id: String,
|
#[path] workspace_id: String,
|
||||||
#[path] subject_id: String,
|
#[path] subject_id: String,
|
||||||
) -> Result<SubjektivSubjectResponse, RepositoryApiError>;
|
) -> Result<SubjektivSubjectResponse, RepositoryApiError>;
|
||||||
|
#[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<SubjektivSubjectResponse, RepositoryApiError>;
|
||||||
#[get(
|
#[get(
|
||||||
"/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/surface",
|
"/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/surface",
|
||||||
status = 200,
|
status = 200,
|
||||||
@@ -4579,9 +4594,20 @@ pub enum SubjektivSubjectState {
|
|||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct SubjektivSubjectCreateRequest {
|
pub struct SubjektivSubjectCreateRequest {
|
||||||
pub role: String,
|
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_BROWSER_MAX_LIST_LIMIT: usize = 100;
|
||||||
|
pub const SUBJEKTIV_MAX_BEHAVIOR_BYTES: usize = 16 * 1024;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
@@ -4609,6 +4635,9 @@ pub struct SubjektivSubjectListResponse {
|
|||||||
pub struct SubjektivSubjectResponse {
|
pub struct SubjektivSubjectResponse {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub role: 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,
|
pub state: SubjektivSubjectState,
|
||||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
|
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
|
||||||
pub store_revision: u64,
|
pub store_revision: u64,
|
||||||
@@ -5442,10 +5471,22 @@ pub struct SubjektivSurfaceFailureResponse {
|
|||||||
pub status: String,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||||
#[serde(tag = "operation", content = "input", rename_all = "snake_case")]
|
#[serde(tag = "operation", content = "input", rename_all = "snake_case")]
|
||||||
pub enum SubjektivMemoryBackendOperation {
|
pub enum SubjektivMemoryBackendOperation {
|
||||||
ResidentSummary(memory::backend::MemoryResidentSummaryOperation),
|
ResidentSummary(memory::backend::MemoryResidentSummaryOperation),
|
||||||
|
ResidentContext(memory::backend::MemoryResidentSummaryOperation),
|
||||||
Query(SubjektivMemoryQueryRequest),
|
Query(SubjektivMemoryQueryRequest),
|
||||||
Read(SubjektivMemoryReadRequest),
|
Read(SubjektivMemoryReadRequest),
|
||||||
ListRevisions(SubjektivMemoryListRevisionsRequest),
|
ListRevisions(SubjektivMemoryListRevisionsRequest),
|
||||||
@@ -5618,6 +5659,7 @@ pub struct SubjektivMemoryStageExplicitResponse {
|
|||||||
#[serde(tag = "result", content = "data", rename_all = "snake_case")]
|
#[serde(tag = "result", content = "data", rename_all = "snake_case")]
|
||||||
pub enum SubjektivMemoryBackendResponse {
|
pub enum SubjektivMemoryBackendResponse {
|
||||||
ResidentSummary(memory::backend::MemoryResidentSummaryOutput),
|
ResidentSummary(memory::backend::MemoryResidentSummaryOutput),
|
||||||
|
ResidentContext(SubjektivResidentContextOutput),
|
||||||
Query(SubjektivMemoryQueryResponse),
|
Query(SubjektivMemoryQueryResponse),
|
||||||
Read(SubjektivMemoryReadResponse),
|
Read(SubjektivMemoryReadResponse),
|
||||||
ListRevisions(SubjektivMemoryListRevisionsResponse),
|
ListRevisions(SubjektivMemoryListRevisionsResponse),
|
||||||
@@ -9861,9 +9903,11 @@ pub fn memory_api_typescript() -> String {
|
|||||||
// `ts-rs` solely for this read-only projection.
|
// `ts-rs` solely for this read-only projection.
|
||||||
let subjektiv_browser_declarations = r#"export type SubjektivSubjectState = "active" | "retired";
|
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<SubjektivSubjectResponse>, next_cursor?: string | null, has_more: boolean, };
|
export type SubjektivSubjectListResponse = { limit: number, items: Array<SubjektivSubjectResponse>, next_cursor?: string | null, has_more: boolean, };
|
||||||
|
|
||||||
@@ -9896,8 +9940,11 @@ export type SubjektivMemoryListRevisionsResponse = { memory_id: string, current_
|
|||||||
let limits = format!(
|
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;"
|
"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!(
|
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
|
declarations
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|declaration| format!("export {declaration}"))
|
.map(|declaration| format!("export {declaration}"))
|
||||||
|
|||||||
@@ -167,6 +167,15 @@ pub enum SystemItem {
|
|||||||
prompt_provenance: Option<PromptRenderProvenance>,
|
prompt_provenance: Option<PromptRenderProvenance>,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// 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<PromptRenderProvenance>,
|
||||||
|
},
|
||||||
|
|
||||||
/// Synthetic note inserted after an interrupted turn before the next
|
/// Synthetic note inserted after an interrupted turn before the next
|
||||||
/// user input. `body` is the exact LLM-context text explaining that the
|
/// user input. `body` is the exact LLM-context text explaining that the
|
||||||
/// previous turn was cut short.
|
/// previous turn was cut short.
|
||||||
@@ -192,6 +201,7 @@ impl SystemItem {
|
|||||||
}
|
}
|
||||||
SystemItem::TaskReminder { body, .. } => body.clone(),
|
SystemItem::TaskReminder { body, .. } => body.clone(),
|
||||||
SystemItem::ResidentSummaryRefresh { body, .. } => body.clone(),
|
SystemItem::ResidentSummaryRefresh { body, .. } => body.clone(),
|
||||||
|
SystemItem::SubjectBehaviorRefresh { body, .. } => body.clone(),
|
||||||
SystemItem::Interrupt { body, .. } => body.clone(),
|
SystemItem::Interrupt { body, .. } => body.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -214,6 +224,7 @@ impl SystemItem {
|
|||||||
SystemItem::LegacyIgnored { .. } => "legacy_ignored",
|
SystemItem::LegacyIgnored { .. } => "legacy_ignored",
|
||||||
SystemItem::TaskReminder { .. } => "task_reminder",
|
SystemItem::TaskReminder { .. } => "task_reminder",
|
||||||
SystemItem::ResidentSummaryRefresh { .. } => "resident_summary_refresh",
|
SystemItem::ResidentSummaryRefresh { .. } => "resident_summary_refresh",
|
||||||
|
SystemItem::SubjectBehaviorRefresh { .. } => "subject_behavior_refresh",
|
||||||
SystemItem::Interrupt { .. } => "interrupt",
|
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]
|
#[test]
|
||||||
fn notification_history_text_returns_stored_body() {
|
fn notification_history_text_returns_stored_body() {
|
||||||
let item = SystemItem::Notification {
|
let item = SystemItem::Notification {
|
||||||
|
|||||||
@@ -2618,6 +2618,7 @@ impl App {
|
|||||||
session_store::SystemItem::FileAttachment { body, .. }
|
session_store::SystemItem::FileAttachment { body, .. }
|
||||||
| session_store::SystemItem::SkillActivation { body, .. }
|
| session_store::SystemItem::SkillActivation { body, .. }
|
||||||
| session_store::SystemItem::ResidentSummaryRefresh { body, .. }
|
| session_store::SystemItem::ResidentSummaryRefresh { body, .. }
|
||||||
|
| session_store::SystemItem::SubjectBehaviorRefresh { body, .. }
|
||||||
| session_store::SystemItem::Interrupt { body, .. } => {
|
| session_store::SystemItem::Interrupt { body, .. } => {
|
||||||
self.task_store.apply_system_message_text(&body);
|
self.task_store.apply_system_message_text(&body);
|
||||||
self.blocks.push(Block::SystemMessage { text: body });
|
self.blocks.push(Block::SystemMessage { text: body });
|
||||||
|
|||||||
@@ -1375,6 +1375,7 @@ where
|
|||||||
crate::feature::builtin::memory::ordinary_subjektiv_resident_summary_source(
|
crate::feature::builtin::memory::ordinary_subjektiv_resident_summary_source(
|
||||||
worker.manifest(),
|
worker.manifest(),
|
||||||
worker.workspace_client_handle(),
|
worker.workspace_client_handle(),
|
||||||
|
worker.prompts(),
|
||||||
)?
|
)?
|
||||||
{
|
{
|
||||||
let workspace_id = worker
|
let workspace_id = worker
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
//! authority. In that case model-visible Memory tools must go through the
|
//! authority. In that case model-visible Memory tools must go through the
|
||||||
//! workspace backend instead of resolving `.yoi/memory` from a Worker workdir.
|
//! 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 std::time::Duration;
|
||||||
|
|
||||||
use agen::tool::{Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
|
use agen::tool::{Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
|
||||||
@@ -24,11 +25,15 @@ use crate::feature::{
|
|||||||
FeatureDescriptor, FeatureHookPoint, FeatureInstallContext, FeatureInstallError, FeatureModule,
|
FeatureDescriptor, FeatureHookPoint, FeatureInstallContext, FeatureInstallError, FeatureModule,
|
||||||
HookDeclaration, ToolContribution, ToolDeclaration,
|
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::prompt::catalog::{PromptCatalog, WorkerPrompt};
|
||||||
use crate::worker::{
|
use crate::worker::{
|
||||||
SystemPromptContribution, SystemPromptContributionSource, WorkspaceClient,
|
ResidentContextRefresh, SystemPromptContribution, SystemPromptContributionSource,
|
||||||
WorkspaceClientError, WorkspaceRequest, WorkspaceRequestMethod,
|
WorkspaceClient, WorkspaceClientError, WorkspaceRequest, WorkspaceRequestMethod,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
@@ -74,6 +79,8 @@ pub enum WorkspaceMemoryBackendError {
|
|||||||
Decode(#[from] serde_json::Error),
|
Decode(#[from] serde_json::Error),
|
||||||
#[error("workspace memory backend rejected operation: {0}")]
|
#[error("workspace memory backend rejected operation: {0}")]
|
||||||
Backend(String),
|
Backend(String),
|
||||||
|
#[error("workspace memory backend returned an invalid response: {0}")]
|
||||||
|
InvalidResponse(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl dyn WorkspaceClient + '_ {
|
impl dyn WorkspaceClient + '_ {
|
||||||
@@ -672,12 +679,17 @@ struct WorkspaceResidentSummarySource {
|
|||||||
client: Arc<dyn WorkspaceClient>,
|
client: Arc<dyn WorkspaceClient>,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct WorkspaceSubjektivResidentSummarySource {
|
#[derive(Default)]
|
||||||
client: Arc<dyn WorkspaceClient>,
|
struct SubjectBehaviorRefreshState {
|
||||||
|
applied: Option<(u64, String)>,
|
||||||
|
pending: HashMap<u64, String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
const READY_EMPTY_RESIDENT_SURFACE: &str =
|
struct WorkspaceSubjektivResidentSummarySource {
|
||||||
"The current ready resident memory surface is intentionally empty.";
|
client: Arc<dyn WorkspaceClient>,
|
||||||
|
prompts: Arc<ArcSwap<PromptCatalog>>,
|
||||||
|
behavior_state: Mutex<SubjectBehaviorRefreshState>,
|
||||||
|
}
|
||||||
|
|
||||||
fn resident_summary_contribution(
|
fn resident_summary_contribution(
|
||||||
output: memory::backend::MemoryResidentSummaryOutput,
|
output: memory::backend::MemoryResidentSummaryOutput,
|
||||||
@@ -715,37 +727,133 @@ impl SystemPromptContributionSource for WorkspaceResidentSummarySource {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
impl WorkspaceSubjektivResidentSummarySource {
|
||||||
impl SystemPromptContributionSource for WorkspaceSubjektivResidentSummarySource {
|
async fn fetch(
|
||||||
async fn load(&self) -> SystemPromptContribution {
|
&self,
|
||||||
|
) -> Result<server_api::SubjektivResidentContextOutput, WorkspaceMemoryBackendError> {
|
||||||
match execute_subjektiv_memory_operation_with(
|
match execute_subjektiv_memory_operation_with(
|
||||||
self.client.as_ref(),
|
self.client.as_ref(),
|
||||||
server_api::SubjektivMemoryBackendOperation::ResidentSummary(Default::default()),
|
server_api::SubjektivMemoryBackendOperation::ResidentContext(Default::default()),
|
||||||
|client, request| {
|
|client, request| {
|
||||||
client.execute_with_timeout(request, RESIDENT_SUMMARY_REQUEST_TIMEOUT)
|
client.execute_with_timeout(request, RESIDENT_SUMMARY_REQUEST_TIMEOUT)
|
||||||
},
|
},
|
||||||
) {
|
)? {
|
||||||
Ok(server_api::SubjektivMemoryBackendResponse::ResidentSummary(output)) => {
|
server_api::SubjektivMemoryBackendResponse::ResidentContext(output) => Ok(output),
|
||||||
match resident_summary_contribution(output) {
|
other => Err(WorkspaceMemoryBackendError::InvalidResponse(format!(
|
||||||
SystemPromptContribution::Ready(content) if content.trim().is_empty() => {
|
"unexpected subject resident context response: {other:?}"
|
||||||
SystemPromptContribution::Ready(READY_EMPTY_RESIDENT_SURFACE.to_string())
|
))),
|
||||||
}
|
}
|
||||||
contribution => contribution,
|
}
|
||||||
|
|
||||||
|
fn render(
|
||||||
|
&self,
|
||||||
|
output: &server_api::SubjektivResidentContextOutput,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
}
|
Err(error) => {
|
||||||
Ok(other) => {
|
tracing::error!(%error, "subject resident context render failed");
|
||||||
tracing::debug!(?other, "unexpected subject resident Memory response");
|
SystemPromptContribution::Ready(
|
||||||
SystemPromptContribution::Unavailable
|
"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) => {
|
Err(error) => {
|
||||||
tracing::debug!(%error, "subject resident Memory unavailable");
|
tracing::error!(%error, "subject resident context fetch failed");
|
||||||
SystemPromptContribution::Unavailable
|
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<Option<ResidentContextRefresh>, 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_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
|
/// subjektiv-owned restore behavior. Initial prompt contribution remains a
|
||||||
/// shared host facility, while loading and rendering a post-restore refresh is
|
/// shared host facility, while loading and rendering a post-restore refresh is
|
||||||
@@ -793,6 +901,14 @@ impl FeatureModule for SubjektivResidentRestoreRefreshFeature {
|
|||||||
SUBJEKTIV_RESTORE_REFRESH_HOOK,
|
SUBJEKTIV_RESTORE_REFRESH_HOOK,
|
||||||
FeatureHookPoint::WorkerRestored,
|
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> {
|
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
|
||||||
@@ -804,10 +920,64 @@ impl FeatureModule for SubjektivResidentRestoreRefreshFeature {
|
|||||||
prompts: Arc::clone(&self.prompts),
|
prompts: Arc::clone(&self.prompts),
|
||||||
workspace_id: self.workspace_id.clone(),
|
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<dyn SystemPromptContributionSource>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Hook<BeforeSessionRewrite> for SubjektivBehaviorRewriteHook {
|
||||||
|
async fn call(
|
||||||
|
&self,
|
||||||
|
_context: &BeforeSessionRewriteContext,
|
||||||
|
) -> Result<BeforeSessionRewriteAction, HookError> {
|
||||||
|
self.source.invalidate_resident_context_representation();
|
||||||
|
Ok(BeforeSessionRewriteAction::Continue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SubjektivBehaviorRefreshHook {
|
||||||
|
source: Arc<dyn SystemPromptContributionSource>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Hook<PreLlmRequest> for SubjektivBehaviorRefreshHook {
|
||||||
|
async fn call(&self, context: &PreRequestContext) -> Result<HookPreRequestAction, HookError> {
|
||||||
|
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 {
|
struct SubjektivResidentRestoreRefreshHook {
|
||||||
source: Arc<dyn SystemPromptContributionSource>,
|
source: Arc<dyn SystemPromptContributionSource>,
|
||||||
prompts: Arc<ArcSwap<PromptCatalog>>,
|
prompts: Arc<ArcSwap<PromptCatalog>>,
|
||||||
@@ -951,6 +1121,7 @@ pub(crate) fn ordinary_subjektiv_features_enabled(manifest: &manifest::WorkerMan
|
|||||||
pub(crate) fn ordinary_subjektiv_resident_summary_source(
|
pub(crate) fn ordinary_subjektiv_resident_summary_source(
|
||||||
manifest: &manifest::WorkerManifest,
|
manifest: &manifest::WorkerManifest,
|
||||||
client: Arc<dyn WorkspaceClient>,
|
client: Arc<dyn WorkspaceClient>,
|
||||||
|
prompts: Arc<ArcSwap<PromptCatalog>>,
|
||||||
) -> std::io::Result<Option<Arc<dyn SystemPromptContributionSource>>> {
|
) -> std::io::Result<Option<Arc<dyn SystemPromptContributionSource>>> {
|
||||||
if !ordinary_subjektiv_features_enabled(manifest) {
|
if !ordinary_subjektiv_features_enabled(manifest) {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
@@ -988,6 +1159,8 @@ pub(crate) fn ordinary_subjektiv_resident_summary_source(
|
|||||||
}
|
}
|
||||||
Ok(Some(Arc::new(WorkspaceSubjektivResidentSummarySource {
|
Ok(Some(Arc::new(WorkspaceSubjektivResidentSummarySource {
|
||||||
client,
|
client,
|
||||||
|
prompts,
|
||||||
|
behavior_state: Mutex::new(SubjectBehaviorRefreshState::default()),
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1120,6 +1293,10 @@ mod tests {
|
|||||||
use agen::tool::ToolDefinition;
|
use agen::tool::ToolDefinition;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
fn test_prompts() -> Arc<ArcSwap<PromptCatalog>> {
|
||||||
|
Arc::new(ArcSwap::from(PromptCatalog::builtins_only().unwrap()))
|
||||||
|
}
|
||||||
|
|
||||||
fn test_client() -> Arc<dyn WorkspaceClient> {
|
fn test_client() -> Arc<dyn WorkspaceClient> {
|
||||||
Arc::new(crate::worker::TestWorkspaceHttpClient::new(
|
Arc::new(crate::worker::TestWorkspaceHttpClient::new(
|
||||||
"workspace",
|
"workspace",
|
||||||
@@ -1132,6 +1309,7 @@ mod tests {
|
|||||||
availability: memory::backend::MemoryResidentSummaryAvailability,
|
availability: memory::backend::MemoryResidentSummaryAvailability,
|
||||||
content: Option<String>,
|
content: Option<String>,
|
||||||
scope_allowed: bool,
|
scope_allowed: bool,
|
||||||
|
behavior: Mutex<(u64, String)>,
|
||||||
paths: Mutex<Vec<String>>,
|
paths: Mutex<Vec<String>>,
|
||||||
timeouts: Mutex<Vec<Duration>>,
|
timeouts: Mutex<Vec<Duration>>,
|
||||||
}
|
}
|
||||||
@@ -1145,6 +1323,7 @@ mod tests {
|
|||||||
availability,
|
availability,
|
||||||
content: content.map(str::to_string),
|
content: content.map(str::to_string),
|
||||||
scope_allowed: true,
|
scope_allowed: true,
|
||||||
|
behavior: Mutex::new((3, "Be deliberate.".to_string())),
|
||||||
paths: Mutex::new(Vec::new()),
|
paths: Mutex::new(Vec::new()),
|
||||||
timeouts: Mutex::new(Vec::new()),
|
timeouts: Mutex::new(Vec::new()),
|
||||||
}
|
}
|
||||||
@@ -1155,6 +1334,7 @@ mod tests {
|
|||||||
availability: memory::backend::MemoryResidentSummaryAvailability::Ready,
|
availability: memory::backend::MemoryResidentSummaryAvailability::Ready,
|
||||||
content: Some("must not be injected".into()),
|
content: Some("must not be injected".into()),
|
||||||
scope_allowed: false,
|
scope_allowed: false,
|
||||||
|
behavior: Mutex::new((3, "must not be injected".to_string())),
|
||||||
paths: Mutex::new(Vec::new()),
|
paths: Mutex::new(Vec::new()),
|
||||||
timeouts: 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();
|
serde_json::from_str(request.body.as_deref().unwrap_or_default()).unwrap();
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
request.operation,
|
request.operation,
|
||||||
server_api::SubjektivMemoryBackendOperation::ResidentSummary(_)
|
server_api::SubjektivMemoryBackendOperation::ResidentContext(_)
|
||||||
));
|
));
|
||||||
if !self.scope_allowed {
|
if !self.scope_allowed {
|
||||||
return Ok(WorkspaceResponse {
|
return Ok(WorkspaceResponse {
|
||||||
@@ -1192,13 +1372,18 @@ mod tests {
|
|||||||
body: "subject scope unavailable".into(),
|
body: "subject scope unavailable".into(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
let (behavior_revision, behavior_md) = self.behavior.lock().unwrap().clone();
|
||||||
Ok(WorkspaceResponse {
|
Ok(WorkspaceResponse {
|
||||||
status: 200,
|
status: 200,
|
||||||
body: serde_json::to_string(
|
body: serde_json::to_string(
|
||||||
&server_api::SubjektivMemoryBackendResponse::ResidentSummary(
|
&server_api::SubjektivMemoryBackendResponse::ResidentContext(
|
||||||
memory::backend::MemoryResidentSummaryOutput {
|
server_api::SubjektivResidentContextOutput {
|
||||||
availability: self.availability,
|
behavior_md,
|
||||||
content: self.content.clone(),
|
behavior_revision,
|
||||||
|
memory_surface: memory::backend::MemoryResidentSummaryOutput {
|
||||||
|
availability: self.availability,
|
||||||
|
content: self.content.clone(),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -1511,13 +1696,19 @@ permission = "write"
|
|||||||
memory::backend::MemoryResidentSummaryAvailability::Ready,
|
memory::backend::MemoryResidentSummaryAvailability::Ready,
|
||||||
Some("current subject surface"),
|
Some("current subject surface"),
|
||||||
));
|
));
|
||||||
let ready = ordinary_subjektiv_resident_summary_source(&manifest, ready_client.clone())
|
let ready = ordinary_subjektiv_resident_summary_source(
|
||||||
.unwrap()
|
&manifest,
|
||||||
.unwrap();
|
ready_client.clone(),
|
||||||
assert_eq!(
|
test_prompts(),
|
||||||
ready.load().await,
|
)
|
||||||
SystemPromptContribution::Ready("current subject surface".into())
|
.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!(
|
assert_eq!(
|
||||||
ready_client.paths.lock().unwrap().clone(),
|
ready_client.paths.lock().unwrap().clone(),
|
||||||
vec!["/api/w/workspace/subjektiv/memory".to_string()]
|
vec!["/api/w/workspace/subjektiv/memory".to_string()]
|
||||||
@@ -1531,13 +1722,18 @@ permission = "write"
|
|||||||
memory::backend::MemoryResidentSummaryAvailability::Ready,
|
memory::backend::MemoryResidentSummaryAvailability::Ready,
|
||||||
None,
|
None,
|
||||||
));
|
));
|
||||||
let ready_empty = ordinary_subjektiv_resident_summary_source(&manifest, ready_empty_client)
|
let ready_empty = ordinary_subjektiv_resident_summary_source(
|
||||||
.unwrap()
|
&manifest,
|
||||||
.unwrap();
|
ready_empty_client,
|
||||||
assert_eq!(
|
test_prompts(),
|
||||||
ready_empty.load().await,
|
)
|
||||||
SystemPromptContribution::Ready(READY_EMPTY_RESIDENT_SURFACE.into())
|
.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 [
|
for availability in [
|
||||||
memory::backend::MemoryResidentSummaryAvailability::Ungenerated,
|
memory::backend::MemoryResidentSummaryAvailability::Ungenerated,
|
||||||
@@ -1550,22 +1746,107 @@ permission = "write"
|
|||||||
availability,
|
availability,
|
||||||
Some("must not be injected"),
|
Some("must not be injected"),
|
||||||
)),
|
)),
|
||||||
|
test_prompts(),
|
||||||
)
|
)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.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]
|
#[tokio::test]
|
||||||
async fn subject_resident_source_fails_closed_without_trusted_subject_scope() {
|
async fn subject_resident_source_fails_closed_without_trusted_subject_scope() {
|
||||||
let manifest = subject_manifest("workspace");
|
let manifest = subject_manifest("workspace");
|
||||||
let client = Arc::new(SubjectResidentClient::denied());
|
let client = Arc::new(SubjectResidentClient::denied());
|
||||||
let source = ordinary_subjektiv_resident_summary_source(&manifest, client.clone())
|
let source =
|
||||||
.unwrap()
|
ordinary_subjektiv_resident_summary_source(&manifest, client.clone(), test_prompts())
|
||||||
.unwrap();
|
.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!(
|
assert_eq!(
|
||||||
client.paths.lock().unwrap().clone(),
|
client.paths.lock().unwrap().clone(),
|
||||||
vec!["/api/w/workspace/subjektiv/memory".to_string()]
|
vec!["/api/w/workspace/subjektiv/memory".to_string()]
|
||||||
@@ -1593,7 +1874,7 @@ permission = "write"
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
ordinary_subjektiv_resident_summary_source(&disabled, test_client())
|
ordinary_subjektiv_resident_summary_source(&disabled, test_client(), test_prompts())
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.is_none()
|
.is_none()
|
||||||
);
|
);
|
||||||
@@ -1605,22 +1886,30 @@ permission = "write"
|
|||||||
Some("policy-only surface must not load"),
|
Some("policy-only surface must not load"),
|
||||||
));
|
));
|
||||||
assert!(
|
assert!(
|
||||||
ordinary_subjektiv_resident_summary_source(&policy_only, unattached_client.clone())
|
ordinary_subjektiv_resident_summary_source(
|
||||||
.unwrap()
|
&policy_only,
|
||||||
.is_none()
|
unattached_client.clone(),
|
||||||
|
test_prompts()
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.is_none()
|
||||||
);
|
);
|
||||||
assert!(unattached_client.paths.lock().unwrap().is_empty());
|
assert!(unattached_client.paths.lock().unwrap().is_empty());
|
||||||
|
|
||||||
let foreign = subject_manifest("other-workspace");
|
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]
|
#[test]
|
||||||
fn subject_restore_refresh_is_suppressed_for_internal_hosts() {
|
fn subject_restore_refresh_is_suppressed_for_internal_hosts() {
|
||||||
let manifest = subject_manifest("workspace");
|
let manifest = subject_manifest("workspace");
|
||||||
let source = ordinary_subjektiv_resident_summary_source(&manifest, test_client())
|
let source =
|
||||||
.unwrap()
|
ordinary_subjektiv_resident_summary_source(&manifest, test_client(), test_prompts())
|
||||||
.unwrap();
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
let prompts = Arc::new(ArcSwap::from(
|
let prompts = Arc::new(ArcSwap::from(
|
||||||
crate::prompt::catalog::PromptCatalog::builtins_only().unwrap(),
|
crate::prompt::catalog::PromptCatalog::builtins_only().unwrap(),
|
||||||
));
|
));
|
||||||
@@ -1634,9 +1923,10 @@ permission = "write"
|
|||||||
#[test]
|
#[test]
|
||||||
fn subject_restore_refresh_feature_declares_only_restore_lifecycle() {
|
fn subject_restore_refresh_feature_declares_only_restore_lifecycle() {
|
||||||
let manifest = subject_manifest("workspace");
|
let manifest = subject_manifest("workspace");
|
||||||
let source = ordinary_subjektiv_resident_summary_source(&manifest, test_client())
|
let source =
|
||||||
.unwrap()
|
ordinary_subjektiv_resident_summary_source(&manifest, test_client(), test_prompts())
|
||||||
.unwrap();
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
let feature = SubjektivResidentRestoreRefreshFeature::new(
|
let feature = SubjektivResidentRestoreRefreshFeature::new(
|
||||||
source,
|
source,
|
||||||
Arc::new(ArcSwap::from(
|
Arc::new(ArcSwap::from(
|
||||||
@@ -1647,8 +1937,13 @@ permission = "write"
|
|||||||
let descriptor = feature.descriptor();
|
let descriptor = feature.descriptor();
|
||||||
|
|
||||||
assert!(descriptor.tools.is_empty());
|
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[0].point, FeatureHookPoint::WorkerRestored);
|
||||||
|
assert_eq!(descriptor.hooks[1].point, FeatureHookPoint::PreLlmRequest);
|
||||||
|
assert_eq!(
|
||||||
|
descriptor.hooks[2].point,
|
||||||
|
FeatureHookPoint::BeforeSessionRewrite
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -281,6 +281,20 @@ impl SystemItemAppendHandle {
|
|||||||
.expect("system-item append queue poisoned")
|
.expect("system-item append queue poisoned")
|
||||||
.push(item);
|
.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<String>, 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
|
/// Host-created narrow append authority exposed only at a restore lifecycle
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ use crate::hook::{
|
|||||||
use crate::ipc::notify_buffer::{NotifyBuffer, build_system_item_with_provenance};
|
use crate::ipc::notify_buffer::{NotifyBuffer, build_system_item_with_provenance};
|
||||||
use crate::prompt::catalog::PromptCatalog;
|
use crate::prompt::catalog::PromptCatalog;
|
||||||
use crate::session_history::SessionHistoryMetadata;
|
use crate::session_history::SessionHistoryMetadata;
|
||||||
use crate::worker::SystemItemCommitter;
|
use crate::worker::{SystemItemCommitter, SystemPromptContributionSource};
|
||||||
use agen::HistoryEntry;
|
use agen::HistoryEntry;
|
||||||
use agen::token_counter::total_tokens;
|
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
|
/// worker. `None` in tests / `Worker::new` paths where no writer is
|
||||||
/// attached.
|
/// attached.
|
||||||
log_writer: Option<Arc<dyn SystemItemCommitter>>,
|
log_writer: Option<Arc<dyn SystemItemCommitter>>,
|
||||||
|
resident_context_source: Option<Arc<dyn SystemPromptContributionSource>>,
|
||||||
pending_committed_history: Arc<Mutex<VecDeque<HistoryEntry<SessionHistoryMetadata>>>>,
|
pending_committed_history: Arc<Mutex<VecDeque<HistoryEntry<SessionHistoryMetadata>>>>,
|
||||||
/// Next turn index assigned by `on_prompt_submit`.
|
/// Next turn index assigned by `on_prompt_submit`.
|
||||||
next_turn_index: AtomicUsize,
|
next_turn_index: AtomicUsize,
|
||||||
@@ -163,6 +164,7 @@ impl WorkerInterceptor {
|
|||||||
prompts,
|
prompts,
|
||||||
prompt_workspace_id: None,
|
prompt_workspace_id: None,
|
||||||
log_writer,
|
log_writer,
|
||||||
|
resident_context_source: None,
|
||||||
pending_committed_history,
|
pending_committed_history,
|
||||||
next_turn_index: AtomicUsize::new(0),
|
next_turn_index: AtomicUsize::new(0),
|
||||||
tool_calls_this_turn: AtomicUsize::new(0),
|
tool_calls_this_turn: AtomicUsize::new(0),
|
||||||
@@ -179,11 +181,31 @@ impl WorkerInterceptor {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn with_resident_context_source(
|
||||||
|
mut self,
|
||||||
|
source: Option<Arc<dyn SystemPromptContributionSource>>,
|
||||||
|
) -> Self {
|
||||||
|
self.resident_context_source = source;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn with_prompt_workspace_id(mut self, workspace_id: Option<String>) -> Self {
|
pub(crate) fn with_prompt_workspace_id(mut self, workspace_id: Option<String>) -> Self {
|
||||||
self.prompt_workspace_id = workspace_id;
|
self.prompt_workspace_id = workspace_id;
|
||||||
self
|
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`
|
/// Commit each `SystemItem` as its own `LogEntry::AnnotatedSystemItem`
|
||||||
/// entry through the attached writer (no-op when no writer is
|
/// entry through the attached writer (no-op when no writer is
|
||||||
/// wired). Sync — writes complete before the matching
|
/// wired). Sync — writes complete before the matching
|
||||||
@@ -211,6 +233,7 @@ impl WorkerInterceptor {
|
|||||||
.lock()
|
.lock()
|
||||||
.expect("pending committed history poisoned")
|
.expect("pending committed history poisoned")
|
||||||
.push_back(entry);
|
.push_back(entry);
|
||||||
|
self.confirm_resident_context_item(item);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -350,6 +373,11 @@ impl WorkerInterceptor {
|
|||||||
} if prompt_provenance.is_none() => {
|
} if prompt_provenance.is_none() => {
|
||||||
*prompt_provenance = Some(provenance("internal.task_reminder"));
|
*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 {
|
SystemItem::Interrupt {
|
||||||
prompt_provenance, ..
|
prompt_provenance, ..
|
||||||
} if prompt_provenance.is_none() => {
|
} if prompt_provenance.is_none() => {
|
||||||
@@ -763,6 +791,7 @@ mod tests {
|
|||||||
HookTurnEndAction, OnTurnEnd, PostToolCall, PreLlmRequest, PreToolCall,
|
HookTurnEndAction, OnTurnEnd, PostToolCall, PreLlmRequest, PreToolCall,
|
||||||
};
|
};
|
||||||
use crate::session_history::{WorkerHistoryProvenance, history_entry};
|
use crate::session_history::{WorkerHistoryProvenance, history_entry};
|
||||||
|
use crate::worker::SystemPromptContribution;
|
||||||
|
|
||||||
fn test_prompts() -> Arc<ArcSwap<PromptCatalog>> {
|
fn test_prompts() -> Arc<ArcSwap<PromptCatalog>> {
|
||||||
Arc::new(ArcSwap::from(PromptCatalog::builtins_only().unwrap()))
|
Arc::new(ArcSwap::from(PromptCatalog::builtins_only().unwrap()))
|
||||||
@@ -859,6 +888,53 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct AppendingSubjectBehaviorHook;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Hook<PreLlmRequest> for AppendingSubjectBehaviorHook {
|
||||||
|
async fn call(
|
||||||
|
&self,
|
||||||
|
input: &PreRequestContext,
|
||||||
|
) -> Result<HookPreRequestAction, crate::hook::HookError> {
|
||||||
|
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<Mutex<Vec<u64>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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(
|
fn task_tool_call_info(
|
||||||
name: &str,
|
name: &str,
|
||||||
input: serde_json::Value,
|
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<dyn SystemPromptContributionSource> =
|
||||||
|
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<dyn SystemPromptContributionSource> =
|
||||||
|
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]
|
#[tokio::test]
|
||||||
async fn pre_llm_request_without_log_writer_does_not_expose_system_item_handle() {
|
async fn pre_llm_request_without_log_writer_does_not_expose_system_item_handle() {
|
||||||
let saw_handle = Arc::new(AtomicBool::new(false));
|
let saw_handle = Arc::new(AtomicBool::new(false));
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ pub enum WorkerPrompt {
|
|||||||
AgentsMdSection,
|
AgentsMdSection,
|
||||||
ResidentMemorySummarySection,
|
ResidentMemorySummarySection,
|
||||||
ResidentMemoryRestoreSection,
|
ResidentMemoryRestoreSection,
|
||||||
|
SubjektivResidentContext,
|
||||||
WorkerOrchestrationGuidanceSection,
|
WorkerOrchestrationGuidanceSection,
|
||||||
SubWorkerSpawnToolDescription,
|
SubWorkerSpawnToolDescription,
|
||||||
}
|
}
|
||||||
@@ -128,6 +129,7 @@ impl WorkerPrompt {
|
|||||||
Self::AgentsMdSection => "internal.agents_md_section",
|
Self::AgentsMdSection => "internal.agents_md_section",
|
||||||
Self::ResidentMemorySummarySection => "internal.resident_memory_summary_section",
|
Self::ResidentMemorySummarySection => "internal.resident_memory_summary_section",
|
||||||
Self::ResidentMemoryRestoreSection => "internal.resident_memory_restore_section",
|
Self::ResidentMemoryRestoreSection => "internal.resident_memory_restore_section",
|
||||||
|
Self::SubjektivResidentContext => "internal.subjektiv_resident_context",
|
||||||
Self::WorkerOrchestrationGuidanceSection => {
|
Self::WorkerOrchestrationGuidanceSection => {
|
||||||
"internal.worker_orchestration_guidance_section"
|
"internal.worker_orchestration_guidance_section"
|
||||||
}
|
}
|
||||||
@@ -150,6 +152,7 @@ impl WorkerPrompt {
|
|||||||
WorkerPrompt::AgentsMdSection,
|
WorkerPrompt::AgentsMdSection,
|
||||||
WorkerPrompt::ResidentMemorySummarySection,
|
WorkerPrompt::ResidentMemorySummarySection,
|
||||||
WorkerPrompt::ResidentMemoryRestoreSection,
|
WorkerPrompt::ResidentMemoryRestoreSection,
|
||||||
|
WorkerPrompt::SubjektivResidentContext,
|
||||||
WorkerPrompt::WorkerOrchestrationGuidanceSection,
|
WorkerPrompt::WorkerOrchestrationGuidanceSection,
|
||||||
WorkerPrompt::SubWorkerSpawnToolDescription,
|
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<String, CatalogError> {
|
||||||
|
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<String, CatalogError> {
|
pub fn worker_orchestration_guidance_section(&self) -> Result<String, CatalogError> {
|
||||||
self.render(
|
self.render(
|
||||||
WorkerPrompt::WorkerOrchestrationGuidanceSection,
|
WorkerPrompt::WorkerOrchestrationGuidanceSection,
|
||||||
|
|||||||
@@ -2361,9 +2361,30 @@ pub(crate) enum SystemPromptContribution {
|
|||||||
Unavailable,
|
Unavailable,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub(crate) struct ResidentContextRefresh {
|
||||||
|
pub body: String,
|
||||||
|
pub revision: u64,
|
||||||
|
}
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
pub(crate) trait SystemPromptContributionSource: Send + Sync {
|
pub(crate) trait SystemPromptContributionSource: Send + Sync {
|
||||||
async fn load(&self) -> SystemPromptContribution;
|
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<Option<ResidentContextRefresh>, 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)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -3840,6 +3861,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
|||||||
)
|
)
|
||||||
.with_usage_tracker(self.usage_tracker.clone())
|
.with_usage_tracker(self.usage_tracker.clone())
|
||||||
.with_metrics_tracker(self.metrics_tracker.clone())
|
.with_metrics_tracker(self.metrics_tracker.clone())
|
||||||
|
.with_resident_context_source(self.feature_resident_summary_source.clone())
|
||||||
.with_prompt_workspace_id(
|
.with_prompt_workspace_id(
|
||||||
self.workspace_context
|
self.workspace_context
|
||||||
.workspace_id()
|
.workspace_id()
|
||||||
@@ -9224,16 +9246,20 @@ mod build_summary_prompt_tests {
|
|||||||
serde_json::from_str(request.body.as_deref().unwrap_or_default()).unwrap();
|
serde_json::from_str(request.body.as_deref().unwrap_or_default()).unwrap();
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
request.operation,
|
request.operation,
|
||||||
server_api::SubjektivMemoryBackendOperation::ResidentSummary(_)
|
server_api::SubjektivMemoryBackendOperation::ResidentContext(_)
|
||||||
));
|
));
|
||||||
self.load_count.fetch_add(1, Ordering::SeqCst);
|
self.load_count.fetch_add(1, Ordering::SeqCst);
|
||||||
Ok(WorkspaceResponse {
|
Ok(WorkspaceResponse {
|
||||||
status: 200,
|
status: 200,
|
||||||
body: serde_json::to_string(
|
body: serde_json::to_string(
|
||||||
&server_api::SubjektivMemoryBackendResponse::ResidentSummary(
|
&server_api::SubjektivMemoryBackendResponse::ResidentContext(
|
||||||
memory::backend::MemoryResidentSummaryOutput {
|
server_api::SubjektivResidentContextOutput {
|
||||||
availability: self.availability,
|
behavior_md: "restore behavior".to_string(),
|
||||||
content: self.content.clone(),
|
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),
|
load_count: Arc::clone(&load_count),
|
||||||
});
|
});
|
||||||
let source = crate::feature::builtin::memory::ordinary_subjektiv_resident_summary_source(
|
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()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -12219,9 +12247,10 @@ permission = "write"
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.to_string();
|
.to_string();
|
||||||
assert!(
|
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);
|
drop(empty_restore);
|
||||||
|
|
||||||
for availability in [
|
for availability in [
|
||||||
@@ -12273,7 +12302,22 @@ permission = "write"
|
|||||||
.as_text()
|
.as_text()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.to_string();
|
.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("latest subject surface"));
|
||||||
assert!(!tombstone.contains("LEGACY_MEMORY_SENTINEL_MUST_NOT_REFRESH"));
|
assert!(!tombstone.contains("LEGACY_MEMORY_SENTINEL_MUST_NOT_REFRESH"));
|
||||||
let entries = store.read_all(session_id, segment_id).unwrap();
|
let entries = store.read_all(session_id, segment_id).unwrap();
|
||||||
|
|||||||
@@ -5811,6 +5811,7 @@ fn generated_workspace_contract_router(service: ServerApiContractService) -> Rou
|
|||||||
.merge(server_api::server_api_axum::subjektiv_subject_get(
|
.merge(server_api::server_api_axum::subjektiv_subject_get(
|
||||||
service.clone(),
|
service.clone(),
|
||||||
))
|
))
|
||||||
|
.merge(server_api::server_api_axum::subjektiv_subject_behavior_update(service.clone()))
|
||||||
.merge(server_api::server_api_axum::subjektiv_resident_surface(
|
.merge(server_api::server_api_axum::subjektiv_resident_surface(
|
||||||
service.clone(),
|
service.clone(),
|
||||||
))
|
))
|
||||||
@@ -7486,6 +7487,26 @@ impl server_api::ServerApi for ServerApiContractService {
|
|||||||
.map_err(ApiError::into_repository_api_error)
|
.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<server_api::SubjektivSubjectResponse, server_api::RepositoryApiError>
|
||||||
|
{
|
||||||
|
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(
|
async fn subjektiv_resident_surface(
|
||||||
&self,
|
&self,
|
||||||
workspace_id: String,
|
workspace_id: String,
|
||||||
@@ -17447,6 +17468,8 @@ fn subjektiv_subject_response(
|
|||||||
server_api::SubjektivSubjectResponse {
|
server_api::SubjektivSubjectResponse {
|
||||||
id: subject.id,
|
id: subject.id,
|
||||||
role: subject.role.as_str().to_string(),
|
role: subject.role.as_str().to_string(),
|
||||||
|
behavior_md: subject.behavior_md,
|
||||||
|
behavior_revision: subject.behavior_revision,
|
||||||
state: match subject.state {
|
state: match subject.state {
|
||||||
crate::subjektiv::SubjectState::Active => server_api::SubjektivSubjectState::Active,
|
crate::subjektiv::SubjectState::Active => server_api::SubjektivSubjectState::Active,
|
||||||
crate::subjektiv::SubjectState::Retired => server_api::SubjektivSubjectState::Retired,
|
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)?;
|
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||||
let role = crate::subjektiv::SubjectRole::new(request.role)
|
let role = crate::subjektiv::SubjectRole::new(request.role)
|
||||||
.map_err(|error| Error::InvalidInput(error.to_string()))?;
|
.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)?
|
let subject = open_subjektiv_store(&api)?
|
||||||
.create_subject(role)
|
.create_subject_with_behavior(role, request.behavior_md)
|
||||||
.map_err(|error| Error::Store(error.to_string()))?;
|
.map_err(subjektiv_store_error)?;
|
||||||
Ok((
|
Ok((
|
||||||
StatusCode::CREATED,
|
StatusCode::CREATED,
|
||||||
Json(subjektiv_subject_response(&api, subject)),
|
Json(subjektiv_subject_response(&api, subject)),
|
||||||
@@ -17560,6 +17585,24 @@ async fn scoped_get_subjektiv_subject(
|
|||||||
Ok(Json(subjektiv_subject_response(&api, subject)))
|
Ok(Json(subjektiv_subject_response(&api, subject)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn scoped_update_subjektiv_subject_behavior(
|
||||||
|
State(api): State<WorkspaceApi>,
|
||||||
|
AxumPath(path): AxumPath<ScopedSubjektivSubjectPath>,
|
||||||
|
Json(request): Json<server_api::SubjektivSubjectBehaviorUpdateRequest>,
|
||||||
|
) -> ApiResult<Json<server_api::SubjektivSubjectResponse>> {
|
||||||
|
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(
|
fn require_subjektiv_subject(
|
||||||
store: &crate::subjektiv::SubjektivStore,
|
store: &crate::subjektiv::SubjektivStore,
|
||||||
subject_id: &str,
|
subject_id: &str,
|
||||||
@@ -19166,6 +19209,23 @@ async fn scoped_subjektiv_memory_backend(
|
|||||||
resident,
|
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::SubjektivMemoryBackendOperation::Query(input) => {
|
||||||
server_api::SubjektivMemoryBackendResponse::Query(subjektiv_memory_query(
|
server_api::SubjektivMemoryBackendResponse::Query(subjektiv_memory_query(
|
||||||
&store,
|
&store,
|
||||||
@@ -20540,6 +20600,7 @@ fn subjektiv_store_error(error: crate::subjektiv::SubjektivError) -> Error {
|
|||||||
Error::WorkspacePermissionDenied(format!("subject_scope_mismatch: {error}"))
|
Error::WorkspacePermissionDenied(format!("subject_scope_mismatch: {error}"))
|
||||||
}
|
}
|
||||||
crate::subjektiv::SubjektivError::RevisionConflict { .. }
|
crate::subjektiv::SubjektivError::RevisionConflict { .. }
|
||||||
|
| crate::subjektiv::SubjektivError::SubjectBehaviorConflict { .. }
|
||||||
| crate::subjektiv::SubjektivError::SurfaceGenerationConflict(_) => {
|
| crate::subjektiv::SubjektivError::SurfaceGenerationConflict(_) => {
|
||||||
Error::RepositoryConflict(format!("revision_conflict: {error}"))
|
Error::RepositoryConflict(format!("revision_conflict: {error}"))
|
||||||
}
|
}
|
||||||
@@ -39199,18 +39260,80 @@ mod tests {
|
|||||||
app.clone(),
|
app.clone(),
|
||||||
"POST",
|
"POST",
|
||||||
&format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects"),
|
&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,
|
&owner_token,
|
||||||
StatusCode::CREATED,
|
StatusCode::CREATED,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert_eq!(created["role"], " Browser author ");
|
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["state"], "active");
|
||||||
assert_eq!(created["store_revision"], 0);
|
assert_eq!(created["store_revision"], 0);
|
||||||
assert!(created.get("current_worker").is_none());
|
assert!(created.get("current_worker").is_none());
|
||||||
let created_id = created["id"].as_str().unwrap();
|
let created_id = created["id"].as_str().unwrap();
|
||||||
let persisted = store.subject(created_id).unwrap().unwrap();
|
let persisted = store.subject(created_id).unwrap().unwrap();
|
||||||
assert_eq!(persisted.role.as_str(), " Browser author ");
|
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!(
|
assert!(
|
||||||
api.store
|
api.store
|
||||||
.current_worker_singleton_owner(
|
.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]
|
#[tokio::test]
|
||||||
async fn subjektiv_browser_reads_are_bounded_scoped_and_revision_exact() {
|
async fn subjektiv_browser_reads_are_bounded_scoped_and_revision_exact() {
|
||||||
let workspace = tempfile::tempdir().unwrap();
|
let workspace = tempfile::tempdir().unwrap();
|
||||||
@@ -39733,7 +39940,10 @@ mod tests {
|
|||||||
assert_eq!(missing.status(), StatusCode::NOT_FOUND);
|
assert_eq!(missing.status(), StatusCode::NOT_FOUND);
|
||||||
let subject = open_subjektiv_store(&api)
|
let subject = open_subjektiv_store(&api)
|
||||||
.unwrap()
|
.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();
|
.unwrap();
|
||||||
let request = CreateWorkspaceWorkerRequest {
|
let request = CreateWorkspaceWorkerRequest {
|
||||||
runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(),
|
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(
|
let missing_legacy = scoped_record_subjektiv_session(
|
||||||
State(api.clone()),
|
State(api.clone()),
|
||||||
AxumPath(ScopedWorkspacePath {
|
AxumPath(ScopedWorkspacePath {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use std::collections::HashSet;
|
|||||||
use chrono::{SecondsFormat, Utc};
|
use chrono::{SecondsFormat, Utc};
|
||||||
use memory::extract::{CandidateKind, STAGING_SCHEMA_VERSION, StagingEvidence, StagingRecord};
|
use memory::extract::{CandidateKind, STAGING_SCHEMA_VERSION, StagingEvidence, StagingRecord};
|
||||||
use memory::schema::{SourceEvidenceRef, SourceRef};
|
use memory::schema::{SourceEvidenceRef, SourceRef};
|
||||||
use rusqlite::{OptionalExtension, Transaction, params};
|
use rusqlite::{Connection, OptionalExtension, Transaction, params};
|
||||||
use serde::{Deserialize, Deserializer, Serialize};
|
use serde::{Deserialize, Deserializer, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@@ -24,6 +24,8 @@ pub const SUBJEKTIV_SCHEMA_VERSION: u32 = 1;
|
|||||||
pub const SUBJEKTIV_FEATURE_ID: &str = "subjektiv";
|
pub const SUBJEKTIV_FEATURE_ID: &str = "subjektiv";
|
||||||
pub const SUBJECT_WORKER_SINGLETON_PREFIX: &str = "subjektiv:";
|
pub const SUBJECT_WORKER_SINGLETON_PREFIX: &str = "subjektiv:";
|
||||||
pub const MAX_STAGING_ANCHORS: usize = 10;
|
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
|
/// Deterministic surface-generation policy. Input estimates use the repository's
|
||||||
/// provider-independent UTF-8 byte estimate (`ceil(bytes / 4)`). The material
|
/// 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
|
/// payload is capped below the whole-input budget to reserve room for the fixed
|
||||||
@@ -74,6 +76,14 @@ pub enum SubjektivError {
|
|||||||
expected: u64,
|
expected: u64,
|
||||||
actual: 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}")]
|
#[error("surface generation conflict: {0}")]
|
||||||
SurfaceGenerationConflict(String),
|
SurfaceGenerationConflict(String),
|
||||||
#[error("memory `{memory_id}` cannot transition from {from:?} to {to:?}")]
|
#[error("memory `{memory_id}` cannot transition from {from:?} to {to:?}")]
|
||||||
@@ -136,6 +146,14 @@ pub struct SubjectRecord {
|
|||||||
pub schema_version: u32,
|
pub schema_version: u32,
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub role: SubjectRole,
|
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,
|
pub state: SubjectState,
|
||||||
/// Monotonic generation incremented exactly once for every committed Memory
|
/// Monotonic generation incremented exactly once for every committed Memory
|
||||||
/// creation or revision for this subject.
|
/// creation or revision for this subject.
|
||||||
@@ -500,6 +518,12 @@ pub struct ResidentSurface {
|
|||||||
pub snapshot: Option<SurfaceSnapshot>,
|
pub snapshot: Option<SurfaceSnapshot>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct SubjectResidentContext {
|
||||||
|
pub subject: SubjectRecord,
|
||||||
|
pub surface: ResidentSurface,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||||
pub enum MemoryRevisionTarget {
|
pub enum MemoryRevisionTarget {
|
||||||
@@ -986,6 +1010,17 @@ END;
|
|||||||
Ok(())
|
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] = &[
|
static MIGRATIONS: &[FeatureMigration] = &[
|
||||||
FeatureMigration::new(1, "create subjektiv subject memory store", create_schema),
|
FeatureMigration::new(1, "create subjektiv subject memory store", create_schema),
|
||||||
FeatureMigration::new(
|
FeatureMigration::new(
|
||||||
@@ -1003,6 +1038,7 @@ static MIGRATIONS: &[FeatureMigration] = &[
|
|||||||
"add bounded surface generation state and runs",
|
"add bounded surface generation state and runs",
|
||||||
add_surface_generation_state,
|
add_surface_generation_state,
|
||||||
),
|
),
|
||||||
|
FeatureMigration::new(5, "add user-managed subject behavior", add_subject_behavior),
|
||||||
];
|
];
|
||||||
|
|
||||||
pub const REGISTRATION: FeatureRegistration =
|
pub const REGISTRATION: FeatureRegistration =
|
||||||
@@ -1070,11 +1106,23 @@ impl SubjektivStore {
|
|||||||
/// Issues a random host-owned subject id. It is never derived from Worker,
|
/// Issues a random host-owned subject id. It is never derived from Worker,
|
||||||
/// Profile, Session, or Memory content.
|
/// Profile, Session, or Memory content.
|
||||||
pub fn create_subject(&self, role: SubjectRole) -> Result<SubjectRecord> {
|
pub fn create_subject(&self, role: SubjectRole) -> Result<SubjectRecord> {
|
||||||
|
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<SubjectRecord> {
|
||||||
|
validate_subject_behavior(&behavior_md)?;
|
||||||
let timestamp = now();
|
let timestamp = now();
|
||||||
let record = SubjectRecord {
|
let record = SubjectRecord {
|
||||||
schema_version: SUBJEKTIV_SCHEMA_VERSION,
|
schema_version: SUBJEKTIV_SCHEMA_VERSION,
|
||||||
id: issued_id("subject"),
|
id: issued_id("subject"),
|
||||||
role,
|
role,
|
||||||
|
behavior_md,
|
||||||
|
behavior_revision: 0,
|
||||||
state: SubjectState::Active,
|
state: SubjectState::Active,
|
||||||
store_revision: 0,
|
store_revision: 0,
|
||||||
created_at: timestamp.clone(),
|
created_at: timestamp.clone(),
|
||||||
@@ -1084,11 +1132,13 @@ impl SubjektivStore {
|
|||||||
self.database.try_transaction(|transaction| {
|
self.database.try_transaction(|transaction| {
|
||||||
transaction.execute(
|
transaction.execute(
|
||||||
"INSERT INTO subjects (
|
"INSERT INTO subjects (
|
||||||
subject_id, role, state, store_revision, record_json, created_at, updated_at
|
subject_id, role, behavior_md, behavior_revision, state,
|
||||||
) VALUES (?1, ?2, 'active', 0, ?3, ?4, ?5)",
|
store_revision, record_json, created_at, updated_at
|
||||||
|
) VALUES (?1, ?2, ?3, 0, 'active', 0, ?4, ?5, ?6)",
|
||||||
params![
|
params![
|
||||||
record.id,
|
record.id,
|
||||||
record.role.as_str(),
|
record.role.as_str(),
|
||||||
|
record.behavior_md,
|
||||||
raw,
|
raw,
|
||||||
record.created_at,
|
record.created_at,
|
||||||
record.updated_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<SubjectRecord> {
|
||||||
|
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
|
/// Validates an explicit product launch connection against this Workspace's
|
||||||
/// subject store and the already-resolved Profile policy. Subject knowledge
|
/// subject store and the already-resolved Profile policy. Subject knowledge
|
||||||
/// alone is insufficient: only an active local record produces host launch material.
|
/// 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<ResidentSurface> {
|
pub fn resident_surface(&self, subject_id: &str) -> Result<ResidentSurface> {
|
||||||
self.database.try_with_connection(|connection| {
|
self.database.try_with_connection(|connection| {
|
||||||
let subject = require_subject_in_connection(connection, subject_id)?;
|
let subject = require_subject_in_connection(connection, subject_id)?;
|
||||||
let state = connection
|
resident_surface_in_connection(connection, &subject)
|
||||||
.query_row(
|
})
|
||||||
"SELECT store_revision, status, snapshot_id
|
}
|
||||||
FROM surface_generation_state WHERE subject_id = ?1",
|
|
||||||
[subject_id],
|
/// Atomically reads user-managed behavior and the independently-fresh
|
||||||
|row| {
|
/// generated Memory surface from one SQLite connection snapshot.
|
||||||
Ok((
|
pub fn resident_context(&self, subject_id: &str) -> Result<SubjectResidentContext> {
|
||||||
row.get::<_, i64>(0)?,
|
self.database.try_transaction(|transaction| {
|
||||||
row.get::<_, String>(1)?,
|
let subject = require_subject(transaction, subject_id)?;
|
||||||
row.get::<_, Option<String>>(2)?,
|
let surface = resident_surface_in_connection(transaction, &subject)?;
|
||||||
))
|
Ok(SubjectResidentContext { subject, surface })
|
||||||
},
|
|
||||||
)
|
|
||||||
.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),
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3414,6 +3466,87 @@ fn validate_range(kind: &str, range: [u64; 2]) -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn resident_surface_in_connection(
|
||||||
|
connection: &Connection,
|
||||||
|
subject: &SubjectRecord,
|
||||||
|
) -> Result<ResidentSurface> {
|
||||||
|
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<String>>(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<()> {
|
fn validate_label(kind: &str, value: &str) -> Result<()> {
|
||||||
validate_nonempty(kind, value)?;
|
validate_nonempty(kind, value)?;
|
||||||
if value.len() > 256 || value.chars().any(char::is_control) {
|
if value.len() > 256 || value.chars().any(char::is_control) {
|
||||||
@@ -3668,6 +3801,67 @@ mod tests {
|
|||||||
assert!(!complete.has_more);
|
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]
|
#[test]
|
||||||
fn attributed_candidates_preserve_revision_and_surface_reference_continuity() {
|
fn attributed_candidates_preserve_revision_and_surface_reference_continuity() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
@@ -3892,10 +4086,38 @@ mod tests {
|
|||||||
))
|
))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let store = SubjektivStore::open(&workspace, ®istration).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 (_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(
|
let lifecycle_attribution = attribution(
|
||||||
&subject_id,
|
&subject_id,
|
||||||
"runtime-1",
|
"runtime-1",
|
||||||
@@ -3935,26 +4157,50 @@ mod tests {
|
|||||||
))
|
))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let store = SubjektivStore::open(&workspace, ®istration).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 {
|
let snapshot = SurfaceSnapshot {
|
||||||
schema_version: SUBJEKTIV_SCHEMA_VERSION,
|
schema_version: SUBJEKTIV_SCHEMA_VERSION,
|
||||||
id: "legacy-surface".into(),
|
id: "legacy-surface".into(),
|
||||||
subject_id: subject.id.clone(),
|
subject_id: subject_id.clone(),
|
||||||
body_md: "Legacy summary without generation-policy evidence.".into(),
|
body_md: "Legacy summary without generation-policy evidence.".into(),
|
||||||
memory_refs: Vec::new(),
|
memory_refs: Vec::new(),
|
||||||
built_from_store_revision: subject.store_revision,
|
built_from_store_revision: 0,
|
||||||
created_at: now(),
|
created_at: now(),
|
||||||
};
|
};
|
||||||
store
|
store
|
||||||
.database
|
.database
|
||||||
.try_transaction(|transaction| -> Result<()> {
|
.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(
|
transaction.execute(
|
||||||
"INSERT INTO surface_snapshots (
|
"INSERT INTO surface_snapshots (
|
||||||
subject_id, snapshot_id, built_from_store_revision,
|
subject_id, snapshot_id, built_from_store_revision,
|
||||||
snapshot_json, created_at
|
snapshot_json, created_at
|
||||||
) VALUES (?1, ?2, ?3, ?4, ?5)",
|
) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||||
params![
|
params![
|
||||||
subject.id,
|
subject_id,
|
||||||
snapshot.id,
|
snapshot.id,
|
||||||
to_i64(snapshot.built_from_store_revision)?,
|
to_i64(snapshot.built_from_store_revision)?,
|
||||||
serde_json::to_string(&snapshot)?,
|
serde_json::to_string(&snapshot)?,
|
||||||
@@ -3964,12 +4210,12 @@ mod tests {
|
|||||||
transaction.execute(
|
transaction.execute(
|
||||||
"INSERT INTO surface_snapshot_seals (subject_id, snapshot_id)
|
"INSERT INTO surface_snapshot_seals (subject_id, snapshot_id)
|
||||||
VALUES (?1, ?2)",
|
VALUES (?1, ?2)",
|
||||||
params![subject.id, snapshot.id],
|
params![subject_id, snapshot.id],
|
||||||
)?;
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
(subject.id, snapshot.id)
|
(subject_id, snapshot.id)
|
||||||
};
|
};
|
||||||
|
|
||||||
let (_manager, _workspace, store) = open_store(&root, "workspace-a");
|
let (_manager, _workspace, store) = open_store(&root, "workspace-a");
|
||||||
|
|||||||
+152
-1
@@ -9315,9 +9315,32 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"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": {
|
"SubjektivSubjectCreateRequest": {
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"behavior_md": {
|
||||||
|
"default": "",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"role": {
|
"role": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
@@ -9383,6 +9406,15 @@
|
|||||||
"SubjektivSubjectResponse": {
|
"SubjektivSubjectResponse": {
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"behavior_md": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"behavior_revision": {
|
||||||
|
"format": "uint64",
|
||||||
|
"maximum": 9007199254740991,
|
||||||
|
"minimum": 0,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
"created_at": {
|
"created_at": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -9419,6 +9451,8 @@
|
|||||||
"required": [
|
"required": [
|
||||||
"id",
|
"id",
|
||||||
"role",
|
"role",
|
||||||
|
"behavior_md",
|
||||||
|
"behavior_revision",
|
||||||
"state",
|
"state",
|
||||||
"store_revision",
|
"store_revision",
|
||||||
"created_at",
|
"created_at",
|
||||||
@@ -14794,7 +14828,7 @@
|
|||||||
"info": {
|
"info": {
|
||||||
"title": "Yoi Server API",
|
"title": "Yoi Server API",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"x-yoi-source-digest": "sha256:f9f0f8918dbcce386e94d5368fa938f02565ca98f9f67f07537bd88ed8c49870"
|
"x-yoi-source-digest": "sha256:025ccf848b72bbadd2144980dbcd5f94f9d0d06e25de34fa2b643de042d4dcb4"
|
||||||
},
|
},
|
||||||
"openapi": "3.1.0",
|
"openapi": "3.1.0",
|
||||||
"paths": {
|
"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": {
|
"/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/memories": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "subjektiv_memory_list",
|
"operationId": "subjektiv_memory_list",
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ internalWorkingBoundariesSection = import "./internal/working_boundaries_section
|
|||||||
internalAgentsMdSection = import "./internal/agents_md_section.md";
|
internalAgentsMdSection = import "./internal/agents_md_section.md";
|
||||||
internalResidentMemorySummarySection = import "./internal/resident_memory_summary_section.md";
|
internalResidentMemorySummarySection = import "./internal/resident_memory_summary_section.md";
|
||||||
internalResidentMemoryRestoreSection = import "./internal/resident_memory_restore_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";
|
internalSubWorkerSpawnToolDescription = import "./internal/sub_worker_spawn_tool_description.md";
|
||||||
panelOrchestratorIdleQueueNotice = import "./panel/orchestrator_idle_queue_notice.md";
|
panelOrchestratorIdleQueueNotice = import "./panel/orchestrator_idle_queue_notice.md";
|
||||||
in
|
in
|
||||||
@@ -74,6 +75,7 @@ in
|
|||||||
agents_md_section = internalAgentsMdSection.content;
|
agents_md_section = internalAgentsMdSection.content;
|
||||||
resident_memory_summary_section = internalResidentMemorySummarySection.content;
|
resident_memory_summary_section = internalResidentMemorySummarySection.content;
|
||||||
resident_memory_restore_section = internalResidentMemoryRestoreSection.content;
|
resident_memory_restore_section = internalResidentMemoryRestoreSection.content;
|
||||||
|
subjektiv_resident_context = internalSubjektivResidentContext.content;
|
||||||
worker_orchestration_guidance_section = commonWorkerOrchestration.content;
|
worker_orchestration_guidance_section = commonWorkerOrchestration.content;
|
||||||
sub_worker_spawn_tool_description = internalSubWorkerSpawnToolDescription.content;
|
sub_worker_spawn_tool_description = internalSubWorkerSpawnToolDescription.content;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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.
|
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 %}
|
{% if surface_ready %}
|
||||||
### Current ready surface
|
### Current resident context
|
||||||
|
|
||||||
{% if summary %}
|
{% if summary %}
|
||||||
{{ summary }}
|
{{ summary }}
|
||||||
{% else %}
|
{% else %}
|
||||||
The current ready resident memory surface is intentionally empty.
|
The current resident context is intentionally empty.
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
No current ready resident memory surface is available. Ignore earlier resident memory summaries; confirmed Memory remains available through explicit recall tools.
|
No current ready resident memory surface is available. Ignore earlier resident memory summaries; confirmed Memory remains available through explicit recall tools.
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -71,6 +71,8 @@ const subjects = [
|
|||||||
"Release coordination",
|
"Release coordination",
|
||||||
"active",
|
"active",
|
||||||
42,
|
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"),
|
connectedWorker("Release coordination Worker with a deliberately long display name"),
|
||||||
),
|
),
|
||||||
subject(emptySubjectId, "Empty ready subject", "active", 0),
|
subject(emptySubjectId, "Empty ready subject", "active", 0),
|
||||||
@@ -110,11 +112,15 @@ function subject(
|
|||||||
role: string,
|
role: string,
|
||||||
state: "active" | "retired",
|
state: "active" | "retired",
|
||||||
storeRevision: number,
|
storeRevision: number,
|
||||||
|
behaviorMd = "",
|
||||||
|
behaviorRevision = 0,
|
||||||
currentWorker?: ReturnType<typeof connectedWorker>,
|
currentWorker?: ReturnType<typeof connectedWorker>,
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
role,
|
role,
|
||||||
|
behavior_md: behaviorMd,
|
||||||
|
behavior_revision: behaviorRevision,
|
||||||
state,
|
state,
|
||||||
store_revision: storeRevision,
|
store_revision: storeRevision,
|
||||||
created_at: "2026-01-01T00:00:00Z",
|
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`;
|
const subjectsPath = `/api/w/${workspaceId}/subjektiv/subjects`;
|
||||||
if (url.pathname === subjectsPath) {
|
if (url.pathname === subjectsPath) {
|
||||||
if (request.method === "POST") {
|
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);
|
subjectCreateRequests.push(payload);
|
||||||
if (!payload || typeof payload.role !== "string" || payload.role.trim().length === 0) {
|
if (!payload || typeof payload.role !== "string" || payload.role.trim().length === 0) {
|
||||||
return json({ error: "Bad Request", message: "subject role must not be empty" }, 400);
|
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") {
|
if (payload.role === "Permission denied") {
|
||||||
return json({ error: "Forbidden", message: "workspace permission denied" }, 403);
|
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;
|
createdSubjectCount += 1;
|
||||||
const id = `created-subject-${String(createdSubjectCount).padStart(4, "0")}`;
|
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);
|
createdSubjectIds.add(id);
|
||||||
subjects.unshift(created);
|
subjects.unshift(created);
|
||||||
return json(created, 201);
|
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);
|
const foundSubject = subjects.find((item) => item.id === subjectId);
|
||||||
if (!foundSubject) return json({}, 404);
|
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 === 1) return json(foundSubject);
|
||||||
if (parts.length === 2 && parts[1] === "surface") return json(surfaceFor(subjectId));
|
if (parts.length === 2 && parts[1] === "surface") return json(surfaceFor(subjectId));
|
||||||
if (parts.length === 2 && parts[1] === "memories") {
|
if (parts.length === 2 && parts[1] === "memories") {
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ async function openSubjectPage(
|
|||||||
|
|
||||||
await hideSidebar.click();
|
await hideSidebar.click();
|
||||||
await showSidebar.waitFor();
|
await showSidebar.waitFor();
|
||||||
|
await page.waitForTimeout(250);
|
||||||
assertEquals(await showSidebar.getAttribute("aria-expanded"), "false");
|
assertEquals(await showSidebar.getAttribute("aria-expanded"), "false");
|
||||||
assertEquals(
|
assertEquals(
|
||||||
await page.locator("main").evaluate((main) => (main as unknown as { inert: boolean }).inert),
|
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)));
|
page.on("pageerror", (error) => errors.push(String(error)));
|
||||||
await openSubjectPage(page, baseUrl, scenario.mobile);
|
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);
|
const initial = await mainScrollState(page);
|
||||||
assert(initial.scrollHeight > initial.clientHeight);
|
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 createPage = await createContext.newPage();
|
||||||
const mutationPaths: string[] = [];
|
const mutationPaths: string[] = [];
|
||||||
createPage.on("request", (request) => {
|
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.goto(`${baseUrl}/w/${workspaceId}/memory?cursor=${subjectPageCursor}`);
|
||||||
await createPage.getByText("Subject on the next page").waitFor();
|
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: "Cancel" }).click();
|
||||||
await createPage.getByRole("button", { name: "New Subject" }).click();
|
await createPage.getByRole("button", { name: "New Subject" }).click();
|
||||||
await createPage.getByRole("textbox", { name: "Role" }).fill(" Release steward ");
|
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.getByRole("button", { name: "Create Subject" }).click();
|
||||||
await createPage.waitForURL("**/memory/created-subject-0001");
|
await createPage.waitForURL("**/memory/created-subject-0001");
|
||||||
await createPage.getByRole("heading", { name: "Release steward", level: 1 }).waitFor();
|
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(
|
assertEquals(
|
||||||
await createPage.locator("code.subject-id", { hasText: "created-subject-0001" }).count(),
|
await createPage.locator("code.subject-id", { hasText: "created-subject-0001" }).count(),
|
||||||
1,
|
1,
|
||||||
@@ -466,15 +488,40 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco
|
|||||||
assert((await currentWorker.textContent())?.includes("Not connected"));
|
assert((await currentWorker.textContent())?.includes("Not connected"));
|
||||||
await createPage.getByText("Resident context has not been generated.").waitFor();
|
await createPage.getByText("Resident context has not been generated.").waitFor();
|
||||||
await createPage.getByText("No committed Memories yet.").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, [
|
assertEquals(mutationPaths, [
|
||||||
`/api/w/${workspaceId}/subjektiv/subjects`,
|
`/api/w/${workspaceId}/subjektiv/subjects`,
|
||||||
`/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`))
|
const createEvidence = await (await fetch(`${baseUrl}/fixture/subject-create-requests`))
|
||||||
.json();
|
.json();
|
||||||
assertEquals(createEvidence, {
|
assertEquals(createEvidence, {
|
||||||
count: 2,
|
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 assertNoPageWideOverflow(createPage);
|
||||||
await createContext.close();
|
await createContext.close();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
|
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// 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
|
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/companion-api.ts
|
// Output: web/workspace/src/lib/generated/companion-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ export const MEMORY_API_LIMITS = {
|
|||||||
maxIdentifierBytes: 512,
|
maxIdentifierBytes: 512,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
export const SUBJEKTIV_API_LIMITS = {
|
||||||
|
maxBehaviorBytes: 16384,
|
||||||
|
} as const;
|
||||||
|
|
||||||
export type DiagnosticSeverity = "info" | "warning" | "error";
|
export type DiagnosticSeverity = "info" | "warning" | "error";
|
||||||
|
|
||||||
export type Diagnostic = { code: string, severity: DiagnosticSeverity, message: string, };
|
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 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<SubjektivSubjectResponse>, next_cursor?: string | null, has_more: boolean, };
|
export type SubjektivSubjectListResponse = { limit: number, items: Array<SubjektivSubjectResponse>, next_cursor?: string | null, has_more: boolean, };
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
|
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// 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
|
// 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
|
// Output: web/workspace/src/lib/generated/repository-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
|
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// 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
|
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/runtime-api.ts
|
// Output: web/workspace/src/lib/generated/runtime-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
|
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// 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
|
// 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
|
// Output: web/workspace/src/lib/generated/ticket-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
|
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// 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
|
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
|
// 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
|
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
|
||||||
|
|||||||
@@ -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 { readBoundedJson, workspaceApiPath } from "#lib/workspace/api/http.ts";
|
||||||
import type {
|
import type {
|
||||||
Diagnostic,
|
Diagnostic,
|
||||||
@@ -26,6 +29,7 @@ import type {
|
|||||||
SubjektivResidentSurfaceAvailability,
|
SubjektivResidentSurfaceAvailability,
|
||||||
SubjektivResidentSurfaceResponse,
|
SubjektivResidentSurfaceResponse,
|
||||||
SubjektivResidentSurfaceSnapshot,
|
SubjektivResidentSurfaceSnapshot,
|
||||||
|
SubjektivSubjectBehaviorUpdateRequest,
|
||||||
SubjektivSubjectCreateRequest,
|
SubjektivSubjectCreateRequest,
|
||||||
SubjektivSubjectListResponse,
|
SubjektivSubjectListResponse,
|
||||||
SubjektivSubjectResponse,
|
SubjektivSubjectResponse,
|
||||||
@@ -84,6 +88,7 @@ export const MEMORY_API_LOAD_POLICY = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const MAX_SUBJECT_ROLE_BYTES = 256;
|
const MAX_SUBJECT_ROLE_BYTES = 256;
|
||||||
|
export const MAX_SUBJECT_BEHAVIOR_BYTES = SUBJEKTIV_API_LIMITS.maxBehaviorBytes;
|
||||||
const MAX_SUBJECT_CREATE_ERROR_BYTES = 1024 * 1024;
|
const MAX_SUBJECT_CREATE_ERROR_BYTES = 1024 * 1024;
|
||||||
|
|
||||||
export type SubjektivSubjectCreateErrorKind =
|
export type SubjektivSubjectCreateErrorKind =
|
||||||
@@ -126,7 +131,31 @@ export function validateSubjektivSubjectCreateRequest(
|
|||||||
"Subject role must be at most 256 bytes and contain no control characters.",
|
"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(
|
export async function createSubjektivSubject(
|
||||||
@@ -170,8 +199,11 @@ export async function createSubjektivSubject(
|
|||||||
const subject = parseSubjektivSubjectResponse(
|
const subject = parseSubjektivSubjectResponse(
|
||||||
await readBoundedJson(response, MEMORY_API_LIMITS.maxResponseBytes),
|
await readBoundedJson(response, MEMORY_API_LIMITS.maxResponseBytes),
|
||||||
);
|
);
|
||||||
if (subject.role !== validated.role) {
|
if (
|
||||||
throw new Error("created Subject role does not match the request");
|
subject.role !== validated.role ||
|
||||||
|
subject.behavior_md !== validated.behavior_md
|
||||||
|
) {
|
||||||
|
throw new Error("created Subject does not match the request");
|
||||||
}
|
}
|
||||||
return subject;
|
return subject;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -182,6 +214,68 @@ export async function createSubjektivSubject(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateSubjektivSubjectBehavior(
|
||||||
|
fetchFn: typeof fetch,
|
||||||
|
workspaceId: string,
|
||||||
|
subjectId: string,
|
||||||
|
request: SubjektivSubjectBehaviorUpdateRequest,
|
||||||
|
): Promise<SubjektivSubjectResponse> {
|
||||||
|
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(
|
async function subjektivSubjectCreateErrorMessage(
|
||||||
response: Response,
|
response: Response,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
@@ -202,7 +296,7 @@ async function subjektivSubjectCreateErrorMessage(
|
|||||||
} catch {
|
} catch {
|
||||||
// A rejected request still has a known outcome even when its body is invalid.
|
// 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(
|
export function parseMemoryDocumentResponse(
|
||||||
@@ -454,6 +548,8 @@ export function parseSubjektivSubjectResponse(
|
|||||||
[
|
[
|
||||||
"id",
|
"id",
|
||||||
"role",
|
"role",
|
||||||
|
"behavior_md",
|
||||||
|
"behavior_revision",
|
||||||
"state",
|
"state",
|
||||||
"store_revision",
|
"store_revision",
|
||||||
"created_at",
|
"created_at",
|
||||||
@@ -472,6 +568,12 @@ export function parseSubjektivSubjectResponse(
|
|||||||
const result: SubjektivSubjectResponse = {
|
const result: SubjektivSubjectResponse = {
|
||||||
id,
|
id,
|
||||||
role: requiredString(record, "role"),
|
role: requiredString(record, "role"),
|
||||||
|
behavior_md: requiredString(
|
||||||
|
record,
|
||||||
|
"behavior_md",
|
||||||
|
MAX_SUBJECT_BEHAVIOR_BYTES,
|
||||||
|
),
|
||||||
|
behavior_revision: requiredNonNegativeInteger(record, "behavior_revision"),
|
||||||
state,
|
state,
|
||||||
store_revision: requiredNonNegativeInteger(record, "store_revision"),
|
store_revision: requiredNonNegativeInteger(record, "store_revision"),
|
||||||
created_at: requiredString(record, "created_at"),
|
created_at: requiredString(record, "created_at"),
|
||||||
|
|||||||
@@ -14,13 +14,16 @@
|
|||||||
const subjects = $derived(data.subjects.data?.items ?? []);
|
const subjects = $derived(data.subjects.data?.items ?? []);
|
||||||
let createExpanded = $state(false);
|
let createExpanded = $state(false);
|
||||||
let role = $state('');
|
let role = $state('');
|
||||||
|
let behaviorMd = $state('');
|
||||||
let submitting = $state(false);
|
let submitting = $state(false);
|
||||||
let createError = $state<string | null>(null);
|
let createError = $state<string | null>(null);
|
||||||
let roleInvalid = $state(false);
|
let roleInvalid = $state(false);
|
||||||
|
let behaviorInvalid = $state(false);
|
||||||
let createOutcomeUnknown = $state(false);
|
let createOutcomeUnknown = $state(false);
|
||||||
let createdSubject = $state<SubjektivSubjectResponse | null>(null);
|
let createdSubject = $state<SubjektivSubjectResponse | null>(null);
|
||||||
let createButton = $state<HTMLButtonElement>();
|
let createButton = $state<HTMLButtonElement>();
|
||||||
let roleInput = $state<HTMLInputElement>();
|
let roleInput = $state<HTMLInputElement>();
|
||||||
|
let behaviorInput = $state<HTMLTextAreaElement>();
|
||||||
|
|
||||||
function subjectHref(subjectId: string): string {
|
function subjectHref(subjectId: string): string {
|
||||||
return workspaceRoute(data.workspaceId, `/memory/${encodeURIComponent(subjectId)}`);
|
return workspaceRoute(data.workspaceId, `/memory/${encodeURIComponent(subjectId)}`);
|
||||||
@@ -39,6 +42,7 @@
|
|||||||
createExpanded = true;
|
createExpanded = true;
|
||||||
createError = null;
|
createError = null;
|
||||||
roleInvalid = false;
|
roleInvalid = false;
|
||||||
|
behaviorInvalid = false;
|
||||||
createOutcomeUnknown = false;
|
createOutcomeUnknown = false;
|
||||||
createdSubject = null;
|
createdSubject = null;
|
||||||
await tick();
|
await tick();
|
||||||
@@ -49,8 +53,10 @@
|
|||||||
if (submitting) return;
|
if (submitting) return;
|
||||||
createExpanded = false;
|
createExpanded = false;
|
||||||
role = '';
|
role = '';
|
||||||
|
behaviorMd = '';
|
||||||
createError = null;
|
createError = null;
|
||||||
roleInvalid = false;
|
roleInvalid = false;
|
||||||
|
behaviorInvalid = false;
|
||||||
createOutcomeUnknown = false;
|
createOutcomeUnknown = false;
|
||||||
createdSubject = null;
|
createdSubject = null;
|
||||||
await tick();
|
await tick();
|
||||||
@@ -62,9 +68,13 @@
|
|||||||
submitting = true;
|
submitting = true;
|
||||||
createError = null;
|
createError = null;
|
||||||
roleInvalid = false;
|
roleInvalid = false;
|
||||||
|
behaviorInvalid = false;
|
||||||
createOutcomeUnknown = false;
|
createOutcomeUnknown = false;
|
||||||
try {
|
try {
|
||||||
const subject = await createSubjektivSubject(fetch, data.workspaceId, { role });
|
const subject = await createSubjektivSubject(fetch, data.workspaceId, {
|
||||||
|
role,
|
||||||
|
behavior_md: behaviorMd,
|
||||||
|
});
|
||||||
createdSubject = subject;
|
createdSubject = subject;
|
||||||
try {
|
try {
|
||||||
await goto(subjectHref(subject.id));
|
await goto(subjectHref(subject.id));
|
||||||
@@ -73,14 +83,16 @@
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof SubjektivSubjectCreateError) {
|
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';
|
createOutcomeUnknown = error.kind === 'unknown_outcome';
|
||||||
createError = error.status === 401 || error.status === 403
|
createError = error.status === 401 || error.status === 403
|
||||||
? 'You do not have permission to create Subjects in this Workspace.'
|
? 'You do not have permission to create Subjects in this Workspace.'
|
||||||
: error.message;
|
: error.message;
|
||||||
if (roleInvalid) {
|
if (roleInvalid || behaviorInvalid) {
|
||||||
await tick();
|
await tick();
|
||||||
roleInput?.focus();
|
if (behaviorInvalid) behaviorInput?.focus();
|
||||||
|
else roleInput?.focus();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
createOutcomeUnknown = true;
|
createOutcomeUnknown = true;
|
||||||
@@ -149,9 +161,22 @@
|
|||||||
disabled={submitting || createdSubject !== null}
|
disabled={submitting || createdSubject !== null}
|
||||||
onkeydown={createFormKeydown}
|
onkeydown={createFormKeydown}
|
||||||
aria-invalid={roleInvalid ? 'true' : undefined}
|
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'}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="subject-role-field">
|
||||||
|
<span>Behavior <small>Optional, user-managed</small></span>
|
||||||
|
<textarea
|
||||||
|
bind:this={behaviorInput}
|
||||||
|
bind:value={behaviorMd}
|
||||||
|
rows="7"
|
||||||
|
disabled={submitting || createdSubject !== null}
|
||||||
|
onkeydown={createFormKeydown}
|
||||||
|
aria-invalid={behaviorInvalid ? 'true' : undefined}
|
||||||
|
aria-describedby={createError && behaviorInvalid ? 'subject-behavior-help subject-create-error' : 'subject-behavior-help'}
|
||||||
|
></textarea>
|
||||||
|
<small id="subject-behavior-help">Persistent guidance injected verbatim for this Subject. It remains separate from generated Memory and can be edited later.</small>
|
||||||
|
</label>
|
||||||
{#if createError}
|
{#if createError}
|
||||||
<div
|
<div
|
||||||
id="subject-create-error"
|
id="subject-create-error"
|
||||||
@@ -318,20 +343,25 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.subject-create-actions {
|
.subject-create-actions {
|
||||||
grid-column: 3;
|
grid-column: 2;
|
||||||
grid-row: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.subject-create-panel {
|
.subject-create-panel {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(14rem, 0.8fr) minmax(18rem, 1fr) auto;
|
grid-template-columns: minmax(14rem, 0.7fr) minmax(18rem, 1.3fr);
|
||||||
align-items: end;
|
align-items: start;
|
||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
padding: var(--space-4);
|
padding: var(--space-4);
|
||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
border-radius: var(--radius-soft);
|
border-radius: var(--radius-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.subject-create-copy {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 1 / span 4;
|
||||||
|
align-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
.subject-create-copy h2,
|
.subject-create-copy h2,
|
||||||
.subject-create-copy p,
|
.subject-create-copy p,
|
||||||
.subject-create-error p {
|
.subject-create-error p {
|
||||||
@@ -353,7 +383,8 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.subject-role-field input {
|
.subject-role-field input,
|
||||||
|
.subject-role-field textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
@@ -363,7 +394,15 @@
|
|||||||
color: var(--text-strong);
|
color: var(--text-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
.subject-role-field input[aria-invalid='true'] {
|
.subject-role-field textarea {
|
||||||
|
min-height: 8rem;
|
||||||
|
resize: vertical;
|
||||||
|
font: inherit;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subject-role-field input[aria-invalid='true'],
|
||||||
|
.subject-role-field textarea[aria-invalid='true'] {
|
||||||
border-color: var(--danger);
|
border-color: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -496,6 +535,7 @@
|
|||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.subject-create-copy,
|
||||||
.subject-create-error,
|
.subject-create-error,
|
||||||
.subject-create-actions {
|
.subject-create-actions {
|
||||||
grid-column: 1;
|
grid-column: 1;
|
||||||
|
|||||||
@@ -1,14 +1,91 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { untrack } from 'svelte';
|
||||||
import DocumentMarkdown from '#lib/workspace/markdown/DocumentMarkdown.svelte';
|
import DocumentMarkdown from '#lib/workspace/markdown/DocumentMarkdown.svelte';
|
||||||
import { formatDate, workspaceRoute } from '#lib/workspace/api/http.ts';
|
import { formatDate, workspaceRoute } from '#lib/workspace/api/http.ts';
|
||||||
import type {
|
import {
|
||||||
SubjektivMemoryState,
|
SubjektivSubjectCreateError,
|
||||||
SubjektivResidentSurfaceAvailability,
|
updateSubjektivSubjectBehavior,
|
||||||
} from '#lib/generated/memory-api.ts';
|
} from '#lib/workspace/memory/api.ts';
|
||||||
|
import type { SubjektivMemoryState, SubjektivResidentSurfaceAvailability } from '#lib/generated/memory-api.ts';
|
||||||
import type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
let { data }: PageProps = $props();
|
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<string | null>(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<void> {
|
||||||
|
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 surface = $derived(data.surface.data);
|
||||||
const snapshot = $derived(surface?.snapshot ?? null);
|
const snapshot = $derived(surface?.snapshot ?? null);
|
||||||
const memories = $derived(data.memories.data?.items ?? []);
|
const memories = $derived(data.memories.data?.items ?? []);
|
||||||
@@ -51,7 +128,7 @@
|
|||||||
if (availability !== 'ready') return 'No current generated context is available to use.';
|
if (availability !== 'ready') return 'No current generated context is available to use.';
|
||||||
return snapshot?.body_md.trim()
|
return snapshot?.body_md.trim()
|
||||||
? 'Current generated context is available.'
|
? '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 {
|
function subjectStateLabel(state: 'active' | 'retired'): string {
|
||||||
@@ -60,7 +137,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>{data.subject.data?.role ?? data.subjectId} · Memory · Yoi Workspace</title>
|
<title>{subject?.role ?? data.subjectId} · Memory · Yoi Workspace</title>
|
||||||
<meta name="description" content="Subject resident surface and current committed Memories" />
|
<meta name="description" content="Subject resident surface and current committed Memories" />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
@@ -70,23 +147,23 @@
|
|||||||
<header class="memory-page-header">
|
<header class="memory-page-header">
|
||||||
<div>
|
<div>
|
||||||
<p class="memory-eyebrow">Memory subject</p>
|
<p class="memory-eyebrow">Memory subject</p>
|
||||||
<h1 id="subject-heading">{data.subject.data?.role ?? data.subjectId}</h1>
|
<h1 id="subject-heading">{subject?.role ?? data.subjectId}</h1>
|
||||||
<code class="subject-id" title={data.subjectId}>{data.subjectId}</code>
|
<code class="subject-id" title={data.subjectId}>{data.subjectId}</code>
|
||||||
</div>
|
</div>
|
||||||
<span class="read-only-label">Read-only</span>
|
<span class="read-only-label">Behavior editable</span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if data.subject.data}
|
{#if subject}
|
||||||
<section class="subject-overview" aria-label="Subject status">
|
<section class="subject-overview" aria-label="Subject status">
|
||||||
<div>
|
<div>
|
||||||
<span>Subject status</span>
|
<span>Subject status</span>
|
||||||
<strong class="subject-state is-{data.subject.data.state}">{subjectStateLabel(data.subject.data.state)}</strong>
|
<strong class="subject-state is-{subject.state}">{subjectStateLabel(subject.state)}</strong>
|
||||||
<p>{data.subject.data.state === 'active' ? 'Available for new Worker connections.' : 'No longer available for new Worker connections.'}</p>
|
<p>{subject.state === 'active' ? 'Available for new Worker connections.' : 'No longer available for new Worker connections.'}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>Worker connection</span>
|
<span>Worker connection</span>
|
||||||
<strong>{data.subject.data.current_worker ? 'Connected' : 'Not connected'}</strong>
|
<strong>{subject.current_worker ? 'Connected' : 'Not connected'}</strong>
|
||||||
<p>{data.subject.data.current_worker?.display_name ?? 'No Worker currently owns this Subject connection.'}</p>
|
<p>{subject.current_worker?.display_name ?? 'No Worker currently owns this Subject connection.'}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>Committed Memories</span>
|
<span>Committed Memories</span>
|
||||||
@@ -99,7 +176,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>Resident context</span>
|
<span>Generated Memory context</span>
|
||||||
{#if surface}
|
{#if surface}
|
||||||
<strong class="availability is-{surface.availability}">{surfaceLabel(surface.availability)}</strong>
|
<strong class="availability is-{surface.availability}">{surfaceLabel(surface.availability)}</strong>
|
||||||
<p>{surfaceSummary(surface.availability)}</p>
|
<p>{surfaceSummary(surface.availability)}</p>
|
||||||
@@ -113,12 +190,12 @@
|
|||||||
<details class="subject-technical-details">
|
<details class="subject-technical-details">
|
||||||
<summary>Technical details</summary>
|
<summary>Technical details</summary>
|
||||||
<dl>
|
<dl>
|
||||||
<div><dt>Subject ID</dt><dd><code>{data.subject.data.id}</code></dd></div>
|
<div><dt>Subject ID</dt><dd><code>{subject.id}</code></dd></div>
|
||||||
<div>
|
<div>
|
||||||
<dt>Subject store revision</dt>
|
<dt>Subject store revision</dt>
|
||||||
<dd>{data.subject.data.store_revision}<small>Internal change number for committed Memories; not a Memory count or content-quality score.</small></dd>
|
<dd>{subject.store_revision}<small>Internal change number for committed Memories; not a Memory count or content-quality score.</small></dd>
|
||||||
</div>
|
</div>
|
||||||
<div><dt>Last changed</dt><dd><time datetime={data.subject.data.updated_at}>{formatDate(data.subject.data.updated_at)}</time></dd></div>
|
<div><dt>Last changed</dt><dd><time datetime={subject.updated_at}>{formatDate(subject.updated_at)}</time></dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
</details>
|
</details>
|
||||||
{:else if data.subject.error}
|
{:else if data.subject.error}
|
||||||
@@ -130,6 +207,40 @@
|
|||||||
<div class="memory-state" role="status"><p>Subject details are unavailable.</p></div>
|
<div class="memory-state" role="status"><p>Subject details are unavailable.</p></div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if subject}
|
||||||
|
<section class="behavior-section" aria-labelledby="subject-behavior-heading">
|
||||||
|
<header class="section-heading">
|
||||||
|
<div>
|
||||||
|
<p class="memory-eyebrow">User-managed context</p>
|
||||||
|
<h2 id="subject-behavior-heading">Behavior</h2>
|
||||||
|
</div>
|
||||||
|
{#if !editingBehavior}
|
||||||
|
<button type="button" class="behavior-edit" onclick={beginBehaviorEdit}>Edit</button>
|
||||||
|
{/if}
|
||||||
|
</header>
|
||||||
|
<p class="behavior-help">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.</p>
|
||||||
|
{#if editingBehavior}
|
||||||
|
<form class="behavior-form" aria-busy={behaviorSaving} onsubmit={(event) => { event.preventDefault(); void saveBehavior(); }}>
|
||||||
|
<label>
|
||||||
|
<span class="sr-only">Subject behavior</span>
|
||||||
|
<textarea bind:value={behaviorDraft} rows="9" disabled={behaviorSaving} aria-invalid={behaviorError ? 'true' : undefined} aria-describedby={behaviorError ? 'subject-behavior-edit-error' : undefined}></textarea>
|
||||||
|
</label>
|
||||||
|
{#if behaviorError}<p id="subject-behavior-edit-error" class="behavior-error" role="alert">{behaviorError}</p>{/if}
|
||||||
|
<div class="behavior-actions">
|
||||||
|
<button type="submit" disabled={behaviorSaving}>{behaviorSaving ? 'Saving…' : 'Save behavior'}</button>
|
||||||
|
<button type="button" disabled={behaviorSaving} onclick={cancelBehaviorEdit}>Cancel</button>
|
||||||
|
<button type="button" disabled={behaviorSaving || behaviorDraft.length === 0} onclick={() => { behaviorDraft = ''; }}>Clear</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{:else if subject.behavior_md.length > 0}
|
||||||
|
<article class="behavior-document" aria-label="User-managed Subject behavior"><pre>{subject.behavior_md}</pre></article>
|
||||||
|
{:else}
|
||||||
|
<div class="memory-state" role="status"><strong>No behavior is set.</strong><p>Connected Workers receive an explicit empty behavior document.</p></div>
|
||||||
|
{/if}
|
||||||
|
{#if behaviorSaveConfirmed}<p class="behavior-saved" role="status">Behavior storage confirmed at revision {subject.behavior_revision}. This page does not confirm application by a connected Worker.</p>{/if}
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<section class="surface-section" aria-labelledby="resident-surface-heading">
|
<section class="surface-section" aria-labelledby="resident-surface-heading">
|
||||||
<header class="section-heading">
|
<header class="section-heading">
|
||||||
<div>
|
<div>
|
||||||
@@ -434,6 +545,7 @@
|
|||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.behavior-section,
|
||||||
.surface-section,
|
.surface-section,
|
||||||
.memories-section {
|
.memories-section {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -442,6 +554,78 @@
|
|||||||
padding-top: var(--space-3);
|
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,
|
.surface-meta,
|
||||||
.pill-row {
|
.pill-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ function subject() {
|
|||||||
return {
|
return {
|
||||||
id: "subject-1",
|
id: "subject-1",
|
||||||
role: "Release coordinator",
|
role: "Release coordinator",
|
||||||
|
behavior_md: "Prefer explicit evidence.",
|
||||||
|
behavior_revision: 2,
|
||||||
state: "active" as const,
|
state: "active" as const,
|
||||||
store_revision: 9,
|
store_revision: 9,
|
||||||
created_at: "2026-01-01T00:00:00Z",
|
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;
|
}) as HTMLInputElement;
|
||||||
await waitFor(() => expect(document.activeElement).toBe(roleInput));
|
await waitFor(() => expect(document.activeElement).toBe(roleInput));
|
||||||
await fireEvent.input(roleInput, { target: { value: " Review lead " } });
|
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" });
|
const submit = screen.getByRole("button", { name: "Create Subject" });
|
||||||
await fireEvent.click(submit);
|
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({
|
resolveRequest(Response.json({
|
||||||
id: "generated-subject-1",
|
id: "generated-subject-1",
|
||||||
role: " Review lead ",
|
role: " Review lead ",
|
||||||
|
behavior_md: "Challenge unsupported assumptions.",
|
||||||
|
behavior_revision: 0,
|
||||||
state: "active",
|
state: "active",
|
||||||
store_revision: 0,
|
store_revision: 0,
|
||||||
created_at: "2026-01-03T00:00:00Z",
|
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(path).toBe("/api/w/workspace-1/subjektiv/subjects");
|
||||||
expect(init.method).toBe("POST");
|
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 () => {
|
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();
|
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(() =>
|
const fetchMock = vi.fn(() =>
|
||||||
Promise.resolve(Response.json(
|
Promise.resolve(Response.json(
|
||||||
{
|
{
|
||||||
@@ -175,6 +187,20 @@ test("preserves invalid and rejected role drafts with accessible errors", async
|
|||||||
expect(fetchMock).not.toHaveBeenCalled();
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
|
||||||
await fireEvent.input(input, { target: { value: "Release reviewer" } });
|
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 fireEvent.click(screen.getByRole("button", { name: "Create Subject" }));
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByRole("alert").textContent).toContain(
|
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 }))
|
expect(screen.getByRole("heading", { name: "Resident context", level: 1 }))
|
||||||
.not.toBeNull();
|
.not.toBeNull();
|
||||||
});
|
});
|
||||||
|
expect(
|
||||||
|
screen.getByRole("article", { name: "User-managed Subject behavior" })
|
||||||
|
.textContent,
|
||||||
|
)
|
||||||
|
.toContain("Prefer explicit evidence.");
|
||||||
expect(screen.getByRole("article", { name: "Resident context" })).not
|
expect(screen.getByRole("article", { name: "Resident context" })).not
|
||||||
.toBeNull();
|
.toBeNull();
|
||||||
expect(screen.getByText("Connected", { exact: true })).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");
|
.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<Response>((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<Response>((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", () => {
|
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
|
// The store regression candidate_decisions_reject_conflicts_and_roll_back_partial_writes
|
||||||
// stages a candidate without committing a Memory. Its public read projection is
|
// 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();
|
).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 () => {
|
test("distinguishes ready-empty, stale, failed, unavailable, and request error surfaces", async () => {
|
||||||
const base = {
|
const base = {
|
||||||
workspaceId: "workspace-1",
|
workspaceId: "workspace-1",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
parseSubjektivSubjectListResponse,
|
parseSubjektivSubjectListResponse,
|
||||||
parseSubjektivSubjectResponse,
|
parseSubjektivSubjectResponse,
|
||||||
SubjektivSubjectCreateError,
|
SubjektivSubjectCreateError,
|
||||||
|
updateSubjektivSubjectBehavior,
|
||||||
validateSubjektivSubjectCreateRequest,
|
validateSubjektivSubjectCreateRequest,
|
||||||
} from "../src/lib/workspace/memory/api.ts";
|
} from "../src/lib/workspace/memory/api.ts";
|
||||||
|
|
||||||
@@ -73,6 +74,8 @@ function subject(id = "subject-1") {
|
|||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
role: "Release coordinator",
|
role: "Release coordinator",
|
||||||
|
behavior_md: "Prefer explicit evidence.",
|
||||||
|
behavior_revision: 2,
|
||||||
state: "active",
|
state: "active",
|
||||||
store_revision: 12,
|
store_revision: 12,
|
||||||
created_at: "2026-09-01T00:00:00Z",
|
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", () => {
|
Deno.test("Subject create request mirrors Backend role validation without rewriting input", () => {
|
||||||
assertEquals(
|
assertEquals(
|
||||||
validateSubjektivSubjectCreateRequest({ role: " Release coordinator " }),
|
validateSubjektivSubjectCreateRequest({ role: " Release coordinator " }),
|
||||||
{ role: " Release coordinator " },
|
{ role: " Release coordinator ", behavior_md: "" },
|
||||||
);
|
);
|
||||||
assertThrows(
|
assertThrows(
|
||||||
() => validateSubjektivSubjectCreateRequest({ role: " " }),
|
() => validateSubjektivSubjectCreateRequest({ role: " " }),
|
||||||
@@ -285,6 +288,70 @@ Deno.test("Subject create request mirrors Backend role validation without rewrit
|
|||||||
() => validateSubjektivSubjectCreateRequest({ role: "界".repeat(86) }),
|
() => validateSubjektivSubjectCreateRequest({ role: "界".repeat(86) }),
|
||||||
"at most 256 bytes",
|
"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 () => {
|
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({
|
return Response.json({
|
||||||
...subject("subject-created"),
|
...subject("subject-created"),
|
||||||
role: " Release coordinator ",
|
role: " Release coordinator ",
|
||||||
|
behavior_md: "Stay concise.",
|
||||||
}, { status: 201 });
|
}, { status: 201 });
|
||||||
}) as typeof fetch;
|
}) as typeof fetch;
|
||||||
|
|
||||||
const created = await createSubjektivSubject(fetchFn, "workspace one", {
|
const created = await createSubjektivSubject(fetchFn, "workspace one", {
|
||||||
role: " Release coordinator ",
|
role: " Release coordinator ",
|
||||||
|
behavior_md: "Stay concise.",
|
||||||
});
|
});
|
||||||
assertEquals(created, {
|
assertEquals(created, {
|
||||||
...subject("subject-created"),
|
...subject("subject-created"),
|
||||||
role: " Release coordinator ",
|
role: " Release coordinator ",
|
||||||
|
behavior_md: "Stay concise.",
|
||||||
});
|
});
|
||||||
assertEquals(requests.length, 1);
|
assertEquals(requests.length, 1);
|
||||||
assertEquals(requests[0].path, "/api/w/workspace%20one/subjektiv/subjects");
|
assertEquals(requests[0].path, "/api/w/workspace%20one/subjektiv/subjects");
|
||||||
assertEquals(requests[0].init?.method, "POST");
|
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 () => {
|
Deno.test("Subject create client distinguishes rejected and unknown outcomes without retrying", async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user