feat: add declarative Feature chat invocations and typed composers

This commit is contained in:
2026-10-05 18:44:41 +09:00
parent cc97987482
commit f7783b3d2a
69 changed files with 10065 additions and 657 deletions
+33
View File
@@ -123,6 +123,14 @@ pub enum SystemItem {
prompt_provenance: Option<PromptRenderProvenance>,
},
/// Result of an explicitly selected chat Feature invocation. The typed
/// result is retained for clients; `body` is the exact bounded context made
/// visible to the Worker model.
FeatureInvocationResult {
result: protocol::FeatureInvocationResult,
body: String,
},
/// `@<path>` file reference resolution. `body` is the rendered
/// LLM-context text (`[File: <path>]\n…` for regular files,
/// `[Dir: <path>]\n…` for directory listings, possibly with a
@@ -184,6 +192,7 @@ impl SystemItem {
match self {
SystemItem::Notification { body, .. } => body.clone(),
SystemItem::WorkerEvent { body, .. } => body.clone(),
SystemItem::FeatureInvocationResult { body, .. } => body.clone(),
SystemItem::FileAttachment { body, .. } => body.clone(),
SystemItem::SkillActivation { body, .. } => body.clone(),
SystemItem::LegacyKnowledgeIgnored { .. } => String::new(),
@@ -208,6 +217,7 @@ impl SystemItem {
match self {
SystemItem::Notification { .. } => "notification",
SystemItem::WorkerEvent { .. } => "worker_event",
SystemItem::FeatureInvocationResult { .. } => "feature_invocation_result",
SystemItem::FileAttachment { .. } => "file_attachment",
SystemItem::SkillActivation { .. } => "skill_activation",
SystemItem::LegacyKnowledgeIgnored { .. } => "legacy_knowledge_ignored",
@@ -363,6 +373,29 @@ mod tests {
}
}
#[test]
fn invocation_result_retains_typed_outcome_and_exact_context() {
let result = protocol::FeatureInvocationResult {
invocation_id: "stable-invoke".into(),
identity: protocol::FeatureInvocationIdentity("builtin:test/prepare".into()),
status: protocol::FeatureInvocationStatus::OutcomeUnknown,
message: "operation may have committed".into(),
context: None,
};
let body = "Outcome unknown; do not retry automatically";
let item = SystemItem::FeatureInvocationResult {
result: result.clone(),
body: body.into(),
};
let restored: SystemItem =
serde_json::from_str(&serde_json::to_string(&item).unwrap()).unwrap();
assert_eq!(restored.history_text(), body);
assert_eq!(restored.kind_label(), "feature_invocation_result");
assert!(
matches!(restored, SystemItem::FeatureInvocationResult { result: saved, .. } if saved == result)
);
}
#[test]
fn round_trip_worker_event() {
let item = SystemItem::WorkerEvent {
@@ -606,6 +606,70 @@ fn rewound_fork_preserves_uploaded_file_segments_in_snapshot() {
)));
}
#[test]
fn feature_invocations_survive_replay_snapshot_and_rewind_without_reparsing() {
let (_dir, store) = make_store();
let config = RequestConfig::default();
let (sid, segid) = session_store::create_segment(
&store,
SegmentStartState {
system_prompt: Some("System prompt"),
config: &config,
history: Vec::new(),
user_segments: Vec::new(),
},
)
.unwrap();
let segments = vec![
Segment::Text {
content: "before ".into(),
},
Segment::FeatureInvoke {
invocation: protocol::FeatureInvocation {
invocation_id: "stable-request-1".into(),
identity: protocol::FeatureInvocationIdentity("builtin:test/prepare".into()),
name: "prepare".into(),
arguments: vec![protocol::InvocationArgumentValue {
name: "path".into(),
value: protocol::InvocationValue::String("資料/a b".into()),
}],
},
},
Segment::Text {
content: " after /prepare(unselected)".into(),
},
];
session_store::save_user_input(
&store,
sid,
segid,
segments.clone(),
annotated(&[Item::user_message(Segment::flatten_to_text(&segments))]),
)
.unwrap();
session_store::save_turn_end(&store, sid, segid, 1).unwrap();
let entries = store.read_all(sid, segid).unwrap();
assert_eq!(
collect_state(&entries).user_segments,
vec![segments.clone()]
);
let fork_id = session_store::fork_at(&store, sid, segid, 1).unwrap();
let fork_entries = store.read_all(sid, fork_id).unwrap();
assert_eq!(
collect_state(&fork_entries).user_segments,
vec![segments.clone()]
);
let snapshot = session_store::public_snapshot::project_session_snapshot(sid, &fork_entries);
assert!(snapshot.entries.iter().any(|entry| matches!(
&entry.data, SessionSnapshotEntryData::UserInput { segments: restored } if restored == &segments
)));
// Original history remains append-only and retains the same exact typed intent.
assert_eq!(
serde_json::to_value(store.read_all(sid, segid).unwrap()).unwrap(),
serde_json::to_value(entries).unwrap()
);
}
#[tokio::test]
async fn session_config_changed_logged() {
let (_dir, store) = make_store();