feat(server): refactor copilot (#14892)

#### PR Dependency Tree


* **PR #14892** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)
This commit is contained in:
DarkSky
2026-05-04 00:36:47 +08:00
committed by GitHub
parent fa8f1a096c
commit d64f368623
239 changed files with 35859 additions and 16777 deletions
@@ -0,0 +1,291 @@
use std::collections::HashSet;
use jsonschema::Draft;
use napi::{Error, Result, Status};
use serde_json::{Value, json};
use super::{
super::contract_schema::{transcript_input_schema, transcript_result_schema},
ActionRecipe, ActionRecipeStep, ActionStepKind,
};
fn invalid_recipe(message: impl Into<String>) -> Error {
Error::new(Status::InvalidArg, message.into())
}
pub fn built_in_recipes() -> Vec<ActionRecipe> {
vec![
action_recipe("mindmap.generate", "v1"),
action_recipe("slides.outline", "v1"),
action_recipe("image.filter.sketch", "v1"),
action_recipe("image.filter.clay", "v1"),
action_recipe("image.filter.anime", "v1"),
action_recipe("image.filter.pixel", "v1"),
transcript_recipe("transcript.audio.gemini", "v1"),
]
}
pub fn find_recipe(id: &str, version: Option<&str>) -> Result<ActionRecipe> {
let catalog = load_catalog()?;
catalog
.into_iter()
.find(|recipe| recipe.id == id && version.is_none_or(|version| recipe.version == version))
.ok_or_else(|| {
invalid_recipe(format!(
"Action recipe not found: {}{}",
id,
version.map(|version| format!("@{version}")).unwrap_or_default()
))
})
}
pub fn load_catalog() -> Result<Vec<ActionRecipe>> {
let recipes = built_in_recipes();
validate_catalog(&recipes)?;
Ok(recipes)
}
pub fn validate_catalog(recipes: &[ActionRecipe]) -> Result<()> {
let mut keys = HashSet::new();
for recipe in recipes {
validate_recipe(recipe)?;
let key = format!("{}@{}", recipe.id, recipe.version);
if !keys.insert(key.clone()) {
return Err(invalid_recipe(format!("Duplicated action recipe: {key}")));
}
}
Ok(())
}
pub fn validate_recipe(recipe: &ActionRecipe) -> Result<()> {
if recipe.id.trim().is_empty() {
return Err(invalid_recipe("Action recipe id is required"));
}
if recipe.version.trim().is_empty() {
return Err(invalid_recipe("Action recipe version is required"));
}
if recipe.steps.is_empty() {
return Err(invalid_recipe(format!(
"Action recipe {}@{} must declare at least one step",
recipe.id, recipe.version
)));
}
compile_schema("inputSchema", &recipe.input_schema)?;
compile_schema("outputSchema", &recipe.output_schema)?;
let mut step_ids = HashSet::new();
let mut has_final = false;
for step in &recipe.steps {
if step.id.trim().is_empty() {
return Err(invalid_recipe(format!(
"Action recipe {}@{} contains a step without id",
recipe.id, recipe.version
)));
}
if !step_ids.insert(step.id.clone()) {
return Err(invalid_recipe(format!(
"Action recipe {}@{} contains duplicated step id {}",
recipe.id, recipe.version, step.id
)));
}
if step.kind == ActionStepKind::Final {
has_final = true;
}
}
if !has_final {
return Err(invalid_recipe(format!(
"Action recipe {}@{} must end with a final step",
recipe.id, recipe.version
)));
}
if recipe
.steps
.last()
.is_some_and(|step| step.kind != ActionStepKind::Final)
{
return Err(invalid_recipe(format!(
"Action recipe {}@{} must end with a final step",
recipe.id, recipe.version
)));
}
Ok(())
}
fn compile_schema(label: &str, schema: &Value) -> Result<()> {
jsonschema::options()
.with_draft(Draft::Draft7)
.build(schema)
.map(|_| ())
.map_err(|error| invalid_recipe(format!("Invalid action recipe {label}: {error}")))
}
fn action_recipe(id: &str, version: &str) -> ActionRecipe {
let steps = if id.starts_with("image.filter.") {
vec![
ActionRecipeStep {
id: "generate-image".to_string(),
kind: ActionStepKind::PromptImage,
input: Some(json!({
"preparedRoutes": { "$state": "preparedRoutes.generate-image" },
"outputKey": "artifact"
})),
state_patch: Some(json!({ "imageGenerated": true })),
},
ActionRecipeStep {
id: "final".to_string(),
kind: ActionStepKind::Final,
input: Some(json!({
"copy": { "$state": "artifact" }
})),
state_patch: Some(json!({ "finalized": true })),
},
]
} else if id == "slides.outline" {
vec![
ActionRecipeStep {
id: "generate-structured".to_string(),
kind: ActionStepKind::PromptStructured,
input: Some(json!({
"preparedRoutes": { "$state": "preparedRoutes.generate" },
"unwrapKey": "result",
"outputKey": "generated"
})),
state_patch: Some(json!({ "generatedAt": "promptStructured" })),
},
ActionRecipeStep {
id: "validate-json".to_string(),
kind: ActionStepKind::ValidateJson,
input: Some(json!({
"value": { "$state": "generated" },
"schema": text_action_output_schema()
})),
state_patch: None,
},
ActionRecipeStep {
id: "project-outline".to_string(),
kind: ActionStepKind::Transform,
input: Some(json!({
"slidesOutlineMarkdown": { "$state": "generated" },
"outputKey": "outlineMarkdown"
})),
state_patch: Some(json!({ "projectedAt": "slidesOutlineMarkdown" })),
},
ActionRecipeStep {
id: "final".to_string(),
kind: ActionStepKind::Final,
input: Some(json!({
"copy": { "$state": "outlineMarkdown" }
})),
state_patch: Some(json!({ "finalized": true })),
},
]
} else {
vec![
ActionRecipeStep {
id: "generate-structured".to_string(),
kind: ActionStepKind::PromptStructured,
input: Some(json!({
"preparedRoutes": { "$state": "preparedRoutes.generate" },
"unwrapKey": "result",
"outputKey": "generated"
})),
state_patch: Some(json!({ "generatedAt": "promptStructured" })),
},
ActionRecipeStep {
id: "validate-json".to_string(),
kind: ActionStepKind::ValidateJson,
input: Some(json!({
"value": { "$state": "generated" },
"schema": text_action_output_schema()
})),
state_patch: None,
},
ActionRecipeStep {
id: "final".to_string(),
kind: ActionStepKind::Final,
input: Some(json!({
"copy": { "$state": "generated" }
})),
state_patch: Some(json!({ "finalized": true })),
},
]
};
recipe(id, version, action_output_schema(id), steps)
}
fn transcript_recipe(id: &str, version: &str) -> ActionRecipe {
let mut recipe = recipe(
id,
version,
transcript_result_schema(),
vec![
ActionRecipeStep {
id: "transcribe".to_string(),
kind: ActionStepKind::PromptStructured,
input: Some(json!({
"preparedRoutes": { "$state": "preparedRoutes.transcribe" },
"outputKey": "transcriptResult"
})),
state_patch: Some(json!({ "transcribedAt": "promptStructured" })),
},
ActionRecipeStep {
id: "final".to_string(),
kind: ActionStepKind::Final,
input: Some(json!({
"sourceAudio": { "$state": "sourceAudio" },
"quality": { "$state": "quality" },
"infos": { "$state": "infos" },
"sliceManifest": { "$state": "sliceManifest" },
"normalizedSegments": { "$state": "transcriptResult.normalizedSegments" },
"normalizedTranscript": { "$state": "transcriptResult.normalizedTranscript" },
"summaryJson": { "$state": "transcriptResult.summaryJson" },
"providerMeta": { "$state": "transcriptResult.providerMeta" },
"version": "transcript-result-v1",
"strategy": id.strip_prefix("transcript.audio.").unwrap_or(id)
})),
state_patch: Some(json!({ "finalized": true })),
},
],
);
recipe.input_schema = transcript_input_schema();
recipe
}
fn action_output_schema(id: &str) -> Value {
if id.starts_with("image.filter.") {
json!({
"type": "object",
"properties": {
"url": { "type": "string" },
"data_base64": { "type": "string" },
"media_type": { "type": "string" }
},
"anyOf": [
{ "required": ["url"] },
{ "required": ["data_base64", "media_type"] }
],
"additionalProperties": true
})
} else {
text_action_output_schema()
}
}
fn text_action_output_schema() -> Value {
json!({
"type": "string",
"minLength": 1
})
}
fn recipe(id: &str, version: &str, output_schema: Value, steps: Vec<ActionRecipeStep>) -> ActionRecipe {
ActionRecipe {
id: id.to_string(),
version: version.to_string(),
input_schema: json!({}),
output_schema,
steps,
}
}
@@ -0,0 +1,260 @@
use napi_derive::napi;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
pub struct ActionRecipe {
pub id: String,
pub version: String,
pub input_schema: Value,
pub output_schema: Value,
pub steps: Vec<ActionRecipeStep>,
}
#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
pub struct ActionRecipeStep {
pub id: String,
pub kind: ActionStepKind,
#[serde(default)]
pub input: Option<Value>,
#[serde(default)]
pub state_patch: Option<Value>,
}
#[derive(Clone, Copy, Debug, Deserialize, JsonSchema, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum ActionStepKind {
PromptStructured,
PromptImage,
ValidateJson,
Transform,
Final,
}
#[napi(string_enum = "snake_case")]
#[derive(Clone, Copy, Debug, Deserialize, JsonSchema, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ActionEventType {
ActionStart,
StepStart,
Attachment,
StepEnd,
ActionDone,
Error,
}
#[napi(object)]
#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
pub struct ActionEvent {
#[serde(rename = "type")]
#[napi(js_name = "type")]
pub event_type: ActionEventType,
pub action_id: String,
pub action_version: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub step_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<ActionRunStatus>,
#[serde(skip_serializing_if = "Option::is_none")]
pub attachment: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub result: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error_code: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error_message: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub trace: Option<ActionTrace>,
}
#[napi(string_enum = "snake_case")]
#[derive(Clone, Copy, Debug, Deserialize, JsonSchema, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ActionRunStatus {
Created,
Running,
Succeeded,
Failed,
Aborted,
}
#[napi(object)]
#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
pub struct ActionRuntimeInput {
pub recipe_id: String,
#[serde(default)]
pub recipe_version: Option<String>,
#[serde(default)]
pub input: Value,
}
#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
pub struct ActionRuntimeOutput {
pub result: Value,
pub status: ActionRunStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub error_code: Option<String>,
pub state: Value,
pub steps: Vec<ActionStepRuntimeState>,
pub trace: ActionTrace,
pub events: Vec<ActionEvent>,
}
#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
pub struct ActionStepRuntimeState {
pub id: String,
pub input: Value,
#[serde(skip_serializing_if = "Option::is_none")]
pub output: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub state_patch: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<ActionStepError>,
}
#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
pub struct ActionStepError {
pub code: String,
pub message: String,
}
#[napi(object)]
#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
pub struct ActionTrace {
pub action_id: String,
pub action_version: String,
pub status: ActionRunStatus,
#[serde(default)]
pub lightweight: Vec<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error_code: Option<String>,
}
#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
pub struct TranscriptInputContract {
#[serde(skip_serializing_if = "Option::is_none")]
pub source_audio: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub quality: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub infos: Option<Vec<TranscriptAudioInfo>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub slice_manifest: Option<Vec<TranscriptSliceManifestItem>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub prepared_routes: Option<Value>,
}
#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
pub struct TranscriptAudioInfo {
pub url: String,
pub mime_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub index: Option<i64>,
}
#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
pub struct TranscriptSliceManifestItem {
pub index: i64,
pub file_name: String,
pub mime_type: String,
pub start_sec: f64,
pub duration_sec: f64,
#[serde(skip_serializing_if = "Option::is_none")]
pub byte_size: Option<i64>,
}
#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
pub struct NormalizedTranscriptSegment {
pub speaker: String,
pub start_sec: f64,
pub end_sec: f64,
pub start: String,
pub end: String,
pub text: String,
}
#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
pub struct MeetingSummary {
pub title: String,
pub duration_minutes: f64,
pub attendees: Vec<String>,
pub key_points: Vec<String>,
pub action_items: Vec<MeetingSummaryActionItem>,
pub decisions: Vec<String>,
pub open_questions: Vec<String>,
pub blockers: Vec<String>,
}
#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
pub struct MeetingSummaryActionItem {
pub description: String,
#[schemars(required)]
pub owner: Option<String>,
#[schemars(required)]
pub deadline: Option<String>,
}
#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
pub struct TranscriptGeneratedResult {
#[schemars(required)]
pub normalized_segments: Option<Vec<NormalizedTranscriptSegment>>,
pub normalized_transcript: String,
#[schemars(required)]
pub summary_json: Option<MeetingSummary>,
#[schemars(required)]
pub provider_meta: Option<Value>,
}
#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
pub struct TranscriptResult {
#[schemars(required)]
pub source_audio: Option<Value>,
#[schemars(required)]
pub quality: Option<Value>,
#[schemars(required)]
pub infos: Option<Vec<TranscriptAudioInfo>>,
#[schemars(required)]
pub slice_manifest: Option<Vec<TranscriptSliceManifestItem>>,
#[schemars(required)]
pub normalized_segments: Option<Vec<NormalizedTranscriptSegment>>,
pub normalized_transcript: String,
#[schemars(required)]
pub summary_json: Option<MeetingSummary>,
#[schemars(required)]
pub provider_meta: Option<Value>,
pub version: String,
pub strategy: String,
}
@@ -0,0 +1,99 @@
mod catalog;
mod contract;
mod runtime;
mod slides_outline;
use std::sync::{Arc, atomic::AtomicBool, mpsc};
#[cfg(test)]
use catalog::{load_catalog, validate_catalog, validate_recipe};
use contract::{
ActionEvent, ActionEventType, ActionRecipe, ActionRecipeStep, ActionRunStatus, ActionRuntimeInput,
ActionRuntimeOutput, ActionStepError, ActionStepKind, ActionStepRuntimeState, ActionTrace,
};
pub(crate) use contract::{TranscriptGeneratedResult, TranscriptInputContract, TranscriptResult};
use napi::{
Result,
threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode},
};
#[cfg(test)]
use runtime::{ACTION_ABORTED_ERROR_CODE, run_action_recipe_for_test, run_action_recipe_for_test_with_control};
use runtime::{ActionRuntimeControl, run_action_recipe_prepared_with_control};
use crate::llm::{LlmStreamHandle, STREAM_END_MARKER};
#[napi(catch_unwind)]
pub fn run_native_action_recipe_prepared_stream(
input: ActionRuntimeInput,
callback: ThreadsafeFunction<String, ()>,
) -> Result<LlmStreamHandle> {
let action_id = input.recipe_id.clone();
let action_version = input.recipe_version.clone().unwrap_or_default();
let aborted = Arc::new(AtomicBool::new(false));
let aborted_in_worker = aborted.clone();
let (event_sender, event_receiver) = mpsc::channel::<ActionEvent>();
let error_sender = event_sender.clone();
std::thread::spawn(move || {
if let Err(error) = run_action_recipe_prepared_with_control(
input,
ActionRuntimeControl {
abort_signal: Some(aborted_in_worker.clone()),
event_sender: Some(event_sender),
#[cfg(test)]
abort_after_events: None,
#[cfg(test)]
mock_output: None,
},
) {
let _ = error_sender.send(ActionEvent {
event_type: ActionEventType::Error,
action_id,
action_version,
step_id: None,
status: Some(ActionRunStatus::Failed),
attachment: None,
result: None,
error_code: Some("action_runtime_error".to_string()),
error_message: Some(error.reason.clone()),
trace: None,
});
}
});
std::thread::spawn(move || {
for event in event_receiver {
match serde_json::to_string(&event) {
Ok(event) => {
let _ = callback.call(Ok(event), ThreadsafeFunctionCallMode::NonBlocking);
}
Err(error) => {
let _ = callback.call(
Ok(
serde_json::json!({
"type": "error",
"actionId": event.action_id,
"actionVersion": event.action_version,
"errorCode": "action_event_encode_failed",
"errorMessage": error.to_string()
})
.to_string(),
),
ThreadsafeFunctionCallMode::NonBlocking,
);
break;
}
}
}
let _ = callback.call(
Ok(STREAM_END_MARKER.to_string()),
ThreadsafeFunctionCallMode::NonBlocking,
);
});
Ok(LlmStreamHandle { aborted })
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,564 @@
use std::{
cell::Cell,
sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
mpsc::Sender,
},
time::Instant,
};
use llm_runtime::{
RecipeDefinition, RecipeRuntimeEvent, RecipeRuntimeOutput, RecipeRuntimeStatus, RecipeStepExecution,
RecipeStepExecutor, StepExecutionError, execute_transform_step, execute_validate_json_step, resolve_state_ref,
run_recipe_runtime, validate_json_schema,
};
use napi::{Error, Result, Status};
use serde_json::{Map, Value, json};
use super::{
ActionEvent, ActionEventType, ActionRecipe, ActionRunStatus, ActionRuntimeInput, ActionRuntimeOutput,
ActionStepError, ActionStepKind, ActionStepRuntimeState, ActionTrace, catalog::find_recipe,
slides_outline::project_slides_outline_markdown,
};
use crate::llm::{
LlmPreparedImageDispatchRoutePayload, dispatch_prepared_image_route_payloads, dispatch_prepared_structured_routes,
};
pub const ACTION_ABORTED_ERROR_CODE: &str = "action_aborted";
pub const ACTION_INVALID_STEP_ERROR_CODE: &str = "action_invalid_step";
#[derive(Clone, Debug, Default)]
pub struct ActionRuntimeControl {
pub abort_signal: Option<Arc<AtomicBool>>,
pub event_sender: Option<Sender<ActionEvent>>,
#[cfg(test)]
pub abort_after_events: Option<usize>,
#[cfg(test)]
pub mock_output: Option<Value>,
}
#[derive(Clone, Debug)]
pub struct ActionRuntimeState {
pub status: ActionRunStatus,
pub result: Value,
pub action_state: Value,
pub steps: Vec<ActionStepRuntimeState>,
pub events: Vec<ActionEvent>,
pub trace: ActionTrace,
pub error_code: Option<String>,
}
fn invalid_input(message: impl Into<String>) -> Error {
Error::new(Status::InvalidArg, message.into())
}
pub fn run_action_recipe_prepared_with_control(
input: ActionRuntimeInput,
control: ActionRuntimeControl,
) -> Result<ActionRuntimeOutput> {
let recipe = find_recipe(&input.recipe_id, input.recipe_version.as_deref())?;
validate_value("input", &recipe.input_schema, &input.input)?;
run_recipe(recipe, input, control)
}
#[cfg(test)]
pub(crate) fn run_action_recipe_for_test(
recipe: ActionRecipe,
input: ActionRuntimeInput,
) -> Result<ActionRuntimeOutput> {
validate_value("input", &recipe.input_schema, &input.input)?;
run_recipe(recipe, input, ActionRuntimeControl::default())
}
#[cfg(test)]
pub(crate) fn run_action_recipe_for_test_with_control(
recipe: ActionRecipe,
input: ActionRuntimeInput,
control: ActionRuntimeControl,
) -> Result<ActionRuntimeOutput> {
validate_value("input", &recipe.input_schema, &input.input)?;
run_recipe(recipe, input, control)
}
fn run_recipe(
recipe: ActionRecipe,
input: ActionRuntimeInput,
control: ActionRuntimeControl,
) -> Result<ActionRuntimeOutput> {
let mut runtime = Runtime::new(recipe, input, control);
runtime.run()
}
struct Runtime {
recipe: ActionRecipe,
state: ActionRuntimeState,
started_at: Instant,
control: ActionRuntimeControl,
}
impl Runtime {
fn new(recipe: ActionRecipe, input: ActionRuntimeInput, control: ActionRuntimeControl) -> Self {
let trace = ActionTrace {
action_id: recipe.id.clone(),
action_version: recipe.version.clone(),
status: ActionRunStatus::Created,
lightweight: Vec::new(),
error_code: None,
};
Self {
recipe,
state: ActionRuntimeState {
status: ActionRunStatus::Created,
result: input.input.clone(),
action_state: input.input,
steps: Vec::new(),
events: Vec::new(),
trace,
error_code: None,
},
started_at: Instant::now(),
control,
}
}
fn run(&mut self) -> Result<ActionRuntimeOutput> {
let recipe = self.recipe_definition();
let action_id = self.recipe.id.clone();
let action_version = self.recipe.version.clone();
let output_schema = self.recipe.output_schema.clone();
let step_patches = self
.recipe
.steps
.iter()
.map(|step| (step.id.clone(), step.state_patch.clone()))
.collect::<std::collections::HashMap<_, _>>();
let attachments = Arc::new(Mutex::new(Vec::new()));
let mut executor = AffineActionStepExecutor::new(&self.control, attachments.clone());
let mut events = Vec::new();
let mut lightweight = Vec::new();
let event_sender = self.control.event_sender.clone();
let abort_signal = self.control.abort_signal.clone();
let event_count = Cell::new(0usize);
#[cfg(test)]
let abort_after_events = self.control.abort_after_events;
let mut record = |event: ActionEvent| {
lightweight.push(json!({
"type": event.event_type,
"stepId": event.step_id,
"status": event.status
}));
if let Some(sender) = &event_sender {
let _ = sender.send(event.clone());
}
events.push(event);
event_count.set(events.len());
};
let runtime_output = run_recipe_runtime(
recipe,
self.state.action_state.clone(),
&mut executor,
|event| {
for action_event in map_recipe_event(&action_id, &action_version, event, &attachments) {
record(action_event);
}
},
|| {
abort_signal
.as_ref()
.is_some_and(|signal| signal.load(Ordering::SeqCst))
|| {
#[cfg(test)]
{
abort_after_events.is_some_and(|max_events| event_count.get() >= max_events)
}
#[cfg(not(test))]
{
false
}
}
},
);
if matches!(runtime_output.status, RecipeRuntimeStatus::Succeeded) {
validate_value("output", &output_schema, &runtime_output.result)?;
}
self.state = self.action_state_from_runtime_output(runtime_output, events, lightweight, step_patches);
self.finalize_trace();
if let Some(event) = self
.state
.events
.iter_mut()
.rev()
.find(|event| matches!(event.event_type, ActionEventType::ActionDone))
{
event.trace = Some(self.state.trace.clone());
}
Ok(self.output())
}
fn recipe_definition(&self) -> RecipeDefinition {
RecipeDefinition {
id: self.recipe.id.clone(),
version: self.recipe.version.clone(),
steps: self
.recipe
.steps
.iter()
.map(|step| RecipeStepExecution {
id: step.id.clone(),
kind: action_step_kind_name(step.kind).to_string(),
input: step.input.clone(),
state_patch: step.state_patch.clone(),
})
.collect(),
}
}
fn action_state_from_runtime_output(
&self,
output: RecipeRuntimeOutput,
events: Vec<ActionEvent>,
lightweight: Vec<Value>,
step_patches: std::collections::HashMap<String, Option<Value>>,
) -> ActionRuntimeState {
let status = recipe_status_to_action_status(&output.status);
let error_code = output
.trace
.error_code
.as_deref()
.map(map_recipe_error_code)
.map(ToString::to_string);
ActionRuntimeState {
status,
result: output.result,
action_state: output.state,
steps: output
.steps
.into_iter()
.map(|step| ActionStepRuntimeState {
id: step.id.clone(),
input: step.input.unwrap_or(Value::Null),
output: step.output,
state_patch: step_patches.get(&step.id).cloned().flatten(),
error: step.error.map(ActionStepError::from),
})
.collect(),
events,
trace: ActionTrace {
action_id: self.recipe.id.clone(),
action_version: self.recipe.version.clone(),
status,
lightweight,
error_code: error_code.clone(),
},
error_code,
}
}
fn output(&mut self) -> ActionRuntimeOutput {
self.finalize_trace();
ActionRuntimeOutput {
result: self.state.result.clone(),
status: self.state.status,
error_code: self.state.error_code.clone(),
state: self.state.action_state.clone(),
steps: self.state.steps.clone(),
trace: self.state.trace.clone(),
events: self.state.events.clone(),
}
}
fn finalize_trace(&mut self) {
self.state.trace.status = self.state.status;
if self
.state
.trace
.lightweight
.last()
.and_then(|event| event.get("type"))
.is_some_and(|event_type| event_type == "action_trace")
{
return;
}
self.state.trace.lightweight.push(json!({
"type": "action_trace",
"actionId": self.recipe.id.clone(),
"actionVersion": self.recipe.version.clone(),
"status": self.state.status,
"durationMs": self.started_at.elapsed().as_millis()
}));
}
}
fn recipe_status_to_action_status(status: &RecipeRuntimeStatus) -> ActionRunStatus {
match status {
RecipeRuntimeStatus::Created => ActionRunStatus::Created,
RecipeRuntimeStatus::Running => ActionRunStatus::Running,
RecipeRuntimeStatus::Succeeded => ActionRunStatus::Succeeded,
RecipeRuntimeStatus::Failed => ActionRunStatus::Failed,
RecipeRuntimeStatus::Aborted => ActionRunStatus::Aborted,
}
}
fn map_recipe_error_code(code: &str) -> &str {
match code {
"aborted" => ACTION_ABORTED_ERROR_CODE,
"invalid_step" | "invalid_schema" | "invalid_value" => ACTION_INVALID_STEP_ERROR_CODE,
other => other,
}
}
fn map_recipe_event(
action_id: &str,
action_version: &str,
event: &RecipeRuntimeEvent,
attachments: &Arc<Mutex<Vec<Value>>>,
) -> Vec<ActionEvent> {
let status = recipe_status_to_action_status(&event.status);
let mut events = Vec::new();
if event.event_type == "step_end" {
let mut pending = attachments.lock().expect("attachment queue lock");
events.extend(pending.drain(..).map(|attachment| ActionEvent {
event_type: ActionEventType::Attachment,
action_id: action_id.to_string(),
action_version: action_version.to_string(),
step_id: None,
status: Some(ActionRunStatus::Running),
attachment: Some(attachment),
result: None,
error_code: None,
error_message: None,
trace: None,
}));
}
let event_type = match event.event_type.as_str() {
"recipe_start" => ActionEventType::ActionStart,
"step_start" => ActionEventType::StepStart,
"step_end" => ActionEventType::StepEnd,
"recipe_done" => ActionEventType::ActionDone,
"error" => ActionEventType::Error,
_ => return events,
};
let error = event.error.as_ref();
events.push(ActionEvent {
event_type,
action_id: action_id.to_string(),
action_version: action_version.to_string(),
step_id: event.step_id.clone(),
status: Some(status),
attachment: None,
result: event.result.clone(),
error_code: error.map(|error| map_recipe_error_code(&error.code).to_string()),
error_message: error.map(|error| error.message.clone()),
trace: None,
});
events
}
impl From<StepExecutionError> for ActionStepError {
fn from(error: StepExecutionError) -> Self {
let code = if error.code == "invalid_step" || error.code == "invalid_schema" || error.code == "invalid_value" {
ACTION_INVALID_STEP_ERROR_CODE.to_string()
} else {
error.code
};
Self {
code,
message: error.message,
}
}
}
fn action_step_kind_name(kind: ActionStepKind) -> &'static str {
match kind {
ActionStepKind::PromptStructured => "promptStructured",
ActionStepKind::PromptImage => "promptImage",
ActionStepKind::ValidateJson => "validateJson",
ActionStepKind::Transform => "transform",
ActionStepKind::Final => "final",
}
}
struct AffineActionStepExecutor<'a> {
#[cfg(test)]
control: &'a ActionRuntimeControl,
#[cfg(not(test))]
_marker: std::marker::PhantomData<&'a ()>,
attachments: Arc<Mutex<Vec<Value>>>,
}
impl<'a> AffineActionStepExecutor<'a> {
fn new(_control: &'a ActionRuntimeControl, attachments: Arc<Mutex<Vec<Value>>>) -> Self {
Self {
#[cfg(test)]
control: _control,
#[cfg(not(test))]
_marker: std::marker::PhantomData,
attachments,
}
}
fn test_mock_output(&self, _step_id: &str) -> Option<&Value> {
#[cfg(test)]
{
self
.control
.mock_output
.as_ref()
.and_then(|mock_output| mock_output.get(_step_id))
.filter(|value| !value.is_null())
}
#[cfg(not(test))]
{
None
}
}
fn prompt_structured_step(
&self,
step: &RecipeStepExecution,
input: Option<Value>,
) -> std::result::Result<Value, StepExecutionError> {
let value = if let Some(routes) = input
.as_ref()
.and_then(|input| input.get("preparedRoutes"))
.filter(|routes| !routes.is_null())
{
let (_provider_id, response) =
dispatch_prepared_structured_routes(&serde_json::to_string(routes).map_err(|error| {
StepExecutionError::new(
"invalid_step",
format!("Invalid promptStructured prepared routes: {error}"),
)
})?)
.map_err(|error| StepExecutionError::new("invalid_step", error.reason.clone()))?;
response.output_json.unwrap_or(Value::Null)
} else if let Some(mock_output) = self.test_mock_output(&step.id) {
mock_output.clone()
} else {
return Err(StepExecutionError::new(
"invalid_step",
"promptStructured requires preparedRoutes",
));
};
Ok(
input
.as_ref()
.and_then(|input| input.get("unwrapKey"))
.and_then(Value::as_str)
.and_then(|key| value.get(key).cloned())
.unwrap_or(value),
)
}
fn prompt_image_step(
&mut self,
step: &RecipeStepExecution,
input: Option<Value>,
) -> std::result::Result<Value, StepExecutionError> {
let attachment = if let Some(routes) = input
.as_ref()
.and_then(|input| input.get("preparedRoutes"))
.filter(|routes| !routes.is_null())
{
let payload =
serde_json::from_value::<Vec<LlmPreparedImageDispatchRoutePayload>>(routes.clone()).map_err(|error| {
StepExecutionError::new("invalid_step", format!("Invalid promptImage prepared routes: {error}"))
})?;
let (_provider_id, response) = dispatch_prepared_image_route_payloads(payload)
.map_err(|error| StepExecutionError::new("invalid_step", error.reason.clone()))?;
image_response_attachment(response.provider_metadata, response.images)
.ok_or_else(|| StepExecutionError::new("invalid_step", "promptImage native dispatch produced no image"))?
} else if let Some(mock_output) = self.test_mock_output(&step.id) {
mock_output.clone()
} else {
return Err(StepExecutionError::new(
"invalid_step",
"promptImage requires preparedRoutes",
));
};
self
.attachments
.lock()
.expect("attachment queue lock")
.push(attachment.clone());
Ok(attachment)
}
fn transform_step(&self, input: Option<Value>, state: &Value) -> std::result::Result<Value, StepExecutionError> {
if let Some(value) = execute_transform_step(input.clone(), state)? {
return Ok(value);
}
let Some(input) = input else {
return Ok(state.clone());
};
if let Some(slides_outline) = input.get("slidesOutlineMarkdown") {
let value = resolve_state_ref(slides_outline, state);
return project_slides_outline_markdown(&value)
.map(Value::String)
.map_err(|message| StepExecutionError::new("invalid_step", message));
}
Ok(input)
}
}
impl RecipeStepExecutor for AffineActionStepExecutor<'_> {
fn execute_step(
&mut self,
step: &RecipeStepExecution,
input: Option<Value>,
state: &Value,
) -> std::result::Result<Value, StepExecutionError> {
match step.kind.as_str() {
"promptStructured" => self.prompt_structured_step(step, input),
"promptImage" => self.prompt_image_step(step, input),
"validateJson" => execute_validate_json_step(input.or_else(|| Some(state.clone()))),
"transform" | "final" => self.transform_step(input, state),
other => Err(StepExecutionError::new(
"invalid_step",
format!("Unsupported action step kind: {other}"),
)),
}
}
}
fn image_response_attachment(provider_metadata: Value, images: Vec<llm_adapter::core::ImageArtifact>) -> Option<Value> {
let image = images.into_iter().next()?;
let mut attachment = Map::new();
if let Some(url) = image.url {
attachment.insert("url".to_string(), Value::String(url));
}
if let Some(data_base64) = image.data_base64 {
attachment.insert("data_base64".to_string(), Value::String(data_base64));
}
attachment.insert("media_type".to_string(), Value::String(image.media_type));
if let Some(width) = image.width {
attachment.insert("width".to_string(), json!(width));
}
if let Some(height) = image.height {
attachment.insert("height".to_string(), json!(height));
}
if !image.provider_metadata.is_null() {
attachment.insert("providerMetadata".to_string(), image.provider_metadata);
} else if !provider_metadata.is_null() {
attachment.insert("providerMetadata".to_string(), provider_metadata);
}
if !attachment.contains_key("url") && !attachment.contains_key("data_base64") {
return None;
}
Some(Value::Object(attachment))
}
fn validate_value(label: &str, schema: &Value, value: &Value) -> Result<()> {
validate_json_schema(label, schema, value).map_err(|error| invalid_input(error.message))
}
@@ -0,0 +1,240 @@
use serde_json::{Map, Value};
pub(super) fn project_slides_outline_markdown(value: &Value) -> Result<String, String> {
let text = match value {
Value::String(text) => text.as_str(),
Value::Object(object) => {
if let Some(Value::String(text)) = object.get("result") {
text
} else if let Some(Value::String(text)) = object.get("content") {
text
} else if let Some(Value::String(text)) = object.get("text") {
text
} else {
return Err("slidesOutlineMarkdown requires a string result".to_string());
}
}
_ => return Err("slidesOutlineMarkdown requires a string result".to_string()),
};
if is_markdown_list(text) {
return Ok(text.to_string());
}
let mut projected = Vec::new();
for line in text.lines().filter(|line| !line.trim().is_empty()) {
let item = serde_json::from_str::<Value>(line)
.map_err(|_| "slidesOutlineMarkdown requires markdown or NDJSON object lines".to_string())?;
if !item.is_object() {
return Err("slidesOutlineMarkdown requires markdown or NDJSON object lines".to_string());
}
projected.push(render_slide_item(&item)?);
}
if projected.is_empty() {
Err("slidesOutlineMarkdown requires markdown or NDJSON object lines".to_string())
} else {
Ok(projected.join("\n"))
}
}
fn is_markdown_list(text: &str) -> bool {
let mut saw_line = false;
for line in text.lines().map(str::trim_start).filter(|line| !line.trim().is_empty()) {
saw_line = true;
if !(line.starts_with("- ") || line.starts_with("* ") || line.starts_with("+ ")) {
return false;
}
}
saw_line
}
fn render_legacy_slide_item(item: &Value) -> Option<String> {
let kind = item.get("type").and_then(Value::as_str)?;
let content = item.get("content").and_then(value_to_optional_string)?;
if content.is_empty() {
return None;
}
match kind {
"name" => Some(format!("- {content}")),
"title" => Some(format!(" - {content}")),
"content" => {
if content.contains('\n') {
Some(
content
.lines()
.map(|line| format!(" - {line}"))
.collect::<Vec<_>>()
.join("\n"),
)
} else {
Some(format!(" - {content}"))
}
}
_ => None,
}
}
fn render_slide_item(item: &Value) -> Result<String, String> {
if let Some(markdown) = render_legacy_slide_item(item) {
return Ok(markdown);
}
if item.get("content").and_then(Value::as_object).is_some() {
return render_structured_slide_item(item);
}
if item.get("content").and_then(Value::as_str).is_some() {
return render_labeled_string_slide_item(item);
}
Err("slidesOutlineMarkdown item is not a recognized slide outline object".to_string())
}
fn render_labeled_string_slide_item(item: &Value) -> Result<String, String> {
let content = item
.get("content")
.and_then(Value::as_str)
.ok_or_else(|| "slidesOutlineMarkdown labeled item requires string content".to_string())?;
if content.trim().is_empty() {
return Err("slidesOutlineMarkdown labeled item requires string content".to_string());
}
let labels = parse_labeled_segments(content);
let title = labels
.get("title")
.cloned()
.filter(|value| !value.is_empty())
.ok_or_else(|| "slidesOutlineMarkdown labeled item requires Title".to_string())?;
let keywords = labels
.get("image keywords")
.cloned()
.or_else(|| labels.get("keywords").cloned())
.filter(|value| !value.is_empty())
.ok_or_else(|| "slidesOutlineMarkdown labeled item requires Image Keywords".to_string())?;
let description = labels
.get("description")
.cloned()
.or_else(|| labels.get("content").cloned())
.filter(|value| !value.is_empty())
.ok_or_else(|| "slidesOutlineMarkdown labeled item requires Description".to_string())?;
Ok(
[
format!("- {title}"),
format!(" - {title}"),
format!(" - {keywords}"),
format!(" - {description}"),
]
.join("\n"),
)
}
fn render_structured_slide_item(item: &Value) -> Result<String, String> {
let item_object = item
.as_object()
.ok_or_else(|| "slidesOutlineMarkdown structured item requires object content".to_string())?;
let content = item
.get("content")
.and_then(Value::as_object)
.ok_or_else(|| "slidesOutlineMarkdown structured item requires object content".to_string())?;
let title = string_prop(content, &["title", "name", "page_name", "pageName"])
.or_else(|| string_prop(item_object, &["title", "name", "page_name", "pageName", "page"]))
.filter(|value| !value.is_empty())
.ok_or_else(|| "slidesOutlineMarkdown requires slide title".to_string())?;
let sections = content.get("sections").and_then(Value::as_array);
let rendered_sections = if let Some(sections) = sections.filter(|sections| !sections.is_empty()) {
sections
.iter()
.enumerate()
.map(|(index, section)| render_slide_section(section, index + 1))
.collect::<Result<Vec<_>, _>>()?
.into_iter()
.flatten()
.collect::<Vec<_>>()
} else {
render_slide_object(content)?
};
Ok(
std::iter::once(format!("- {title}"))
.chain(rendered_sections)
.collect::<Vec<_>>()
.join("\n"),
)
}
fn parse_labeled_segments(text: &str) -> std::collections::HashMap<String, String> {
text
.split(';')
.filter_map(|segment| {
let (key, value) = segment.split_once(':')?;
let key = key.trim().to_ascii_lowercase();
let value = value.trim().to_string();
if key.is_empty() || value.is_empty() {
None
} else {
Some((key, value))
}
})
.collect()
}
fn render_slide_section(section: &Value, index: usize) -> Result<Vec<String>, String> {
let Some(object) = section.as_object() else {
return Err(format!("slidesOutlineMarkdown section {index} requires object content"));
};
render_slide_object(object)
}
fn render_slide_object(object: &Map<String, Value>) -> Result<Vec<String>, String> {
let title = required_string_prop(
object,
&["title", "name", "section", "page_name", "pageName"],
"slide section title",
)?;
let keywords = string_prop(
object,
&["image_keywords", "imageKeywords", "keywords", "image_keywords_optional"],
)
.filter(|value| !value.is_empty())
.unwrap_or_else(|| title.clone());
let content = required_string_prop(
object,
&["content", "description", "summary", "text"],
"slide section content",
)?;
Ok(vec![
format!(" - {title}"),
format!(" - {keywords}"),
format!(" - {content}"),
])
}
fn string_prop(object: &Map<String, Value>, keys: &[&str]) -> Option<String> {
keys
.iter()
.find_map(|key| object.get(*key).and_then(value_to_optional_string))
}
fn required_string_prop(object: &Map<String, Value>, keys: &[&str], name: &str) -> Result<String, String> {
string_prop(object, keys)
.filter(|value| !value.is_empty())
.ok_or_else(|| format!("slidesOutlineMarkdown requires {name}"))
}
fn value_to_optional_string(value: &Value) -> Option<String> {
match value {
Value::String(text) => Some(text.clone()),
Value::Number(number) => Some(number.to_string()),
Value::Array(items) => {
let joined = items
.iter()
.filter_map(value_to_optional_string)
.filter(|value| !value.is_empty())
.collect::<Vec<_>>()
.join(", ");
Some(joined)
}
_ => None,
}
}
@@ -0,0 +1,854 @@
use napi::Status;
use serde_json::json;
use super::{
ACTION_ABORTED_ERROR_CODE, ActionEventType, ActionRecipe, ActionRecipeStep, ActionRunStatus, ActionRuntimeControl,
ActionRuntimeInput, ActionStepKind, load_catalog, run_action_recipe_for_test,
run_action_recipe_for_test_with_control, run_action_recipe_prepared_with_control, validate_catalog, validate_recipe,
};
#[test]
fn validates_built_in_recipe_catalog() {
let catalog = load_catalog().unwrap();
let mindmap = catalog.iter().find(|recipe| recipe.id == "mindmap.generate").unwrap();
assert!(
mindmap
.steps
.iter()
.any(|step| step.kind == ActionStepKind::PromptStructured)
);
assert!(
mindmap
.steps
.iter()
.any(|step| step.kind == ActionStepKind::ValidateJson)
);
let slides = catalog.iter().find(|recipe| recipe.id == "slides.outline").unwrap();
assert!(
slides
.steps
.iter()
.any(|step| step.id == "project-outline" && step.kind == ActionStepKind::Transform)
);
assert!(catalog.iter().any(|recipe| recipe.id == "transcript.audio.gemini"));
assert!(!catalog.iter().any(|recipe| recipe.id == "transcript.audio.local-asr"));
}
#[test]
fn built_in_transcript_action_final_result_is_schema_checked() {
let output = run_action_recipe_prepared_with_control(
ActionRuntimeInput {
recipe_id: "transcript.audio.gemini".to_string(),
recipe_version: Some("v1".to_string()),
input: json!({
"sourceAudio": { "blobId": "blob-1", "mimeType": "audio/opus" },
"quality": null,
"infos": [{ "url": "https://example.com/audio.opus", "mimeType": "audio/opus", "index": 0 }],
"sliceManifest": [{
"index": 0,
"fileName": "audio.opus",
"mimeType": "audio/opus",
"startSec": 12,
"durationSec": 30,
"byteSize": 42
}],
}),
},
mock_control(json!({
"transcribe": {
"normalizedTranscript": "00:00:01 A: Hello",
"summaryJson": {
"title": "Sync",
"durationMinutes": 1,
"attendees": ["A"],
"keyPoints": ["Hello"],
"actionItems": [],
"decisions": [],
"openQuestions": [],
"blockers": []
},
"providerMeta": { "provider": "gemini", "model": "gemini-2.5-flash" }
}
})),
)
.unwrap();
assert_eq!(output.status, ActionRunStatus::Succeeded);
assert_eq!(output.result["version"], json!("transcript-result-v1"));
assert_eq!(output.result["strategy"], json!("gemini"));
assert_eq!(output.result["normalizedSegments"], json!(null));
assert_eq!(output.result["sourceAudio"]["blobId"], json!("blob-1"));
assert_eq!(
output.result["infos"][0]["url"],
json!("https://example.com/audio.opus")
);
assert_eq!(output.result["sliceManifest"][0]["startSec"], json!(12));
}
#[test]
fn built_in_transcript_action_rejects_malformed_summary() {
let error = run_action_recipe_prepared_with_control(
ActionRuntimeInput {
recipe_id: "transcript.audio.gemini".to_string(),
recipe_version: Some("v1".to_string()),
input: json!({}),
},
mock_control(json!({
"transcribe": {
"normalizedTranscript": "00:00:01 A: Hello",
"summaryJson": { "title": "Sync" },
"providerMeta": { "provider": "gemini", "model": "gemini-2.5-flash" }
}
})),
)
.unwrap_err();
assert!(error.reason.contains("does not match JSON schema"));
}
#[test]
fn built_in_action_final_result_comes_from_prompt_output_state() {
let output = run_action_recipe_prepared_with_control(
ActionRuntimeInput {
recipe_id: "mindmap.generate".to_string(),
recipe_version: Some("v1".to_string()),
input: json!({}),
},
mock_control(json!({
"generate-structured": {
"result": "- Root"
}
})),
)
.unwrap();
assert_eq!(output.status, ActionRunStatus::Succeeded);
assert_eq!(output.result, json!("- Root"));
assert_eq!(output.state["generated"], json!("- Root"));
}
#[test]
fn built_in_action_unwraps_structured_text_result() {
let output = run_action_recipe_prepared_with_control(
ActionRuntimeInput {
recipe_id: "mindmap.generate".to_string(),
recipe_version: Some("v1".to_string()),
input: json!({}),
},
mock_control(json!({
"generate-structured": {
"result": "- Root"
}
})),
)
.unwrap();
assert_eq!(output.status, ActionRunStatus::Succeeded);
assert_eq!(output.result, json!("- Root"));
assert_eq!(output.state["generated"], json!("- Root"));
}
#[test]
fn built_in_slides_outline_projects_final_result_to_markdown() {
let outline = [
serde_json::to_string(&json!({
"page": "Cover",
"type": "cover",
"content": {
"title": "Apple Inc.",
"description": "Company overview",
"image_keywords": ["Apple logo", "Apple Park"]
}
}))
.unwrap(),
serde_json::to_string(&json!({
"page": 2,
"type": "content",
"content": {
"title": "Products",
"sections": [{
"title": "iPhone",
"keywords": ["smartphone", "iOS"],
"content": "Flagship product line"
}]
}
}))
.unwrap(),
serde_json::to_string(&json!({
"page": 3,
"type": "cover",
"content": "Page Name: Closing; Title: Outlook; Description: Future strategy; Image Keywords: roadmap, devices"
}))
.unwrap(),
]
.join("\n");
let output = run_action_recipe_prepared_with_control(
ActionRuntimeInput {
recipe_id: "slides.outline".to_string(),
recipe_version: Some("v1".to_string()),
input: json!({}),
},
mock_control(json!({
"generate-structured": {
"result": outline
}
})),
)
.unwrap();
assert_eq!(output.status, ActionRunStatus::Succeeded);
assert_eq!(
output.result,
json!(
[
"- Apple Inc.",
" - Apple Inc.",
" - Apple logo, Apple Park",
" - Company overview",
"- Products",
" - iPhone",
" - smartphone, iOS",
" - Flagship product line",
"- Outlook",
" - Outlook",
" - roadmap, devices",
" - Future strategy"
]
.join("\n")
)
);
assert_eq!(
output
.steps
.iter()
.find(|step| step.id == "project-outline")
.and_then(|step| step.output.as_ref()),
Some(&output.result)
);
}
#[test]
fn slides_outline_transform_keeps_legacy_markdown_shape() {
let outline = [
serde_json::to_string(&json!({ "page": 1, "type": "name", "content": "Launch deck" })).unwrap(),
serde_json::to_string(&json!({ "page": 1, "type": "title", "content": "Context" })).unwrap(),
serde_json::to_string(&json!({ "page": 1, "type": "content", "content": "Problem\nOpportunity" })).unwrap(),
]
.join("\n");
let recipe = test_recipe(vec![
ActionRecipeStep {
id: "project-outline".to_string(),
kind: ActionStepKind::Transform,
input: Some(json!({
"slidesOutlineMarkdown": { "$state": "outline" },
"outputKey": "outlineMarkdown"
})),
state_patch: None,
},
ActionRecipeStep {
id: "final".to_string(),
kind: ActionStepKind::Final,
input: Some(json!({ "copy": { "$state": "outlineMarkdown" } })),
state_patch: None,
},
]);
let output = run_action_recipe_for_test(
recipe,
runtime_input(json!({
"outline": outline
})),
)
.unwrap();
assert_eq!(output.status, ActionRunStatus::Succeeded);
assert_eq!(
output.result,
json!(["- Launch deck", " - Context", " - Problem", " - Opportunity"].join("\n"))
);
}
#[test]
fn slides_outline_transform_rejects_unrecognized_text() {
let recipe = test_recipe(vec![
ActionRecipeStep {
id: "project-outline".to_string(),
kind: ActionStepKind::Transform,
input: Some(json!({
"slidesOutlineMarkdown": { "$state": "outline" },
"outputKey": "outlineMarkdown"
})),
state_patch: None,
},
ActionRecipeStep {
id: "final".to_string(),
kind: ActionStepKind::Final,
input: Some(json!({ "copy": { "$state": "outlineMarkdown" } })),
state_patch: None,
},
]);
let output = run_action_recipe_for_test(
recipe,
runtime_input(json!({
"outline": "not valid ndjson"
})),
)
.unwrap();
assert_eq!(output.status, ActionRunStatus::Failed);
assert_eq!(output.error_code, Some("action_invalid_step".to_string()));
assert_eq!(
output.events.last().and_then(|event| event.error_message.as_deref()),
Some("slidesOutlineMarkdown requires markdown or NDJSON object lines")
);
}
#[test]
fn slides_outline_transform_accepts_cover_without_image_keywords() {
let outline = serde_json::to_string(&json!({
"page": 1,
"type": "cover",
"content": {
"title": "Launch deck",
"description": "Overview"
}
}))
.unwrap();
let recipe = test_recipe(vec![
ActionRecipeStep {
id: "project-outline".to_string(),
kind: ActionStepKind::Transform,
input: Some(json!({
"slidesOutlineMarkdown": { "$state": "outline" },
"outputKey": "outlineMarkdown"
})),
state_patch: None,
},
ActionRecipeStep {
id: "final".to_string(),
kind: ActionStepKind::Final,
input: Some(json!({ "copy": { "$state": "outlineMarkdown" } })),
state_patch: None,
},
]);
let output = run_action_recipe_for_test(
recipe,
runtime_input(json!({
"outline": outline
})),
)
.unwrap();
assert_eq!(
output.result,
json!(
[
"- Launch deck",
" - Launch deck",
" - Launch deck",
" - Overview"
]
.join("\n")
)
);
}
#[test]
fn slides_outline_transform_accepts_page_name_from_item() {
let outline = serde_json::to_string(&json!({
"page": 2,
"type": "content",
"page_name": "Workspace Benefits",
"content": {
"sections": [
{
"section": "Unified writing",
"keywords": ["docs", "canvas"],
"text": "AFFiNE combines documents and whiteboards."
}
]
}
}))
.unwrap();
let recipe = test_recipe(vec![
ActionRecipeStep {
id: "project-outline".to_string(),
kind: ActionStepKind::Transform,
input: Some(json!({
"slidesOutlineMarkdown": { "$state": "outline" },
"outputKey": "outlineMarkdown"
})),
state_patch: None,
},
ActionRecipeStep {
id: "final".to_string(),
kind: ActionStepKind::Final,
input: Some(json!({ "copy": { "$state": "outlineMarkdown" } })),
state_patch: None,
},
]);
let output = run_action_recipe_for_test(
recipe,
runtime_input(json!({
"outline": outline
})),
)
.unwrap();
assert_eq!(output.status, ActionRunStatus::Succeeded);
assert_eq!(
output.result,
json!(
[
"- Workspace Benefits",
" - Unified writing",
" - docs, canvas",
" - AFFiNE combines documents and whiteboards."
]
.join("\n")
)
);
}
#[test]
fn serializes_action_events_for_server_contract() {
let output = run_action_recipe_prepared_with_control(
ActionRuntimeInput {
recipe_id: "mindmap.generate".to_string(),
recipe_version: Some("v1".to_string()),
input: json!({}),
},
mock_control(json!({
"generate-structured": {
"result": "- Root"
}
})),
)
.unwrap();
let first = serde_json::to_value(output.events.first().unwrap()).unwrap();
let last = serde_json::to_value(output.events.last().unwrap()).unwrap();
assert_eq!(first["type"], json!("action_start"));
assert_eq!(last["type"], json!("action_done"));
assert_eq!(last["status"], json!("succeeded"));
assert_eq!(last["trace"]["status"], json!("succeeded"));
}
#[test]
fn built_in_action_fails_without_routes_or_mock_output() {
let output = run_action_recipe_prepared_with_control(
ActionRuntimeInput {
recipe_id: "mindmap.generate".to_string(),
recipe_version: Some("v1".to_string()),
input: json!({}),
},
ActionRuntimeControl::default(),
)
.unwrap();
assert_eq!(output.status, ActionRunStatus::Failed);
assert!(
output
.events
.last()
.and_then(|event| event.error_message.as_deref())
.unwrap_or_default()
.contains("promptStructured requires")
);
}
#[test]
fn built_in_image_action_uses_prompt_image_step_output() {
let output = run_action_recipe_prepared_with_control(
ActionRuntimeInput {
recipe_id: "image.filter.sketch".to_string(),
recipe_version: Some("v1".to_string()),
input: json!({}),
},
mock_control(json!({
"generate-image": {
"url": "https://example.com/artifact-1.png"
}
})),
)
.unwrap();
assert_eq!(output.status, ActionRunStatus::Succeeded);
assert_eq!(output.result, json!({ "url": "https://example.com/artifact-1.png" }));
assert_eq!(
output.state.pointer("/artifact/url"),
Some(&json!("https://example.com/artifact-1.png"))
);
}
#[test]
fn built_in_image_action_accepts_inline_artifact_output() {
let output = run_action_recipe_prepared_with_control(
ActionRuntimeInput {
recipe_id: "image.filter.sketch".to_string(),
recipe_version: Some("v1".to_string()),
input: json!({}),
},
mock_control(json!({
"generate-image": {
"data_base64": "aW1n",
"media_type": "image/webp"
}
})),
)
.unwrap();
assert_eq!(output.status, ActionRunStatus::Succeeded);
assert_eq!(
output.result,
json!({
"data_base64": "aW1n",
"media_type": "image/webp"
})
);
assert_eq!(output.state.pointer("/artifact/data_base64"), Some(&json!("aW1n")));
}
#[test]
fn rejects_invalid_recipe_without_final_step() {
let recipe = ActionRecipe {
id: "invalid.recipe".to_string(),
version: "v1".to_string(),
input_schema: json!({}),
output_schema: json!({}),
steps: vec![ActionRecipeStep {
id: "start".to_string(),
kind: ActionStepKind::ValidateJson,
input: None,
state_patch: None,
}],
};
let error = validate_recipe(&recipe).unwrap_err();
assert_eq!(error.status, Status::InvalidArg);
assert!(error.reason.contains("must end with a final step"));
}
#[test]
fn rejects_duplicated_recipe_identity() {
let recipe = ActionRecipe {
id: "duplicated.recipe".to_string(),
version: "v1".to_string(),
input_schema: json!({}),
output_schema: json!({}),
steps: vec![ActionRecipeStep {
id: "final".to_string(),
kind: ActionStepKind::Final,
input: None,
state_patch: None,
}],
};
let error = validate_catalog(&[recipe.clone(), recipe]).unwrap_err();
assert_eq!(error.status, Status::InvalidArg);
assert!(error.reason.contains("Duplicated action recipe"));
}
#[test]
fn rejects_recipe_where_final_step_is_not_last() {
let recipe = ActionRecipe {
id: "invalid.recipe".to_string(),
version: "v1".to_string(),
input_schema: json!({}),
output_schema: json!({}),
steps: vec![
ActionRecipeStep {
id: "final".to_string(),
kind: ActionStepKind::Final,
input: None,
state_patch: None,
},
ActionRecipeStep {
id: "after-final".to_string(),
kind: ActionStepKind::Transform,
input: None,
state_patch: None,
},
],
};
let error = validate_recipe(&recipe).unwrap_err();
assert_eq!(error.status, Status::InvalidArg);
assert!(error.reason.contains("must end with a final step"));
}
#[test]
fn validates_json_and_prompt_projection_steps() {
let recipe = test_recipe(vec![
ActionRecipeStep {
id: "prompt-structured".to_string(),
kind: ActionStepKind::PromptStructured,
input: Some(json!({})),
state_patch: None,
},
ActionRecipeStep {
id: "prompt-image".to_string(),
kind: ActionStepKind::PromptImage,
input: Some(json!({})),
state_patch: None,
},
ActionRecipeStep {
id: "validate-json".to_string(),
kind: ActionStepKind::ValidateJson,
input: Some(json!({
"schema": { "type": "object", "required": ["title"] },
"value": { "title": "Hello" }
})),
state_patch: None,
},
ActionRecipeStep {
id: "final".to_string(),
kind: ActionStepKind::Final,
input: Some(json!({ "copy": { "done": true } })),
state_patch: None,
},
]);
let output = run_action_recipe_for_test_with_control(
recipe,
runtime_input(json!({})),
mock_control(json!({
"prompt-structured": { "title": "Hello" },
"prompt-image": { "url": "https://example.com/artifact-1.png" }
})),
)
.unwrap();
assert_eq!(
output
.events
.iter()
.map(|event| event.event_type)
.filter(|event_type| matches!(event_type, ActionEventType::Attachment))
.collect::<Vec<_>>(),
vec![ActionEventType::Attachment]
);
assert_eq!(output.steps[2].output, Some(json!(true)));
}
#[test]
fn rejects_prompt_steps_without_prepared_routes_or_explicit_boundary() {
let recipe = test_recipe(vec![
ActionRecipeStep {
id: "prompt".to_string(),
kind: ActionStepKind::PromptStructured,
input: Some(json!({})),
state_patch: None,
},
ActionRecipeStep {
id: "final".to_string(),
kind: ActionStepKind::Final,
input: None,
state_patch: None,
},
]);
let output = run_action_recipe_for_test(recipe, runtime_input(json!({}))).unwrap();
assert_eq!(output.status, ActionRunStatus::Failed);
assert_eq!(output.error_code, Some("action_invalid_step".to_string()));
assert!(
output
.events
.last()
.and_then(|event| event.error_message.as_deref())
.unwrap_or_default()
.contains("requires")
);
}
#[test]
fn rejects_prompt_image_without_prepared_routes() {
let recipe = test_recipe(vec![
ActionRecipeStep {
id: "prompt-image".to_string(),
kind: ActionStepKind::PromptImage,
input: Some(json!({})),
state_patch: None,
},
ActionRecipeStep {
id: "final".to_string(),
kind: ActionStepKind::Final,
input: None,
state_patch: None,
},
]);
let output = run_action_recipe_for_test(recipe, runtime_input(json!({}))).unwrap();
assert_eq!(output.status, ActionRunStatus::Failed);
assert!(
output
.events
.last()
.and_then(|event| event.error_message.as_deref())
.unwrap_or_default()
.contains("preparedRoutes")
);
}
#[test]
fn validate_json_distinguishes_invalid_schema_from_invalid_value() {
let invalid_value = run_action_recipe_for_test(
test_recipe(vec![
ActionRecipeStep {
id: "validate-json".to_string(),
kind: ActionStepKind::ValidateJson,
input: Some(json!({
"schema": { "type": "object", "required": ["title"] },
"value": {}
})),
state_patch: None,
},
ActionRecipeStep {
id: "final".to_string(),
kind: ActionStepKind::Final,
input: Some(json!({ "copy": {} })),
state_patch: None,
},
]),
runtime_input(json!({})),
)
.unwrap();
assert_eq!(invalid_value.status, ActionRunStatus::Succeeded);
assert_eq!(invalid_value.steps[0].output, Some(json!(false)));
let invalid_schema = run_action_recipe_for_test(
test_recipe(vec![
ActionRecipeStep {
id: "validate-json".to_string(),
kind: ActionStepKind::ValidateJson,
input: Some(json!({
"schema": { "type": 1 },
"value": {}
})),
state_patch: None,
},
ActionRecipeStep {
id: "final".to_string(),
kind: ActionStepKind::Final,
input: None,
state_patch: None,
},
]),
runtime_input(json!({})),
)
.unwrap();
assert_eq!(invalid_schema.status, ActionRunStatus::Failed);
}
#[test]
fn emits_ordered_action_events_and_final_result() {
let output = run_action_recipe_for_test(
test_recipe(vec![ActionRecipeStep {
id: "final".to_string(),
kind: ActionStepKind::Final,
input: Some(json!({ "copy": {} })),
state_patch: Some(json!({ "finalized": true })),
}]),
ActionRuntimeInput {
recipe_id: "test.recipe".to_string(),
recipe_version: Some("v1".to_string()),
input: json!({ "content": "hello" }),
},
)
.unwrap();
assert_eq!(output.status, ActionRunStatus::Succeeded);
assert_eq!(output.result, json!({}));
assert_eq!(output.error_code, None);
assert_eq!(output.state, json!({ "content": "hello", "finalized": true }));
assert_eq!(output.steps.len(), 1);
assert_eq!(output.steps[0].id, "final");
assert_eq!(output.steps[0].output, Some(json!({})));
assert_eq!(output.steps[0].state_patch, Some(json!({ "finalized": true })));
assert_eq!(output.steps[0].error, None);
assert_eq!(
output.events.iter().map(|event| event.event_type).collect::<Vec<_>>(),
vec![
ActionEventType::ActionStart,
ActionEventType::StepStart,
ActionEventType::StepEnd,
ActionEventType::ActionDone,
]
);
}
fn runtime_input(input: serde_json::Value) -> ActionRuntimeInput {
ActionRuntimeInput {
recipe_id: "test.recipe".to_string(),
recipe_version: Some("v1".to_string()),
input,
}
}
fn mock_control(mock_output: serde_json::Value) -> ActionRuntimeControl {
ActionRuntimeControl {
abort_signal: None,
event_sender: None,
abort_after_events: None,
mock_output: Some(mock_output),
}
}
fn test_recipe(steps: Vec<ActionRecipeStep>) -> ActionRecipe {
ActionRecipe {
id: "test.recipe".to_string(),
version: "v1".to_string(),
input_schema: json!({}),
output_schema: json!({}),
steps,
}
}
#[test]
fn generates_lightweight_trace() {
let output = run_action_recipe_for_test(
test_recipe(vec![ActionRecipeStep {
id: "final".to_string(),
kind: ActionStepKind::Final,
input: Some(json!({ "copy": {} })),
state_patch: None,
}]),
ActionRuntimeInput {
recipe_id: "test.recipe".to_string(),
recipe_version: Some("v1".to_string()),
input: json!({}),
},
)
.unwrap();
assert_eq!(output.trace.status, ActionRunStatus::Succeeded);
assert!(!output.trace.lightweight.is_empty());
}
#[test]
fn abort_control_stops_runtime() {
let output = run_action_recipe_prepared_with_control(
ActionRuntimeInput {
recipe_id: "image.filter.sketch".to_string(),
recipe_version: Some("v1".to_string()),
input: json!({}),
},
ActionRuntimeControl {
abort_signal: None,
event_sender: None,
abort_after_events: Some(1),
mock_output: None,
},
)
.unwrap();
assert_eq!(output.status, ActionRunStatus::Aborted);
assert_eq!(output.error_code, Some(ACTION_ABORTED_ERROR_CODE.to_string()));
assert_eq!(
output.events.last().map(|event| event.event_type),
Some(ActionEventType::Error)
);
}