finalize run

⚒️ Generated with [Fabro](https://fabro.sh)
This commit is contained in:
Fabro 2026-05-24 14:09:48 -04:00
parent a084b7551b
commit 232736d658
6 changed files with 983 additions and 138 deletions

453
run.json

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,647 @@
diff --git a/apps/fabro-web/app/components/stage-insights-sidebar.tsx b/apps/fabro-web/app/components/stage-insights-sidebar.tsx
index 1b534c181..cc6e496a0 100644
--- a/apps/fabro-web/app/components/stage-insights-sidebar.tsx
+++ b/apps/fabro-web/app/components/stage-insights-sidebar.tsx
@@ -263,22 +263,22 @@ function TodoSection({ todos }: { todos: TodoListProjection | null }) {
}
function TodoRow({ todo }: { todo: TodoProjection }) {
- const { Icon, color, srLabel } = todoStatusVisual(todo.status);
+ const { Icon, color, srLabel, spin } = todoStatusVisual(todo.status);
const muted = todo.status === TodoStatus.COMPLETED;
return (
<li className="flex items-start gap-1.5">
- <Icon className={`mt-0.5 size-3.5 shrink-0 ${color}`} aria-label={srLabel} />
+ <Icon className={`mt-0.5 size-3.5 shrink-0 ${color} ${spin ? "animate-spin" : ""}`} aria-label={srLabel} />
<span className={`min-w-0 text-xs ${muted ? "text-fg-muted line-through" : "text-fg-2"}`}>{todo.subject}</span>
</li>
);
}
-function todoStatusVisual(status: TodoStatus): { Icon: IconType; color: string; srLabel: string } {
+function todoStatusVisual(status: TodoStatus): { Icon: IconType; color: string; srLabel: string; spin?: boolean } {
switch (status) {
case TodoStatus.COMPLETED:
return { Icon: CheckCircleIcon, color: "text-mint", srLabel: "Completed" };
case TodoStatus.IN_PROGRESS:
- return { Icon: ArrowPathIcon, color: "text-teal-500", srLabel: "In progress" };
+ return { Icon: ArrowPathIcon, color: "text-teal-500", srLabel: "In progress", spin: true };
case TodoStatus.DELETED:
return { Icon: XCircleIcon, color: "text-fg-muted", srLabel: "Deleted" };
case TodoStatus.PENDING:
diff --git a/apps/fabro-web/app/routes/runs.preferences.test.tsx b/apps/fabro-web/app/routes/runs.preferences.test.tsx
index 003e44ae1..d3ad1cc1f 100644
--- a/apps/fabro-web/app/routes/runs.preferences.test.tsx
+++ b/apps/fabro-web/app/routes/runs.preferences.test.tsx
@@ -244,6 +244,28 @@ describe("Runs workspace preference restoration", () => {
expect(JSON.parse(storage.getItem(RUNS_PREFERENCES_STORAGE_KEY) ?? "{}").view).toBe("columns");
});
+ test("clicking a sort header in list view updates the URL while preserving other params", async () => {
+ const { renderer, router } = await renderRuns("/runs?view=list&archived=1");
+
+ await act(async () => {
+ compositeByName(renderer, "SortHeader", (props) => props.sortKey === "status").props.onClick("status");
+ });
+
+ expect(router.state.location.search).toContain("sort=status");
+ expect(router.state.location.search).toContain("view=list");
+ expect(router.state.location.search).toContain("archived=1");
+
+ // Clicking the same header again toggles direction to ascending.
+ await act(async () => {
+ compositeByName(renderer, "SortHeader", (props) => props.sortKey === "status").props.onClick("status");
+ });
+
+ expect(router.state.location.search).toContain("sort=status");
+ expect(router.state.location.search).toContain("direction=asc");
+ expect(router.state.location.search).toContain("view=list");
+ expect(router.state.location.search).toContain("archived=1");
+ });
+
test("changing filters and hidden columns persists them", async () => {
const { renderer } = await renderRuns("/runs?view=list");
diff --git a/apps/fabro-web/app/routes/runs.test.tsx b/apps/fabro-web/app/routes/runs.test.tsx
index 85e9a4dd0..0cea3dca4 100644
--- a/apps/fabro-web/app/routes/runs.test.tsx
+++ b/apps/fabro-web/app/routes/runs.test.tsx
@@ -5,7 +5,7 @@ import {
buildBoardColumns,
loadStoredRunsWorkspaceSearchParams,
placeArchivedColumnLast,
- persistRunsWorkspaceSearchParams,
+ persistRunsWorkspacePreferences,
RUNS_PREFERENCES_STORAGE_KEY,
runsQuickStartCommands,
shouldRefreshBoardForEvent,
@@ -279,11 +279,24 @@ describe("runs route workspace preferences", () => {
test("persisting preferences omits page and stores canonical values", () => {
const storage = new MemoryStorage();
- const params = new URLSearchParams(
- "view=columns&search=abc&created=1d&sort=made-up&direction=asc&size=100&page=9&hide=unknown,workflow,repo",
- );
- persistRunsWorkspaceSearchParams(params, storage);
+ persistRunsWorkspacePreferences(
+ {
+ version: 1,
+ view: "columns",
+ search: "abc",
+ repo: "all",
+ workflow: "all",
+ created: "1d",
+ archived: false,
+ sort: "created_at",
+ direction: "asc",
+ size: 100,
+ hide: "repo,workflow",
+ page: 9,
+ },
+ storage,
+ );
expect(JSON.parse(storage.getItem(RUNS_PREFERENCES_STORAGE_KEY) ?? "{}")).toEqual({
version: 1,
diff --git a/apps/fabro-web/app/routes/runs.tsx b/apps/fabro-web/app/routes/runs.tsx
index 7d5878b86..9f6a2aec3 100644
--- a/apps/fabro-web/app/routes/runs.tsx
+++ b/apps/fabro-web/app/routes/runs.tsx
@@ -693,6 +693,8 @@ interface RunsWorkspacePreferences {
direction: ListRunsDirectionEnum;
size: number;
hide: string;
+ // URL-only: never persisted to localStorage.
+ page: number;
}
function defaultRunsWorkspacePreferences(): RunsWorkspacePreferences {
@@ -708,6 +710,7 @@ function defaultRunsWorkspacePreferences(): RunsWorkspacePreferences {
direction: "desc",
size: DEFAULT_LIST_PAGE_SIZE,
hide: "",
+ page: 1,
};
}
@@ -754,6 +757,7 @@ function normalizeStoredRunsWorkspacePreferences(value: unknown): RunsWorkspaceP
direction: parseDirection(stringValue(record.direction)),
size: parsePageSize(typeof size === "number" || typeof size === "string" ? String(size) : null),
hide: serializeHiddenColumns(hiddenColumns) ?? "",
+ page: 1,
};
}
@@ -770,6 +774,7 @@ function runsWorkspacePreferencesFromSearchParams(searchParams: URLSearchParams)
direction: parseDirection(searchParams.get("direction")),
size: parsePageSize(searchParams.get("size")),
hide: serializeHiddenColumns(parseHiddenColumns(searchParams.get("hide"))) ?? "",
+ page: parsePage(searchParams.get("page")),
};
}
@@ -785,6 +790,7 @@ function runsWorkspacePreferencesToSearchParams(preferences: RunsWorkspacePrefer
if (preferences.direction === "asc") params.set("direction", "asc");
if (preferences.size !== DEFAULT_LIST_PAGE_SIZE) params.set("size", String(preferences.size));
if (preferences.hide !== "") params.set("hide", preferences.hide);
+ if (preferences.page > 1) params.set("page", String(preferences.page));
return params;
}
@@ -821,16 +827,15 @@ export function resolveRunsWorkspaceSearchParams(
return stored.toString() === "" ? urlSearchParams : stored;
}
-export function persistRunsWorkspaceSearchParams(
- searchParams: URLSearchParams,
+export function persistRunsWorkspacePreferences(
+ preferences: RunsWorkspacePreferences,
storage: Pick<Storage, "setItem"> | null = runsPreferencesStorage(),
) {
if (storage == null) return;
+ // `page` is URL-only ephemeral view state; strip it before persisting.
+ const { page: _page, ...storable } = preferences;
try {
- storage.setItem(
- RUNS_PREFERENCES_STORAGE_KEY,
- JSON.stringify(runsWorkspacePreferencesFromSearchParams(searchParams)),
- );
+ storage.setItem(RUNS_PREFERENCES_STORAGE_KEY, JSON.stringify(storable));
} catch {
// localStorage persistence is best effort only.
}
@@ -1821,55 +1826,59 @@ export default function Runs() {
[searchParams],
);
- const updateParam = useCallback(
- (key: string, value: string | null) => {
- const next = new URLSearchParams(searchParams);
- if (value == null || value === "") {
- next.delete(key);
- } else {
- next.set(key, value);
- }
- persistRunsWorkspaceSearchParams(next);
- setSearchParams(next, { replace: true });
+ const updatePreferences = useCallback(
+ (updater: (prev: RunsWorkspacePreferences) => RunsWorkspacePreferences) => {
+ setSearchParams(
+ (prevParams) => {
+ const next = updater(runsWorkspacePreferencesFromSearchParams(prevParams));
+ persistRunsWorkspacePreferences(next);
+ return runsWorkspacePreferencesToSearchParams(next);
+ },
+ { replace: true },
+ );
},
- [searchParams, setSearchParams],
+ [setSearchParams],
);
- const setQuery = (value: string) => updateParam("search", value || null);
- const setRepoFilter = (value: string) => updateParam("repo", value === "all" ? null : value);
- const setWorkflowFilter = (value: string) => updateParam("workflow", value === "all" ? null : value);
- const setCreatedFilter = (value: CreatedFilter) => updateParam("created", value === "all" ? null : value);
- const setIncludeArchived = (value: boolean) => updateParam("archived", value ? "1" : null);
- const setView = (value: ViewMode) => updateParam("view", value === "columns" ? null : value);
+ const setQuery = (value: string) =>
+ updatePreferences((prev) => ({ ...prev, search: value }));
+ const setRepoFilter = (value: string) =>
+ updatePreferences((prev) => ({ ...prev, repo: value }));
+ const setWorkflowFilter = (value: string) =>
+ updatePreferences((prev) => ({ ...prev, workflow: value }));
+ const setCreatedFilter = (value: CreatedFilter) =>
+ updatePreferences((prev) => ({ ...prev, created: value }));
+ const setIncludeArchived = (value: boolean) =>
+ updatePreferences((prev) => ({ ...prev, archived: value }));
+ const setView = (value: ViewMode) =>
+ updatePreferences((prev) => ({ ...prev, view: value }));
const setPage = useCallback(
- (next: number) => updateParam("page", next > 1 ? String(next) : null),
- [updateParam],
+ (next: number) => updatePreferences((prev) => ({ ...prev, page: next })),
+ [updatePreferences],
);
const setPageSize = useCallback(
- (next: number) => {
- updateParam("size", next === DEFAULT_LIST_PAGE_SIZE ? null : String(next));
- updateParam("page", null);
- },
- [updateParam],
+ (next: number) => updatePreferences((prev) => ({ ...prev, size: next, page: 1 })),
+ [updatePreferences],
);
const setHiddenColumns = useCallback(
- (next: Set<ToggleableColumn>) => updateParam("hide", serializeHiddenColumns(next)),
- [updateParam],
+ (next: Set<ToggleableColumn>) =>
+ updatePreferences((prev) => ({ ...prev, hide: serializeHiddenColumns(next) ?? "" })),
+ [updatePreferences],
);
const handleSortClick = useCallback(
- (key: ListRunsSortEnum) => {
- if (sort === key) {
- updateParam("direction", direction === "asc" ? null : "asc");
- } else {
- updateParam("sort", key === "created_at" ? null : key);
- updateParam("direction", null);
- }
- updateParam("page", null);
- },
- [sort, direction, updateParam],
+ (key: ListRunsSortEnum) =>
+ updatePreferences((prev) =>
+ prev.sort === key
+ ? { ...prev, direction: prev.direction === "asc" ? "desc" : "asc", page: 1 }
+ : { ...prev, sort: key, direction: "desc", page: 1 },
+ ),
+ [updatePreferences],
);
+ const hydratedFromStorage = useRef(false);
useEffect(() => {
+ if (hydratedFromStorage.current) return;
+ hydratedFromStorage.current = true;
if (searchParams === urlSearchParams) return;
setSearchParams(searchParams, { replace: true });
}, [searchParams, urlSearchParams, setSearchParams]);
diff --git a/lib/crates/fabro-agent/src/session.rs b/lib/crates/fabro-agent/src/session.rs
index f6921361b..9c3a5e4d9 100644
--- a/lib/crates/fabro-agent/src/session.rs
+++ b/lib/crates/fabro-agent/src/session.rs
@@ -592,7 +592,7 @@ impl Session {
} else {
let skills_dir = fabro_util::Home::from_env().skills_dir();
let skills_str = skills_dir.to_string_lossy().to_string();
- default_skill_dirs(Some(&skills_str), self.config.git_root.as_deref())
+ default_skill_dirs(Some(&skills_str), Some(&doc_root))
};
self.skills = discover_skills(self.sandbox.as_ref(), &skill_dirs, &cancel_token).await?;
debug!(skill_count = self.skills.len(), "Skills discovered");
diff --git a/lib/crates/fabro-server/tests/it/scenario/run_completion.rs b/lib/crates/fabro-server/tests/it/scenario/run_completion.rs
index fa0f6a4a7..4dfec9f30 100644
--- a/lib/crates/fabro-server/tests/it/scenario/run_completion.rs
+++ b/lib/crates/fabro-server/tests/it/scenario/run_completion.rs
@@ -1,14 +1,83 @@
+use std::sync::Arc;
+
use axum::body::Body;
use axum::http::{Request, StatusCode};
+use fabro_auth::EnvCredentialSource;
+use fabro_model::{Catalog, ProviderId};
+use fabro_test::{TwinScenario, TwinScenarios, twin_openai};
+use fabro_types::RunId;
use tokio::time::sleep;
use tower::ServiceExt;
use crate::helpers::{
- MINIMAL_DOT, api, checked_response, create_and_start_run_from_manifest,
+ MINIMAL_DOT, api, checked_response, create_and_start_run_from_manifest, minimal_manifest_json,
minimal_manifest_json_with_dry_run, response_text, test_app_state_with_options,
test_app_with_scheduler, test_settings, wait_for_run_status,
};
+const OPENAI_AGENT_MODEL: &str = "gpt-5.4";
+
+const PROJECT_SKILL_AGENT_DOT: &str = r#"digraph ProjectSkillAgent {
+ graph [goal="Verify project skills are visible to agent runs"]
+ rankdir=LR
+
+ start [shape=Mdiamond, label="Start"]
+ exit [shape=Msquare, label="Exit"]
+
+ work [shape=box, label="Work", prompt="Respond with done."]
+
+ start -> work -> exit
+}"#;
+
+fn test_app_with_openai_agent_backend(openai_base_url: String, api_key: String) -> axum::Router {
+ let settings = test_settings();
+ let llm_catalog_settings =
+ fabro_server::test_support::llm_catalog_settings_with_provider_base_url(
+ "openai",
+ openai_base_url,
+ );
+ let catalog = Arc::new(
+ Catalog::from_builtin_with_overrides(&llm_catalog_settings)
+ .expect("test catalog should build"),
+ );
+ let source_api_key = api_key.clone();
+ let env_api_key = api_key;
+ let llm_source: Arc<dyn fabro_auth::CredentialSource> = Arc::new(
+ EnvCredentialSource::with_env_lookup(Arc::new(move |name| match name {
+ "OPENAI_API_KEY" => Some(source_api_key.clone()),
+ _ => None,
+ })),
+ );
+ let state = fabro_server::test_support::TestAppStateBuilder::new()
+ .runtime_settings(settings.server_settings, settings.manifest_run_defaults)
+ .max_concurrent_runs(5)
+ .llm_catalog_settings(llm_catalog_settings)
+ .registry_factory(move |interviewer| {
+ let catalog = Arc::clone(&catalog);
+ let llm_source = Arc::clone(&llm_source);
+ let emitter = Arc::new(fabro_workflow::event::Emitter::new(RunId::new()));
+ let steering_hub = Arc::new(fabro_workflow::SteeringHub::new(emitter));
+ fabro_workflow::handler::default_registry(interviewer, move || {
+ Some(Box::new(
+ fabro_workflow::handler::llm::AgentApiBackend::new_with_catalog(
+ OPENAI_AGENT_MODEL.to_string(),
+ ProviderId::openai(),
+ Vec::new(),
+ Arc::clone(&llm_source),
+ Arc::clone(&steering_hub),
+ Arc::clone(&catalog),
+ ),
+ ))
+ })
+ })
+ .env_lookup(move |name| match name {
+ "OPENAI_API_KEY" => Some(env_api_key.clone()),
+ _ => None,
+ })
+ .build();
+ test_app_with_scheduler(state)
+}
+
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn run_completes_and_status_is_completed() {
let state = test_app_state_with_options(test_settings(), 5);
@@ -22,6 +91,62 @@ async fn run_completes_and_status_is_completed() {
assert_eq!(status, "succeeded");
}
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn agent_run_includes_project_skills_from_local_sandbox_working_directory() {
+ let project = tempfile::tempdir().expect("project tempdir should create");
+ let skill_dir = project
+ .path()
+ .join(".fabro")
+ .join("skills")
+ .join("local-server-project-skill");
+ tokio::fs::create_dir_all(&skill_dir)
+ .await
+ .expect("project skill dir should create");
+ tokio::fs::write(
+ skill_dir.join("SKILL.md"),
+ "---\nname: local-server-project-skill\ndescription: Project-only skill\n---\nUse the project skill.\n",
+ )
+ .await
+ .expect("project skill should write");
+
+ let twin = twin_openai().await;
+ let namespace = format!("{}::{}", module_path!(), line!());
+ TwinScenarios::new(&namespace)
+ .scenario(
+ TwinScenario::responses(OPENAI_AGENT_MODEL)
+ .stream(true)
+ .text("Done"),
+ )
+ .load(twin)
+ .await;
+ let app = test_app_with_openai_agent_backend(twin.base_url.clone(), namespace.clone());
+
+ let mut manifest = minimal_manifest_json(PROJECT_SKILL_AGENT_DOT);
+ manifest["title"] = serde_json::Value::String("Project skill agent".to_string());
+ manifest["cwd"] = serde_json::Value::String(project.path().display().to_string());
+ let run_id = create_and_start_run_from_manifest(&app, manifest).await;
+
+ let status = wait_for_run_status(&app, &run_id, &["succeeded", "failed"]).await;
+ assert_eq!(status, "succeeded");
+ let logs = twin.request_logs(&namespace).await;
+ let requests = logs["requests"]
+ .as_array()
+ .expect("twin-openai request logs should be an array");
+ let instructions = requests
+ .iter()
+ .find(|request| request["model"] == OPENAI_AGENT_MODEL)
+ .and_then(|request| request["instructions_text"].as_str())
+ .unwrap_or_default();
+ assert!(
+ instructions.contains("local-server-project-skill"),
+ "expected project skill name in OpenAI instructions, got logs: {logs}"
+ );
+ assert!(
+ instructions.contains("Project-only skill"),
+ "expected project skill description in OpenAI instructions, got logs: {logs}"
+ );
+}
+
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn attach_run_events_returns_sse_stream() {
let state = test_app_state_with_options(test_settings(), 5);
diff --git a/lib/crates/fabro-test/src/lib.rs b/lib/crates/fabro-test/src/lib.rs
index 9dd283d68..48c6ef4fc 100644
--- a/lib/crates/fabro-test/src/lib.rs
+++ b/lib/crates/fabro-test/src/lib.rs
@@ -2067,6 +2067,22 @@ impl TwinOpenAi {
.expect("reset twin-openai namespace");
assert_reqwest_status(response, fabro_http::StatusCode::OK, "POST /__admin/reset").await;
}
+
+ pub async fn request_logs(&self, namespace: &str) -> serde_json::Value {
+ let response = test_http_client()
+ .get(format!("{}/__admin/requests", self.admin_url()))
+ .bearer_auth(namespace)
+ .send()
+ .await
+ .expect("fetch twin-openai request logs");
+ let response = expect_reqwest_status(
+ response,
+ fabro_http::StatusCode::OK,
+ "GET /__admin/requests",
+ )
+ .await;
+ response.json().await.expect("request logs should be JSON")
+ }
}
#[derive(Debug, Default, Clone)]
diff --git a/test/twin/openai/src/engine/mod.rs b/test/twin/openai/src/engine/mod.rs
index 56359ecd6..867735baa 100644
--- a/test/twin/openai/src/engine/mod.rs
+++ b/test/twin/openai/src/engine/mod.rs
@@ -19,11 +19,12 @@ pub fn execute_responses_request(
) -> Result<ExecutionOutcome, OpenAiError> {
request.validate()?;
let context = RequestContext {
- endpoint: "responses".to_owned(),
- model: request.model.clone(),
- stream: request.stream,
- metadata: request.metadata.clone(),
- input_text: request.extract_user_text(),
+ endpoint: "responses".to_owned(),
+ model: request.model.clone(),
+ stream: request.stream,
+ metadata: request.metadata.clone(),
+ input_text: request.extract_user_text(),
+ instructions_text: request.extract_instruction_text(),
};
state.log_request(namespace, context.clone());
@@ -52,11 +53,12 @@ pub fn execute_chat_request(
) -> Result<ExecutionOutcome, OpenAiError> {
request.validate()?;
let context = RequestContext {
- endpoint: "chat.completions".to_owned(),
- model: request.model.clone(),
- stream: request.stream,
- metadata: serde_json::Map::new(),
- input_text: request.extract_user_text(),
+ endpoint: "chat.completions".to_owned(),
+ model: request.model.clone(),
+ stream: request.stream,
+ metadata: serde_json::Map::new(),
+ input_text: request.extract_user_text(),
+ instructions_text: request.extract_instruction_text(),
};
state.log_request(namespace, context.clone());
diff --git a/test/twin/openai/src/engine/scenario.rs b/test/twin/openai/src/engine/scenario.rs
index 59d8fd642..ee7ff7377 100644
--- a/test/twin/openai/src/engine/scenario.rs
+++ b/test/twin/openai/src/engine/scenario.rs
@@ -62,11 +62,12 @@ pub struct ToolCallTemplate {
#[derive(Clone, Debug)]
pub struct RequestContext {
- pub endpoint: String,
- pub model: String,
- pub stream: bool,
- pub metadata: Map<String, Value>,
- pub input_text: String,
+ pub endpoint: String,
+ pub model: String,
+ pub stream: bool,
+ pub metadata: Map<String, Value>,
+ pub input_text: String,
+ pub instructions_text: String,
}
impl ScenarioScript {
diff --git a/test/twin/openai/src/logs.rs b/test/twin/openai/src/logs.rs
index 1edc6cd8b..0fce6eafd 100644
--- a/test/twin/openai/src/logs.rs
+++ b/test/twin/openai/src/logs.rs
@@ -3,9 +3,10 @@ use serde_json::{Map, Value};
#[derive(Clone, Debug, Serialize)]
pub struct RequestLog {
- pub endpoint: String,
- pub model: String,
- pub stream: bool,
- pub input_text: String,
- pub metadata: Map<String, Value>,
+ pub endpoint: String,
+ pub model: String,
+ pub stream: bool,
+ pub input_text: String,
+ pub instructions_text: String,
+ pub metadata: Map<String, Value>,
}
diff --git a/test/twin/openai/src/openai/models.rs b/test/twin/openai/src/openai/models.rs
index 42fa186f5..bd7996891 100644
--- a/test/twin/openai/src/openai/models.rs
+++ b/test/twin/openai/src/openai/models.rs
@@ -11,6 +11,7 @@ pub struct ResponsesRequest {
pub model: String,
#[serde(default)]
pub input: ResponseInput,
+ pub instructions: Option<String>,
#[serde(default)]
pub stream: bool,
#[serde(default)]
@@ -40,6 +41,13 @@ impl ResponsesRequest {
}
}
+ pub fn extract_instruction_text(&self) -> String {
+ self.instructions
+ .as_deref()
+ .map(normalize_whitespace)
+ .unwrap_or_default()
+ }
+
pub fn response_format(&self) -> Option<ResponseFormat> {
let format = self.text.as_ref()?.format.as_ref()?;
response_format_from_kind(
@@ -476,6 +484,16 @@ impl ChatCompletionsRequest {
}
}
+ pub fn extract_instruction_text(&self) -> String {
+ let pieces: Vec<String> = self
+ .messages
+ .iter()
+ .filter(|message| message.role == "system" || message.role == "developer")
+ .flat_map(ChatMessage::extract_texts)
+ .collect();
+ normalize_whitespace(&pieces.join(" "))
+ }
+
pub fn response_format(&self) -> Option<ResponseFormat> {
let format = self.response_format.as_ref()?;
response_format_from_kind(
@@ -721,18 +739,26 @@ fn validate_tools(
return Err(OpenAiError::invalid_request(param, "tool type is required"));
};
- if tool_type != "function" {
- return Err(OpenAiError::invalid_request(
- param,
- "only function tools are supported",
- ));
- }
-
- if function_tool_name(tool, surface).is_none() {
- return Err(OpenAiError::invalid_request(
- param,
- "function tool name is required",
- ));
+ match tool_type {
+ "function" => {
+ if function_tool_name(tool, surface).is_none() {
+ return Err(OpenAiError::invalid_request(
+ param,
+ "function tool name is required",
+ ));
+ }
+ }
+ "custom" if surface == ToolSurface::Responses => {
+ if function_tool_name(tool, surface).is_none() {
+ return Err(OpenAiError::invalid_request(
+ param,
+ "custom tool name is required",
+ ));
+ }
+ }
+ _ => {
+ return Err(OpenAiError::invalid_request(param, "unsupported tool type"));
+ }
}
}
diff --git a/test/twin/openai/src/state.rs b/test/twin/openai/src/state.rs
index d456ab6c5..29cece646 100644
--- a/test/twin/openai/src/state.rs
+++ b/test/twin/openai/src/state.rs
@@ -125,11 +125,12 @@ impl AppState {
.or_default()
.request_logs
.push(RequestLog {
- endpoint: request.endpoint,
- model: request.model,
- stream: request.stream,
- input_text: request.input_text,
- metadata: request.metadata,
+ endpoint: request.endpoint,
+ model: request.model,
+ stream: request.stream,
+ input_text: request.input_text,
+ instructions_text: request.instructions_text,
+ metadata: request.metadata,
});
}

View file

@ -0,0 +1 @@
blob://sha256/4a229e77c774df9e4587431eec40dc119a6d55f115aaf1205d37c54bc78d1c8f

View file

@ -0,0 +1,8 @@
{
"output": "blob://sha256/4a229e77c774df9e4587431eec40dc119a6d55f115aaf1205d37c54bc78d1c8f",
"exit_code": 0,
"duration_ms": 513889,
"termination": "exited",
"output_bytes": 207579,
"live_streaming": true
}

View file

@ -0,0 +1,6 @@
{
"outcome": "succeeded",
"notes": "Script completed: git fetch origin main 2>&1 && git merge --no-edit --no-stat origin/main 2>&1 && cargo +nightly-2026-04-14 fmt --all 2>&1 && cargo dev docs refresh 2>&1 && cargo +nightly-2026-04-14 fmt --check --all 2>&1 && { command -v rg >/dev/null 2>&1 || { echo 'rg is required for verify'; exit 127; }; } && ! rg -n 'AuthMode::Disabled|RunAuthMethod|RunSubjectProvenance|\\bActorRef\\b|\\bActorKind\\b|AuthenticatedSubject|AuthenticatedService|AuthorizeRunScoped|AuthorizeRunBlob|AuthorizeStageArtifact|AuthorizeCommandLog|auth_method\\s*==\\s*\"disabled\"' lib/crates apps lib/packages docs/public/api-reference/fabro-api.yaml 2>&1 && cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --workspace --status-level slow --profile ci 2>&1 && cargo dev docs check 2>&1 && bun install --frozen-lockfile 2>&1 && (cd apps/fabro-web && bun run typecheck) 2>&1 && (cd apps/fabro-web && bun run test) 2>&1 && (cd lib/packages/fabro-api-client && bun run typecheck) 2>&1 && cargo dev build -- -p fabro-cli --release 2>&1",
"failure_reason": null,
"timestamp": "2026-05-24T18:09:44.085765Z"
}

View file

@ -0,0 +1,6 @@
{
"outcome": "succeeded",
"notes": null,
"failure_reason": null,
"timestamp": "2026-05-24T18:09:48.016057Z"
}