mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-14 23:22:51 +00:00
parent
a084b7551b
commit
232736d658
6 changed files with 983 additions and 138 deletions
453
run.json
453
run.json
File diff suppressed because one or more lines are too long
647
stages/008-verify@1/diff.patch
Normal file
647
stages/008-verify@1/diff.patch
Normal 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,
|
||||
});
|
||||
}
|
||||
|
||||
1
stages/008-verify@1/output.log
Normal file
1
stages/008-verify@1/output.log
Normal file
|
|
@ -0,0 +1 @@
|
|||
blob://sha256/4a229e77c774df9e4587431eec40dc119a6d55f115aaf1205d37c54bc78d1c8f
|
||||
8
stages/008-verify@1/script_timing.json
Normal file
8
stages/008-verify@1/script_timing.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"output": "blob://sha256/4a229e77c774df9e4587431eec40dc119a6d55f115aaf1205d37c54bc78d1c8f",
|
||||
"exit_code": 0,
|
||||
"duration_ms": 513889,
|
||||
"termination": "exited",
|
||||
"output_bytes": 207579,
|
||||
"live_streaming": true
|
||||
}
|
||||
6
stages/008-verify@1/status.json
Normal file
6
stages/008-verify@1/status.json
Normal 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"
|
||||
}
|
||||
6
stages/009-exit@1/status.json
Normal file
6
stages/009-exit@1/status.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"outcome": "succeeded",
|
||||
"notes": null,
|
||||
"failure_reason": null,
|
||||
"timestamp": "2026-05-24T18:09:48.016057Z"
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue