fix: reconcile T-697 generated contracts with latest develop
This commit is contained in:
@@ -243,6 +243,7 @@ impl_openapi_schema!(
|
||||
MemoryConsolidateStagingRequest,
|
||||
MemoryConsolidationResponse,
|
||||
SubjektivSubjectCreateRequest,
|
||||
SubjektivSubjectBehaviorUpdateRequest,
|
||||
SubjektivSubjectListQuery,
|
||||
SubjektivSubjectListResponse,
|
||||
SubjektivSubjectResponse,
|
||||
@@ -1169,6 +1170,20 @@ pub trait ServerApi {
|
||||
#[path] workspace_id: String,
|
||||
#[path] subject_id: String,
|
||||
) -> 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(
|
||||
"/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/surface",
|
||||
status = 200,
|
||||
@@ -4612,9 +4627,20 @@ pub enum SubjektivSubjectState {
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SubjektivSubjectCreateRequest {
|
||||
pub role: String,
|
||||
#[serde(default)]
|
||||
pub behavior_md: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SubjektivSubjectBehaviorUpdateRequest {
|
||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
|
||||
pub expected_behavior_revision: u64,
|
||||
pub behavior_md: String,
|
||||
}
|
||||
|
||||
pub const SUBJEKTIV_BROWSER_MAX_LIST_LIMIT: usize = 100;
|
||||
pub const SUBJEKTIV_MAX_BEHAVIOR_BYTES: usize = 16 * 1024;
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -4642,6 +4668,9 @@ pub struct SubjektivSubjectListResponse {
|
||||
pub struct SubjektivSubjectResponse {
|
||||
pub id: String,
|
||||
pub role: String,
|
||||
pub behavior_md: String,
|
||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
|
||||
pub behavior_revision: u64,
|
||||
pub state: SubjektivSubjectState,
|
||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
|
||||
pub store_revision: u64,
|
||||
@@ -5475,10 +5504,22 @@ pub struct SubjektivSurfaceFailureResponse {
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SubjektivResidentContextOutput {
|
||||
/// Exact user-managed document. Empty means explicitly unset.
|
||||
pub behavior_md: String,
|
||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
|
||||
pub behavior_revision: u64,
|
||||
/// Independently-fresh generated Memory projection.
|
||||
pub memory_surface: memory::backend::MemoryResidentSummaryOutput,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(tag = "operation", content = "input", rename_all = "snake_case")]
|
||||
pub enum SubjektivMemoryBackendOperation {
|
||||
ResidentSummary(memory::backend::MemoryResidentSummaryOperation),
|
||||
ResidentContext(memory::backend::MemoryResidentSummaryOperation),
|
||||
Query(SubjektivMemoryQueryRequest),
|
||||
Read(SubjektivMemoryReadRequest),
|
||||
ListRevisions(SubjektivMemoryListRevisionsRequest),
|
||||
@@ -5651,6 +5692,7 @@ pub struct SubjektivMemoryStageExplicitResponse {
|
||||
#[serde(tag = "result", content = "data", rename_all = "snake_case")]
|
||||
pub enum SubjektivMemoryBackendResponse {
|
||||
ResidentSummary(memory::backend::MemoryResidentSummaryOutput),
|
||||
ResidentContext(SubjektivResidentContextOutput),
|
||||
Query(SubjektivMemoryQueryResponse),
|
||||
Read(SubjektivMemoryReadResponse),
|
||||
ListRevisions(SubjektivMemoryListRevisionsResponse),
|
||||
@@ -9957,9 +9999,11 @@ pub fn memory_api_typescript() -> String {
|
||||
// `ts-rs` solely for this read-only projection.
|
||||
let subjektiv_browser_declarations = r#"export type SubjektivSubjectState = "active" | "retired";
|
||||
|
||||
export type SubjektivSubjectCreateRequest = { role: string, };
|
||||
export type SubjektivSubjectCreateRequest = { role: string, behavior_md?: string, };
|
||||
|
||||
export type SubjektivSubjectResponse = { id: string, role: string, state: SubjektivSubjectState, store_revision: number, created_at: string, updated_at: string, current_worker?: | import("./worker-launch-api").WorkerLaunchWorkerSummary | null, };
|
||||
export type SubjektivSubjectBehaviorUpdateRequest = { expected_behavior_revision: number, behavior_md: string, };
|
||||
|
||||
export type SubjektivSubjectResponse = { id: string, role: string, behavior_md: string, behavior_revision: number, state: SubjektivSubjectState, store_revision: number, created_at: string, updated_at: string, current_worker?: | import("./worker-launch-api").WorkerLaunchWorkerSummary | null, };
|
||||
|
||||
export type SubjektivSubjectListResponse = { limit: number, items: Array<SubjektivSubjectResponse>, next_cursor?: string | null, has_more: boolean, };
|
||||
|
||||
@@ -9992,8 +10036,11 @@ export type SubjektivMemoryListRevisionsResponse = { memory_id: string, current_
|
||||
let limits = format!(
|
||||
"export const MEMORY_API_LIMITS = {{\n maxResponseBytes: {MEMORY_API_MAX_RESPONSE_BYTES},\n maxDocumentBytes: {MEMORY_API_MAX_DOCUMENT_BYTES},\n maxCollectionItems: {MEMORY_API_MAX_COLLECTION_ITEMS},\n maxStringBytes: {MEMORY_API_MAX_STRING_BYTES},\n maxIdentifierBytes: {MEMORY_API_MAX_IDENTIFIER_BYTES},\n}} as const;"
|
||||
);
|
||||
let subjektiv_limits = format!(
|
||||
"export const SUBJEKTIV_API_LIMITS = {{\n maxBehaviorBytes: {SUBJEKTIV_MAX_BEHAVIOR_BYTES},\n}} as const;"
|
||||
);
|
||||
format!(
|
||||
"// Generated from server-api. Do not edit by hand.\n// Regenerate: cargo run -q -p server-api --features typescript --example generate_memory_api_types > web/workspace/src/lib/generated/memory-api.ts\n\n{limits}\n\n{}\n\n{subjektiv_browser_declarations}\n",
|
||||
"// Generated from server-api. Do not edit by hand.\n// Regenerate: cargo run -q -p server-api --features typescript --example generate_memory_api_types > web/workspace/src/lib/generated/memory-api.ts\n\n{limits}\n\n{subjektiv_limits}\n\n{}\n\n{subjektiv_browser_declarations}\n",
|
||||
declarations
|
||||
.into_iter()
|
||||
.map(|declaration| format!("export {declaration}"))
|
||||
|
||||
@@ -167,6 +167,15 @@ pub enum SystemItem {
|
||||
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
|
||||
/// user input. `body` is the exact LLM-context text explaining that the
|
||||
/// previous turn was cut short.
|
||||
@@ -192,6 +201,7 @@ impl SystemItem {
|
||||
}
|
||||
SystemItem::TaskReminder { body, .. } => body.clone(),
|
||||
SystemItem::ResidentSummaryRefresh { body, .. } => body.clone(),
|
||||
SystemItem::SubjectBehaviorRefresh { body, .. } => body.clone(),
|
||||
SystemItem::Interrupt { body, .. } => body.clone(),
|
||||
}
|
||||
}
|
||||
@@ -214,6 +224,7 @@ impl SystemItem {
|
||||
SystemItem::LegacyIgnored { .. } => "legacy_ignored",
|
||||
SystemItem::TaskReminder { .. } => "task_reminder",
|
||||
SystemItem::ResidentSummaryRefresh { .. } => "resident_summary_refresh",
|
||||
SystemItem::SubjectBehaviorRefresh { .. } => "subject_behavior_refresh",
|
||||
SystemItem::Interrupt { .. } => "interrupt",
|
||||
}
|
||||
}
|
||||
@@ -270,6 +281,25 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subject_behavior_refresh_round_trips_with_revision_and_exact_body() {
|
||||
let item = SystemItem::SubjectBehaviorRefresh {
|
||||
body: "current Subject context".to_string(),
|
||||
behavior_revision: 7,
|
||||
prompt_provenance: None,
|
||||
};
|
||||
let raw = serde_json::to_string(&item).unwrap();
|
||||
let parsed: SystemItem = serde_json::from_str(&raw).unwrap();
|
||||
assert_eq!(parsed.history_text(), "current Subject context");
|
||||
assert!(matches!(
|
||||
parsed,
|
||||
SystemItem::SubjectBehaviorRefresh {
|
||||
behavior_revision: 7,
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notification_history_text_returns_stored_body() {
|
||||
let item = SystemItem::Notification {
|
||||
|
||||
@@ -2618,6 +2618,7 @@ impl App {
|
||||
session_store::SystemItem::FileAttachment { body, .. }
|
||||
| session_store::SystemItem::SkillActivation { body, .. }
|
||||
| session_store::SystemItem::ResidentSummaryRefresh { body, .. }
|
||||
| session_store::SystemItem::SubjectBehaviorRefresh { body, .. }
|
||||
| session_store::SystemItem::Interrupt { body, .. } => {
|
||||
self.task_store.apply_system_message_text(&body);
|
||||
self.blocks.push(Block::SystemMessage { text: body });
|
||||
|
||||
@@ -1375,6 +1375,7 @@ where
|
||||
crate::feature::builtin::memory::ordinary_subjektiv_resident_summary_source(
|
||||
worker.manifest(),
|
||||
worker.workspace_client_handle(),
|
||||
worker.prompts(),
|
||||
)?
|
||||
{
|
||||
let workspace_id = worker
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
//! authority. In that case model-visible Memory tools must go through the
|
||||
//! workspace backend instead of resolving `.yoi/memory` from a Worker workdir.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use agen::tool::{Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
|
||||
@@ -24,11 +25,15 @@ use crate::feature::{
|
||||
FeatureDescriptor, FeatureHookPoint, FeatureInstallContext, FeatureInstallError, FeatureModule,
|
||||
HookDeclaration, ToolContribution, ToolDeclaration,
|
||||
};
|
||||
use crate::hook::{Hook, HookError, HookErrorCategory, HookExecutionPolicy, WorkerRestored};
|
||||
use crate::hook::{
|
||||
BeforeSessionRewrite, BeforeSessionRewriteAction, BeforeSessionRewriteContext, Hook, HookError,
|
||||
HookErrorCategory, HookExecutionPolicy, HookPreRequestAction, PreLlmRequest, PreRequestContext,
|
||||
WorkerRestored,
|
||||
};
|
||||
use crate::prompt::catalog::{PromptCatalog, WorkerPrompt};
|
||||
use crate::worker::{
|
||||
SystemPromptContribution, SystemPromptContributionSource, WorkspaceClient,
|
||||
WorkspaceClientError, WorkspaceRequest, WorkspaceRequestMethod,
|
||||
ResidentContextRefresh, SystemPromptContribution, SystemPromptContributionSource,
|
||||
WorkspaceClient, WorkspaceClientError, WorkspaceRequest, WorkspaceRequestMethod,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -74,6 +79,8 @@ pub enum WorkspaceMemoryBackendError {
|
||||
Decode(#[from] serde_json::Error),
|
||||
#[error("workspace memory backend rejected operation: {0}")]
|
||||
Backend(String),
|
||||
#[error("workspace memory backend returned an invalid response: {0}")]
|
||||
InvalidResponse(String),
|
||||
}
|
||||
|
||||
impl dyn WorkspaceClient + '_ {
|
||||
@@ -672,12 +679,17 @@ struct WorkspaceResidentSummarySource {
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
}
|
||||
|
||||
struct WorkspaceSubjektivResidentSummarySource {
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
#[derive(Default)]
|
||||
struct SubjectBehaviorRefreshState {
|
||||
applied: Option<(u64, String)>,
|
||||
pending: HashMap<u64, String>,
|
||||
}
|
||||
|
||||
const READY_EMPTY_RESIDENT_SURFACE: &str =
|
||||
"The current ready resident memory surface is intentionally empty.";
|
||||
struct WorkspaceSubjektivResidentSummarySource {
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
prompts: Arc<ArcSwap<PromptCatalog>>,
|
||||
behavior_state: Mutex<SubjectBehaviorRefreshState>,
|
||||
}
|
||||
|
||||
fn resident_summary_contribution(
|
||||
output: memory::backend::MemoryResidentSummaryOutput,
|
||||
@@ -715,37 +727,133 @@ impl SystemPromptContributionSource for WorkspaceResidentSummarySource {
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SystemPromptContributionSource for WorkspaceSubjektivResidentSummarySource {
|
||||
async fn load(&self) -> SystemPromptContribution {
|
||||
impl WorkspaceSubjektivResidentSummarySource {
|
||||
async fn fetch(
|
||||
&self,
|
||||
) -> Result<server_api::SubjektivResidentContextOutput, WorkspaceMemoryBackendError> {
|
||||
match execute_subjektiv_memory_operation_with(
|
||||
self.client.as_ref(),
|
||||
server_api::SubjektivMemoryBackendOperation::ResidentSummary(Default::default()),
|
||||
server_api::SubjektivMemoryBackendOperation::ResidentContext(Default::default()),
|
||||
|client, request| {
|
||||
client.execute_with_timeout(request, RESIDENT_SUMMARY_REQUEST_TIMEOUT)
|
||||
},
|
||||
) {
|
||||
Ok(server_api::SubjektivMemoryBackendResponse::ResidentSummary(output)) => {
|
||||
match resident_summary_contribution(output) {
|
||||
SystemPromptContribution::Ready(content) if content.trim().is_empty() => {
|
||||
SystemPromptContribution::Ready(READY_EMPTY_RESIDENT_SURFACE.to_string())
|
||||
}
|
||||
contribution => contribution,
|
||||
)? {
|
||||
server_api::SubjektivMemoryBackendResponse::ResidentContext(output) => Ok(output),
|
||||
other => Err(WorkspaceMemoryBackendError::InvalidResponse(format!(
|
||||
"unexpected subject resident context response: {other:?}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn render(
|
||||
&self,
|
||||
output: &server_api::SubjektivResidentContextOutput,
|
||||
) -> Result<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)
|
||||
}
|
||||
}
|
||||
Ok(other) => {
|
||||
tracing::debug!(?other, "unexpected subject resident Memory response");
|
||||
SystemPromptContribution::Unavailable
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::error!(%error, "subject resident context render failed");
|
||||
SystemPromptContribution::Ready(
|
||||
"The Host could not render the Subject resident context. Do not treat this as unset behavior or infer replacement behavior from Memory."
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
},
|
||||
Err(error) => {
|
||||
tracing::debug!(%error, "subject resident Memory unavailable");
|
||||
SystemPromptContribution::Unavailable
|
||||
tracing::error!(%error, "subject resident context fetch failed");
|
||||
SystemPromptContribution::Ready(
|
||||
"The Host could not fetch the Subject resident context. Do not treat this as unset behavior or infer replacement behavior from Memory."
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn load_changed_resident_context(
|
||||
&self,
|
||||
) -> Result<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_BEHAVIOR_REFRESH_HOOK: &str = "refresh-subject-behavior-before-request";
|
||||
const SUBJEKTIV_BEHAVIOR_REWRITE_HOOK: &str = "invalidate-subject-behavior-before-rewrite";
|
||||
|
||||
/// subjektiv-owned restore behavior. Initial prompt contribution remains a
|
||||
/// shared host facility, while loading and rendering a post-restore refresh is
|
||||
@@ -793,6 +901,14 @@ impl FeatureModule for SubjektivResidentRestoreRefreshFeature {
|
||||
SUBJEKTIV_RESTORE_REFRESH_HOOK,
|
||||
FeatureHookPoint::WorkerRestored,
|
||||
))
|
||||
.with_hook(HookDeclaration::new(
|
||||
SUBJEKTIV_BEHAVIOR_REFRESH_HOOK,
|
||||
FeatureHookPoint::PreLlmRequest,
|
||||
))
|
||||
.with_hook(HookDeclaration::new(
|
||||
SUBJEKTIV_BEHAVIOR_REWRITE_HOOK,
|
||||
FeatureHookPoint::BeforeSessionRewrite,
|
||||
))
|
||||
}
|
||||
|
||||
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
|
||||
@@ -804,10 +920,64 @@ impl FeatureModule for SubjektivResidentRestoreRefreshFeature {
|
||||
prompts: Arc::clone(&self.prompts),
|
||||
workspace_id: self.workspace_id.clone(),
|
||||
},
|
||||
)?;
|
||||
context.hooks().add_pre_llm_request(
|
||||
SUBJEKTIV_BEHAVIOR_REFRESH_HOOK,
|
||||
HookExecutionPolicy::fail_closed(),
|
||||
SubjektivBehaviorRefreshHook {
|
||||
source: Arc::clone(&self.source),
|
||||
},
|
||||
)?;
|
||||
context.hooks().add_before_session_rewrite(
|
||||
SUBJEKTIV_BEHAVIOR_REWRITE_HOOK,
|
||||
HookExecutionPolicy::fail_closed(),
|
||||
SubjektivBehaviorRewriteHook {
|
||||
source: Arc::clone(&self.source),
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct SubjektivBehaviorRewriteHook {
|
||||
source: Arc<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 {
|
||||
source: Arc<dyn SystemPromptContributionSource>,
|
||||
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(
|
||||
manifest: &manifest::WorkerManifest,
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
prompts: Arc<ArcSwap<PromptCatalog>>,
|
||||
) -> std::io::Result<Option<Arc<dyn SystemPromptContributionSource>>> {
|
||||
if !ordinary_subjektiv_features_enabled(manifest) {
|
||||
return Ok(None);
|
||||
@@ -988,6 +1159,8 @@ pub(crate) fn ordinary_subjektiv_resident_summary_source(
|
||||
}
|
||||
Ok(Some(Arc::new(WorkspaceSubjektivResidentSummarySource {
|
||||
client,
|
||||
prompts,
|
||||
behavior_state: Mutex::new(SubjectBehaviorRefreshState::default()),
|
||||
})))
|
||||
}
|
||||
|
||||
@@ -1120,6 +1293,10 @@ mod tests {
|
||||
use agen::tool::ToolDefinition;
|
||||
use std::sync::Mutex;
|
||||
|
||||
fn test_prompts() -> Arc<ArcSwap<PromptCatalog>> {
|
||||
Arc::new(ArcSwap::from(PromptCatalog::builtins_only().unwrap()))
|
||||
}
|
||||
|
||||
fn test_client() -> Arc<dyn WorkspaceClient> {
|
||||
Arc::new(crate::worker::TestWorkspaceHttpClient::new(
|
||||
"workspace",
|
||||
@@ -1132,6 +1309,7 @@ mod tests {
|
||||
availability: memory::backend::MemoryResidentSummaryAvailability,
|
||||
content: Option<String>,
|
||||
scope_allowed: bool,
|
||||
behavior: Mutex<(u64, String)>,
|
||||
paths: Mutex<Vec<String>>,
|
||||
timeouts: Mutex<Vec<Duration>>,
|
||||
}
|
||||
@@ -1145,6 +1323,7 @@ mod tests {
|
||||
availability,
|
||||
content: content.map(str::to_string),
|
||||
scope_allowed: true,
|
||||
behavior: Mutex::new((3, "Be deliberate.".to_string())),
|
||||
paths: Mutex::new(Vec::new()),
|
||||
timeouts: Mutex::new(Vec::new()),
|
||||
}
|
||||
@@ -1155,6 +1334,7 @@ mod tests {
|
||||
availability: memory::backend::MemoryResidentSummaryAvailability::Ready,
|
||||
content: Some("must not be injected".into()),
|
||||
scope_allowed: false,
|
||||
behavior: Mutex::new((3, "must not be injected".to_string())),
|
||||
paths: Mutex::new(Vec::new()),
|
||||
timeouts: Mutex::new(Vec::new()),
|
||||
}
|
||||
@@ -1184,7 +1364,7 @@ mod tests {
|
||||
serde_json::from_str(request.body.as_deref().unwrap_or_default()).unwrap();
|
||||
assert!(matches!(
|
||||
request.operation,
|
||||
server_api::SubjektivMemoryBackendOperation::ResidentSummary(_)
|
||||
server_api::SubjektivMemoryBackendOperation::ResidentContext(_)
|
||||
));
|
||||
if !self.scope_allowed {
|
||||
return Ok(WorkspaceResponse {
|
||||
@@ -1192,13 +1372,18 @@ mod tests {
|
||||
body: "subject scope unavailable".into(),
|
||||
});
|
||||
}
|
||||
let (behavior_revision, behavior_md) = self.behavior.lock().unwrap().clone();
|
||||
Ok(WorkspaceResponse {
|
||||
status: 200,
|
||||
body: serde_json::to_string(
|
||||
&server_api::SubjektivMemoryBackendResponse::ResidentSummary(
|
||||
memory::backend::MemoryResidentSummaryOutput {
|
||||
availability: self.availability,
|
||||
content: self.content.clone(),
|
||||
&server_api::SubjektivMemoryBackendResponse::ResidentContext(
|
||||
server_api::SubjektivResidentContextOutput {
|
||||
behavior_md,
|
||||
behavior_revision,
|
||||
memory_surface: memory::backend::MemoryResidentSummaryOutput {
|
||||
availability: self.availability,
|
||||
content: self.content.clone(),
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -1511,13 +1696,19 @@ permission = "write"
|
||||
memory::backend::MemoryResidentSummaryAvailability::Ready,
|
||||
Some("current subject surface"),
|
||||
));
|
||||
let ready = ordinary_subjektiv_resident_summary_source(&manifest, ready_client.clone())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
ready.load().await,
|
||||
SystemPromptContribution::Ready("current subject surface".into())
|
||||
);
|
||||
let ready = ordinary_subjektiv_resident_summary_source(
|
||||
&manifest,
|
||||
ready_client.clone(),
|
||||
test_prompts(),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let loaded = ready.load().await;
|
||||
let SystemPromptContribution::Ready(loaded) = loaded else {
|
||||
panic!("subject resident context must be present");
|
||||
};
|
||||
assert!(loaded.contains("Be deliberate."));
|
||||
assert!(loaded.contains("current subject surface"));
|
||||
assert_eq!(
|
||||
ready_client.paths.lock().unwrap().clone(),
|
||||
vec!["/api/w/workspace/subjektiv/memory".to_string()]
|
||||
@@ -1531,13 +1722,18 @@ permission = "write"
|
||||
memory::backend::MemoryResidentSummaryAvailability::Ready,
|
||||
None,
|
||||
));
|
||||
let ready_empty = ordinary_subjektiv_resident_summary_source(&manifest, ready_empty_client)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
ready_empty.load().await,
|
||||
SystemPromptContribution::Ready(READY_EMPTY_RESIDENT_SURFACE.into())
|
||||
);
|
||||
let ready_empty = ordinary_subjektiv_resident_summary_source(
|
||||
&manifest,
|
||||
ready_empty_client,
|
||||
test_prompts(),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let SystemPromptContribution::Ready(ready_empty) = ready_empty.load().await else {
|
||||
panic!("empty surface must retain Subject behavior");
|
||||
};
|
||||
assert!(ready_empty.contains("Be deliberate."));
|
||||
assert!(ready_empty.contains("intentionally empty"));
|
||||
|
||||
for availability in [
|
||||
memory::backend::MemoryResidentSummaryAvailability::Ungenerated,
|
||||
@@ -1550,22 +1746,107 @@ permission = "write"
|
||||
availability,
|
||||
Some("must not be injected"),
|
||||
)),
|
||||
test_prompts(),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(source.load().await, SystemPromptContribution::Unavailable);
|
||||
let SystemPromptContribution::Ready(loaded) = source.load().await else {
|
||||
panic!("Subject behavior must survive unavailable surfaces");
|
||||
};
|
||||
assert!(loaded.contains("Be deliberate."));
|
||||
assert!(!loaded.contains("must not be injected"));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn subject_resident_source_emits_only_changed_behavior_revisions() {
|
||||
let manifest = subject_manifest("workspace");
|
||||
let client = Arc::new(SubjectResidentClient::new(
|
||||
memory::backend::MemoryResidentSummaryAvailability::Ungenerated,
|
||||
None,
|
||||
));
|
||||
let source =
|
||||
ordinary_subjektiv_resident_summary_source(&manifest, client.clone(), test_prompts())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
let initial = source.load().await;
|
||||
assert!(matches!(initial, SystemPromptContribution::Ready(_)));
|
||||
assert!(
|
||||
source
|
||||
.load_changed_resident_context()
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
|
||||
*client.behavior.lock().unwrap() = (4, "Ask when uncertain.".to_string());
|
||||
let refresh = source
|
||||
.load_changed_resident_context()
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("changed behavior must produce one refresh");
|
||||
assert_eq!(refresh.revision, 4);
|
||||
assert!(refresh.body.contains("Ask when uncertain."));
|
||||
source.confirm_resident_context_revision(refresh.revision);
|
||||
assert!(
|
||||
source
|
||||
.load_changed_resident_context()
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
|
||||
*client.behavior.lock().unwrap() = (5, String::new());
|
||||
let cleared = source
|
||||
.load_changed_resident_context()
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("cleared behavior must produce one refresh");
|
||||
assert_eq!(cleared.revision, 5);
|
||||
assert!(cleared.body.contains("No user-managed behavior is set"));
|
||||
source.confirm_resident_context_revision(cleared.revision);
|
||||
assert!(
|
||||
source
|
||||
.load_changed_resident_context()
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
|
||||
source.invalidate_resident_context_representation();
|
||||
let replay_after_rewrite = source
|
||||
.load_changed_resident_context()
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("history rewrite must require a durable current representation");
|
||||
assert_eq!(replay_after_rewrite.revision, 5);
|
||||
assert!(
|
||||
replay_after_rewrite
|
||||
.body
|
||||
.contains("No user-managed behavior is set")
|
||||
);
|
||||
// Initial load and every live update/clear/replay probe retain the
|
||||
// target-side bounded resident request contract.
|
||||
assert_eq!(
|
||||
client.timeouts.lock().unwrap().as_slice(),
|
||||
&[RESIDENT_SUMMARY_REQUEST_TIMEOUT; 7]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn subject_resident_source_fails_closed_without_trusted_subject_scope() {
|
||||
let manifest = subject_manifest("workspace");
|
||||
let client = Arc::new(SubjectResidentClient::denied());
|
||||
let source = ordinary_subjektiv_resident_summary_source(&manifest, client.clone())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let source =
|
||||
ordinary_subjektiv_resident_summary_source(&manifest, client.clone(), test_prompts())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(source.load().await, SystemPromptContribution::Unavailable);
|
||||
let SystemPromptContribution::Ready(failure) = source.load().await else {
|
||||
panic!("fetch failure must be explicit in resident context");
|
||||
};
|
||||
assert!(failure.contains("could not fetch"));
|
||||
assert_eq!(
|
||||
client.paths.lock().unwrap().clone(),
|
||||
vec!["/api/w/workspace/subjektiv/memory".to_string()]
|
||||
@@ -1593,7 +1874,7 @@ permission = "write"
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
ordinary_subjektiv_resident_summary_source(&disabled, test_client())
|
||||
ordinary_subjektiv_resident_summary_source(&disabled, test_client(), test_prompts())
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
@@ -1605,22 +1886,30 @@ permission = "write"
|
||||
Some("policy-only surface must not load"),
|
||||
));
|
||||
assert!(
|
||||
ordinary_subjektiv_resident_summary_source(&policy_only, unattached_client.clone())
|
||||
.unwrap()
|
||||
.is_none()
|
||||
ordinary_subjektiv_resident_summary_source(
|
||||
&policy_only,
|
||||
unattached_client.clone(),
|
||||
test_prompts()
|
||||
)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert!(unattached_client.paths.lock().unwrap().is_empty());
|
||||
|
||||
let foreign = subject_manifest("other-workspace");
|
||||
assert!(ordinary_subjektiv_resident_summary_source(&foreign, test_client()).is_err());
|
||||
assert!(
|
||||
ordinary_subjektiv_resident_summary_source(&foreign, test_client(), test_prompts())
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subject_restore_refresh_is_suppressed_for_internal_hosts() {
|
||||
let manifest = subject_manifest("workspace");
|
||||
let source = ordinary_subjektiv_resident_summary_source(&manifest, test_client())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let source =
|
||||
ordinary_subjektiv_resident_summary_source(&manifest, test_client(), test_prompts())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let prompts = Arc::new(ArcSwap::from(
|
||||
crate::prompt::catalog::PromptCatalog::builtins_only().unwrap(),
|
||||
));
|
||||
@@ -1634,9 +1923,10 @@ permission = "write"
|
||||
#[test]
|
||||
fn subject_restore_refresh_feature_declares_only_restore_lifecycle() {
|
||||
let manifest = subject_manifest("workspace");
|
||||
let source = ordinary_subjektiv_resident_summary_source(&manifest, test_client())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let source =
|
||||
ordinary_subjektiv_resident_summary_source(&manifest, test_client(), test_prompts())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let feature = SubjektivResidentRestoreRefreshFeature::new(
|
||||
source,
|
||||
Arc::new(ArcSwap::from(
|
||||
@@ -1647,8 +1937,13 @@ permission = "write"
|
||||
let descriptor = feature.descriptor();
|
||||
|
||||
assert!(descriptor.tools.is_empty());
|
||||
assert_eq!(descriptor.hooks.len(), 1);
|
||||
assert_eq!(descriptor.hooks.len(), 3);
|
||||
assert_eq!(descriptor.hooks[0].point, FeatureHookPoint::WorkerRestored);
|
||||
assert_eq!(descriptor.hooks[1].point, FeatureHookPoint::PreLlmRequest);
|
||||
assert_eq!(
|
||||
descriptor.hooks[2].point,
|
||||
FeatureHookPoint::BeforeSessionRewrite
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -281,6 +281,20 @@ impl SystemItemAppendHandle {
|
||||
.expect("system-item append queue poisoned")
|
||||
.push(item);
|
||||
}
|
||||
|
||||
/// Queue a revisioned Subject behavior refresh. The caller receives this
|
||||
/// narrow operation only at a pre-request boundary, and the host commits it
|
||||
/// before exposing it to the model.
|
||||
pub fn append_subject_behavior_refresh(&self, body: impl Into<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
|
||||
|
||||
@@ -38,7 +38,7 @@ use crate::hook::{
|
||||
use crate::ipc::notify_buffer::{NotifyBuffer, build_system_item_with_provenance};
|
||||
use crate::prompt::catalog::PromptCatalog;
|
||||
use crate::session_history::SessionHistoryMetadata;
|
||||
use crate::worker::SystemItemCommitter;
|
||||
use crate::worker::{SystemItemCommitter, SystemPromptContributionSource};
|
||||
use agen::HistoryEntry;
|
||||
use agen::token_counter::total_tokens;
|
||||
|
||||
@@ -109,6 +109,7 @@ pub(crate) struct WorkerInterceptor {
|
||||
/// worker. `None` in tests / `Worker::new` paths where no writer is
|
||||
/// attached.
|
||||
log_writer: Option<Arc<dyn SystemItemCommitter>>,
|
||||
resident_context_source: Option<Arc<dyn SystemPromptContributionSource>>,
|
||||
pending_committed_history: Arc<Mutex<VecDeque<HistoryEntry<SessionHistoryMetadata>>>>,
|
||||
/// Next turn index assigned by `on_prompt_submit`.
|
||||
next_turn_index: AtomicUsize,
|
||||
@@ -163,6 +164,7 @@ impl WorkerInterceptor {
|
||||
prompts,
|
||||
prompt_workspace_id: None,
|
||||
log_writer,
|
||||
resident_context_source: None,
|
||||
pending_committed_history,
|
||||
next_turn_index: AtomicUsize::new(0),
|
||||
tool_calls_this_turn: AtomicUsize::new(0),
|
||||
@@ -179,11 +181,31 @@ impl WorkerInterceptor {
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn with_resident_context_source(
|
||||
mut self,
|
||||
source: Option<Arc<dyn SystemPromptContributionSource>>,
|
||||
) -> Self {
|
||||
self.resident_context_source = source;
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn with_prompt_workspace_id(mut self, workspace_id: Option<String>) -> Self {
|
||||
self.prompt_workspace_id = workspace_id;
|
||||
self
|
||||
}
|
||||
|
||||
fn confirm_resident_context_item(&self, item: &SystemItem) {
|
||||
let Some(source) = self.resident_context_source.as_ref() else {
|
||||
return;
|
||||
};
|
||||
if let SystemItem::SubjectBehaviorRefresh {
|
||||
behavior_revision, ..
|
||||
} = item
|
||||
{
|
||||
source.confirm_resident_context_revision(*behavior_revision);
|
||||
}
|
||||
}
|
||||
|
||||
/// Commit each `SystemItem` as its own `LogEntry::AnnotatedSystemItem`
|
||||
/// entry through the attached writer (no-op when no writer is
|
||||
/// wired). Sync — writes complete before the matching
|
||||
@@ -211,6 +233,7 @@ impl WorkerInterceptor {
|
||||
.lock()
|
||||
.expect("pending committed history poisoned")
|
||||
.push_back(entry);
|
||||
self.confirm_resident_context_item(item);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -350,6 +373,11 @@ impl WorkerInterceptor {
|
||||
} if prompt_provenance.is_none() => {
|
||||
*prompt_provenance = Some(provenance("internal.task_reminder"));
|
||||
}
|
||||
SystemItem::SubjectBehaviorRefresh {
|
||||
prompt_provenance, ..
|
||||
} if prompt_provenance.is_none() => {
|
||||
*prompt_provenance = Some(provenance("internal.subjektiv_resident_context"));
|
||||
}
|
||||
SystemItem::Interrupt {
|
||||
prompt_provenance, ..
|
||||
} if prompt_provenance.is_none() => {
|
||||
@@ -763,6 +791,7 @@ mod tests {
|
||||
HookTurnEndAction, OnTurnEnd, PostToolCall, PreLlmRequest, PreToolCall,
|
||||
};
|
||||
use crate::session_history::{WorkerHistoryProvenance, history_entry};
|
||||
use crate::worker::SystemPromptContribution;
|
||||
|
||||
fn test_prompts() -> Arc<ArcSwap<PromptCatalog>> {
|
||||
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(
|
||||
name: &str,
|
||||
input: serde_json::Value,
|
||||
@@ -1346,6 +1422,88 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn subject_behavior_revision_is_confirmed_only_after_durable_commit() {
|
||||
let mut builder = HookRegistryBuilder::new();
|
||||
builder.add_pre_llm_request(AppendingSubjectBehaviorHook);
|
||||
let committed = Arc::new(Mutex::new(Vec::new()));
|
||||
let confirmed = Arc::new(Mutex::new(Vec::new()));
|
||||
let source: Arc<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]
|
||||
async fn pre_llm_request_without_log_writer_does_not_expose_system_item_handle() {
|
||||
let saw_handle = Arc::new(AtomicBool::new(false));
|
||||
|
||||
@@ -105,6 +105,7 @@ pub enum WorkerPrompt {
|
||||
AgentsMdSection,
|
||||
ResidentMemorySummarySection,
|
||||
ResidentMemoryRestoreSection,
|
||||
SubjektivResidentContext,
|
||||
WorkerOrchestrationGuidanceSection,
|
||||
SubWorkerSpawnToolDescription,
|
||||
}
|
||||
@@ -128,6 +129,7 @@ impl WorkerPrompt {
|
||||
Self::AgentsMdSection => "internal.agents_md_section",
|
||||
Self::ResidentMemorySummarySection => "internal.resident_memory_summary_section",
|
||||
Self::ResidentMemoryRestoreSection => "internal.resident_memory_restore_section",
|
||||
Self::SubjektivResidentContext => "internal.subjektiv_resident_context",
|
||||
Self::WorkerOrchestrationGuidanceSection => {
|
||||
"internal.worker_orchestration_guidance_section"
|
||||
}
|
||||
@@ -150,6 +152,7 @@ impl WorkerPrompt {
|
||||
WorkerPrompt::AgentsMdSection,
|
||||
WorkerPrompt::ResidentMemorySummarySection,
|
||||
WorkerPrompt::ResidentMemoryRestoreSection,
|
||||
WorkerPrompt::SubjektivResidentContext,
|
||||
WorkerPrompt::WorkerOrchestrationGuidanceSection,
|
||||
WorkerPrompt::SubWorkerSpawnToolDescription,
|
||||
];
|
||||
@@ -518,6 +521,21 @@ impl PromptCatalog {
|
||||
},
|
||||
)
|
||||
}
|
||||
pub fn subjektiv_resident_context(
|
||||
&self,
|
||||
behavior_md: &str,
|
||||
surface_availability: &str,
|
||||
surface_body: Option<&str>,
|
||||
) -> Result<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> {
|
||||
self.render(
|
||||
WorkerPrompt::WorkerOrchestrationGuidanceSection,
|
||||
|
||||
+133
-9
@@ -2361,9 +2361,30 @@ pub(crate) enum SystemPromptContribution {
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct ResidentContextRefresh {
|
||||
pub body: String,
|
||||
pub revision: u64,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub(crate) trait SystemPromptContributionSource: Send + Sync {
|
||||
async fn load(&self) -> SystemPromptContribution;
|
||||
|
||||
/// Returns a durable model-visible refresh only when user-managed resident
|
||||
/// context changed since the last successful load.
|
||||
async fn load_changed_resident_context(
|
||||
&self,
|
||||
) -> Result<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)]
|
||||
@@ -3840,6 +3861,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
||||
)
|
||||
.with_usage_tracker(self.usage_tracker.clone())
|
||||
.with_metrics_tracker(self.metrics_tracker.clone())
|
||||
.with_resident_context_source(self.feature_resident_summary_source.clone())
|
||||
.with_prompt_workspace_id(
|
||||
self.workspace_context
|
||||
.workspace_id()
|
||||
@@ -9224,16 +9246,20 @@ mod build_summary_prompt_tests {
|
||||
serde_json::from_str(request.body.as_deref().unwrap_or_default()).unwrap();
|
||||
assert!(matches!(
|
||||
request.operation,
|
||||
server_api::SubjektivMemoryBackendOperation::ResidentSummary(_)
|
||||
server_api::SubjektivMemoryBackendOperation::ResidentContext(_)
|
||||
));
|
||||
self.load_count.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(WorkspaceResponse {
|
||||
status: 200,
|
||||
body: serde_json::to_string(
|
||||
&server_api::SubjektivMemoryBackendResponse::ResidentSummary(
|
||||
memory::backend::MemoryResidentSummaryOutput {
|
||||
availability: self.availability,
|
||||
content: self.content.clone(),
|
||||
&server_api::SubjektivMemoryBackendResponse::ResidentContext(
|
||||
server_api::SubjektivResidentContextOutput {
|
||||
behavior_md: "restore behavior".to_string(),
|
||||
behavior_revision: 1,
|
||||
memory_surface: memory::backend::MemoryResidentSummaryOutput {
|
||||
availability: self.availability,
|
||||
content: self.content.clone(),
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -9254,7 +9280,9 @@ mod build_summary_prompt_tests {
|
||||
load_count: Arc::clone(&load_count),
|
||||
});
|
||||
let source = crate::feature::builtin::memory::ordinary_subjektiv_resident_summary_source(
|
||||
manifest, client,
|
||||
manifest,
|
||||
client,
|
||||
Arc::new(ArcSwap::from(PromptCatalog::builtins_only().unwrap())),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
@@ -12219,9 +12247,10 @@ permission = "write"
|
||||
.unwrap()
|
||||
.to_string();
|
||||
assert!(
|
||||
empty_boundary.contains("current ready resident memory surface is intentionally empty")
|
||||
empty_boundary
|
||||
.contains("current generated Memory surface is ready and intentionally empty")
|
||||
);
|
||||
assert!(!empty_boundary.contains("No current ready resident memory surface"));
|
||||
assert!(!empty_boundary.contains("No generated Memory surface exists yet"));
|
||||
drop(empty_restore);
|
||||
|
||||
for availability in [
|
||||
@@ -12273,7 +12302,22 @@ permission = "write"
|
||||
.as_text()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
assert!(tombstone.contains("No current ready resident memory surface"));
|
||||
let unavailable_message = match availability {
|
||||
memory::backend::MemoryResidentSummaryAvailability::Ungenerated => {
|
||||
"No generated Memory surface exists yet"
|
||||
}
|
||||
memory::backend::MemoryResidentSummaryAvailability::Stale => {
|
||||
"generated Memory surface is stale"
|
||||
}
|
||||
memory::backend::MemoryResidentSummaryAvailability::Failed => {
|
||||
"Generation of the Memory surface failed"
|
||||
}
|
||||
memory::backend::MemoryResidentSummaryAvailability::Unavailable => {
|
||||
"Host could not fetch the Subject resident context"
|
||||
}
|
||||
memory::backend::MemoryResidentSummaryAvailability::Ready => unreachable!(),
|
||||
};
|
||||
assert!(tombstone.contains(unavailable_message));
|
||||
assert!(!tombstone.contains("latest subject surface"));
|
||||
assert!(!tombstone.contains("LEGACY_MEMORY_SENTINEL_MUST_NOT_REFRESH"));
|
||||
let entries = store.read_all(session_id, segment_id).unwrap();
|
||||
@@ -13290,6 +13334,86 @@ permission = "write"
|
||||
assert_eq!(state.pending_notifications[1].provenance, account_b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notification_lost_acceptance_retry_after_restore_commits_once_not_by_body() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let handle = PendingSubmissionHandle::for_test(temp.path());
|
||||
let source = "backend:orchestrator-attention";
|
||||
let accept = |handle: &PendingSubmissionHandle<session_store::FsStore>, id: &str| {
|
||||
handle
|
||||
.accept_notification_from_source(
|
||||
id.into(),
|
||||
"Queued Tickets require attention".into(),
|
||||
source.into(),
|
||||
WorkerHistoryProvenance::BackendInstruction { operation_id: None },
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
// The sender lost this acceptance response; the receipt is already durable.
|
||||
assert!(accept(&handle, "attention-1"));
|
||||
let restored_state: PendingActivationState = handle
|
||||
.persisted_entries_for_test()
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(|entry| match entry {
|
||||
LogEntry::Extension {
|
||||
domain, payload, ..
|
||||
} if domain == SESSION_PENDING_ACTIVATIONS_EXTENSION_DOMAIN => {
|
||||
Some(serde_json::from_value(payload.clone()).unwrap())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(restored_state.notification_receipts.len(), 1);
|
||||
let handle = PendingSubmissionHandle {
|
||||
state: Arc::new(Mutex::new(restored_state)),
|
||||
writer: handle.writer.clone(),
|
||||
};
|
||||
assert!(!accept(&handle, "attention-1"));
|
||||
let commit_batch = |handle: &PendingSubmissionHandle<session_store::FsStore>| {
|
||||
let batch = handle.prepare_notification_batch();
|
||||
let count = batch.len();
|
||||
for (notification, extension) in batch {
|
||||
handle
|
||||
.writer
|
||||
.commit_system_item_with_extensions(
|
||||
SystemItem::Notification {
|
||||
body: notification.message.clone(),
|
||||
message: notification.message,
|
||||
prompt_provenance: None,
|
||||
},
|
||||
vec![extension],
|
||||
Some(notification.provenance),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
handle.finish_notification_batch();
|
||||
count
|
||||
};
|
||||
assert_eq!(commit_batch(&handle), 1);
|
||||
assert!(!accept(&handle, "attention-1"));
|
||||
assert_eq!(commit_batch(&handle), 0);
|
||||
let notification_count = |handle: &PendingSubmissionHandle<session_store::FsStore>| {
|
||||
handle
|
||||
.persisted_entries_for_test()
|
||||
.iter()
|
||||
.filter(|entry| {
|
||||
matches!(entry,
|
||||
LogEntry::AnnotatedSystemItem { entry, .. }
|
||||
if matches!(entry.item, SystemItem::Notification { .. })
|
||||
)
|
||||
})
|
||||
.count()
|
||||
};
|
||||
assert_eq!(notification_count(&handle), 1);
|
||||
assert_eq!(handle.state.lock().unwrap().notification_receipts.len(), 1);
|
||||
// Same body with a new logical ID is legitimate, not content-deduped.
|
||||
assert!(accept(&handle, "attention-2"));
|
||||
assert_eq!(commit_batch(&handle), 1);
|
||||
assert_eq!(notification_count(&handle), 2);
|
||||
assert_eq!(handle.state.lock().unwrap().notification_receipts.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notification_batch_preserves_fifo_order_and_notification_dedupes() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
|
||||
+1306
-116
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,7 @@ use std::collections::HashSet;
|
||||
use chrono::{SecondsFormat, Utc};
|
||||
use memory::extract::{CandidateKind, STAGING_SCHEMA_VERSION, StagingEvidence, StagingRecord};
|
||||
use memory::schema::{SourceEvidenceRef, SourceRef};
|
||||
use rusqlite::{OptionalExtension, Transaction, params};
|
||||
use rusqlite::{Connection, OptionalExtension, Transaction, params};
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -24,6 +24,8 @@ pub const SUBJEKTIV_SCHEMA_VERSION: u32 = 1;
|
||||
pub const SUBJEKTIV_FEATURE_ID: &str = "subjektiv";
|
||||
pub const SUBJECT_WORKER_SINGLETON_PREFIX: &str = "subjektiv:";
|
||||
pub const MAX_STAGING_ANCHORS: usize = 10;
|
||||
/// Maximum UTF-8 size of the user-managed Subject behavior document.
|
||||
pub const MAX_SUBJECT_BEHAVIOR_BYTES: usize = server_api::SUBJEKTIV_MAX_BEHAVIOR_BYTES;
|
||||
/// Deterministic surface-generation policy. Input estimates use the repository's
|
||||
/// provider-independent UTF-8 byte estimate (`ceil(bytes / 4)`). The material
|
||||
/// payload is capped below the whole-input budget to reserve room for the fixed
|
||||
@@ -74,6 +76,14 @@ pub enum SubjektivError {
|
||||
expected: u64,
|
||||
actual: u64,
|
||||
},
|
||||
#[error(
|
||||
"subject `{subject_id}` behavior revision conflict: expected {expected}, current {actual}"
|
||||
)]
|
||||
SubjectBehaviorConflict {
|
||||
subject_id: String,
|
||||
expected: u64,
|
||||
actual: u64,
|
||||
},
|
||||
#[error("surface generation conflict: {0}")]
|
||||
SurfaceGenerationConflict(String),
|
||||
#[error("memory `{memory_id}` cannot transition from {from:?} to {to:?}")]
|
||||
@@ -136,6 +146,14 @@ pub struct SubjectRecord {
|
||||
pub schema_version: u32,
|
||||
pub id: String,
|
||||
pub role: SubjectRole,
|
||||
/// User-managed, host-injected behavior document. This is not Memory and
|
||||
/// Memory lifecycle operations must preserve it byte-for-byte.
|
||||
#[serde(default)]
|
||||
pub behavior_md: String,
|
||||
/// Monotonic CAS generation for behavior updates, independent of Memory's
|
||||
/// `store_revision`.
|
||||
#[serde(default)]
|
||||
pub behavior_revision: u64,
|
||||
pub state: SubjectState,
|
||||
/// Monotonic generation incremented exactly once for every committed Memory
|
||||
/// creation or revision for this subject.
|
||||
@@ -500,6 +518,12 @@ pub struct ResidentSurface {
|
||||
pub snapshot: Option<SurfaceSnapshot>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SubjectResidentContext {
|
||||
pub subject: SubjectRecord,
|
||||
pub surface: ResidentSurface,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum MemoryRevisionTarget {
|
||||
@@ -986,6 +1010,17 @@ END;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn add_subject_behavior(transaction: &Transaction<'_>) -> crate::feature_storage::Result<()> {
|
||||
transaction.execute_batch(
|
||||
r#"
|
||||
ALTER TABLE subjects ADD COLUMN behavior_md TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE subjects ADD COLUMN behavior_revision INTEGER NOT NULL DEFAULT 0
|
||||
CHECK (behavior_revision >= 0);
|
||||
"#,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
static MIGRATIONS: &[FeatureMigration] = &[
|
||||
FeatureMigration::new(1, "create subjektiv subject memory store", create_schema),
|
||||
FeatureMigration::new(
|
||||
@@ -1003,6 +1038,7 @@ static MIGRATIONS: &[FeatureMigration] = &[
|
||||
"add bounded surface generation state and runs",
|
||||
add_surface_generation_state,
|
||||
),
|
||||
FeatureMigration::new(5, "add user-managed subject behavior", add_subject_behavior),
|
||||
];
|
||||
|
||||
pub const REGISTRATION: FeatureRegistration =
|
||||
@@ -1070,11 +1106,23 @@ impl SubjektivStore {
|
||||
/// Issues a random host-owned subject id. It is never derived from Worker,
|
||||
/// Profile, Session, or Memory content.
|
||||
pub fn create_subject(&self, role: SubjectRole) -> Result<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 record = SubjectRecord {
|
||||
schema_version: SUBJEKTIV_SCHEMA_VERSION,
|
||||
id: issued_id("subject"),
|
||||
role,
|
||||
behavior_md,
|
||||
behavior_revision: 0,
|
||||
state: SubjectState::Active,
|
||||
store_revision: 0,
|
||||
created_at: timestamp.clone(),
|
||||
@@ -1084,11 +1132,13 @@ impl SubjektivStore {
|
||||
self.database.try_transaction(|transaction| {
|
||||
transaction.execute(
|
||||
"INSERT INTO subjects (
|
||||
subject_id, role, state, store_revision, record_json, created_at, updated_at
|
||||
) VALUES (?1, ?2, 'active', 0, ?3, ?4, ?5)",
|
||||
subject_id, role, behavior_md, behavior_revision, state,
|
||||
store_revision, record_json, created_at, updated_at
|
||||
) VALUES (?1, ?2, ?3, 0, 'active', 0, ?4, ?5, ?6)",
|
||||
params![
|
||||
record.id,
|
||||
record.role.as_str(),
|
||||
record.behavior_md,
|
||||
raw,
|
||||
record.created_at,
|
||||
record.updated_at
|
||||
@@ -1111,6 +1161,51 @@ impl SubjektivStore {
|
||||
})
|
||||
}
|
||||
|
||||
/// Replaces or clears the user-managed behavior document using its own CAS
|
||||
/// generation. Memory revisions do not participate in this generation.
|
||||
pub fn update_subject_behavior(
|
||||
&self,
|
||||
subject_id: &str,
|
||||
expected_behavior_revision: u64,
|
||||
behavior_md: String,
|
||||
) -> Result<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
|
||||
/// subject store and the already-resolved Profile policy. Subject knowledge
|
||||
/// alone is insufficient: only an active local record produces host launch material.
|
||||
@@ -2021,60 +2116,17 @@ impl SubjektivStore {
|
||||
pub fn resident_surface(&self, subject_id: &str) -> Result<ResidentSurface> {
|
||||
self.database.try_with_connection(|connection| {
|
||||
let subject = require_subject_in_connection(connection, subject_id)?;
|
||||
let state = connection
|
||||
.query_row(
|
||||
"SELECT store_revision, status, snapshot_id
|
||||
FROM surface_generation_state WHERE subject_id = ?1",
|
||||
[subject_id],
|
||||
|row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, Option<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),
|
||||
})
|
||||
resident_surface_in_connection(connection, &subject)
|
||||
})
|
||||
}
|
||||
|
||||
/// Atomically reads user-managed behavior and the independently-fresh
|
||||
/// generated Memory surface from one SQLite connection snapshot.
|
||||
pub fn resident_context(&self, subject_id: &str) -> Result<SubjectResidentContext> {
|
||||
self.database.try_transaction(|transaction| {
|
||||
let subject = require_subject(transaction, subject_id)?;
|
||||
let surface = resident_surface_in_connection(transaction, &subject)?;
|
||||
Ok(SubjectResidentContext { subject, surface })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3414,6 +3466,87 @@ fn validate_range(kind: &str, range: [u64; 2]) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
fn resident_surface_in_connection(
|
||||
connection: &Connection,
|
||||
subject: &SubjectRecord,
|
||||
) -> Result<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<()> {
|
||||
validate_nonempty(kind, value)?;
|
||||
if value.len() > 256 || value.chars().any(char::is_control) {
|
||||
@@ -3668,6 +3801,67 @@ mod tests {
|
||||
assert!(!complete.has_more);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subject_behavior_is_bounded_revisioned_and_independent_from_memory_revision() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (_manager, _workspace, store) = open_store(temp.path(), "workspace-a");
|
||||
let subject = store
|
||||
.create_subject_with_behavior(role(), "Prefer explicit evidence.".to_string())
|
||||
.unwrap();
|
||||
assert_eq!(subject.behavior_revision, 0);
|
||||
assert_eq!(subject.store_revision, 0);
|
||||
|
||||
let updated = store
|
||||
.update_subject_behavior(
|
||||
&subject.id,
|
||||
0,
|
||||
"Prefer explicit evidence.\nAsk when uncertain.".to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(updated.behavior_revision, 1);
|
||||
assert_eq!(updated.store_revision, 0);
|
||||
|
||||
store
|
||||
.create_memory(
|
||||
&subject.id,
|
||||
draft(
|
||||
"A confirmed observation",
|
||||
"exercise Memory lifecycle isolation",
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let after_memory = store.subject(&subject.id).unwrap().unwrap();
|
||||
assert_eq!(after_memory.behavior_md, updated.behavior_md);
|
||||
assert_eq!(after_memory.behavior_revision, 1);
|
||||
assert_eq!(after_memory.store_revision, 1);
|
||||
|
||||
let (_reopened_manager, _reopened_workspace, reopened) =
|
||||
open_store(temp.path(), "workspace-a");
|
||||
let persisted = reopened.subject(&subject.id).unwrap().unwrap();
|
||||
assert_eq!(persisted.behavior_md, updated.behavior_md);
|
||||
assert_eq!(persisted.behavior_revision, 1);
|
||||
assert!(matches!(
|
||||
store.update_subject_behavior(&subject.id, 0, String::new()),
|
||||
Err(SubjektivError::SubjectBehaviorConflict {
|
||||
expected: 0,
|
||||
actual: 1,
|
||||
..
|
||||
})
|
||||
));
|
||||
|
||||
let unchanged = store
|
||||
.update_subject_behavior(&subject.id, 1, updated.behavior_md.clone())
|
||||
.unwrap();
|
||||
assert_eq!(unchanged.behavior_revision, 1);
|
||||
let cleared = store
|
||||
.update_subject_behavior(&subject.id, 1, String::new())
|
||||
.unwrap();
|
||||
assert_eq!(cleared.behavior_revision, 2);
|
||||
assert!(cleared.behavior_md.is_empty());
|
||||
assert!(validate_subject_behavior(&"x".repeat(MAX_SUBJECT_BEHAVIOR_BYTES + 1)).is_err());
|
||||
assert!(validate_subject_behavior(" ").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attributed_candidates_preserve_revision_and_surface_reference_continuity() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
@@ -3892,10 +4086,38 @@ mod tests {
|
||||
))
|
||||
.unwrap();
|
||||
let store = SubjektivStore::open(&workspace, ®istration).unwrap();
|
||||
store.create_subject(role()).unwrap().id
|
||||
let timestamp = now();
|
||||
let subject_id = issued_id("subject");
|
||||
let mut raw = serde_json::json!({
|
||||
"schema_version": SUBJEKTIV_SCHEMA_VERSION,
|
||||
"id": subject_id,
|
||||
"role": role(),
|
||||
"state": SubjectState::Active,
|
||||
"store_revision": 0,
|
||||
"created_at": timestamp.clone(),
|
||||
"updated_at": timestamp.clone(),
|
||||
});
|
||||
let raw = serde_json::to_string(&raw.take()).unwrap();
|
||||
store
|
||||
.database
|
||||
.try_transaction(|transaction| -> Result<()> {
|
||||
transaction.execute(
|
||||
"INSERT INTO subjects (
|
||||
subject_id, role, state, store_revision,
|
||||
record_json, created_at, updated_at
|
||||
) VALUES (?1, ?2, 'active', 0, ?3, ?4, ?5)",
|
||||
params![subject_id, role().as_str(), raw, timestamp, timestamp],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
subject_id
|
||||
};
|
||||
|
||||
let (_manager, _workspace, store) = open_store(&root, "workspace-a");
|
||||
let migrated_subject = store.subject(&subject_id).unwrap().unwrap();
|
||||
assert!(migrated_subject.behavior_md.is_empty());
|
||||
assert_eq!(migrated_subject.behavior_revision, 0);
|
||||
let lifecycle_attribution = attribution(
|
||||
&subject_id,
|
||||
"runtime-1",
|
||||
@@ -3935,26 +4157,50 @@ mod tests {
|
||||
))
|
||||
.unwrap();
|
||||
let store = SubjektivStore::open(&workspace, ®istration).unwrap();
|
||||
let subject = store.create_subject(role()).unwrap();
|
||||
let subject_id = issued_id("subject");
|
||||
let timestamp = now();
|
||||
let subject_json = serde_json::to_string(&serde_json::json!({
|
||||
"schema_version": SUBJEKTIV_SCHEMA_VERSION,
|
||||
"id": subject_id,
|
||||
"role": role(),
|
||||
"state": SubjectState::Active,
|
||||
"store_revision": 0,
|
||||
"created_at": timestamp,
|
||||
"updated_at": timestamp,
|
||||
}))
|
||||
.unwrap();
|
||||
let snapshot = SurfaceSnapshot {
|
||||
schema_version: SUBJEKTIV_SCHEMA_VERSION,
|
||||
id: "legacy-surface".into(),
|
||||
subject_id: subject.id.clone(),
|
||||
subject_id: subject_id.clone(),
|
||||
body_md: "Legacy summary without generation-policy evidence.".into(),
|
||||
memory_refs: Vec::new(),
|
||||
built_from_store_revision: subject.store_revision,
|
||||
built_from_store_revision: 0,
|
||||
created_at: now(),
|
||||
};
|
||||
store
|
||||
.database
|
||||
.try_transaction(|transaction| -> Result<()> {
|
||||
transaction.execute(
|
||||
"INSERT INTO subjects (
|
||||
subject_id, role, state, store_revision,
|
||||
record_json, created_at, updated_at
|
||||
) VALUES (?1, ?2, 'active', 0, ?3, ?4, ?5)",
|
||||
params![
|
||||
subject_id,
|
||||
role().as_str(),
|
||||
subject_json,
|
||||
timestamp,
|
||||
timestamp
|
||||
],
|
||||
)?;
|
||||
transaction.execute(
|
||||
"INSERT INTO surface_snapshots (
|
||||
subject_id, snapshot_id, built_from_store_revision,
|
||||
snapshot_json, created_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![
|
||||
subject.id,
|
||||
subject_id,
|
||||
snapshot.id,
|
||||
to_i64(snapshot.built_from_store_revision)?,
|
||||
serde_json::to_string(&snapshot)?,
|
||||
@@ -3964,12 +4210,12 @@ mod tests {
|
||||
transaction.execute(
|
||||
"INSERT INTO surface_snapshot_seals (subject_id, snapshot_id)
|
||||
VALUES (?1, ?2)",
|
||||
params![subject.id, snapshot.id],
|
||||
params![subject_id, snapshot.id],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
(subject.id, snapshot.id)
|
||||
(subject_id, snapshot.id)
|
||||
};
|
||||
|
||||
let (_manager, _workspace, store) = open_store(&root, "workspace-a");
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# Reviewer verdict のvalidation rejectionとone-call制約
|
||||
|
||||
## 確認した事実
|
||||
|
||||
T-703のMR `01a10b3b-87ec-7093-b0c4-a00a78095695`、source
|
||||
`3174142bd1314fe34c5de7b6a66654ff1c2a8af2` の独立ReviewerはRestoreとno-op
|
||||
attention checkの競合を見つけ、`ReviewMergeRequest`でrequest_changesを提出しようとした。
|
||||
severityに`medium`を指定したためHTTP 422となった。Backendの許容値は
|
||||
`blocker` / `major` / `minor` / `note` だった。
|
||||
|
||||
親の`ShowMergeRequest`再読でthreadはReviewRequested seq2までであり、verdictが
|
||||
永続化されていないことを確認した。Reviewerは「ReviewMergeRequestを正確に一度だけ
|
||||
呼ぶ」という指示に従い、validation rejection後も再提出を行わなかった。親のfollow-upでも
|
||||
同じ境界を報告したため、再提出を強制せずReviewerを停止した。
|
||||
|
||||
## 障壁
|
||||
|
||||
正しいBackend拒否を迂回すべきではない一方、mutationが行われていない入力validation失敗が
|
||||
一回限りの提出枠を消費する扱いだと、レビュー済みの指摘がMR authorityに残らずpendingになる。
|
||||
セッションでの報告をverdictや承認の代わりにはできない。
|
||||
|
||||
## 改善案(このTicketの実装対象ではない)
|
||||
|
||||
- Reviewer-facing tool schemaにseverityのBackend enumを明示し、不正値を入力時に防ぐ。
|
||||
- one-call指示について「一度のdurable verdict」と「validationで拒否された呼出」を区別できるか
|
||||
設計上判断する。無制限再提出や記録済みverdictの変更を認める提案ではない。
|
||||
- mutationなし/retry可否をtyped errorとして返せると、親とReviewerの双方が推測なしに扱える。
|
||||
|
||||
T-703では元のMR/source selectorを保持し、指摘を修正したcommitを通常pushした後、fresh
|
||||
exact-source reviewで修正内容とその証拠をMRに記録する。未記録のverdictを承認扱いしない。
|
||||
+152
-1
@@ -9315,9 +9315,32 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"SubjektivSubjectBehaviorUpdateRequest": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"behavior_md": {
|
||||
"type": "string"
|
||||
},
|
||||
"expected_behavior_revision": {
|
||||
"format": "uint64",
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"expected_behavior_revision",
|
||||
"behavior_md"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"SubjektivSubjectCreateRequest": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"behavior_md": {
|
||||
"default": "",
|
||||
"type": "string"
|
||||
},
|
||||
"role": {
|
||||
"type": "string"
|
||||
}
|
||||
@@ -9383,6 +9406,15 @@
|
||||
"SubjektivSubjectResponse": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"behavior_md": {
|
||||
"type": "string"
|
||||
},
|
||||
"behavior_revision": {
|
||||
"format": "uint64",
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -9419,6 +9451,8 @@
|
||||
"required": [
|
||||
"id",
|
||||
"role",
|
||||
"behavior_md",
|
||||
"behavior_revision",
|
||||
"state",
|
||||
"store_revision",
|
||||
"created_at",
|
||||
@@ -14794,7 +14828,7 @@
|
||||
"info": {
|
||||
"title": "Yoi Server API",
|
||||
"version": "0.1.0",
|
||||
"x-yoi-source-digest": "sha256:4dc4cdeb9c6f3830b9797af0dc286a1baa6b07d9ed0bf1e576712cd155582ece"
|
||||
"x-yoi-source-digest": "sha256:f09c555159412f3e5ec7328317cc99b9d3916f6e8b9d78235f16a6b3f7e2eab0"
|
||||
},
|
||||
"openapi": "3.1.0",
|
||||
"paths": {
|
||||
@@ -29131,6 +29165,123 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/behavior": {
|
||||
"patch": {
|
||||
"operationId": "subjektiv_subject_behavior_update",
|
||||
"parameters": [
|
||||
{
|
||||
"explode": false,
|
||||
"in": "path",
|
||||
"name": "workspace_id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/string"
|
||||
},
|
||||
"style": "simple"
|
||||
},
|
||||
{
|
||||
"explode": false,
|
||||
"in": "path",
|
||||
"name": "subject_id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/string"
|
||||
},
|
||||
"style": "simple"
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SubjektivSubjectBehaviorUpdateRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SubjektivSubjectResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful response"
|
||||
},
|
||||
"400": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RepositoryApiError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Error response"
|
||||
},
|
||||
"401": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RepositoryApiError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Error response"
|
||||
},
|
||||
"403": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RepositoryApiError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Error response"
|
||||
},
|
||||
"404": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RepositoryApiError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Error response"
|
||||
},
|
||||
"409": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RepositoryApiError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Error response"
|
||||
},
|
||||
"500": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RepositoryApiError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Error response"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
},
|
||||
{
|
||||
"browserSession": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/memories": {
|
||||
"get": {
|
||||
"operationId": "subjektiv_memory_list",
|
||||
|
||||
@@ -34,6 +34,7 @@ internalWorkingBoundariesSection = import "./internal/working_boundaries_section
|
||||
internalAgentsMdSection = import "./internal/agents_md_section.md";
|
||||
internalResidentMemorySummarySection = import "./internal/resident_memory_summary_section.md";
|
||||
internalResidentMemoryRestoreSection = import "./internal/resident_memory_restore_section.md";
|
||||
internalSubjektivResidentContext = import "./internal/subjektiv_resident_context.md";
|
||||
internalSubWorkerSpawnToolDescription = import "./internal/sub_worker_spawn_tool_description.md";
|
||||
panelOrchestratorIdleQueueNotice = import "./panel/orchestrator_idle_queue_notice.md";
|
||||
in
|
||||
@@ -74,6 +75,7 @@ in
|
||||
agents_md_section = internalAgentsMdSection.content;
|
||||
resident_memory_summary_section = internalResidentMemorySummarySection.content;
|
||||
resident_memory_restore_section = internalResidentMemoryRestoreSection.content;
|
||||
subjektiv_resident_context = internalSubjektivResidentContext.content;
|
||||
worker_orchestration_guidance_section = commonWorkerOrchestration.content;
|
||||
sub_worker_spawn_tool_description = internalSubWorkerSpawnToolDescription.content;
|
||||
};
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
This restore-boundary record supersedes every resident memory summary embedded earlier in the system prompt or session history. Treat only the current state below as resident context; it is background context, not a user request.
|
||||
|
||||
{% if surface_ready %}
|
||||
### Current ready surface
|
||||
### Current resident context
|
||||
|
||||
{% if summary %}
|
||||
{{ summary }}
|
||||
{% else %}
|
||||
The current ready resident memory surface is intentionally empty.
|
||||
The current resident context is intentionally empty.
|
||||
{% endif %}
|
||||
{% else %}
|
||||
No current ready resident memory surface is available. Ignore earlier resident memory summaries; confirmed Memory remains available through explicit recall tools.
|
||||
|
||||
@@ -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",
|
||||
"active",
|
||||
42,
|
||||
"Prefer explicit evidence over assumptions.\nAsk before irreversible actions, and state uncertainty plainly.",
|
||||
3,
|
||||
connectedWorker("Release coordination Worker with a deliberately long display name"),
|
||||
),
|
||||
subject(emptySubjectId, "Empty ready subject", "active", 0),
|
||||
@@ -110,11 +112,15 @@ function subject(
|
||||
role: string,
|
||||
state: "active" | "retired",
|
||||
storeRevision: number,
|
||||
behaviorMd = "",
|
||||
behaviorRevision = 0,
|
||||
currentWorker?: ReturnType<typeof connectedWorker>,
|
||||
) {
|
||||
return {
|
||||
id,
|
||||
role,
|
||||
behavior_md: behaviorMd,
|
||||
behavior_revision: behaviorRevision,
|
||||
state,
|
||||
store_revision: storeRevision,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
@@ -440,7 +446,10 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
|
||||
const subjectsPath = `/api/w/${workspaceId}/subjektiv/subjects`;
|
||||
if (url.pathname === subjectsPath) {
|
||||
if (request.method === "POST") {
|
||||
const payload = await request.json().catch(() => null) as { role?: unknown } | null;
|
||||
const payload = await request.json().catch(() => null) as {
|
||||
role?: unknown;
|
||||
behavior_md?: unknown;
|
||||
} | null;
|
||||
subjectCreateRequests.push(payload);
|
||||
if (!payload || typeof payload.role !== "string" || payload.role.trim().length === 0) {
|
||||
return json({ error: "Bad Request", message: "subject role must not be empty" }, 400);
|
||||
@@ -448,9 +457,12 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
|
||||
if (payload.role === "Permission denied") {
|
||||
return json({ error: "Forbidden", message: "workspace permission denied" }, 403);
|
||||
}
|
||||
if (typeof payload.behavior_md !== "string") {
|
||||
return json({ error: "Bad Request", message: "subject behavior must be a string" }, 400);
|
||||
}
|
||||
createdSubjectCount += 1;
|
||||
const id = `created-subject-${String(createdSubjectCount).padStart(4, "0")}`;
|
||||
const created = subject(id, payload.role, "active", 0);
|
||||
const created = subject(id, payload.role, "active", 0, payload.behavior_md);
|
||||
createdSubjectIds.add(id);
|
||||
subjects.unshift(created);
|
||||
return json(created, 201);
|
||||
@@ -479,6 +491,27 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
|
||||
const foundSubject = subjects.find((item) => item.id === subjectId);
|
||||
if (!foundSubject) return json({}, 404);
|
||||
|
||||
if (parts.length === 2 && parts[1] === "behavior" && request.method === "PATCH") {
|
||||
const payload = await request.json().catch(() => null) as {
|
||||
expected_behavior_revision?: unknown;
|
||||
behavior_md?: unknown;
|
||||
} | null;
|
||||
if (
|
||||
!payload ||
|
||||
typeof payload.expected_behavior_revision !== "number" ||
|
||||
typeof payload.behavior_md !== "string"
|
||||
) {
|
||||
return json({ error: "Bad Request", message: "invalid behavior update" }, 400);
|
||||
}
|
||||
if (payload.expected_behavior_revision !== foundSubject.behavior_revision) {
|
||||
return json({ error: "Conflict", message: "subject behavior revision conflict" }, 409);
|
||||
}
|
||||
foundSubject.behavior_md = payload.behavior_md;
|
||||
foundSubject.behavior_revision += 1;
|
||||
foundSubject.updated_at = "2026-01-03T04:05:06Z";
|
||||
return json(foundSubject);
|
||||
}
|
||||
if (request.method !== "GET") return new Response("method not allowed", { status: 405 });
|
||||
if (parts.length === 1) return json(foundSubject);
|
||||
if (parts.length === 2 && parts[1] === "surface") return json(surfaceFor(subjectId));
|
||||
if (parts.length === 2 && parts[1] === "memories") {
|
||||
|
||||
@@ -111,6 +111,7 @@ async function openSubjectPage(
|
||||
|
||||
await hideSidebar.click();
|
||||
await showSidebar.waitFor();
|
||||
await page.waitForTimeout(250);
|
||||
assertEquals(await showSidebar.getAttribute("aria-expanded"), "false");
|
||||
assertEquals(
|
||||
await page.locator("main").evaluate((main) => (main as unknown as { inert: boolean }).inert),
|
||||
@@ -217,6 +218,13 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco
|
||||
});
|
||||
page.on("pageerror", (error) => errors.push(String(error)));
|
||||
await openSubjectPage(page, baseUrl, scenario.mobile);
|
||||
assertEquals(
|
||||
await page.getByRole("article", { name: "User-managed Subject behavior" }).count(),
|
||||
1,
|
||||
);
|
||||
await page.screenshot({
|
||||
path: join(createInteractionArtifacts, `subject-${scenario.label}.png`),
|
||||
});
|
||||
|
||||
const initial = await mainScrollState(page);
|
||||
assert(initial.scrollHeight > initial.clientHeight);
|
||||
@@ -434,7 +442,9 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco
|
||||
const createPage = await createContext.newPage();
|
||||
const mutationPaths: string[] = [];
|
||||
createPage.on("request", (request) => {
|
||||
if (request.method() === "POST") mutationPaths.push(new URL(request.url()).pathname);
|
||||
if (["POST", "PATCH"].includes(request.method())) {
|
||||
mutationPaths.push(new URL(request.url()).pathname);
|
||||
}
|
||||
});
|
||||
await createPage.goto(`${baseUrl}/w/${workspaceId}/memory?cursor=${subjectPageCursor}`);
|
||||
await createPage.getByText("Subject on the next page").waitFor();
|
||||
@@ -453,9 +463,21 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco
|
||||
await createPage.getByRole("button", { name: "Cancel" }).click();
|
||||
await createPage.getByRole("button", { name: "New Subject" }).click();
|
||||
await createPage.getByRole("textbox", { name: "Role" }).fill(" Release steward ");
|
||||
await createPage.getByRole("textbox", { name: /Behavior/ }).fill(
|
||||
"Challenge unsupported assumptions before release.",
|
||||
);
|
||||
await createPage.getByRole("button", { name: "Create Subject" }).click();
|
||||
await createPage.waitForURL("**/memory/created-subject-0001");
|
||||
await createPage.getByRole("heading", { name: "Release steward", level: 1 }).waitFor();
|
||||
assertEquals(
|
||||
await createPage.getByRole("article", { name: "User-managed Subject behavior" }).count(),
|
||||
1,
|
||||
);
|
||||
assert(
|
||||
(await createPage.getByRole("article", {
|
||||
name: "User-managed Subject behavior",
|
||||
}).textContent())?.includes("Challenge unsupported assumptions before release."),
|
||||
);
|
||||
assertEquals(
|
||||
await createPage.locator("code.subject-id", { hasText: "created-subject-0001" }).count(),
|
||||
1,
|
||||
@@ -466,15 +488,40 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco
|
||||
assert((await currentWorker.textContent())?.includes("Not connected"));
|
||||
await createPage.getByText("Resident context has not been generated.").waitFor();
|
||||
await createPage.getByText("No committed Memories yet.").waitFor();
|
||||
await createPage.getByRole("button", { name: "Edit" }).click();
|
||||
const behaviorEditor = createPage.getByRole("textbox", { name: "Subject behavior" });
|
||||
await behaviorEditor.fill(
|
||||
"Challenge unsupported assumptions before release.\nName the evidence and any remaining uncertainty.",
|
||||
);
|
||||
await createPage.screenshot({
|
||||
path: join(createInteractionArtifacts, "behavior-edit-desktop-light.png"),
|
||||
});
|
||||
await createPage.getByRole("button", { name: "Save behavior" }).click();
|
||||
await createPage.getByText(/Behavior storage confirmed at revision 1/).waitFor();
|
||||
assert(
|
||||
(await createPage.getByRole("status").filter({
|
||||
hasText: "This page does not confirm application",
|
||||
}).textContent())?.includes("does not confirm application"),
|
||||
);
|
||||
await createPage.screenshot({
|
||||
path: join(createInteractionArtifacts, "behavior-saved-desktop-light.png"),
|
||||
});
|
||||
assertEquals(mutationPaths, [
|
||||
`/api/w/${workspaceId}/subjektiv/subjects`,
|
||||
`/api/w/${workspaceId}/subjektiv/subjects`,
|
||||
`/api/w/${workspaceId}/subjektiv/subjects/created-subject-0001/behavior`,
|
||||
]);
|
||||
const createEvidence = await (await fetch(`${baseUrl}/fixture/subject-create-requests`))
|
||||
.json();
|
||||
assertEquals(createEvidence, {
|
||||
count: 2,
|
||||
requests: [{ role: "Permission denied" }, { role: " Release steward " }],
|
||||
requests: [
|
||||
{ role: "Permission denied", behavior_md: "" },
|
||||
{
|
||||
role: " Release steward ",
|
||||
behavior_md: "Challenge unsupported assumptions before release.",
|
||||
},
|
||||
],
|
||||
});
|
||||
await assertNoPageWideOverflow(createPage);
|
||||
await createContext.close();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:4dc4cdeb9c6f3830b9797af0dc286a1baa6b07d9ed0bf1e576712cd155582ece)
|
||||
// Input contract: 0.1.0 (sha256:f09c555159412f3e5ec7328317cc99b9d3916f6e8b9d78235f16a6b3f7e2eab0)
|
||||
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/companion-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
|
||||
|
||||
@@ -9,6 +9,10 @@ export const MEMORY_API_LIMITS = {
|
||||
maxIdentifierBytes: 512,
|
||||
} as const;
|
||||
|
||||
export const SUBJEKTIV_API_LIMITS = {
|
||||
maxBehaviorBytes: 16384,
|
||||
} as const;
|
||||
|
||||
export type DiagnosticSeverity = "info" | "warning" | "error";
|
||||
|
||||
export type Diagnostic = { code: string, severity: DiagnosticSeverity, message: string, };
|
||||
@@ -35,9 +39,11 @@ export type MemoryStagingListResponse = { limit: number, returned_count: number,
|
||||
|
||||
export type SubjektivSubjectState = "active" | "retired";
|
||||
|
||||
export type SubjektivSubjectCreateRequest = { role: string, };
|
||||
export type SubjektivSubjectCreateRequest = { role: string, behavior_md?: string, };
|
||||
|
||||
export type SubjektivSubjectResponse = { id: string, role: string, state: SubjektivSubjectState, store_revision: number, created_at: string, updated_at: string, current_worker?: | import("./worker-launch-api").WorkerLaunchWorkerSummary | null, };
|
||||
export type SubjektivSubjectBehaviorUpdateRequest = { expected_behavior_revision: number, behavior_md: string, };
|
||||
|
||||
export type SubjektivSubjectResponse = { id: string, role: string, behavior_md: string, behavior_revision: number, state: SubjektivSubjectState, store_revision: number, created_at: string, updated_at: string, current_worker?: | import("./worker-launch-api").WorkerLaunchWorkerSummary | null, };
|
||||
|
||||
export type SubjektivSubjectListResponse = { limit: number, items: Array<SubjektivSubjectResponse>, next_cursor?: string | null, has_more: boolean, };
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:4dc4cdeb9c6f3830b9797af0dc286a1baa6b07d9ed0bf1e576712cd155582ece)
|
||||
// Input contract: 0.1.0 (sha256:f09c555159412f3e5ec7328317cc99b9d3916f6e8b9d78235f16a6b3f7e2eab0)
|
||||
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/repository-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:4dc4cdeb9c6f3830b9797af0dc286a1baa6b07d9ed0bf1e576712cd155582ece)
|
||||
// Input contract: 0.1.0 (sha256:f09c555159412f3e5ec7328317cc99b9d3916f6e8b9d78235f16a6b3f7e2eab0)
|
||||
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/runtime-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:4dc4cdeb9c6f3830b9797af0dc286a1baa6b07d9ed0bf1e576712cd155582ece)
|
||||
// Input contract: 0.1.0 (sha256:f09c555159412f3e5ec7328317cc99b9d3916f6e8b9d78235f16a6b3f7e2eab0)
|
||||
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/ticket-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-workdir-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:4dc4cdeb9c6f3830b9797af0dc286a1baa6b07d9ed0bf1e576712cd155582ece)
|
||||
// Input contract: 0.1.0 (sha256:f09c555159412f3e5ec7328317cc99b9d3916f6e8b9d78235f16a6b3f7e2eab0)
|
||||
// Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/workdir-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_workdir_api_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:4dc4cdeb9c6f3830b9797af0dc286a1baa6b07d9ed0bf1e576712cd155582ece)
|
||||
// Input contract: 0.1.0 (sha256:f09c555159412f3e5ec7328317cc99b9d3916f6e8b9d78235f16a6b3f7e2eab0)
|
||||
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { MEMORY_API_LIMITS } from "#lib/generated/memory-api.ts";
|
||||
import {
|
||||
MEMORY_API_LIMITS,
|
||||
SUBJEKTIV_API_LIMITS,
|
||||
} from "#lib/generated/memory-api.ts";
|
||||
import { readBoundedJson, workspaceApiPath } from "#lib/workspace/api/http.ts";
|
||||
import type {
|
||||
Diagnostic,
|
||||
@@ -26,6 +29,7 @@ import type {
|
||||
SubjektivResidentSurfaceAvailability,
|
||||
SubjektivResidentSurfaceResponse,
|
||||
SubjektivResidentSurfaceSnapshot,
|
||||
SubjektivSubjectBehaviorUpdateRequest,
|
||||
SubjektivSubjectCreateRequest,
|
||||
SubjektivSubjectListResponse,
|
||||
SubjektivSubjectResponse,
|
||||
@@ -84,6 +88,7 @@ export const MEMORY_API_LOAD_POLICY = {
|
||||
} as const;
|
||||
|
||||
const MAX_SUBJECT_ROLE_BYTES = 256;
|
||||
export const MAX_SUBJECT_BEHAVIOR_BYTES = SUBJEKTIV_API_LIMITS.maxBehaviorBytes;
|
||||
const MAX_SUBJECT_CREATE_ERROR_BYTES = 1024 * 1024;
|
||||
|
||||
export type SubjektivSubjectCreateErrorKind =
|
||||
@@ -126,7 +131,31 @@ export function validateSubjektivSubjectCreateRequest(
|
||||
"Subject role must be at most 256 bytes and contain no control characters.",
|
||||
);
|
||||
}
|
||||
return { role: request.role };
|
||||
const behaviorMd = request.behavior_md ?? "";
|
||||
validateSubjectBehavior(behaviorMd);
|
||||
return { role: request.role, behavior_md: behaviorMd };
|
||||
}
|
||||
|
||||
function validateSubjectBehavior(behaviorMd: string): void {
|
||||
const bytes = new TextEncoder().encode(behaviorMd).byteLength;
|
||||
if (bytes > MAX_SUBJECT_BEHAVIOR_BYTES) {
|
||||
throw new SubjektivSubjectCreateError(
|
||||
"validation",
|
||||
`Subject behavior must be at most ${MAX_SUBJECT_BEHAVIOR_BYTES} bytes.`,
|
||||
);
|
||||
}
|
||||
if (behaviorMd.length > 0 && behaviorMd.trim().length === 0) {
|
||||
throw new SubjektivSubjectCreateError(
|
||||
"validation",
|
||||
"Subject behavior must be empty or contain non-whitespace text.",
|
||||
);
|
||||
}
|
||||
if (/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/u.test(behaviorMd)) {
|
||||
throw new SubjektivSubjectCreateError(
|
||||
"validation",
|
||||
"Subject behavior contains an unsupported control character.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createSubjektivSubject(
|
||||
@@ -170,8 +199,11 @@ export async function createSubjektivSubject(
|
||||
const subject = parseSubjektivSubjectResponse(
|
||||
await readBoundedJson(response, MEMORY_API_LIMITS.maxResponseBytes),
|
||||
);
|
||||
if (subject.role !== validated.role) {
|
||||
throw new Error("created Subject role does not match the request");
|
||||
if (
|
||||
subject.role !== validated.role ||
|
||||
subject.behavior_md !== validated.behavior_md
|
||||
) {
|
||||
throw new Error("created Subject does not match the request");
|
||||
}
|
||||
return subject;
|
||||
} catch {
|
||||
@@ -182,6 +214,68 @@ export async function createSubjektivSubject(
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateSubjektivSubjectBehavior(
|
||||
fetchFn: typeof fetch,
|
||||
workspaceId: string,
|
||||
subjectId: string,
|
||||
request: SubjektivSubjectBehaviorUpdateRequest,
|
||||
): Promise<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(
|
||||
response: Response,
|
||||
): Promise<string> {
|
||||
@@ -202,7 +296,7 @@ async function subjektivSubjectCreateErrorMessage(
|
||||
} catch {
|
||||
// A rejected request still has a known outcome even when its body is invalid.
|
||||
}
|
||||
return `Subject creation failed with HTTP ${response.status}.`;
|
||||
return `Subject request failed with HTTP ${response.status}.`;
|
||||
}
|
||||
|
||||
export function parseMemoryDocumentResponse(
|
||||
@@ -454,6 +548,8 @@ export function parseSubjektivSubjectResponse(
|
||||
[
|
||||
"id",
|
||||
"role",
|
||||
"behavior_md",
|
||||
"behavior_revision",
|
||||
"state",
|
||||
"store_revision",
|
||||
"created_at",
|
||||
@@ -472,6 +568,12 @@ export function parseSubjektivSubjectResponse(
|
||||
const result: SubjektivSubjectResponse = {
|
||||
id,
|
||||
role: requiredString(record, "role"),
|
||||
behavior_md: requiredString(
|
||||
record,
|
||||
"behavior_md",
|
||||
MAX_SUBJECT_BEHAVIOR_BYTES,
|
||||
),
|
||||
behavior_revision: requiredNonNegativeInteger(record, "behavior_revision"),
|
||||
state,
|
||||
store_revision: requiredNonNegativeInteger(record, "store_revision"),
|
||||
created_at: requiredString(record, "created_at"),
|
||||
|
||||
@@ -14,13 +14,16 @@
|
||||
const subjects = $derived(data.subjects.data?.items ?? []);
|
||||
let createExpanded = $state(false);
|
||||
let role = $state('');
|
||||
let behaviorMd = $state('');
|
||||
let submitting = $state(false);
|
||||
let createError = $state<string | null>(null);
|
||||
let roleInvalid = $state(false);
|
||||
let behaviorInvalid = $state(false);
|
||||
let createOutcomeUnknown = $state(false);
|
||||
let createdSubject = $state<SubjektivSubjectResponse | null>(null);
|
||||
let createButton = $state<HTMLButtonElement>();
|
||||
let roleInput = $state<HTMLInputElement>();
|
||||
let behaviorInput = $state<HTMLTextAreaElement>();
|
||||
|
||||
function subjectHref(subjectId: string): string {
|
||||
return workspaceRoute(data.workspaceId, `/memory/${encodeURIComponent(subjectId)}`);
|
||||
@@ -39,6 +42,7 @@
|
||||
createExpanded = true;
|
||||
createError = null;
|
||||
roleInvalid = false;
|
||||
behaviorInvalid = false;
|
||||
createOutcomeUnknown = false;
|
||||
createdSubject = null;
|
||||
await tick();
|
||||
@@ -49,8 +53,10 @@
|
||||
if (submitting) return;
|
||||
createExpanded = false;
|
||||
role = '';
|
||||
behaviorMd = '';
|
||||
createError = null;
|
||||
roleInvalid = false;
|
||||
behaviorInvalid = false;
|
||||
createOutcomeUnknown = false;
|
||||
createdSubject = null;
|
||||
await tick();
|
||||
@@ -62,9 +68,13 @@
|
||||
submitting = true;
|
||||
createError = null;
|
||||
roleInvalid = false;
|
||||
behaviorInvalid = false;
|
||||
createOutcomeUnknown = false;
|
||||
try {
|
||||
const subject = await createSubjektivSubject(fetch, data.workspaceId, { role });
|
||||
const subject = await createSubjektivSubject(fetch, data.workspaceId, {
|
||||
role,
|
||||
behavior_md: behaviorMd,
|
||||
});
|
||||
createdSubject = subject;
|
||||
try {
|
||||
await goto(subjectHref(subject.id));
|
||||
@@ -73,14 +83,16 @@
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof SubjektivSubjectCreateError) {
|
||||
roleInvalid = error.kind === 'validation';
|
||||
roleInvalid = error.kind === 'validation' && error.message.startsWith('Subject role');
|
||||
behaviorInvalid = error.kind === 'validation' && error.message.startsWith('Subject behavior');
|
||||
createOutcomeUnknown = error.kind === 'unknown_outcome';
|
||||
createError = error.status === 401 || error.status === 403
|
||||
? 'You do not have permission to create Subjects in this Workspace.'
|
||||
: error.message;
|
||||
if (roleInvalid) {
|
||||
if (roleInvalid || behaviorInvalid) {
|
||||
await tick();
|
||||
roleInput?.focus();
|
||||
if (behaviorInvalid) behaviorInput?.focus();
|
||||
else roleInput?.focus();
|
||||
}
|
||||
} else {
|
||||
createOutcomeUnknown = true;
|
||||
@@ -149,9 +161,22 @@
|
||||
disabled={submitting || createdSubject !== null}
|
||||
onkeydown={createFormKeydown}
|
||||
aria-invalid={roleInvalid ? 'true' : undefined}
|
||||
aria-describedby={createError ? 'subject-role-help subject-create-error' : 'subject-role-help'}
|
||||
aria-describedby={createError && roleInvalid ? 'subject-role-help subject-create-error' : 'subject-role-help'}
|
||||
/>
|
||||
</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}
|
||||
<div
|
||||
id="subject-create-error"
|
||||
@@ -318,20 +343,25 @@
|
||||
}
|
||||
|
||||
.subject-create-actions {
|
||||
grid-column: 3;
|
||||
grid-row: 1;
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
.subject-create-panel {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(14rem, 0.8fr) minmax(18rem, 1fr) auto;
|
||||
align-items: end;
|
||||
grid-template-columns: minmax(14rem, 0.7fr) minmax(18rem, 1.3fr);
|
||||
align-items: start;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-4);
|
||||
background: var(--bg-raised);
|
||||
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 p,
|
||||
.subject-create-error p {
|
||||
@@ -353,7 +383,8 @@
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.subject-role-field input {
|
||||
.subject-role-field input,
|
||||
.subject-role-field textarea {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--line);
|
||||
@@ -363,7 +394,15 @@
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -496,6 +535,7 @@
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.subject-create-copy,
|
||||
.subject-create-error,
|
||||
.subject-create-actions {
|
||||
grid-column: 1;
|
||||
|
||||
@@ -1,14 +1,91 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import DocumentMarkdown from '#lib/workspace/markdown/DocumentMarkdown.svelte';
|
||||
import { formatDate, workspaceRoute } from '#lib/workspace/api/http.ts';
|
||||
import type {
|
||||
SubjektivMemoryState,
|
||||
SubjektivResidentSurfaceAvailability,
|
||||
} from '#lib/generated/memory-api.ts';
|
||||
import {
|
||||
SubjektivSubjectCreateError,
|
||||
updateSubjektivSubjectBehavior,
|
||||
} from '#lib/workspace/memory/api.ts';
|
||||
import type { SubjektivMemoryState, SubjektivResidentSurfaceAvailability } from '#lib/generated/memory-api.ts';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
|
||||
let subject = $state(untrack(() => data.subject.data));
|
||||
let editingBehavior = $state(false);
|
||||
let behaviorDraft = $state(untrack(() => subject?.behavior_md ?? ''));
|
||||
let behaviorSaving = $state(false);
|
||||
let behaviorError = $state<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 snapshot = $derived(surface?.snapshot ?? null);
|
||||
const memories = $derived(data.memories.data?.items ?? []);
|
||||
@@ -51,7 +128,7 @@
|
||||
if (availability !== 'ready') return 'No current generated context is available to use.';
|
||||
return snapshot?.body_md.trim()
|
||||
? 'Current generated context is available.'
|
||||
: 'A current empty surface exists; there is no resident context to show.';
|
||||
: 'A current empty surface exists; there is no generated Memory context to show.';
|
||||
}
|
||||
|
||||
function subjectStateLabel(state: 'active' | 'retired'): string {
|
||||
@@ -60,7 +137,7 @@
|
||||
</script>
|
||||
|
||||
<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" />
|
||||
</svelte:head>
|
||||
|
||||
@@ -70,23 +147,23 @@
|
||||
<header class="memory-page-header">
|
||||
<div>
|
||||
<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>
|
||||
</div>
|
||||
<span class="read-only-label">Read-only</span>
|
||||
<span class="read-only-label">Behavior editable</span>
|
||||
</header>
|
||||
|
||||
{#if data.subject.data}
|
||||
{#if subject}
|
||||
<section class="subject-overview" aria-label="Subject status">
|
||||
<div>
|
||||
<span>Subject status</span>
|
||||
<strong class="subject-state is-{data.subject.data.state}">{subjectStateLabel(data.subject.data.state)}</strong>
|
||||
<p>{data.subject.data.state === 'active' ? 'Available for new Worker connections.' : 'No longer available for new Worker connections.'}</p>
|
||||
<strong class="subject-state is-{subject.state}">{subjectStateLabel(subject.state)}</strong>
|
||||
<p>{subject.state === 'active' ? 'Available for new Worker connections.' : 'No longer available for new Worker connections.'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span>Worker connection</span>
|
||||
<strong>{data.subject.data.current_worker ? 'Connected' : 'Not connected'}</strong>
|
||||
<p>{data.subject.data.current_worker?.display_name ?? 'No Worker currently owns this Subject connection.'}</p>
|
||||
<strong>{subject.current_worker ? 'Connected' : 'Not connected'}</strong>
|
||||
<p>{subject.current_worker?.display_name ?? 'No Worker currently owns this Subject connection.'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span>Committed Memories</span>
|
||||
@@ -99,7 +176,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
<span>Resident context</span>
|
||||
<span>Generated Memory context</span>
|
||||
{#if surface}
|
||||
<strong class="availability is-{surface.availability}">{surfaceLabel(surface.availability)}</strong>
|
||||
<p>{surfaceSummary(surface.availability)}</p>
|
||||
@@ -113,12 +190,12 @@
|
||||
<details class="subject-technical-details">
|
||||
<summary>Technical details</summary>
|
||||
<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>
|
||||
<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><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>
|
||||
</details>
|
||||
{:else if data.subject.error}
|
||||
@@ -130,6 +207,40 @@
|
||||
<div class="memory-state" role="status"><p>Subject details are unavailable.</p></div>
|
||||
{/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">
|
||||
<header class="section-heading">
|
||||
<div>
|
||||
@@ -434,6 +545,7 @@
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.behavior-section,
|
||||
.surface-section,
|
||||
.memories-section {
|
||||
display: grid;
|
||||
@@ -442,6 +554,78 @@
|
||||
padding-top: var(--space-3);
|
||||
}
|
||||
|
||||
.behavior-help,
|
||||
.behavior-saved,
|
||||
.behavior-error {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-compact);
|
||||
}
|
||||
|
||||
.behavior-error { color: var(--danger); }
|
||||
.behavior-saved { color: var(--success); }
|
||||
|
||||
.behavior-form,
|
||||
.behavior-form label {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.behavior-form textarea {
|
||||
width: 100%;
|
||||
min-height: 10rem;
|
||||
padding: var(--space-3);
|
||||
color: var(--text);
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
font: inherit;
|
||||
line-height: 1.5;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.behavior-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.behavior-edit,
|
||||
.behavior-actions button {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
color: var(--text);
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.behavior-actions button:disabled { cursor: not-allowed; opacity: 0.55; }
|
||||
|
||||
.behavior-document pre {
|
||||
margin: 0;
|
||||
padding: var(--space-4);
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
font: inherit;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.surface-meta,
|
||||
.pill-row {
|
||||
display: flex;
|
||||
|
||||
@@ -34,6 +34,8 @@ function subject() {
|
||||
return {
|
||||
id: "subject-1",
|
||||
role: "Release coordinator",
|
||||
behavior_md: "Prefer explicit evidence.",
|
||||
behavior_revision: 2,
|
||||
state: "active" as const,
|
||||
store_revision: 9,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
@@ -99,6 +101,12 @@ test("creates one Subject with the exact role and navigates to its generated det
|
||||
}) as HTMLInputElement;
|
||||
await waitFor(() => expect(document.activeElement).toBe(roleInput));
|
||||
await fireEvent.input(roleInput, { target: { value: " Review lead " } });
|
||||
const behaviorInput = screen.getByRole("textbox", {
|
||||
name: /Behavior/,
|
||||
}) as HTMLTextAreaElement;
|
||||
await fireEvent.input(behaviorInput, {
|
||||
target: { value: "Challenge unsupported assumptions." },
|
||||
});
|
||||
|
||||
const submit = screen.getByRole("button", { name: "Create Subject" });
|
||||
await fireEvent.click(submit);
|
||||
@@ -113,6 +121,8 @@ test("creates one Subject with the exact role and navigates to its generated det
|
||||
resolveRequest(Response.json({
|
||||
id: "generated-subject-1",
|
||||
role: " Review lead ",
|
||||
behavior_md: "Challenge unsupported assumptions.",
|
||||
behavior_revision: 0,
|
||||
state: "active",
|
||||
store_revision: 0,
|
||||
created_at: "2026-01-03T00:00:00Z",
|
||||
@@ -130,7 +140,9 @@ test("creates one Subject with the exact role and navigates to its generated det
|
||||
];
|
||||
expect(path).toBe("/api/w/workspace-1/subjektiv/subjects");
|
||||
expect(init.method).toBe("POST");
|
||||
expect(init.body).toBe('{"role":" Review lead "}');
|
||||
expect(init.body).toBe(
|
||||
'{"role":" Review lead ","behavior_md":"Challenge unsupported assumptions."}',
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps creation available for an empty list and restores focus on cancel", async () => {
|
||||
@@ -149,7 +161,7 @@ test("keeps creation available for an empty list and restores focus on cancel",
|
||||
expect(screen.queryByRole("textbox", { name: "Role" })).toBeNull();
|
||||
});
|
||||
|
||||
test("preserves invalid and rejected role drafts with accessible errors", async () => {
|
||||
test("preserves invalid and rejected Subject drafts with accessible field errors", async () => {
|
||||
const fetchMock = vi.fn(() =>
|
||||
Promise.resolve(Response.json(
|
||||
{
|
||||
@@ -175,6 +187,20 @@ test("preserves invalid and rejected role drafts with accessible errors", async
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
|
||||
await fireEvent.input(input, { target: { value: "Release reviewer" } });
|
||||
const behaviorInput = screen.getByRole("textbox", {
|
||||
name: /Behavior/,
|
||||
}) as HTMLTextAreaElement;
|
||||
await fireEvent.input(behaviorInput, { target: { value: " " } });
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Create Subject" }));
|
||||
expect(screen.getByRole("alert").textContent).toContain(
|
||||
"empty or contain non-whitespace",
|
||||
);
|
||||
expect(behaviorInput.getAttribute("aria-invalid")).toBe("true");
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
|
||||
await fireEvent.input(behaviorInput, {
|
||||
target: { value: "Challenge unsupported assumptions." },
|
||||
});
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Create Subject" }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("alert").textContent).toContain(
|
||||
@@ -273,6 +299,11 @@ test("renders a ready resident surface and committed Memory lifecycle states", a
|
||||
expect(screen.getByRole("heading", { name: "Resident context", level: 1 }))
|
||||
.not.toBeNull();
|
||||
});
|
||||
expect(
|
||||
screen.getByRole("article", { name: "User-managed Subject behavior" })
|
||||
.textContent,
|
||||
)
|
||||
.toContain("Prefer explicit evidence.");
|
||||
expect(screen.getByRole("article", { name: "Resident context" })).not
|
||||
.toBeNull();
|
||||
expect(screen.getByText("Connected", { exact: true })).not.toBeNull();
|
||||
@@ -292,6 +323,272 @@ test("renders a ready resident surface and committed Memory lifecycle states", a
|
||||
.toContain("cursor=memory-next");
|
||||
});
|
||||
|
||||
test("edits and clears user-managed behavior with CAS while distinguishing save from Worker application", async () => {
|
||||
const fetchMock = vi.fn(async (_path: string, init?: RequestInit) => {
|
||||
const request = JSON.parse(String(init?.body));
|
||||
return Response.json({
|
||||
...subject(),
|
||||
behavior_md: request.behavior_md,
|
||||
behavior_revision: request.expected_behavior_revision + 1,
|
||||
});
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
render(SubjectPage, {
|
||||
data: {
|
||||
workspaceId: "workspace-1",
|
||||
subjectId: "subject-1",
|
||||
subject: result(subject()),
|
||||
surface: result({
|
||||
subject_id: "subject-1",
|
||||
availability: "ungenerated" as const,
|
||||
}),
|
||||
memories: result({ items: [], has_more: false }),
|
||||
},
|
||||
} as never);
|
||||
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Edit" }));
|
||||
const textarea = screen.getByRole("textbox", { name: "Subject behavior" });
|
||||
await fireEvent.input(textarea, {
|
||||
target: { value: "Ask before irreversible actions." },
|
||||
});
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Save behavior" }));
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
||||
const [path, init] = fetchMock.mock.calls[0] as unknown as [
|
||||
string,
|
||||
RequestInit,
|
||||
];
|
||||
expect(path).toBe("/api/w/workspace-1/subjektiv/subjects/subject-1/behavior");
|
||||
expect(init.method).toBe("PATCH");
|
||||
expect(init.body).toBe(
|
||||
'{"expected_behavior_revision":2,"behavior_md":"Ask before irreversible actions."}',
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/Behavior storage confirmed at revision 3/).textContent,
|
||||
)
|
||||
.toContain("does not confirm application");
|
||||
});
|
||||
expect(
|
||||
screen.getByRole("article", { name: "User-managed Subject behavior" })
|
||||
.textContent,
|
||||
)
|
||||
.toContain("Ask before irreversible actions.");
|
||||
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Edit" }));
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Clear" }));
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Save behavior" }));
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
|
||||
const secondBody =
|
||||
(fetchMock.mock.calls[1] as unknown as [string, RequestInit])[1].body;
|
||||
expect(secondBody).toBe('{"expected_behavior_revision":3,"behavior_md":""}');
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("No behavior is set.")).not.toBeNull()
|
||||
);
|
||||
});
|
||||
|
||||
function subjectPageData(record = subject(), workspaceId = "workspace-1") {
|
||||
return {
|
||||
workspaceId,
|
||||
subjectId: record.id,
|
||||
subject: result(record),
|
||||
surface: result({
|
||||
subject_id: record.id,
|
||||
availability: "ungenerated" as const,
|
||||
}),
|
||||
memories: result({ items: [], has_more: false }),
|
||||
};
|
||||
}
|
||||
|
||||
test.each([
|
||||
["subject-2", "workspace-1"],
|
||||
["subject-1", "workspace-2"],
|
||||
])(
|
||||
"resets reused Subject editor for %s in %s and saves only its own behavior",
|
||||
async (id, workspaceId) => {
|
||||
const nextSubject = { ...subject(), id, behavior_md: "Only B behavior." };
|
||||
const fetchMock = vi.fn(async (_path: string, init?: RequestInit) => {
|
||||
const request = JSON.parse(String(init?.body));
|
||||
return Response.json({
|
||||
...nextSubject,
|
||||
behavior_md: request.behavior_md,
|
||||
behavior_revision: request.expected_behavior_revision + 1,
|
||||
});
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const view = render(SubjectPage, { data: subjectPageData() } as never);
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Edit" }));
|
||||
await fireEvent.input(
|
||||
screen.getByRole("textbox", { name: "Subject behavior" }),
|
||||
{
|
||||
target: { value: "Unsaved A draft" },
|
||||
},
|
||||
);
|
||||
|
||||
await view.rerender(
|
||||
{ data: subjectPageData(nextSubject, workspaceId) } as never,
|
||||
);
|
||||
expect(screen.queryByRole("textbox", { name: "Subject behavior" }))
|
||||
.toBeNull();
|
||||
expect(
|
||||
screen.getByRole("article", { name: "User-managed Subject behavior" })
|
||||
.textContent,
|
||||
)
|
||||
.toContain("Only B behavior.");
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Edit" }));
|
||||
expect(
|
||||
(screen.getByRole("textbox", {
|
||||
name: "Subject behavior",
|
||||
}) as HTMLTextAreaElement).value,
|
||||
)
|
||||
.toBe("Only B behavior.");
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: "Save behavior" }),
|
||||
);
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
||||
const [path, init] = fetchMock.mock.calls[0] as unknown as [
|
||||
string,
|
||||
RequestInit,
|
||||
];
|
||||
expect(path).toBe(
|
||||
`/api/w/${workspaceId}/subjektiv/subjects/${id}/behavior`,
|
||||
);
|
||||
expect(JSON.parse(String(init.body))).toEqual({
|
||||
expected_behavior_revision: 2,
|
||||
behavior_md: "Only B behavior.",
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/storage confirmed at revision 3/)).not.toBeNull()
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test.each(["success", "failure"])(
|
||||
"fences late %s from a previous Subject without clearing the new save state",
|
||||
async (outcome) => {
|
||||
const pending: Array<(response: Response) => void> = [];
|
||||
const fetchMock = vi.fn(() =>
|
||||
new Promise<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", () => {
|
||||
// The store regression candidate_decisions_reject_conflicts_and_roll_back_partial_writes
|
||||
// stages a candidate without committing a Memory. Its public read projection is
|
||||
@@ -320,6 +617,65 @@ test("candidate-only Subjects show no committed Memories, not an inferred candid
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test.each(["ready", "ungenerated", "stale", "failed"] as const)(
|
||||
"user behavior and diagnostic disclosures stay independent of %s generated Memory",
|
||||
async (availability) => {
|
||||
const record = { ...subject(), store_revision: 0 };
|
||||
const view = render(SubjectPage, {
|
||||
data: {
|
||||
...subjectPageData(record),
|
||||
surface: result({
|
||||
subject_id: record.id,
|
||||
availability,
|
||||
...(availability === "ready"
|
||||
? {
|
||||
snapshot: {
|
||||
snapshot_id: "empty-snapshot",
|
||||
body_md: "",
|
||||
memory_refs: [],
|
||||
built_from_store_revision: 0,
|
||||
created_at: "2026-01-02T03:04:05Z",
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
},
|
||||
} as never);
|
||||
expect(
|
||||
screen.getByRole("article", { name: "User-managed Subject behavior" })
|
||||
.textContent,
|
||||
)
|
||||
.toContain(record.behavior_md);
|
||||
expect(screen.getByText("Generated Memory context", { exact: true })).not
|
||||
.toBeNull();
|
||||
expect(screen.getByText("No committed Memories yet.")).not.toBeNull();
|
||||
expect(screen.getByText("Not connected", { exact: true })).not.toBeNull();
|
||||
const technical = view.container.querySelector(
|
||||
"details.subject-technical-details",
|
||||
) as HTMLDetailsElement;
|
||||
expect(technical.open).toBe(false);
|
||||
expect(technical.textContent).toContain("Subject store revision");
|
||||
if (availability === "ready") {
|
||||
expect(screen.getByText("Resident context is current but empty.")).not
|
||||
.toBeNull();
|
||||
expect(screen.getByText(/no generated Memory context to show/)).not
|
||||
.toBeNull();
|
||||
expect(
|
||||
view.container.querySelector("details.surface-details")?.textContent,
|
||||
)
|
||||
.toContain("Generated from Subject revision");
|
||||
}
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Edit" }));
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
expect(
|
||||
screen.getByRole("article", { name: "User-managed Subject behavior" })
|
||||
.textContent,
|
||||
)
|
||||
.toContain(record.behavior_md);
|
||||
expect(technical.open).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
test("distinguishes ready-empty, stale, failed, unavailable, and request error surfaces", async () => {
|
||||
const base = {
|
||||
workspaceId: "workspace-1",
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
parseSubjektivSubjectListResponse,
|
||||
parseSubjektivSubjectResponse,
|
||||
SubjektivSubjectCreateError,
|
||||
updateSubjektivSubjectBehavior,
|
||||
validateSubjektivSubjectCreateRequest,
|
||||
} from "../src/lib/workspace/memory/api.ts";
|
||||
|
||||
@@ -73,6 +74,8 @@ function subject(id = "subject-1") {
|
||||
return {
|
||||
id,
|
||||
role: "Release coordinator",
|
||||
behavior_md: "Prefer explicit evidence.",
|
||||
behavior_revision: 2,
|
||||
state: "active",
|
||||
store_revision: 12,
|
||||
created_at: "2026-09-01T00:00:00Z",
|
||||
@@ -271,7 +274,7 @@ Deno.test("Subject list parser preserves the exact bounded response", () => {
|
||||
Deno.test("Subject create request mirrors Backend role validation without rewriting input", () => {
|
||||
assertEquals(
|
||||
validateSubjektivSubjectCreateRequest({ role: " Release coordinator " }),
|
||||
{ role: " Release coordinator " },
|
||||
{ role: " Release coordinator ", behavior_md: "" },
|
||||
);
|
||||
assertThrows(
|
||||
() => validateSubjektivSubjectCreateRequest({ role: " " }),
|
||||
@@ -285,6 +288,70 @@ Deno.test("Subject create request mirrors Backend role validation without rewrit
|
||||
() => validateSubjektivSubjectCreateRequest({ role: "界".repeat(86) }),
|
||||
"at most 256 bytes",
|
||||
);
|
||||
assertThrows(
|
||||
() =>
|
||||
validateSubjektivSubjectCreateRequest({
|
||||
role: "Reviewer",
|
||||
behavior_md: " ".repeat(3),
|
||||
}),
|
||||
"empty or contain non-whitespace",
|
||||
);
|
||||
assertThrows(
|
||||
() =>
|
||||
validateSubjektivSubjectCreateRequest({
|
||||
role: "Reviewer",
|
||||
behavior_md: "x".repeat(16 * 1024 + 1),
|
||||
}),
|
||||
"at most 16384 bytes",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Subject behavior update uses CAS and preserves exact text", async () => {
|
||||
const requests: Array<{ path: string; init?: RequestInit }> = [];
|
||||
const fetchFn = (async (path: string | URL | Request, init?: RequestInit) => {
|
||||
requests.push({ path: String(path), init });
|
||||
return Response.json({
|
||||
...subject("subject/one"),
|
||||
behavior_md: "Line one.\nLine two.",
|
||||
behavior_revision: 3,
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
const updated = await updateSubjektivSubjectBehavior(
|
||||
fetchFn,
|
||||
"workspace one",
|
||||
"subject/one",
|
||||
{ expected_behavior_revision: 2, behavior_md: "Line one.\nLine two." },
|
||||
);
|
||||
assertEquals(updated.behavior_revision, 3);
|
||||
assertEquals(updated.behavior_md, "Line one.\nLine two.");
|
||||
assertEquals(
|
||||
requests[0].path,
|
||||
"/api/w/workspace%20one/subjektiv/subjects/subject%2Fone/behavior",
|
||||
);
|
||||
assertEquals(requests[0].init?.method, "PATCH");
|
||||
assertEquals(
|
||||
requests[0].init?.body,
|
||||
'{"expected_behavior_revision":2,"behavior_md":"Line one.\\nLine two."}',
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Subject behavior update reports conflicts without claiming Worker application", async () => {
|
||||
const conflictFetch =
|
||||
(async () =>
|
||||
Response.json({ message: "revision conflict" }, {
|
||||
status: 409,
|
||||
})) as typeof fetch;
|
||||
await assertSubjectCreateRejects(
|
||||
() =>
|
||||
updateSubjektivSubjectBehavior(conflictFetch, "workspace", "subject", {
|
||||
expected_behavior_revision: 1,
|
||||
behavior_md: "new",
|
||||
}),
|
||||
"rejected",
|
||||
"changed elsewhere",
|
||||
409,
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Subject create client sends the exact typed body once and parses the created Subject", async () => {
|
||||
@@ -294,20 +361,26 @@ Deno.test("Subject create client sends the exact typed body once and parses the
|
||||
return Response.json({
|
||||
...subject("subject-created"),
|
||||
role: " Release coordinator ",
|
||||
behavior_md: "Stay concise.",
|
||||
}, { status: 201 });
|
||||
}) as typeof fetch;
|
||||
|
||||
const created = await createSubjektivSubject(fetchFn, "workspace one", {
|
||||
role: " Release coordinator ",
|
||||
behavior_md: "Stay concise.",
|
||||
});
|
||||
assertEquals(created, {
|
||||
...subject("subject-created"),
|
||||
role: " Release coordinator ",
|
||||
behavior_md: "Stay concise.",
|
||||
});
|
||||
assertEquals(requests.length, 1);
|
||||
assertEquals(requests[0].path, "/api/w/workspace%20one/subjektiv/subjects");
|
||||
assertEquals(requests[0].init?.method, "POST");
|
||||
assertEquals(requests[0].init?.body, '{"role":" Release coordinator "}');
|
||||
assertEquals(
|
||||
requests[0].init?.body,
|
||||
'{"role":" Release coordinator ","behavior_md":"Stay concise."}',
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Subject create client distinguishes rejected and unknown outcomes without retrying", async () => {
|
||||
|
||||
Reference in New Issue
Block a user