feat: add Subject async chat with explicit durable publication

This commit is contained in:
2026-10-29 22:27:23 +09:00
parent 7ad6030498
commit 882316fb3b
54 changed files with 6187 additions and 21 deletions
+82
View File
@@ -295,6 +295,8 @@ pub struct SubjektivFeatureConfigPartial {
#[serde(default)]
pub consolidation_tools: Option<bool>,
#[serde(default)]
pub chat: Option<FeatureFlagConfigPartial>,
#[serde(default)]
pub extraction: Option<MemoryExtractionProfileConfigPartial>,
}
@@ -303,6 +305,7 @@ impl SubjektivFeatureConfigPartial {
Self {
enabled: other.enabled.or(self.enabled),
consolidation_tools: other.consolidation_tools.or(self.consolidation_tools),
chat: merge_option(self.chat, other.chat, FeatureFlagConfigPartial::merge),
extraction: merge_option(
self.extraction,
other.extraction,
@@ -554,6 +557,7 @@ impl From<SubjektivFeatureConfigPartial> for ResolvedSubjektivFeatureConfig {
profile: SubjektivFeatureProfileConfig {
enabled: value.enabled.unwrap_or_default(),
consolidation_tools: value.consolidation_tools.unwrap_or_default(),
chat: value.chat.map(FeatureFlagConfig::from).unwrap_or_default(),
extraction: MemoryExtractionProfileConfig {
enabled: extraction.enabled.unwrap_or(true),
model: extraction.model,
@@ -574,6 +578,7 @@ impl From<ResolvedSubjektivFeatureConfig> for SubjektivFeatureConfigPartial {
Self {
enabled: Some(value.profile.enabled),
consolidation_tools: Some(value.profile.consolidation_tools),
chat: Some(value.profile.chat.into()),
extraction: Some(MemoryExtractionProfileConfigPartial {
enabled: Some(value.profile.extraction.enabled),
model: value.profile.extraction.model,
@@ -2988,6 +2993,83 @@ worker_max_turns = 7
assert_eq!(encoded["enabled"], true);
}
#[test]
fn subjektiv_chat_requires_explicit_opt_in_and_roundtrips() {
for subject in [
"",
"[feature.subjektiv]\nenabled = true\n",
"[feature.subjektiv.chat]\n",
] {
let config = WorkerManifestConfig::from_toml(subject).unwrap();
let feature = FeatureConfig::from(config.feature);
assert!(!feature.subjektiv.profile.chat.enabled);
}
assert!(
!ResolvedSubjektivFeatureConfig::default()
.profile
.chat
.enabled
);
let legacy: ResolvedSubjektivFeatureConfig = serde_json::from_value(serde_json::json!({
"profile": { "enabled": true }
}))
.unwrap();
assert!(!legacy.profile.chat.enabled);
for enabled in [true, false] {
let config = WorkerManifestConfig::from_toml(&format!(
"[feature.subjektiv]\nenabled = true\n[feature.subjektiv.chat]\nenabled = {enabled}\n"
)).unwrap();
let resolved = FeatureConfig::from(config.feature).subjektiv;
let json = serde_json::to_value(&resolved).unwrap();
assert_eq!(json["profile"]["chat"]["enabled"], enabled);
assert_eq!(
serde_json::from_value::<ResolvedSubjektivFeatureConfig>(json).unwrap(),
resolved
);
assert!(resolved.workspace_settings.is_none());
assert!(!resolved.execution_enabled());
let partial = SubjektivFeatureConfigPartial::from(resolved.clone());
let toml = toml::to_string(&partial).unwrap();
assert_eq!(
ResolvedSubjektivFeatureConfig::from(
toml::from_str::<SubjektivFeatureConfigPartial>(&toml).unwrap()
),
resolved
);
}
}
#[test]
fn subjektiv_chat_merge_preserves_omissions_and_honors_explicit_disable() {
let base = WorkerManifestConfig::from_toml(
"[feature.subjektiv]\nenabled = true\n[feature.subjektiv.chat]\nenabled = true\n[feature.subjektiv.extraction]\nthreshold = 321\n"
).unwrap();
for overlay in [
"",
"[feature.subjektiv]\nconsolidation_tools = false\n",
"[feature.subjektiv.chat]\n",
] {
let merged = base
.clone()
.merge(WorkerManifestConfig::from_toml(overlay).unwrap());
let feature = FeatureConfig::from(merged.feature);
assert!(feature.subjektiv.profile.chat.enabled);
assert_eq!(feature.subjektiv.profile.extraction.threshold, Some(321));
}
for enabled in [false, true] {
let overlay = WorkerManifestConfig::from_toml(&format!(
"[feature.subjektiv.chat]\nenabled = {enabled}\n"
))
.unwrap();
let merged = base.clone().merge(overlay);
let feature = FeatureConfig::from(merged.feature);
assert_eq!(feature.subjektiv.profile.chat.enabled, enabled);
assert!(feature.subjektiv.profile.enabled);
assert_eq!(feature.subjektiv.profile.extraction.threshold, Some(321));
}
}
#[test]
fn merge_preserves_ref() {
let lower = WorkerManifestConfig {
+3
View File
@@ -412,6 +412,8 @@ pub struct SubjektivFeatureProfileConfig {
pub enabled: bool,
/// Explicit consolidation tool policy; never grants Job or subject authority.
pub consolidation_tools: bool,
/// Opt-in conversation tools; the Host must bind an explicit Subject connection.
pub chat: FeatureFlagConfig,
pub extraction: MemoryExtractionProfileConfig,
}
@@ -420,6 +422,7 @@ impl Default for SubjektivFeatureProfileConfig {
Self {
enabled: false,
consolidation_tools: false,
chat: FeatureFlagConfig::disabled(),
extraction: MemoryExtractionProfileConfig::default(),
}
}
+131
View File
@@ -1727,6 +1727,137 @@ mod tests {
assert!(!resolved.manifest.feature.worker.enabled);
}
#[test]
fn builtin_async_chat_exposes_only_opted_in_chat_and_bounded_features() {
let tmp = TempDir::new().unwrap();
let resolved = ProfileResolver::new()
.with_workspace_base(tmp.path())
.resolve(
&ProfileSelector::source_named(ProfileRegistrySource::Builtin, "async-chat"),
ProfileResolveOptions::with_worker_name("chat-worker"),
)
.unwrap();
let manifest = resolved.manifest;
let feature = &manifest.feature;
assert_eq!(manifest.engine.instruction, "role.async_chat");
assert!(resolved.raw_artifact["scope"].is_null());
assert!(resolved.raw_artifact["delegation_scope"].is_null());
assert!(manifest.scope.allow.is_empty());
assert!(manifest.delegation_scope.allow.is_empty());
assert!(manifest.mcp.stdio_servers.is_empty());
assert!(feature.subjektiv.profile.enabled);
assert!(feature.subjektiv.profile.chat.enabled);
assert!(!feature.subjektiv.profile.consolidation_tools);
assert!(!feature.subjektiv.profile.extraction.enabled);
assert!(feature.subjektiv.workspace_settings.is_none());
assert!(!feature.subjektiv.execution_enabled());
assert!(!feature.memory.profile.enabled);
assert!(!feature.memory.profile.staging_tools);
assert!(!feature.task.enabled);
assert!(!feature.sub_worker.enabled);
assert!(!feature.flow.enabled);
assert!(feature.worker.enabled);
assert!(feature.worker.direct_spawn);
assert!(!feature.workspace_worker_discovery.enabled);
assert!(feature.objective.enabled);
assert!(feature.ticket.enabled);
assert!(!feature.ticket.authoring);
assert!(!feature.ticket.thread);
assert!(!feature.ticket.intake);
assert!(!feature.ticket.workflow);
assert!(!feature.merge_request.show);
assert!(!feature.merge_request.open);
assert!(!feature.merge_request.review);
assert!(!feature.merge_request.readiness_check);
assert!(!feature.merge_request.complete);
for flag in [
feature.web,
feature.image,
feature.drive,
feature.manage_workdir,
feature.workdir_catalog,
feature.workspace_config,
feature.orchestration,
] {
assert!(!flag.enabled);
}
let permissions = manifest.permissions.unwrap();
assert_eq!(
permissions.default_action,
crate::ToolPermissionAction::Deny
);
// Exact tool names and all-argument rules let the Worker statically hide
// denied filesystem tools while its permission hook enforces each call.
let allowed = permissions
.rules
.iter()
.map(|rule| {
assert_eq!(rule.action, crate::ToolPermissionAction::Allow);
assert_eq!(rule.pattern, "*");
rule.tool.as_str()
})
.collect::<BTreeSet<_>>();
assert_eq!(allowed.len(), permissions.rules.len());
assert_eq!(
allowed,
BTreeSet::from([
"ConversationRead",
"ConversationSend",
"WorkerList",
"WorkerSpawn",
"WorkerSendInput",
"WorkerNotify",
"WorkerCancel",
"WorkerStop",
"WorkerRestore",
"ViewSessionOverview",
"SearchSessionEntries",
"ReadSessionEntry",
"SubjektivSessionList",
"SubjektivSessionSearch",
"SubjektivSessionRead",
"SubjektivMemoryQuery",
"SubjektivMemoryRead",
"SubjektivMemoryListChanges",
"QueryObjective",
"ShowObjective",
"QueryTicket",
"ShowTicket",
"WorkspaceTagList",
"TicketAssignmentTrackingQuery",
"TicketAssignmentTrackingHistory",
"TicketAssignmentsQuery",
"TicketAssignmentDiagnostics",
"TicketImplementationResultQuery",
"NotificationDiagnostics",
])
);
}
#[test]
fn builtin_profiles_do_not_implicitly_enable_subject_chat() {
for entry in builtin_profile_entrypoints() {
if entry.selector == Some("builtin:async-chat") {
continue;
}
let artifact = resolve_builtin_profile_artifact(entry.selector.unwrap())
.unwrap()
.unwrap();
let feature: FeatureConfigPartial =
serde_json::from_value(artifact["feature"].clone()).unwrap();
assert!(
!crate::FeatureConfig::from(feature)
.subjektiv
.profile
.chat
.enabled,
"{:?} must remain opted out",
entry.selector
);
}
}
#[test]
fn builtin_companion_combines_runtime_and_sub_worker_control_with_discovery() {
let tmp = TempDir::new().unwrap();