diff --git a/run.json b/run.json index 87ac202f8..155a5499c 100644 --- a/run.json +++ b/run.json @@ -493,7 +493,7 @@ "kind": "running" }, "status_updated_at": "2026-05-23T16:30:00.579662Z", - "last_event_at": "2026-05-23T17:56:25.955099Z", + "last_event_at": "2026-05-23T18:03:03.197240Z", "pending_control": null, "checkpoints": [ { @@ -1707,9 +1707,9 @@ } }, { - "seq": 0, + "seq": 2023, "checkpoint": { - "timestamp": "2026-05-23T18:02:58.130202Z", + "timestamp": "2026-05-23T18:03:03.196956Z", "current_node": "verify", "completed_nodes": [ "start", @@ -1725,48 +1725,89 @@ ], "node_retries": {}, "context_values": { - "internal.fidelity": "compact", - "response.implement": "Implemented the mid-stage agent interview tools and metadata plumbing.\n\nSummary of changes:\n- Extended canonical `InterviewOption` with optional `description` and `preview`.\n- Updated OpenAPI and regenerated the TypeScript API client:\n - `ApiQuestion.options` now uses `InterviewOption`.\n - Removed stale `ApiQuestionOption` export/file.\n- Added model-native agent question tools:\n - OpenAI: `request_user_input`\n - Anthropic: `AskUserQuestion`\n - Gemini: no question tool\n- Added per-turn `AgentToolRuntime` so cached sessions receive current workflow/stage interview context dynamically.\n- Enforced question-tool round rules:\n - question tool runs alone\n - mixed tool peers get model-visible errors\n - multiple separate question-tool calls execute only the first and tell the model to batch\n- Added shared run-level interview blocker/guard used by both human gates and agent questions.\n- Added workflow runtime for agent-originated batched questions:\n - emits/registers all questions first\n - blocks once per batch\n - waits concurrently\n - emits completion/interruption/timeout events per question\n - cleans up pending questions on cancellation/drop\n- Preserved answer endpoints/paths through existing pending interview projection and submission flow.\n- Updated web/Slack display:\n - web shows option descriptions\n - Slack renders option descriptions where practical\n - preview is captured/exposed but not specially rendered\n- Added/updated tests across agent, workflow, API, store, Slack, and web.\n\nValidation run:\n- `cargo check -q -p fabro-agent -p fabro-workflow -p fabro-api -p fabro-server -p fabro-slack -p fabro-store -p fabro-cli --tests` ✅\n- `cargo +nightly-2026-04-14 fmt --check --all` ✅\n- `cargo +nightly-2026-04-14 clippy -q -p fabro-agent -p fabro-workflow -p fabro-api -p fabro-server -p fabro-slack -p fabro-store -p fabro-cli --all-targets -- -D warnings` ✅\n- `cargo nextest run -p fabro-agent -p fabro-workflow -p fabro-api ...focused question/API tests...` ✅\n- `cargo nextest run -p fabro-interview` ✅\n- `cargo nextest run -p fabro-slack -p fabro-store --lib --tests` ✅\n- `cd apps/fabro-web && bun test ./app/components/interview-dock.test.tsx ./app/components/stage-renderers/helpers.test.ts` ✅\n- `cd apps/fabro-web && bun run typecheck` ✅\n- `cd lib/packages/fabro-api-client && bun run typecheck` ✅\n\nNoted failures:\n- `cargo nextest run -p fabro-server ...` fails on `server::tests::get_graph_returns_svg`; rerun of the single test also fails. The error is the graph renderer subprocess returning nextest output (`running 0 tests`) instead of SVG, unrelated to the interview changes.\n- Full `cd apps/fabro-web && bun test` still has existing unrelated failures in run-detail/lifecycle tests; focused changed web tests pass and typecheck passes.", - "failure_signature": "", - "failure_class": "", - "thread.toolchain.current_node": "preflight_compile", - "command.output": "blob://sha256/b663b197754cdb2bc9c1da19b2877e9a61f47dca0a73612b5487c2da37c63958", - "internal.thread_id": "fixup", - "thread.preflight_lint.current_node": "implement", - "internal.retry_count.simplify_opus": 0, - "internal.retry_count.simplify_gpt": 0, - "thread.simplify_gpt.current_node": "verify", - "internal.retry_count.preflight_lint": 0, - "response.fixup": "All verify steps pass. The verify failure was due to an unset git identity preventing the merge, and the merge introduced two conflicts:\n\n1. **`lib/crates/fabro-slack/src/blocks.rs`** — Resolved by keeping both new tests (the local `option_descriptions_are_rendered_and_preview_is_not_special_cased` test and the four new run-lifecycle tests from main).\n\n2. **`lib/crates/fabro-workflow/src/handler/agent.rs`** — Resolved by adopting `main`'s new `emit_stage_prompt` helper while preserving the local `AgentToolRuntime` construction (with `WorkflowAgentQuestionRuntime`) needed for the agent question tools.\n\nVerification results:\n- `cargo +nightly-2026-04-14 fmt --check --all`: clean\n- Forbidden-token grep: clean\n- `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings`: clean\n- `cargo nextest run --workspace --profile ci`: 6203 passed, 181 skipped\n- `cargo dev docs check`: up to date\n- `bun install --frozen-lockfile`: no changes\n- `apps/fabro-web` typecheck + test: 440 pass, 0 fail\n- `lib/packages/fabro-api-client` typecheck: clean\n- `cargo dev build -- -p fabro-cli --release`: success", - "internal.retry_count.preflight_compile": 0, - "response.simplify_opus": "All clean. Final summary:\n\n## Cleanup Applied\n\nAggregated findings from three reviews and applied 7 focused fixes (3 files, net −31 lines):\n\n**Efficiency**\n- `bounded_display_field` now truncates in a single `char_indices().nth(...)` pass instead of `chars().count()` + `chars().take()`.\n- Dropped redundant `Ulid::new()` from `internal_question_id` — slug + stage visit + tool-call-id + index is already unique.\n\n**Code quality**\n- De-duped the access-denial / hook-block / normal-execution event triple via shared `emit_tool_call_started` and `emit_tool_call_result` helpers in `tool_execution.rs`, dropping ~25 lines of copy-pasted emit code.\n- Cleaned up dead `unwrap_or_else` fallbacks in `format_anthropic_answers` (infallible string serialization).\n- Extracted repeated \"root agent session\" error string into `ROOT_SESSION_REQUIRED_ERROR` constant.\n- Renamed `option_label` to `label_for_key`, dropping the redundant `selected_option.filter(key)` defensive check. Preserved the `selected_option`-first lookup for `Selected` (control-protocol path supplies `Selected(key)` with `selected_option: None`).\n- Removed the unused `Default for RunInterviewBlocker` impl.\n- De-duped `RunInterviewGuard::resolve` and `Drop` via a shared `resolve_in_place` method.\n\n**Findings noted but not applied** (with rationale):\n- Lifting `agent_tool_runtime` from a parameter into a task-local set once at the top: large cross-crate refactor; skipped to keep scope bounded.\n- Unifying OpenAI/Anthropic question tools into one schema: schemas intentionally differ (`id`/`header` required for OpenAI, `multiSelect` and `preview` for Anthropic); separation matches provider contracts.\n- `AgentToolRuntime` one-field wrapper: keeping a struct allows future expansion at no extra friction.\n- `register_question_tools` moving into `AgentProfile` trait: cross-crate refactor; current explicit dispatch is fine for two profiles.\n- Cleaning `ControlInterviewer.pending` on agent-batch cancel: needs a new `Interviewer` API; flagged as the most substantial follow-up.\n- `..InterviewOption::default()` adoption in tests: high-churn, low-value compared to other items in this pass.\n- The `mem::forget` suggestion for `RunInterviewGuard::resolve` is buggy — it would leak the `Arc` and `Arc` refs. Used a shared method instead.\n\n**Verification**\n- `cargo nextest run -p fabro-agent -p fabro-workflow -p fabro-interview -p fabro-server`: all tests pass except 3 pre-existing flaky SVG-render tests (confirmed by stashing changes and reproducing the same failures on the pre-cleanup tree).\n- `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings`: clean.\n- `cargo +nightly-2026-04-14 fmt --check --all`: clean.", - "internal.retry_count.start": 0, - "internal.run_id": "01KSATQNAXG41FHKV0QH5N1QGC", - "thread.start.current_node": "toolchain", - "thread.simplify_opus.current_node": "simplify_gpt", - "response.simplify_gpt": "Review/cleanup completed.\n\nFixed:\n- Added a ULID suffix to agent-generated internal question IDs so they include stage/tool-call context but are still unique/safe.\n- Changed agent answer label mapping to use the persisted option list as the source of truth instead of trusting `selected_option` from the submission.\n- Made `bounded_display_field` private to avoid exposing a helper as public API unnecessarily.\n\nValidation run:\n- `cargo test -q -p fabro-workflow interview_runtime -- --nocapture`\n- `cargo test -q -p fabro-agent question_tools -- --nocapture`\n- `cargo test -q -p fabro-agent question_tool_round -- --nocapture`\n- `cargo +nightly-2026-04-14 fmt --check --all`\n- `cargo check -q -p fabro-agent -p fabro-workflow`\n\nAll passed.", - "graph.goal": "# Mid-Stage Agent Interview Tools\n\n## Summary\n\nAdd model-native question tools that let agents pause mid-stage and ask the human for input through Fabro's existing interview system.\n\nOpenAI-profile agents get `request_user_input`; Anthropic-profile agents get `AskUserQuestion`. When either tool is called, Fabro creates pending interview questions, surfaces them through the existing web/API/Slack paths, waits for answers, then returns provider-shaped tool results so the model can continue the same stage.\n\n## Key Changes\n\n- Extend the existing interview contract without type sprawl:\n - Add optional `description` and `preview` fields to the canonical `fabro_types::InterviewOption`; reuse that type through `fabro-api` replacements instead of introducing `AgentQuestionOption`, API-only aliases, or adapter-only duplicate types.\n - Update OpenAPI, generated Rust/TypeScript clients, event conversion, projection, Slack/web mappers, and the existing `with_replacement(\"InterviewOption\", \"fabro_types::InterviewOption\", ...)` parity tests.\n - Treat both fields as untrusted model-authored display data. Store and expose them after enforcing bounded lengths; truncate or reject oversized values consistently before persistence.\n - Initial UI behavior: display `description` under option labels where practical. Capture and expose `preview`, but do not render preview content specially in web or Slack v1.\n\n- Add a shared run-level interview runtime:\n - Move the private human-node blocked-state refcount into a reusable run-level guard used by both `HumanHandler` and agent question tools, so `RunUnblocked` is emitted only when all human and agent interviews for the run are resolved.\n - Runtime accepts the interviewer, workflow emitter, stage scope, stage id, tool call id, and normalized questions.\n - Support batch asks as a first-class operation: emit/register all questions first, mark the run blocked once, await all answers concurrently, then emit completion/timeout/interrupted events per question and unblock when the batch resolves.\n - Batch support applies only to multiple `questions[]` inside one question-tool call. Do not aggregate multiple separate question-tool calls from the same model round.\n - Generate safe internal question IDs with a ULID/UUID plus stage visit/tool-call context; store original model question IDs/text in question metadata for provider result mapping.\n\n- Add provider-specific agent tools:\n - `request_user_input` for `AgentProfileKind::OpenAi`.\n - Accept Codex-compatible schema: `questions[]` with `id`, `header`, `question`, and `options[] { label, description }`.\n - Normalize each question to `QuestionType::MultipleChoice` with `allow_freeform: true`.\n - Return JSON text matching Codex shape, keyed by the original model question ID: `{\"answers\":{\"id\":{\"answers\":[\"...\"]}}}`.\n - `AskUserQuestion` for `AgentProfileKind::Anthropic`.\n - Accept Claude-compatible schema: `questions[]` with `question`, `header`, `options[] { label, description, preview? }`, and `multiSelect`.\n - Normalize single-select to `MultipleChoice`, multi-select to `MultiSelect`, always with `allow_freeform: true`.\n - Return Claude-style tool result text keyed by the original question text: `User has answered your questions: \"...question...\"=\"answer\". You can now continue...`.\n - Answer formatting for both tools returns user-facing option labels to the model. Preserve internal option keys for validation and event storage. For multi-select, preserve the submission order supplied by the answer path.\n\n- Thread workflow interview context into agent tool execution:\n - Add an explicit per-turn agent tool runtime context passed into `process_input` or an adjacent `process_input_with_runtime` API. It carries the interviewer, workflow emitter, stage scope/id, shared block guard, and provider answer formatter.\n - Do not capture stage-specific interview handles in the profile registry or cached session construction; cached full-fidelity sessions must receive the current turn's stage context dynamically.\n - Child/subagent sessions must not expose these question tools. If somehow called outside the root session, return a model-visible error.\n - Question tools must execute alone in a model tool round. If a round contains one question tool plus any other tool call, execute the question tool and return model-visible error results for the non-question peers, preserving tool-call/tool-result ordering. If a round contains multiple separate question-tool calls, execute only the first and return model-visible error results for the later question-tool calls instructing the model to combine questions into one `questions[]` batch.\n - Agent-originated questions have no per-question timeout in v1 because the provider schemas do not include timeout. They rely on existing stage timeout, wall-clock timeout, cancellation, and interruption behavior.\n\n- Preserve existing answer paths:\n - Do not add a new answer endpoint.\n - Continue using `GET /runs/{id}/questions` and `POST /runs/{id}/questions/{qid}/answer`.\n - Keep `ControlInterviewer`, web `InterviewDock`, Slack blocks, and run projection as the delivery mechanism.\n\n## Test Plan\n\n- Unit tests for schema parsing and normalization:\n - Codex request with descriptions maps to Fabro multiple-choice questions and returns answers by model question ID.\n - Claude request with `multiSelect: true` maps to `MultiSelect` and returns comma-separated answer text.\n - Batched Codex and Claude requests surface all questions as pending before awaiting answers, then return one result with every answer mapped to the original model ID/text.\n - Optional `preview` and `description` survive event, projection, API conversion, OpenAPI replacement tests, and TypeScript client generation.\n - Oversized `description`/`preview` values are bounded before persistence and never rendered as trusted HTML.\n\n- Workflow and agent tests:\n - OpenAI-profile session advertises `request_user_input`; Anthropic-profile session advertises `AskUserQuestion`; Gemini advertises neither.\n - Subagent profiles do not advertise the question tools.\n - Root agent can ask a question and resume after the answer.\n - Subagent or missing interview context returns a clear tool error.\n - Cached full-fidelity session emits interview events against the current stage, not the original cached stage.\n - A mixed tool round containing a human-question tool plus another tool preserves all required tool results and rejects the peer calls with model-visible errors.\n - A round with multiple separate question-tool calls executes only the first and rejects later question-tool calls with model-visible errors.\n\n- Server, projection, and UI tests:\n - `InterviewStarted` with option metadata appears in pending questions.\n - Submitting valid selected, multi-selected, and freeform answers unblocks the waiting tool.\n - Duplicate answer submission remains rejected through existing accepted-question logic.\n - Parallel human gate plus agent question keeps the run blocked until both are answered.\n - Pause, cancel, and interrupt while an agent question is waiting resolve pending questions consistently and do not leave the run blocked.\n - Stage timeout or wall-clock timeout while an agent question is waiting interrupts the batch; no per-question timeout event is expected unless a future schema adds timeout.\n - Slack answer submissions work for agent-originated questions using the same pending interview transport.\n\n- Run checks:\n - `cargo nextest run -p fabro-interview -p fabro-workflow -p fabro-server -p fabro-agent`\n - `cd apps/fabro-web && bun test && bun run typecheck`\n - Regenerate and verify OpenAPI-derived Rust and TypeScript clients after schema changes.\n\n## Assumptions\n\n- This feature is only for in-process/API-backed agent sessions, not ACP external agents in v1.\n- `preview` is stored and exposed but not rendered specially in the first implementation.\n- Human-question tools are available only during root agent execution inside a workflow run with an active interviewer.\n- Existing interview events remain the source of truth for pending questions; no separate agent-question event family is added.\n- The implementation should prefer extending existing interview structs and replacement mappings over adding parallel API DTOs or conversion-only aliases.\n", - "internal.work_dir": "/home/daytona/workspace/fabro", - "last_stage": "fixup", - "graph.rankdir": "LR", "internal.retry_count.implement": 0, - "thread.verify.current_node": "fixup", + "internal.retry_count.simplify_opus": 0, + "response.implement": "Implemented the mid-stage agent interview tools and metadata plumbing.\n\nSummary of changes:\n- Extended canonical `InterviewOption` with optional `description` and `preview`.\n- Updated OpenAPI and regenerated the TypeScript API client:\n - `ApiQuestion.options` now uses `InterviewOption`.\n - Removed stale `ApiQuestionOption` export/file.\n- Added model-native agent question tools:\n - OpenAI: `request_user_input`\n - Anthropic: `AskUserQuestion`\n - Gemini: no question tool\n- Added per-turn `AgentToolRuntime` so cached sessions receive current workflow/stage interview context dynamically.\n- Enforced question-tool round rules:\n - question tool runs alone\n - mixed tool peers get model-visible errors\n - multiple separate question-tool calls execute only the first and tell the model to batch\n- Added shared run-level interview blocker/guard used by both human gates and agent questions.\n- Added workflow runtime for agent-originated batched questions:\n - emits/registers all questions first\n - blocks once per batch\n - waits concurrently\n - emits completion/interruption/timeout events per question\n - cleans up pending questions on cancellation/drop\n- Preserved answer endpoints/paths through existing pending interview projection and submission flow.\n- Updated web/Slack display:\n - web shows option descriptions\n - Slack renders option descriptions where practical\n - preview is captured/exposed but not specially rendered\n- Added/updated tests across agent, workflow, API, store, Slack, and web.\n\nValidation run:\n- `cargo check -q -p fabro-agent -p fabro-workflow -p fabro-api -p fabro-server -p fabro-slack -p fabro-store -p fabro-cli --tests` ✅\n- `cargo +nightly-2026-04-14 fmt --check --all` ✅\n- `cargo +nightly-2026-04-14 clippy -q -p fabro-agent -p fabro-workflow -p fabro-api -p fabro-server -p fabro-slack -p fabro-store -p fabro-cli --all-targets -- -D warnings` ✅\n- `cargo nextest run -p fabro-agent -p fabro-workflow -p fabro-api ...focused question/API tests...` ✅\n- `cargo nextest run -p fabro-interview` ✅\n- `cargo nextest run -p fabro-slack -p fabro-store --lib --tests` ✅\n- `cd apps/fabro-web && bun test ./app/components/interview-dock.test.tsx ./app/components/stage-renderers/helpers.test.ts` ✅\n- `cd apps/fabro-web && bun run typecheck` ✅\n- `cd lib/packages/fabro-api-client && bun run typecheck` ✅\n\nNoted failures:\n- `cargo nextest run -p fabro-server ...` fails on `server::tests::get_graph_returns_svg`; rerun of the single test also fails. The error is the graph renderer subprocess returning nextest output (`running 0 tests`) instead of SVG, unrelated to the interview changes.\n- Full `cd apps/fabro-web && bun test` still has existing unrelated failures in run-detail/lifecycle tests; focused changed web tests pass and typecheck passes.", + "internal.run_id": "01KSATQNAXG41FHKV0QH5N1QGC", + "outcome": "succeeded", "preferred_label": "second", - "internal.retry_count.fixup": 0, + "thread.preflight_lint.current_node": "implement", + "command.output": "blob://sha256/b663b197754cdb2bc9c1da19b2877e9a61f47dca0a73612b5487c2da37c63958", "current_node": "verify", + "internal.retry_count.preflight_compile": 0, + "internal.retry_count.preflight_lint": 0, + "thread.toolchain.current_node": "preflight_compile", "internal.node_visit_count": 2, - "internal.retry_count.toolchain": 0, + "response.simplify_gpt": "Review/cleanup completed.\n\nFixed:\n- Added a ULID suffix to agent-generated internal question IDs so they include stage/tool-call context but are still unique/safe.\n- Changed agent answer label mapping to use the persisted option list as the source of truth instead of trusting `selected_option` from the submission.\n- Made `bounded_display_field` private to avoid exposing a helper as public API unnecessarily.\n\nValidation run:\n- `cargo test -q -p fabro-workflow interview_runtime -- --nocapture`\n- `cargo test -q -p fabro-agent question_tools -- --nocapture`\n- `cargo test -q -p fabro-agent question_tool_round -- --nocapture`\n- `cargo +nightly-2026-04-14 fmt --check --all`\n- `cargo check -q -p fabro-agent -p fabro-workflow`\n\nAll passed.", + "thread.start.current_node": "toolchain", + "internal.work_dir": "/home/daytona/workspace/fabro", + "failure_class": "", + "last_stage": "fixup", "thread.preflight_compile.current_node": "preflight_lint", - "last_response": "All verify steps pass. The verify failure was due to an unset git identity preventing the merge, and the merge introduced two conflicts:\n\n1. **`lib/crates/fabro-slack/src/blocks.rs`** — Resolved by ", "thread.implement.current_node": "simplify_opus", + "thread.simplify_opus.current_node": "simplify_gpt", + "thread.simplify_gpt.current_node": "verify", + "last_response": "All verify steps pass. The verify failure was due to an unset git identity preventing the merge, and the merge introduced two conflicts:\n\n1. **`lib/crates/fabro-slack/src/blocks.rs`** — Resolved by ", + "thread.verify.current_node": "fixup", + "graph.goal": "# Mid-Stage Agent Interview Tools\n\n## Summary\n\nAdd model-native question tools that let agents pause mid-stage and ask the human for input through Fabro's existing interview system.\n\nOpenAI-profile agents get `request_user_input`; Anthropic-profile agents get `AskUserQuestion`. When either tool is called, Fabro creates pending interview questions, surfaces them through the existing web/API/Slack paths, waits for answers, then returns provider-shaped tool results so the model can continue the same stage.\n\n## Key Changes\n\n- Extend the existing interview contract without type sprawl:\n - Add optional `description` and `preview` fields to the canonical `fabro_types::InterviewOption`; reuse that type through `fabro-api` replacements instead of introducing `AgentQuestionOption`, API-only aliases, or adapter-only duplicate types.\n - Update OpenAPI, generated Rust/TypeScript clients, event conversion, projection, Slack/web mappers, and the existing `with_replacement(\"InterviewOption\", \"fabro_types::InterviewOption\", ...)` parity tests.\n - Treat both fields as untrusted model-authored display data. Store and expose them after enforcing bounded lengths; truncate or reject oversized values consistently before persistence.\n - Initial UI behavior: display `description` under option labels where practical. Capture and expose `preview`, but do not render preview content specially in web or Slack v1.\n\n- Add a shared run-level interview runtime:\n - Move the private human-node blocked-state refcount into a reusable run-level guard used by both `HumanHandler` and agent question tools, so `RunUnblocked` is emitted only when all human and agent interviews for the run are resolved.\n - Runtime accepts the interviewer, workflow emitter, stage scope, stage id, tool call id, and normalized questions.\n - Support batch asks as a first-class operation: emit/register all questions first, mark the run blocked once, await all answers concurrently, then emit completion/timeout/interrupted events per question and unblock when the batch resolves.\n - Batch support applies only to multiple `questions[]` inside one question-tool call. Do not aggregate multiple separate question-tool calls from the same model round.\n - Generate safe internal question IDs with a ULID/UUID plus stage visit/tool-call context; store original model question IDs/text in question metadata for provider result mapping.\n\n- Add provider-specific agent tools:\n - `request_user_input` for `AgentProfileKind::OpenAi`.\n - Accept Codex-compatible schema: `questions[]` with `id`, `header`, `question`, and `options[] { label, description }`.\n - Normalize each question to `QuestionType::MultipleChoice` with `allow_freeform: true`.\n - Return JSON text matching Codex shape, keyed by the original model question ID: `{\"answers\":{\"id\":{\"answers\":[\"...\"]}}}`.\n - `AskUserQuestion` for `AgentProfileKind::Anthropic`.\n - Accept Claude-compatible schema: `questions[]` with `question`, `header`, `options[] { label, description, preview? }`, and `multiSelect`.\n - Normalize single-select to `MultipleChoice`, multi-select to `MultiSelect`, always with `allow_freeform: true`.\n - Return Claude-style tool result text keyed by the original question text: `User has answered your questions: \"...question...\"=\"answer\". You can now continue...`.\n - Answer formatting for both tools returns user-facing option labels to the model. Preserve internal option keys for validation and event storage. For multi-select, preserve the submission order supplied by the answer path.\n\n- Thread workflow interview context into agent tool execution:\n - Add an explicit per-turn agent tool runtime context passed into `process_input` or an adjacent `process_input_with_runtime` API. It carries the interviewer, workflow emitter, stage scope/id, shared block guard, and provider answer formatter.\n - Do not capture stage-specific interview handles in the profile registry or cached session construction; cached full-fidelity sessions must receive the current turn's stage context dynamically.\n - Child/subagent sessions must not expose these question tools. If somehow called outside the root session, return a model-visible error.\n - Question tools must execute alone in a model tool round. If a round contains one question tool plus any other tool call, execute the question tool and return model-visible error results for the non-question peers, preserving tool-call/tool-result ordering. If a round contains multiple separate question-tool calls, execute only the first and return model-visible error results for the later question-tool calls instructing the model to combine questions into one `questions[]` batch.\n - Agent-originated questions have no per-question timeout in v1 because the provider schemas do not include timeout. They rely on existing stage timeout, wall-clock timeout, cancellation, and interruption behavior.\n\n- Preserve existing answer paths:\n - Do not add a new answer endpoint.\n - Continue using `GET /runs/{id}/questions` and `POST /runs/{id}/questions/{qid}/answer`.\n - Keep `ControlInterviewer`, web `InterviewDock`, Slack blocks, and run projection as the delivery mechanism.\n\n## Test Plan\n\n- Unit tests for schema parsing and normalization:\n - Codex request with descriptions maps to Fabro multiple-choice questions and returns answers by model question ID.\n - Claude request with `multiSelect: true` maps to `MultiSelect` and returns comma-separated answer text.\n - Batched Codex and Claude requests surface all questions as pending before awaiting answers, then return one result with every answer mapped to the original model ID/text.\n - Optional `preview` and `description` survive event, projection, API conversion, OpenAPI replacement tests, and TypeScript client generation.\n - Oversized `description`/`preview` values are bounded before persistence and never rendered as trusted HTML.\n\n- Workflow and agent tests:\n - OpenAI-profile session advertises `request_user_input`; Anthropic-profile session advertises `AskUserQuestion`; Gemini advertises neither.\n - Subagent profiles do not advertise the question tools.\n - Root agent can ask a question and resume after the answer.\n - Subagent or missing interview context returns a clear tool error.\n - Cached full-fidelity session emits interview events against the current stage, not the original cached stage.\n - A mixed tool round containing a human-question tool plus another tool preserves all required tool results and rejects the peer calls with model-visible errors.\n - A round with multiple separate question-tool calls executes only the first and rejects later question-tool calls with model-visible errors.\n\n- Server, projection, and UI tests:\n - `InterviewStarted` with option metadata appears in pending questions.\n - Submitting valid selected, multi-selected, and freeform answers unblocks the waiting tool.\n - Duplicate answer submission remains rejected through existing accepted-question logic.\n - Parallel human gate plus agent question keeps the run blocked until both are answered.\n - Pause, cancel, and interrupt while an agent question is waiting resolve pending questions consistently and do not leave the run blocked.\n - Stage timeout or wall-clock timeout while an agent question is waiting interrupts the batch; no per-question timeout event is expected unless a future schema adds timeout.\n - Slack answer submissions work for agent-originated questions using the same pending interview transport.\n\n- Run checks:\n - `cargo nextest run -p fabro-interview -p fabro-workflow -p fabro-server -p fabro-agent`\n - `cd apps/fabro-web && bun test && bun run typecheck`\n - Regenerate and verify OpenAPI-derived Rust and TypeScript clients after schema changes.\n\n## Assumptions\n\n- This feature is only for in-process/API-backed agent sessions, not ACP external agents in v1.\n- `preview` is stored and exposed but not rendered specially in the first implementation.\n- Human-question tools are available only during root agent execution inside a workflow run with an active interviewer.\n- Existing interview events remain the source of truth for pending questions; no separate agent-question event family is added.\n- The implementation should prefer extending existing interview structs and replacement mappings over adding parallel API DTOs or conversion-only aliases.\n", + "internal.fidelity": "compact", "thread.fixup.current_node": "verify", + "internal.retry_count.start": 0, "internal.retry_count.verify": 0, - "graph.model_stylesheet": "\n * { model: claude-opus-4-7; }\n ", - "outcome": "succeeded" + "internal.retry_count.simplify_gpt": 0, + "internal.retry_count.fixup": 0, + "internal.thread_id": "fixup", + "response.simplify_opus": "All clean. Final summary:\n\n## Cleanup Applied\n\nAggregated findings from three reviews and applied 7 focused fixes (3 files, net −31 lines):\n\n**Efficiency**\n- `bounded_display_field` now truncates in a single `char_indices().nth(...)` pass instead of `chars().count()` + `chars().take()`.\n- Dropped redundant `Ulid::new()` from `internal_question_id` — slug + stage visit + tool-call-id + index is already unique.\n\n**Code quality**\n- De-duped the access-denial / hook-block / normal-execution event triple via shared `emit_tool_call_started` and `emit_tool_call_result` helpers in `tool_execution.rs`, dropping ~25 lines of copy-pasted emit code.\n- Cleaned up dead `unwrap_or_else` fallbacks in `format_anthropic_answers` (infallible string serialization).\n- Extracted repeated \"root agent session\" error string into `ROOT_SESSION_REQUIRED_ERROR` constant.\n- Renamed `option_label` to `label_for_key`, dropping the redundant `selected_option.filter(key)` defensive check. Preserved the `selected_option`-first lookup for `Selected` (control-protocol path supplies `Selected(key)` with `selected_option: None`).\n- Removed the unused `Default for RunInterviewBlocker` impl.\n- De-duped `RunInterviewGuard::resolve` and `Drop` via a shared `resolve_in_place` method.\n\n**Findings noted but not applied** (with rationale):\n- Lifting `agent_tool_runtime` from a parameter into a task-local set once at the top: large cross-crate refactor; skipped to keep scope bounded.\n- Unifying OpenAI/Anthropic question tools into one schema: schemas intentionally differ (`id`/`header` required for OpenAI, `multiSelect` and `preview` for Anthropic); separation matches provider contracts.\n- `AgentToolRuntime` one-field wrapper: keeping a struct allows future expansion at no extra friction.\n- `register_question_tools` moving into `AgentProfile` trait: cross-crate refactor; current explicit dispatch is fine for two profiles.\n- Cleaning `ControlInterviewer.pending` on agent-batch cancel: needs a new `Interviewer` API; flagged as the most substantial follow-up.\n- `..InterviewOption::default()` adoption in tests: high-churn, low-value compared to other items in this pass.\n- The `mem::forget` suggestion for `RunInterviewGuard::resolve` is buggy — it would leak the `Arc` and `Arc` refs. Used a shared method instead.\n\n**Verification**\n- `cargo nextest run -p fabro-agent -p fabro-workflow -p fabro-interview -p fabro-server`: all tests pass except 3 pre-existing flaky SVG-render tests (confirmed by stashing changes and reproducing the same failures on the pre-cleanup tree).\n- `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings`: clean.\n- `cargo +nightly-2026-04-14 fmt --check --all`: clean.", + "response.fixup": "All verify steps pass. The verify failure was due to an unset git identity preventing the merge, and the merge introduced two conflicts:\n\n1. **`lib/crates/fabro-slack/src/blocks.rs`** — Resolved by keeping both new tests (the local `option_descriptions_are_rendered_and_preview_is_not_special_cased` test and the four new run-lifecycle tests from main).\n\n2. **`lib/crates/fabro-workflow/src/handler/agent.rs`** — Resolved by adopting `main`'s new `emit_stage_prompt` helper while preserving the local `AgentToolRuntime` construction (with `WorkflowAgentQuestionRuntime`) needed for the agent question tools.\n\nVerification results:\n- `cargo +nightly-2026-04-14 fmt --check --all`: clean\n- Forbidden-token grep: clean\n- `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings`: clean\n- `cargo nextest run --workspace --profile ci`: 6203 passed, 181 skipped\n- `cargo dev docs check`: up to date\n- `bun install --frozen-lockfile`: no changes\n- `apps/fabro-web` typecheck + test: 440 pass, 0 fail\n- `lib/packages/fabro-api-client` typecheck: clean\n- `cargo dev build -- -p fabro-cli --release`: success", + "graph.rankdir": "LR", + "failure_signature": "", + "internal.retry_count.toolchain": 0, + "graph.model_stylesheet": "\n * { model: claude-opus-4-7; }\n " }, "node_outcomes": { - "start": { + "fixup": { "status": "succeeded", + "preferred_label": "second", + "context_updates": { + "last_stage": "fixup", + "last_response": "All verify steps pass. The verify failure was due to an unset git identity preventing the merge, and the merge introduced two conflicts:\n\n1. **`lib/crates/fabro-slack/src/blocks.rs`** — Resolved by ", + "response.fixup": "All verify steps pass. The verify failure was due to an unset git identity preventing the merge, and the merge introduced two conflicts:\n\n1. **`lib/crates/fabro-slack/src/blocks.rs`** — Resolved by keeping both new tests (the local `option_descriptions_are_rendered_and_preview_is_not_special_cased` test and the four new run-lifecycle tests from main).\n\n2. **`lib/crates/fabro-workflow/src/handler/agent.rs`** — Resolved by adopting `main`'s new `emit_stage_prompt` helper while preserving the local `AgentToolRuntime` construction (with `WorkflowAgentQuestionRuntime`) needed for the agent question tools.\n\nVerification results:\n- `cargo +nightly-2026-04-14 fmt --check --all`: clean\n- Forbidden-token grep: clean\n- `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings`: clean\n- `cargo nextest run --workspace --profile ci`: 6203 passed, 181 skipped\n- `cargo dev docs check`: up to date\n- `bun install --frozen-lockfile`: no changes\n- `apps/fabro-web` typecheck + test: 440 pass, 0 fail\n- `lib/packages/fabro-api-client` typecheck: clean\n- `cargo dev build -- -p fabro-cli --release`: success" + }, + "notes": "Stage completed: fixup", + "usage": { + "input": { + "usage": { + "model": { + "provider": "anthropic", + "model_id": "claude-opus-4-7" + }, + "tokens": { + "input_tokens": 49270, + "output_tokens": 8150, + "reasoning_tokens": 0, + "cache_read_tokens": 1464725, + "cache_write_tokens": 177335 + } + }, + "facts": { + "algorithm": "anthropic", + "cache_write_5m_tokens": 177335, + "cache_write_1h_tokens": 0 + } + }, + "total_usd_micros": 2290805 + }, + "files_touched": [ + "/home/daytona/workspace/fabro/lib/crates/fabro-slack/src/blocks.rs", + "/home/daytona/workspace/fabro/lib/crates/fabro-workflow/src/handler/agent.rs" + ] + }, + "preflight_compile": { + "status": "succeeded", + "context_updates": { + "command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126" + }, + "notes": "Script completed: cargo check -q --workspace 2>&1", "usage": null }, "simplify_gpt": { @@ -1799,72 +1840,6 @@ "total_usd_micros": 1661057 } }, - "verify": { - "status": "succeeded", - "context_updates": { - "command.output": "blob://sha256/b663b197754cdb2bc9c1da19b2877e9a61f47dca0a73612b5487c2da37c63958" - }, - "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 && ! 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", - "usage": null - }, - "implement": { - "status": "succeeded", - "context_updates": { - "last_response": "Implemented the mid-stage agent interview tools and metadata plumbing.\n\nSummary of changes:\n- Extended canonical `InterviewOption` with optional `description` and `preview`.\n- Updated OpenAPI and rege", - "last_stage": "implement", - "response.implement": "Implemented the mid-stage agent interview tools and metadata plumbing.\n\nSummary of changes:\n- Extended canonical `InterviewOption` with optional `description` and `preview`.\n- Updated OpenAPI and regenerated the TypeScript API client:\n - `ApiQuestion.options` now uses `InterviewOption`.\n - Removed stale `ApiQuestionOption` export/file.\n- Added model-native agent question tools:\n - OpenAI: `request_user_input`\n - Anthropic: `AskUserQuestion`\n - Gemini: no question tool\n- Added per-turn `AgentToolRuntime` so cached sessions receive current workflow/stage interview context dynamically.\n- Enforced question-tool round rules:\n - question tool runs alone\n - mixed tool peers get model-visible errors\n - multiple separate question-tool calls execute only the first and tell the model to batch\n- Added shared run-level interview blocker/guard used by both human gates and agent questions.\n- Added workflow runtime for agent-originated batched questions:\n - emits/registers all questions first\n - blocks once per batch\n - waits concurrently\n - emits completion/interruption/timeout events per question\n - cleans up pending questions on cancellation/drop\n- Preserved answer endpoints/paths through existing pending interview projection and submission flow.\n- Updated web/Slack display:\n - web shows option descriptions\n - Slack renders option descriptions where practical\n - preview is captured/exposed but not specially rendered\n- Added/updated tests across agent, workflow, API, store, Slack, and web.\n\nValidation run:\n- `cargo check -q -p fabro-agent -p fabro-workflow -p fabro-api -p fabro-server -p fabro-slack -p fabro-store -p fabro-cli --tests` ✅\n- `cargo +nightly-2026-04-14 fmt --check --all` ✅\n- `cargo +nightly-2026-04-14 clippy -q -p fabro-agent -p fabro-workflow -p fabro-api -p fabro-server -p fabro-slack -p fabro-store -p fabro-cli --all-targets -- -D warnings` ✅\n- `cargo nextest run -p fabro-agent -p fabro-workflow -p fabro-api ...focused question/API tests...` ✅\n- `cargo nextest run -p fabro-interview` ✅\n- `cargo nextest run -p fabro-slack -p fabro-store --lib --tests` ✅\n- `cd apps/fabro-web && bun test ./app/components/interview-dock.test.tsx ./app/components/stage-renderers/helpers.test.ts` ✅\n- `cd apps/fabro-web && bun run typecheck` ✅\n- `cd lib/packages/fabro-api-client && bun run typecheck` ✅\n\nNoted failures:\n- `cargo nextest run -p fabro-server ...` fails on `server::tests::get_graph_returns_svg`; rerun of the single test also fails. The error is the graph renderer subprocess returning nextest output (`running 0 tests`) instead of SVG, unrelated to the interview changes.\n- Full `cd apps/fabro-web && bun test` still has existing unrelated failures in run-detail/lifecycle tests; focused changed web tests pass and typecheck passes." - }, - "notes": "Stage completed: implement", - "usage": { - "input": { - "usage": { - "model": { - "provider": "openai", - "model_id": "gpt-5.5" - }, - "tokens": { - "input_tokens": 483262, - "output_tokens": 43089, - "reasoning_tokens": 20029, - "cache_read_tokens": 63283712, - "cache_write_tokens": 0 - } - }, - "facts": { - "algorithm": "openai" - } - }, - "total_usd_micros": 35951706 - }, - "files_touched": [ - "/home/daytona/workspace/fabro/lib/crates/fabro-agent/src/question_tools.rs", - "/home/daytona/workspace/fabro/lib/crates/fabro-workflow/src/interview_runtime.rs" - ] - }, - "preflight_lint": { - "status": "succeeded", - "context_updates": { - "command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126" - }, - "notes": "Script completed: cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1", - "usage": null - }, - "toolchain": { - "status": "succeeded", - "context_updates": { - "command.output": "blob://sha256/fc14b2ba2d770e5cd3169df7a29525c962adfc4cfa3097b9098c63ebd61a748c" - }, - "notes": "Script completed: command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", - "usage": null - }, - "preflight_compile": { - "status": "succeeded", - "context_updates": { - "command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126" - }, - "notes": "Script completed: cargo check -q --workspace 2>&1", - "usage": null - }, "simplify_opus": { "status": "succeeded", "context_updates": { @@ -1902,61 +1877,223 @@ "/home/daytona/workspace/fabro/lib/crates/fabro-workflow/src/interview_runtime.rs" ] }, - "fixup": { + "preflight_lint": { "status": "succeeded", - "preferred_label": "second", "context_updates": { - "last_stage": "fixup", - "last_response": "All verify steps pass. The verify failure was due to an unset git identity preventing the merge, and the merge introduced two conflicts:\n\n1. **`lib/crates/fabro-slack/src/blocks.rs`** — Resolved by ", - "response.fixup": "All verify steps pass. The verify failure was due to an unset git identity preventing the merge, and the merge introduced two conflicts:\n\n1. **`lib/crates/fabro-slack/src/blocks.rs`** — Resolved by keeping both new tests (the local `option_descriptions_are_rendered_and_preview_is_not_special_cased` test and the four new run-lifecycle tests from main).\n\n2. **`lib/crates/fabro-workflow/src/handler/agent.rs`** — Resolved by adopting `main`'s new `emit_stage_prompt` helper while preserving the local `AgentToolRuntime` construction (with `WorkflowAgentQuestionRuntime`) needed for the agent question tools.\n\nVerification results:\n- `cargo +nightly-2026-04-14 fmt --check --all`: clean\n- Forbidden-token grep: clean\n- `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings`: clean\n- `cargo nextest run --workspace --profile ci`: 6203 passed, 181 skipped\n- `cargo dev docs check`: up to date\n- `bun install --frozen-lockfile`: no changes\n- `apps/fabro-web` typecheck + test: 440 pass, 0 fail\n- `lib/packages/fabro-api-client` typecheck: clean\n- `cargo dev build -- -p fabro-cli --release`: success" + "command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126" }, - "notes": "Stage completed: fixup", + "notes": "Script completed: cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1", + "usage": null + }, + "implement": { + "status": "succeeded", + "context_updates": { + "last_response": "Implemented the mid-stage agent interview tools and metadata plumbing.\n\nSummary of changes:\n- Extended canonical `InterviewOption` with optional `description` and `preview`.\n- Updated OpenAPI and rege", + "last_stage": "implement", + "response.implement": "Implemented the mid-stage agent interview tools and metadata plumbing.\n\nSummary of changes:\n- Extended canonical `InterviewOption` with optional `description` and `preview`.\n- Updated OpenAPI and regenerated the TypeScript API client:\n - `ApiQuestion.options` now uses `InterviewOption`.\n - Removed stale `ApiQuestionOption` export/file.\n- Added model-native agent question tools:\n - OpenAI: `request_user_input`\n - Anthropic: `AskUserQuestion`\n - Gemini: no question tool\n- Added per-turn `AgentToolRuntime` so cached sessions receive current workflow/stage interview context dynamically.\n- Enforced question-tool round rules:\n - question tool runs alone\n - mixed tool peers get model-visible errors\n - multiple separate question-tool calls execute only the first and tell the model to batch\n- Added shared run-level interview blocker/guard used by both human gates and agent questions.\n- Added workflow runtime for agent-originated batched questions:\n - emits/registers all questions first\n - blocks once per batch\n - waits concurrently\n - emits completion/interruption/timeout events per question\n - cleans up pending questions on cancellation/drop\n- Preserved answer endpoints/paths through existing pending interview projection and submission flow.\n- Updated web/Slack display:\n - web shows option descriptions\n - Slack renders option descriptions where practical\n - preview is captured/exposed but not specially rendered\n- Added/updated tests across agent, workflow, API, store, Slack, and web.\n\nValidation run:\n- `cargo check -q -p fabro-agent -p fabro-workflow -p fabro-api -p fabro-server -p fabro-slack -p fabro-store -p fabro-cli --tests` ✅\n- `cargo +nightly-2026-04-14 fmt --check --all` ✅\n- `cargo +nightly-2026-04-14 clippy -q -p fabro-agent -p fabro-workflow -p fabro-api -p fabro-server -p fabro-slack -p fabro-store -p fabro-cli --all-targets -- -D warnings` ✅\n- `cargo nextest run -p fabro-agent -p fabro-workflow -p fabro-api ...focused question/API tests...` ✅\n- `cargo nextest run -p fabro-interview` ✅\n- `cargo nextest run -p fabro-slack -p fabro-store --lib --tests` ✅\n- `cd apps/fabro-web && bun test ./app/components/interview-dock.test.tsx ./app/components/stage-renderers/helpers.test.ts` ✅\n- `cd apps/fabro-web && bun run typecheck` ✅\n- `cd lib/packages/fabro-api-client && bun run typecheck` ✅\n\nNoted failures:\n- `cargo nextest run -p fabro-server ...` fails on `server::tests::get_graph_returns_svg`; rerun of the single test also fails. The error is the graph renderer subprocess returning nextest output (`running 0 tests`) instead of SVG, unrelated to the interview changes.\n- Full `cd apps/fabro-web && bun test` still has existing unrelated failures in run-detail/lifecycle tests; focused changed web tests pass and typecheck passes." + }, + "notes": "Stage completed: implement", "usage": { "input": { "usage": { "model": { - "provider": "anthropic", - "model_id": "claude-opus-4-7" + "provider": "openai", + "model_id": "gpt-5.5" }, "tokens": { - "input_tokens": 49270, - "output_tokens": 8150, - "reasoning_tokens": 0, - "cache_read_tokens": 1464725, - "cache_write_tokens": 177335 + "input_tokens": 483262, + "output_tokens": 43089, + "reasoning_tokens": 20029, + "cache_read_tokens": 63283712, + "cache_write_tokens": 0 } }, "facts": { - "algorithm": "anthropic", - "cache_write_5m_tokens": 177335, - "cache_write_1h_tokens": 0 + "algorithm": "openai" } }, - "total_usd_micros": 2290805 + "total_usd_micros": 35951706 }, "files_touched": [ - "/home/daytona/workspace/fabro/lib/crates/fabro-slack/src/blocks.rs", - "/home/daytona/workspace/fabro/lib/crates/fabro-workflow/src/handler/agent.rs" + "/home/daytona/workspace/fabro/lib/crates/fabro-agent/src/question_tools.rs", + "/home/daytona/workspace/fabro/lib/crates/fabro-workflow/src/interview_runtime.rs" ] + }, + "start": { + "status": "succeeded", + "usage": null + }, + "toolchain": { + "status": "succeeded", + "context_updates": { + "command.output": "blob://sha256/fc14b2ba2d770e5cd3169df7a29525c962adfc4cfa3097b9098c63ebd61a748c" + }, + "notes": "Script completed: command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", + "usage": null + }, + "verify": { + "status": "succeeded", + "context_updates": { + "command.output": "blob://sha256/b663b197754cdb2bc9c1da19b2877e9a61f47dca0a73612b5487c2da37c63958" + }, + "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 && ! 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", + "usage": null } }, "next_node_id": "exit", + "git_commit_sha": "106860730369b9970cc8fe82eb47986a06542aa4", + "loop_failure_signatures": { + "verify|deterministic|script failed with exit code: ## output from https://github.com/fabro-sh/fabro * branch main -> fetch_head .. main -> origin/main committer identity unknown *** please tell me who you are. run git config --global user.email \"y": 1 + }, "node_visits": { - "fixup": 1, - "toolchain": 1, - "preflight_compile": 1, - "implement": 1, - "simplify_gpt": 1, - "simplify_opus": 1, - "preflight_lint": 1, "start": 1, - "verify": 2 + "preflight_lint": 1, + "simplify_gpt": 1, + "implement": 1, + "fixup": 1, + "simplify_opus": 1, + "verify": 2, + "toolchain": 1, + "preflight_compile": 1 } }, - "diff": {} + "diff": { + "patch": "diff --git a/apps/fabro-web/app/routes/run-detail.test.ts b/apps/fabro-web/app/routes/run-detail.test.ts\nindex f0782fd97..b6d8d3341 100644\n--- a/apps/fabro-web/app/routes/run-detail.test.ts\n+++ b/apps/fabro-web/app/routes/run-detail.test.ts\n@@ -382,7 +382,7 @@ describe(\"RunDetail full-height child routes\", () => {\n const outletWrappers = renderer.root.findAll(\n (node) =>\n node.type === \"div\" &&\n- hasClasses(node.props.className, [\"min-h-0\", \"flex-1\", \"flex-col\"]),\n+ hasClasses(node.props.className, [\"pt-3\", \"min-h-0\", \"flex-1\", \"flex-col\"]),\n );\n expect(outletWrappers).toHaveLength(1);\n });\n@@ -540,7 +540,10 @@ describe(\"RunDetail full-height child routes\", () => {\n const outletWrappers = renderer.root.findAll(\n (node) =>\n node.type === \"div\" &&\n- hasClasses(node.props.className, [\"pb-[var(--fabro-interview-dock-clearance)]\"]),\n+ hasClasses(node.props.className, [\n+ \"pt-3\",\n+ \"pb-[var(--fabro-interview-dock-clearance)]\",\n+ ]),\n );\n expect(outletWrappers).toHaveLength(1);\n });\ndiff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml\nindex 978beb090..dfd45b73a 100644\n--- a/docs/public/api-reference/fabro-api.yaml\n+++ b/docs/public/api-reference/fabro-api.yaml\n@@ -5881,6 +5881,7 @@ components:\n - estimated_output_tps\n - aliases\n - default\n+ - small_default\n - configured\n properties:\n id:\n@@ -5924,6 +5925,9 @@ components:\n default:\n type: boolean\n description: Whether this is the default model for its provider.\n+ small_default:\n+ type: boolean\n+ description: Whether this is the provider's small/default utility model.\n configured:\n type: boolean\n description: |\ndiff --git a/docs/public/core-concepts/models.mdx b/docs/public/core-concepts/models.mdx\nindex 177f8eb77..61b26419b 100644\n--- a/docs/public/core-concepts/models.mdx\n+++ b/docs/public/core-concepts/models.mdx\n@@ -64,6 +64,7 @@ agent_profile = \"anthropic\"\n display_name = \"Team Code Large\"\n family = \"team-code\"\n default = true\n+small_default = true\n aliases = [\"team-code\"]\n estimated_output_tps = 80\n \n@@ -119,6 +120,8 @@ reasoning = false\n \n `api_id` is the model name sent to the provider API. Omit it when the Fabro model ID and provider model ID are the same.\n \n+Model roles are separate: `default = true` controls normal model selection for workflow execution, while `small_default = true` marks the provider's small/cheap utility model for metadata tasks such as generated run titles. If a provider has no small default, Fabro falls back to that provider's normal default.\n+\n Provider auth is declared in `[llm.providers..auth]` with ordered `env:` or `vault:` refs. The primary auth header defaults to `bearer`; override with `header = { custom = \"Header-Name\" }` for providers like Anthropic that use `x-api-key`. Omit the `[llm.providers..auth]` block entirely for providers that need no API key (e.g. Ollama). Custom headers for any provider — including providers that need only typed headers and no API-key auth — go in `extra_headers` as `{ env = \"NAME\" }`, `{ vault = \"NAME\" }`, or `{ literal = \"value\" }`.\n \n Provider `agent_profile` defaults from `adapter` and controls profile-specific behavior such as project-memory filenames, CLI/ACP command selection, and native session routing. Valid values are `anthropic`, `openai`, and `gemini`; model-level values override provider-level values.\ndiff --git a/docs/public/integrations/litellm.mdx b/docs/public/integrations/litellm.mdx\nindex a19ac43e9..7e9102dde 100644\n--- a/docs/public/integrations/litellm.mdx\n+++ b/docs/public/integrations/litellm.mdx\n@@ -111,7 +111,7 @@ vision = false\n reasoning = false\n ```\n \n-Only one model for a provider should set `default = true`.\n+Only one model for a provider should set `default = true`. You may also mark one small/cheap utility model with `small_default = true`; Fabro uses it for metadata tasks such as generated run titles and falls back to the provider default when it is omitted.\n \n ## Troubleshooting\n \ndiff --git a/lib/crates/fabro-api/tests/model_round_trip.rs b/lib/crates/fabro-api/tests/model_round_trip.rs\nindex 6e89a980a..ad896d4c4 100644\n--- a/lib/crates/fabro-api/tests/model_round_trip.rs\n+++ b/lib/crates/fabro-api/tests/model_round_trip.rs\n@@ -38,6 +38,7 @@ fn model_json_matches_openapi_shape() {\n estimated_output_tps: Some(25.0),\n aliases: vec![\"opus\".to_string()],\n default: false,\n+ small_default: true,\n configured: true,\n };\n \n@@ -48,6 +49,7 @@ fn model_json_matches_openapi_shape() {\n assert_eq!(json[\"features\"][\"reasoning_effort\"], \"levels\");\n assert_eq!(json[\"features\"][\"prompt_cache\"], true);\n assert_eq!(json[\"estimated_output_tps\"], 25.0);\n+ assert_eq!(json[\"small_default\"], true);\n assert_eq!(json[\"configured\"], true);\n \n let round_trip: ApiModel = serde_json::from_value(json).unwrap();\ndiff --git a/lib/crates/fabro-api/tests/provider_id_round_trip.rs b/lib/crates/fabro-api/tests/provider_id_round_trip.rs\nindex b20e15370..21c5227c6 100644\n--- a/lib/crates/fabro-api/tests/provider_id_round_trip.rs\n+++ b/lib/crates/fabro-api/tests/provider_id_round_trip.rs\n@@ -48,6 +48,7 @@ fn provider_id_json_matches_openapi_shape_through_model() {\n estimated_output_tps: None,\n aliases: Vec::new(),\n default: false,\n+ small_default: false,\n configured: true,\n };\n \ndiff --git a/lib/crates/fabro-cli/src/commands/model.rs b/lib/crates/fabro-cli/src/commands/model.rs\nindex 659e4af3d..706cbbf84 100644\n--- a/lib/crates/fabro-cli/src/commands/model.rs\n+++ b/lib/crates/fabro-cli/src/commands/model.rs\n@@ -503,6 +503,7 @@ mod tests {\n estimated_output_tps: Some(100.0),\n aliases: vec![\"tm\".to_string()],\n default: false,\n+ small_default: false,\n configured: false,\n })\n .unwrap()\n@@ -535,6 +536,7 @@ mod tests {\n estimated_output_tps: Some(100.0),\n aliases: vec![],\n default: false,\n+ small_default: false,\n configured: true,\n })\n .unwrap()\ndiff --git a/lib/crates/fabro-config/src/builders.rs b/lib/crates/fabro-config/src/builders.rs\nindex 80017af26..3562328f0 100644\n--- a/lib/crates/fabro-config/src/builders.rs\n+++ b/lib/crates/fabro-config/src/builders.rs\n@@ -337,6 +337,7 @@ fn model_settings_to_catalog(settings: ModelSettings) -> model_catalog::ModelCat\n training,\n knowledge_cutoff,\n default,\n+ small_default,\n probe,\n enabled,\n aliases,\n@@ -355,6 +356,7 @@ fn model_settings_to_catalog(settings: ModelSettings) -> model_catalog::ModelCat\n training,\n knowledge_cutoff,\n default,\n+ small_default,\n probe,\n enabled,\n aliases,\ndiff --git a/lib/crates/fabro-config/src/layers/llm.rs b/lib/crates/fabro-config/src/layers/llm.rs\nindex 94b44ab2a..9afb41d9e 100644\n--- a/lib/crates/fabro-config/src/layers/llm.rs\n+++ b/lib/crates/fabro-config/src/layers/llm.rs\n@@ -115,6 +115,10 @@ pub struct ModelSettings {\n pub knowledge_cutoff: Option,\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub default: Option,\n+ /// Whether this model should be preferred for small/cheap utility tasks.\n+ /// Missing or false falls back to the provider default model.\n+ #[serde(default, skip_serializing_if = \"Option::is_none\")]\n+ pub small_default: Option,\n /// Whether this model should be preferred for provider connectivity\n /// probes. Missing or false falls back to the provider default model.\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\ndiff --git a/lib/crates/fabro-llm/src/model_test.rs b/lib/crates/fabro-llm/src/model_test.rs\nindex 4fee55a24..e177901a2 100644\n--- a/lib/crates/fabro-llm/src/model_test.rs\n+++ b/lib/crates/fabro-llm/src/model_test.rs\n@@ -195,6 +195,7 @@ mod tests {\n estimated_output_tps: None,\n aliases: vec![],\n default: false,\n+ small_default: false,\n configured: false,\n }\n }\ndiff --git a/lib/crates/fabro-model/src/catalog.rs b/lib/crates/fabro-model/src/catalog.rs\nindex 69f687670..d0fa36cb9 100644\n--- a/lib/crates/fabro-model/src/catalog.rs\n+++ b/lib/crates/fabro-model/src/catalog.rs\n@@ -82,6 +82,8 @@ pub struct ModelCatalogSettings {\n #[serde(default)]\n pub default: Option,\n #[serde(default)]\n+ pub small_default: Option,\n+ #[serde(default)]\n pub probe: Option,\n #[serde(default)]\n pub enabled: Option,\n@@ -564,6 +566,11 @@ pub enum CatalogBuildError {\n provider: ProviderId,\n models: Vec,\n },\n+ #[error(\"provider '{provider}' has multiple small default models: {models:?}\")]\n+ MultipleProviderSmallDefaults {\n+ provider: ProviderId,\n+ models: Vec,\n+ },\n #[error(\"catalog must contain at least one enabled default model\")]\n NoDefaultModel,\n #[error(\"model '{model}' has invalid reasoning_effort '{value}'\")]\n@@ -639,6 +646,7 @@ impl Catalog {\n let mut models_with_settings = Vec::new();\n let mut model_identifiers = BTreeMap::::new();\n let mut defaults_by_provider = HashMap::>::new();\n+ let mut small_defaults_by_provider = HashMap::>::new();\n \n let mut model_ids = settings.models.keys().cloned().collect::>();\n model_ids.sort_unstable();\n@@ -679,6 +687,12 @@ impl Catalog {\n .or_default()\n .push(model.id.clone());\n }\n+ if model.small_default {\n+ small_defaults_by_provider\n+ .entry(model.provider.clone())\n+ .or_default()\n+ .push(model.id.clone());\n+ }\n models_with_settings.push((model, resolved_settings));\n }\n \n@@ -690,6 +704,14 @@ impl Catalog {\n });\n }\n }\n+ for (provider, small_defaults) in small_defaults_by_provider {\n+ if small_defaults.len() > 1 {\n+ return Err(CatalogBuildError::MultipleProviderSmallDefaults {\n+ provider,\n+ models: small_defaults,\n+ });\n+ }\n+ }\n if !models_with_settings.iter().any(|(model, _)| model.default) {\n return Err(CatalogBuildError::NoDefaultModel);\n }\n@@ -890,6 +912,18 @@ impl Catalog {\n .find(|m| m.provider == provider_id && m.default)\n }\n \n+ /// Small default model for a provider — the small/cheap utility model used\n+ /// for metadata enrichment. Falls back to the provider's normal default\n+ /// when no explicit small default is configured.\n+ #[must_use]\n+ pub fn small_default_for_provider(&self, p: &ProviderId) -> Option<&Model> {\n+ let provider_id = self.provider(p).map_or(p, |provider| &provider.id);\n+ self.models\n+ .iter()\n+ .find(|m| &m.provider == provider_id && m.small_default)\n+ .or_else(|| self.default_for_provider(provider_id))\n+ }\n+\n /// Default model for the best-available provider (based on API keys),\n /// falling back to the global catalog default.\n #[must_use]\n@@ -931,6 +965,24 @@ impl Catalog {\n .unwrap_or_else(|| self.default_model())\n }\n \n+ /// Small default model for the best-available built-in provider IDs,\n+ /// falling back to the global catalog default.\n+ #[must_use]\n+ pub fn small_default_for_configured_ids(&self, configured: &[ProviderId]) -> &Model {\n+ if configured.is_empty() {\n+ return self.default_model();\n+ }\n+ let configured = configured\n+ .iter()\n+ .filter_map(|id| self.provider(id).map(|provider| provider.id.clone()))\n+ .collect::>();\n+ self.providers\n+ .iter()\n+ .filter(|provider| configured.contains(&provider.id))\n+ .find_map(|provider| self.small_default_for_provider(&provider.id))\n+ .unwrap_or_else(|| self.default_model())\n+ }\n+\n /// Probe model for a provider — the cheapest model suitable for\n /// connectivity checks. Falls back to the provider's default when no\n /// explicit override is configured.\n@@ -1077,6 +1129,7 @@ fn merge_model_settings(\n training: higher.training.or(fallback.training),\n knowledge_cutoff: higher.knowledge_cutoff.or(fallback.knowledge_cutoff),\n default: higher.default.or(fallback.default),\n+ small_default: higher.small_default.or(fallback.small_default),\n probe: higher.probe.or(fallback.probe),\n enabled: higher.enabled.or(fallback.enabled),\n aliases: higher.aliases.or(fallback.aliases),\n@@ -1319,6 +1372,7 @@ fn build_model(\n estimated_output_tps: settings.estimated_output_tps,\n aliases: settings.aliases.clone().unwrap_or_default(),\n default: settings.default.unwrap_or_default(),\n+ small_default: settings.small_default.unwrap_or_default(),\n configured: false,\n };\n let catalog_settings = CatalogModelSettings {\n@@ -1846,6 +1900,41 @@ enabled = true\n assert_eq!(m.id, \"gemini-3.1-pro-preview\");\n }\n \n+ #[test]\n+ fn builtin_small_defaults_are_marked_per_provider() {\n+ let catalog = Catalog::builtin();\n+\n+ assert_eq!(\n+ catalog\n+ .small_default_for_provider(&ProviderId::anthropic())\n+ .unwrap()\n+ .id,\n+ \"claude-haiku-4-5\"\n+ );\n+ assert_eq!(\n+ catalog\n+ .small_default_for_provider(&ProviderId::openai())\n+ .unwrap()\n+ .id,\n+ \"gpt-5.4-mini\"\n+ );\n+ assert_eq!(\n+ catalog\n+ .small_default_for_provider(&ProviderId::gemini())\n+ .unwrap()\n+ .id,\n+ \"gemini-3.1-flash-lite-preview\"\n+ );\n+ assert!(catalog.get(\"claude-haiku-4-5\").unwrap().small_default);\n+ assert!(catalog.get(\"gpt-5.4-mini\").unwrap().small_default);\n+ assert!(\n+ catalog\n+ .get(\"gemini-3.1-flash-lite-preview\")\n+ .unwrap()\n+ .small_default\n+ );\n+ }\n+\n #[test]\n fn builtin_closest_opus_to_gemini() {\n let opus = Catalog::builtin().get(\"claude-opus-4-6\").unwrap();\n@@ -2464,6 +2553,365 @@ reasoning = false\n );\n }\n \n+ #[test]\n+ fn small_default_for_provider_prefers_enabled_small_default_model_over_provider_default() {\n+ let layer = minimal_settings(\n+ r#\"\n+[providers.test]\n+display_name = \"Test\"\n+adapter = \"openai\"\n+agent_profile = \"openai\"\n+\n+[models.default_model]\n+provider = \"test\"\n+display_name = \"Default Model\"\n+family = \"test\"\n+default = true\n+\n+[models.default_model.limits]\n+context_window = 1000\n+\n+[models.default_model.features]\n+tools = false\n+vision = false\n+reasoning = false\n+\n+[models.small_model]\n+provider = \"test\"\n+display_name = \"Small Model\"\n+family = \"test\"\n+small_default = true\n+\n+[models.small_model.limits]\n+context_window = 1000\n+\n+[models.small_model.features]\n+tools = false\n+vision = false\n+reasoning = false\n+\"#,\n+ );\n+ let catalog = Catalog::from_settings(&layer).unwrap();\n+\n+ assert_eq!(\n+ catalog\n+ .small_default_for_provider(&ProviderId::new(\"test\"))\n+ .unwrap()\n+ .id,\n+ \"small_model\"\n+ );\n+ }\n+\n+ #[test]\n+ fn small_default_for_provider_falls_back_to_provider_default_when_no_small_default_marked() {\n+ let layer = minimal_settings(\n+ r#\"\n+[providers.test]\n+display_name = \"Test\"\n+adapter = \"openai\"\n+agent_profile = \"openai\"\n+\n+[models.default_model]\n+provider = \"test\"\n+display_name = \"Default Model\"\n+family = \"test\"\n+default = true\n+\n+[models.default_model.limits]\n+context_window = 1000\n+\n+[models.default_model.features]\n+tools = false\n+vision = false\n+reasoning = false\n+\n+[models.other_model]\n+provider = \"test\"\n+display_name = \"Other Model\"\n+family = \"test\"\n+\n+[models.other_model.limits]\n+context_window = 1000\n+\n+[models.other_model.features]\n+tools = false\n+vision = false\n+reasoning = false\n+\"#,\n+ );\n+ let catalog = Catalog::from_settings(&layer).unwrap();\n+\n+ assert_eq!(\n+ catalog\n+ .small_default_for_provider(&ProviderId::new(\"test\"))\n+ .unwrap()\n+ .id,\n+ \"default_model\"\n+ );\n+ }\n+\n+ #[test]\n+ fn small_default_for_provider_resolves_provider_alias() {\n+ let layer = minimal_settings(\n+ r#\"\n+[providers.canonical]\n+display_name = \"Canonical\"\n+adapter = \"openai\"\n+agent_profile = \"openai\"\n+aliases = [\"alias\"]\n+\n+[models.default_model]\n+provider = \"canonical\"\n+display_name = \"Default Model\"\n+family = \"test\"\n+default = true\n+\n+[models.default_model.limits]\n+context_window = 1000\n+\n+[models.default_model.features]\n+tools = false\n+vision = false\n+reasoning = false\n+\n+[models.small_model]\n+provider = \"canonical\"\n+display_name = \"Small Model\"\n+family = \"test\"\n+small_default = true\n+\n+[models.small_model.limits]\n+context_window = 1000\n+\n+[models.small_model.features]\n+tools = false\n+vision = false\n+reasoning = false\n+\"#,\n+ );\n+ let catalog = Catalog::from_settings(&layer).unwrap();\n+\n+ assert_eq!(\n+ catalog\n+ .small_default_for_provider(&ProviderId::new(\"alias\"))\n+ .unwrap()\n+ .id,\n+ \"small_model\"\n+ );\n+ }\n+\n+ #[test]\n+ fn small_default_for_configured_ids_uses_highest_priority_configured_provider() {\n+ let layer = minimal_settings(\n+ r#\"\n+[providers.low]\n+display_name = \"Low\"\n+adapter = \"openai\"\n+agent_profile = \"openai\"\n+priority = 10\n+\n+[providers.high]\n+display_name = \"High\"\n+adapter = \"openai\"\n+agent_profile = \"openai\"\n+priority = 20\n+\n+[models.low_default]\n+provider = \"low\"\n+display_name = \"Low Default\"\n+family = \"test\"\n+default = true\n+\n+[models.low_default.limits]\n+context_window = 1000\n+\n+[models.low_default.features]\n+tools = false\n+vision = false\n+reasoning = false\n+\n+[models.low_small]\n+provider = \"low\"\n+display_name = \"Low Small\"\n+family = \"test\"\n+small_default = true\n+\n+[models.low_small.limits]\n+context_window = 1000\n+\n+[models.low_small.features]\n+tools = false\n+vision = false\n+reasoning = false\n+\n+[models.high_default]\n+provider = \"high\"\n+display_name = \"High Default\"\n+family = \"test\"\n+default = true\n+\n+[models.high_default.limits]\n+context_window = 1000\n+\n+[models.high_default.features]\n+tools = false\n+vision = false\n+reasoning = false\n+\n+[models.high_small]\n+provider = \"high\"\n+display_name = \"High Small\"\n+family = \"test\"\n+small_default = true\n+\n+[models.high_small.limits]\n+context_window = 1000\n+\n+[models.high_small.features]\n+tools = false\n+vision = false\n+reasoning = false\n+\"#,\n+ );\n+ let catalog = Catalog::from_settings(&layer).unwrap();\n+\n+ assert_eq!(\n+ catalog\n+ .small_default_for_configured_ids(&[\n+ ProviderId::new(\"low\"),\n+ ProviderId::new(\"high\")\n+ ])\n+ .id,\n+ \"high_small\"\n+ );\n+ assert_eq!(\n+ catalog\n+ .small_default_for_configured_ids(&[ProviderId::new(\"low\")])\n+ .id,\n+ \"low_small\"\n+ );\n+ assert_eq!(\n+ catalog.small_default_for_configured_ids(&[]).id,\n+ catalog.default_model().id\n+ );\n+ }\n+\n+ #[test]\n+ fn small_default_for_configured_ids_falls_back_to_provider_default() {\n+ let layer = minimal_settings(\n+ r#\"\n+[providers.test]\n+display_name = \"Test\"\n+adapter = \"openai\"\n+agent_profile = \"openai\"\n+\n+[models.default_model]\n+provider = \"test\"\n+display_name = \"Default Model\"\n+family = \"test\"\n+default = true\n+\n+[models.default_model.limits]\n+context_window = 1000\n+\n+[models.default_model.features]\n+tools = false\n+vision = false\n+reasoning = false\n+\"#,\n+ );\n+ let catalog = Catalog::from_settings(&layer).unwrap();\n+\n+ assert_eq!(\n+ catalog\n+ .small_default_for_configured_ids(&[ProviderId::new(\"test\")])\n+ .id,\n+ \"default_model\"\n+ );\n+ }\n+\n+ #[test]\n+ fn multiple_small_default_models_for_provider_fail_catalog_build() {\n+ let layer = minimal_settings(\n+ r#\"\n+[providers.test]\n+display_name = \"Test\"\n+adapter = \"openai\"\n+agent_profile = \"openai\"\n+\n+[models.default_model]\n+provider = \"test\"\n+display_name = \"Default Model\"\n+family = \"test\"\n+default = true\n+\n+[models.default_model.limits]\n+context_window = 1000\n+\n+[models.default_model.features]\n+tools = false\n+vision = false\n+reasoning = false\n+\n+[models.first_small]\n+provider = \"test\"\n+display_name = \"First Small\"\n+family = \"test\"\n+small_default = true\n+\n+[models.first_small.limits]\n+context_window = 1000\n+\n+[models.first_small.features]\n+tools = false\n+vision = false\n+reasoning = false\n+\n+[models.second_small]\n+provider = \"test\"\n+display_name = \"Second Small\"\n+family = \"test\"\n+small_default = true\n+\n+[models.second_small.limits]\n+context_window = 1000\n+\n+[models.second_small.features]\n+tools = false\n+vision = false\n+reasoning = false\n+\"#,\n+ );\n+\n+ let err = Catalog::from_settings(&layer).unwrap_err();\n+\n+ assert!(matches!(\n+ err,\n+ CatalogBuildError::MultipleProviderSmallDefaults { provider, models }\n+ if provider == ProviderId::new(\"test\")\n+ && models == vec![\"first_small\".to_string(), \"second_small\".to_string()]\n+ ));\n+ }\n+\n+ #[test]\n+ fn small_default_false_override_clears_inherited_builtin_small_default_marker() {\n+ let catalog = Catalog::from_builtin_with_overrides(&minimal_settings(\n+ r#\"\n+[models.\"gpt-5.4-mini\"]\n+small_default = false\n+\"#,\n+ ))\n+ .expect(\"sparse built-in model override should build\");\n+\n+ assert_eq!(\n+ catalog\n+ .small_default_for_provider(&ProviderId::openai())\n+ .unwrap()\n+ .id,\n+ \"gpt-5.4\"\n+ );\n+ }\n+\n #[test]\n fn multiple_probe_models_are_non_fatal_and_select_a_probe_model() {\n let layer = minimal_settings(\n@@ -3215,6 +3663,7 @@ reasoning_effort = \"levels\"\n ),\n aliases: [],\n default: false,\n+ small_default: false,\n configured: false,\n }\n \"#);\n@@ -3286,6 +3735,7 @@ reasoning_effort = \"levels\"\n \"gemini-flash-lite\",\n ],\n default: false,\n+ small_default: true,\n configured: false,\n }\n \"#);\n@@ -3343,6 +3793,7 @@ reasoning_effort = \"levels\"\n \"kimi\",\n ],\n default: true,\n+ small_default: false,\n configured: false,\n }\n \"#);\n@@ -3405,6 +3856,7 @@ reasoning_effort = \"levels\"\n \"mercury\",\n ],\n default: true,\n+ small_default: false,\n configured: false,\n }\n \"#);\n@@ -3462,6 +3914,7 @@ reasoning_effort = \"levels\"\n \"gpt-54\",\n ],\n default: true,\n+ small_default: false,\n configured: false,\n }\n \"#);\n@@ -3514,6 +3967,7 @@ reasoning_effort = \"levels\"\n \"gpt-54-pro\",\n ],\n default: false,\n+ small_default: false,\n configured: false,\n }\n \"#);\n@@ -3585,6 +4039,7 @@ reasoning_effort = \"levels\"\n \"codex-spark\",\n ],\n default: false,\n+ small_default: false,\n configured: false,\n }\n \"#);\ndiff --git a/lib/crates/fabro-model/src/catalog/providers/anthropic.toml b/lib/crates/fabro-model/src/catalog/providers/anthropic.toml\nindex ed3bd88ff..efa72ded7 100644\n--- a/lib/crates/fabro-model/src/catalog/providers/anthropic.toml\n+++ b/lib/crates/fabro-model/src/catalog/providers/anthropic.toml\n@@ -140,6 +140,7 @@ knowledge_cutoff = \"May 2025\"\n estimated_output_tps = 100\n aliases = [\"haiku\", \"claude-haiku\"]\n probe = true\n+small_default = true\n \n [models.\"claude-haiku-4-5\".limits]\n context_window = 200000\ndiff --git a/lib/crates/fabro-model/src/catalog/providers/gemini.toml b/lib/crates/fabro-model/src/catalog/providers/gemini.toml\nindex f91ca5c63..fb73d6723 100644\n--- a/lib/crates/fabro-model/src/catalog/providers/gemini.toml\n+++ b/lib/crates/fabro-model/src/catalog/providers/gemini.toml\n@@ -94,6 +94,7 @@ training = \"2025-01-01\"\n knowledge_cutoff = \"January 2025\"\n estimated_output_tps = 200\n aliases = [\"gemini-flash-lite\"]\n+small_default = true\n \n [models.\"gemini-3.1-flash-lite-preview\".limits]\n context_window = 1048576\ndiff --git a/lib/crates/fabro-model/src/catalog/providers/openai.toml b/lib/crates/fabro-model/src/catalog/providers/openai.toml\nindex 87dd2d8ac..3cdba296e 100644\n--- a/lib/crates/fabro-model/src/catalog/providers/openai.toml\n+++ b/lib/crates/fabro-model/src/catalog/providers/openai.toml\n@@ -238,6 +238,7 @@ knowledge_cutoff = \"April 2025\"\n estimated_output_tps = 140\n aliases = [\"gpt54-mini\", \"gpt-54-mini\"]\n probe = true\n+small_default = true\n \n [models.\"gpt-5.4-mini\".limits]\n context_window = 400000\ndiff --git a/lib/crates/fabro-model/src/types.rs b/lib/crates/fabro-model/src/types.rs\nindex f6b05d072..764943dd6 100644\n--- a/lib/crates/fabro-model/src/types.rs\n+++ b/lib/crates/fabro-model/src/types.rs\n@@ -67,6 +67,8 @@ pub struct Model {\n pub aliases: Vec,\n #[serde(default)]\n pub default: bool,\n+ #[serde(default)]\n+ pub small_default: bool,\n /// Whether the server has any credential configured for this model's\n /// provider at the time of the response. Always `false` in static catalog\n /// data; populated by `GET /models` per request.\n@@ -150,6 +152,10 @@ impl Model {\n pub fn is_default(&self) -> bool {\n self.default\n }\n+\n+ pub fn is_small_default(&self) -> bool {\n+ self.small_default\n+ }\n }\n \n #[cfg(test)]\n@@ -178,5 +184,6 @@ mod tests {\n assert_eq!(info.estimated_output_tps(), Some(25.0));\n assert!(!info.aliases().is_empty());\n assert!(!info.is_default());\n+ assert!(!info.is_small_default());\n }\n }\ndiff --git a/lib/crates/fabro-server/src/lib.rs b/lib/crates/fabro-server/src/lib.rs\nindex 873482712..5bbad6ea5 100644\n--- a/lib/crates/fabro-server/src/lib.rs\n+++ b/lib/crates/fabro-server/src/lib.rs\n@@ -31,6 +31,7 @@ mod run_files;\n mod run_files_security;\n mod run_manifest;\n mod run_selector;\n+mod run_title_generation;\n pub mod run_tool_manifest;\n pub mod security_headers;\n pub mod serve;\ndiff --git a/lib/crates/fabro-server/src/run_title_generation.rs b/lib/crates/fabro-server/src/run_title_generation.rs\nnew file mode 100644\nindex 000000000..c9d258cc9\n--- /dev/null\n+++ b/lib/crates/fabro-server/src/run_title_generation.rs\n@@ -0,0 +1,374 @@\n+use std::collections::HashMap;\n+use std::sync::Arc;\n+\n+use fabro_llm::client::Client;\n+use fabro_llm::generate::{self, GenerateParams};\n+use fabro_llm::types::TimeoutOptions;\n+use fabro_model::ProviderId;\n+use fabro_types::{Graph, MAX_RUN_TITLE_CHARS, RunId};\n+use serde::Serialize;\n+use toml::Value as TomlValue;\n+\n+const TRUNCATED_MARKER: &str = \"...[truncated]\";\n+const MAX_PROMPT_SECTION_CHARS: usize = 4_000;\n+\n+pub(crate) struct TitlePromptInput<'a> {\n+ pub(crate) run_id: &'a RunId,\n+ pub(crate) current_title: &'a str,\n+ pub(crate) workflow_target: Option<&'a str>,\n+ pub(crate) run_inputs: &'a HashMap,\n+ pub(crate) workflow: &'a WorkflowSummary,\n+}\n+\n+pub(crate) struct GenerateTitleInput<'a> {\n+ pub(crate) client: Arc,\n+ pub(crate) model_id: String,\n+ pub(crate) provider_id: ProviderId,\n+ pub(crate) prompt: TitlePromptInput<'a>,\n+}\n+\n+pub(crate) async fn generate_title_or_current(input: GenerateTitleInput<'_>) -> String {\n+ let current_title = input.prompt.current_title.to_string();\n+ let prompt = build_title_prompt(&input.prompt);\n+ let params = GenerateParams::new(input.model_id, input.client)\n+ .provider(input.provider_id.to_string())\n+ .prompt(prompt)\n+ .max_tokens(64)\n+ .max_retries(0)\n+ .timeout(TimeoutOptions {\n+ total: Some(10.0),\n+ per_step: Some(5.0),\n+ });\n+\n+ let result = match generate::generate_object(params, title_response_schema()).await {\n+ Ok(result) => result,\n+ Err(err) => {\n+ tracing::debug!(error = %err, \"Run title generation failed\");\n+ return current_title;\n+ }\n+ };\n+ result\n+ .output\n+ .as_ref()\n+ .and_then(|output| output.get(\"title\"))\n+ .and_then(serde_json::Value::as_str)\n+ .and_then(normalize_generated_title)\n+ .unwrap_or(current_title)\n+}\n+\n+fn build_title_prompt(input: &TitlePromptInput<'_>) -> String {\n+ let workflow_identity = serde_json::json!({\n+ \"run_id\": input.run_id.to_string(),\n+ \"current_deterministic_title\": input.current_title,\n+ \"target\": input.workflow_target,\n+ \"name\": input.workflow.graph_name,\n+ \"goal\": input.workflow.goal,\n+ });\n+ let identity = pretty_json(&workflow_identity);\n+ let inputs = pretty_json(input.run_inputs);\n+ let workflow = pretty_json(input.workflow);\n+\n+ format!(\n+ r#\"Generate a concise, human-readable title for this Fabro workflow run.\n+\n+Base the title on the workflow identity, workflow goal, and run input values.\n+Preserve meaningful proper nouns, ticket IDs, repositories, branches, environments, and explicit user goals.\n+Return only structured JSON with one field: {{\"title\":\"...\"}}.\n+The title must be a single line, not blank, and no more than {MAX_RUN_TITLE_CHARS} characters.\n+\n+Workflow identity:\n+```json\n+{}\n+```\n+\n+Run inputs (raw values, not redacted):\n+```json\n+{}\n+```\n+\n+Workflow summary:\n+```json\n+{}\n+```\n+\"#,\n+ truncate_section(&identity, MAX_PROMPT_SECTION_CHARS),\n+ truncate_section(&inputs, MAX_PROMPT_SECTION_CHARS),\n+ truncate_section(&workflow, MAX_PROMPT_SECTION_CHARS),\n+ )\n+}\n+\n+fn normalize_generated_title(title: &str) -> Option {\n+ let trimmed = title.trim();\n+ if trimmed.is_empty() || trimmed.chars().any(char::is_control) {\n+ return None;\n+ }\n+ Some(trimmed.chars().take(MAX_RUN_TITLE_CHARS).collect())\n+}\n+\n+fn title_response_schema() -> serde_json::Value {\n+ serde_json::json!({\n+ \"type\": \"object\",\n+ \"additionalProperties\": false,\n+ \"required\": [\"title\"],\n+ \"properties\": {\n+ \"title\": {\n+ \"type\": \"string\",\n+ \"description\": \"Concise single-line run title\"\n+ }\n+ }\n+ })\n+}\n+\n+#[derive(Serialize)]\n+pub(crate) struct WorkflowSummary {\n+ pub(crate) graph_name: String,\n+ pub(crate) goal: String,\n+ pub(crate) stage_count: usize,\n+ pub(crate) edge_count: usize,\n+ pub(crate) stages: Vec,\n+}\n+\n+#[derive(Serialize)]\n+pub(crate) struct StageSummary {\n+ id: String,\n+ label: String,\n+ handler_type: Option,\n+}\n+\n+pub(crate) fn workflow_summary(graph: &Graph) -> WorkflowSummary {\n+ let mut stages = graph\n+ .nodes\n+ .values()\n+ .map(|node| StageSummary {\n+ id: node.id.clone(),\n+ label: node.label().to_string(),\n+ handler_type: node.handler_type().map(str::to_string),\n+ })\n+ .collect::>();\n+ stages.sort_by(|left, right| left.id.cmp(&right.id));\n+\n+ WorkflowSummary {\n+ graph_name: graph.name.clone(),\n+ goal: graph.goal().to_string(),\n+ stage_count: stages.len(),\n+ edge_count: graph.edges.len(),\n+ stages,\n+ }\n+}\n+\n+fn pretty_json(value: &impl Serialize) -> String {\n+ serde_json::to_string_pretty(value).unwrap_or_else(|_| \"{}\".to_string())\n+}\n+\n+fn truncate_section(value: &str, max_chars: usize) -> String {\n+ if value.chars().count() <= max_chars {\n+ return value.to_string();\n+ }\n+ let keep = max_chars.saturating_sub(TRUNCATED_MARKER.chars().count());\n+ let truncated = value.chars().take(keep).collect::();\n+ format!(\"{truncated}{TRUNCATED_MARKER}\")\n+}\n+\n+#[cfg(test)]\n+mod tests {\n+ use std::collections::HashMap;\n+ use std::pin::Pin;\n+ use std::sync::{Arc, Mutex};\n+\n+ use async_trait::async_trait;\n+ use fabro_graphviz::parser;\n+ use fabro_llm::client::Client;\n+ use fabro_llm::error::Error as LlmError;\n+ use fabro_llm::provider::{ProviderAdapter, StreamEventStream};\n+ use fabro_llm::token_count::InputTokenCount;\n+ use fabro_llm::types::{FinishReason, Message, Request, Response, StreamEvent, TokenCounts};\n+ use fabro_model::ProviderId;\n+ use fabro_types::RunId;\n+ use futures_util::stream;\n+ use toml::Value as TomlValue;\n+\n+ use super::*;\n+\n+ fn title_test_graph() -> fabro_types::Graph {\n+ parser::parse(\n+ r#\"digraph Ship {\n+ graph [goal=\"Deploy API token SECRET_123 to production\"]\n+ start [shape=Mdiamond, label=\"Start\"]\n+ plan [shape=box, label=\"Plan rollout\"]\n+ deploy [shape=parallelogram, label=\"Deploy\"]\n+ exit [shape=Msquare, label=\"Exit\"]\n+ start -> plan -> deploy -> exit\n+ }\"#,\n+ )\n+ .unwrap()\n+ }\n+\n+ #[test]\n+ fn prompt_includes_goal_inputs_and_workflow_summary_without_redaction() {\n+ let run_id = RunId::new();\n+ let graph = title_test_graph();\n+ let summary = workflow_summary(&graph);\n+ let inputs = HashMap::from([\n+ (\n+ \"api_key\".to_string(),\n+ TomlValue::String(\"SECRET_123\".to_string()),\n+ ),\n+ (\n+ \"region\".to_string(),\n+ TomlValue::String(\"us-east-1\".to_string()),\n+ ),\n+ ]);\n+\n+ let prompt = build_title_prompt(&TitlePromptInput {\n+ run_id: &run_id,\n+ current_title: \"Deploy API token SECRET_123 to production\",\n+ workflow_target: Some(\"workflows/deploy.fabro\"),\n+ run_inputs: &inputs,\n+ workflow: &summary,\n+ });\n+\n+ assert!(prompt.contains(\"Deploy API token SECRET_123 to production\"));\n+ assert!(prompt.contains(\"\\\"api_key\\\": \\\"SECRET_123\\\"\"));\n+ assert!(prompt.contains(\"workflows/deploy.fabro\"));\n+ assert!(prompt.contains(\"\\\"id\\\": \\\"deploy\\\"\"));\n+ assert!(prompt.contains(\"\\\"handler_type\\\": \\\"command\\\"\"));\n+ assert!(!prompt.contains(\"[REDACTED]\"));\n+ }\n+\n+ #[test]\n+ fn prompt_bounds_large_input_and_workflow_sections() {\n+ let run_id = RunId::new();\n+ let graph = title_test_graph();\n+ let summary = workflow_summary(&graph);\n+ let inputs = HashMap::from([(\n+ \"large\".to_string(),\n+ TomlValue::String(\"x\".repeat(MAX_PROMPT_SECTION_CHARS * 2)),\n+ )]);\n+\n+ let prompt = build_title_prompt(&TitlePromptInput {\n+ run_id: &run_id,\n+ current_title: \"Current\",\n+ workflow_target: Some(\"workflow.fabro\"),\n+ run_inputs: &inputs,\n+ workflow: &summary,\n+ });\n+\n+ // Section truncation: per-section budget × 3 + small boilerplate.\n+ assert!(prompt.chars().count() < MAX_PROMPT_SECTION_CHARS * 3 + 1_000);\n+ assert!(prompt.contains(\"...[truncated]\"));\n+ // One section was truncated, the others were not; ensure we don't\n+ // emit a doubly-truncated marker run.\n+ assert!(!prompt.contains(\"...[truncated]...[truncated]\"));\n+ }\n+\n+ #[test]\n+ fn generated_title_normalization_accepts_and_truncates_valid_titles() {\n+ let normalized = normalize_generated_title(\" Ship the API rollout \").unwrap();\n+ assert_eq!(normalized, \"Ship the API rollout\");\n+\n+ let long = normalize_generated_title(&\"a\".repeat(101)).unwrap();\n+ assert_eq!(long.chars().count(), 100);\n+ }\n+\n+ #[test]\n+ fn generated_title_normalization_rejects_blank_and_control_output() {\n+ assert!(normalize_generated_title(\" \").is_none());\n+ assert!(normalize_generated_title(\"first\\nsecond\").is_none());\n+ assert!(normalize_generated_title(\"first\\tsecond\").is_none());\n+ }\n+\n+ #[tokio::test]\n+ async fn generation_uses_supplied_small_default_model_id() {\n+ let (title, captured) = title_with_mocked_response(r#\"{\"title\":\"Generated title\"}\"#).await;\n+\n+ assert_eq!(title, \"Generated title\");\n+ let captured = captured.lock().unwrap();\n+ assert_eq!(captured[0].model, \"small-model\");\n+ assert_eq!(captured[0].provider.as_deref(), Some(\"openai\"));\n+ assert_eq!(captured[0].max_tokens, Some(64));\n+ }\n+\n+ #[tokio::test]\n+ async fn invalid_or_failed_generation_returns_current_title() {\n+ let (invalid, _) = title_with_mocked_response(r#\"{\"title\":\"first\\nsecond\"}\"#).await;\n+ assert_eq!(invalid, \"Current\");\n+\n+ let (invalid_shape, _) = title_with_mocked_response(r#\"{\"name\":\"missing\"}\"#).await;\n+ assert_eq!(invalid_shape, \"Current\");\n+ }\n+\n+ async fn title_with_mocked_response(response_text: &str) -> (String, Arc>>) {\n+ let captured = Arc::new(Mutex::new(Vec::new()));\n+ let provider = Arc::new(CapturingProvider {\n+ captured: Arc::clone(&captured),\n+ response_text: response_text.to_string(),\n+ });\n+ let client = Arc::new(Client::new(\n+ HashMap::from([(\"openai\".to_string(), provider as Arc)]),\n+ Some(\"openai\".to_string()),\n+ Vec::new(),\n+ ));\n+ let run_id = RunId::new();\n+ let graph = title_test_graph();\n+ let summary = workflow_summary(&graph);\n+ let inputs = HashMap::new();\n+ let title = generate_title_or_current(GenerateTitleInput {\n+ client,\n+ model_id: \"small-model\".to_string(),\n+ provider_id: ProviderId::openai(),\n+ prompt: TitlePromptInput {\n+ run_id: &run_id,\n+ current_title: \"Current\",\n+ workflow_target: Some(\"workflow.fabro\"),\n+ run_inputs: &inputs,\n+ workflow: &summary,\n+ },\n+ })\n+ .await;\n+ (title, captured)\n+ }\n+\n+ struct CapturingProvider {\n+ captured: Arc>>,\n+ response_text: String,\n+ }\n+\n+ #[async_trait]\n+ impl ProviderAdapter for CapturingProvider {\n+ #[expect(\n+ clippy::unnecessary_literal_bound,\n+ reason = \"ProviderAdapter trait signature returns &str.\"\n+ )]\n+ fn name(&self) -> &str {\n+ \"openai\"\n+ }\n+\n+ async fn complete(&self, request: &Request) -> Result {\n+ self.captured.lock().unwrap().push(request.clone());\n+ Ok(Response {\n+ id: \"resp_title\".to_string(),\n+ model: request.model.clone(),\n+ provider: \"openai\".to_string(),\n+ message: Message::assistant(self.response_text.clone()),\n+ finish_reason: FinishReason::Stop,\n+ usage: TokenCounts::default(),\n+ raw: None,\n+ warnings: Vec::new(),\n+ rate_limit: None,\n+ })\n+ }\n+\n+ async fn stream(&self, _request: &Request) -> Result {\n+ Ok(Pin::from(Box::new(stream::empty::<\n+ Result,\n+ >())))\n+ }\n+\n+ async fn count_input_tokens(\n+ &self,\n+ _request: &Request,\n+ ) -> Result, LlmError> {\n+ Ok(None)\n+ }\n+ }\n+}\ndiff --git a/lib/crates/fabro-server/src/server/handler/runs.rs b/lib/crates/fabro-server/src/server/handler/runs.rs\nindex 0f1057071..a9aab7b05 100644\n--- a/lib/crates/fabro-server/src/server/handler/runs.rs\n+++ b/lib/crates/fabro-server/src/server/handler/runs.rs\n@@ -18,8 +18,10 @@ use fabro_api::types::{\n };\n use fabro_config::Storage;\n use fabro_interview::AnswerSubmission;\n+use fabro_llm::client::Client as LlmClient;\n use fabro_types::{\n- Principal, RunClientProvenance, RunId, RunProvenance, RunServerProvenance, parse_blob_ref,\n+ Principal, RunClientProvenance, RunId, RunProvenance, RunServerProvenance, SystemActorKind,\n+ parse_blob_ref,\n };\n use fabro_util::version::FABRO_VERSION;\n use fabro_workflow::command_log::{command_log_path, read_json_string_blob, read_log_slice};\n@@ -42,6 +44,8 @@ use crate::principal_middleware::{\n use crate::run_files::{list_run_commits, list_run_files};\n use crate::run_manifest;\n use crate::run_selector::{ResolveRunError, resolve_run_by_selector};\n+use crate::run_title_generation::{self, GenerateTitleInput, TitlePromptInput, WorkflowSummary};\n+use crate::server_secrets::LlmClientResult;\n \n pub(super) fn manifest_routes() -> Router> {\n Router::new()\n@@ -553,6 +557,7 @@ async fn create_run(\n Ok(req) => req,\n Err(err) => return ApiError::bad_request(err.to_string()).into_response(),\n };\n+ let explicit_title_supplied = req.title.is_some();\n let manifest_run_defaults = state.manifest_run_defaults();\n let manifest_environment_defaults = state.manifest_environment_defaults();\n let prepared = match run_manifest::prepare_manifest_with_environment_defaults(\n@@ -576,9 +581,26 @@ async fn create_run(\n \n let web_url = state.run_web_url(&run_id);\n let catalog = state.catalog();\n- let configured_providers = state.ready_llm_provider_ids().await;\n- let mut create_input =\n- run_manifest::create_run_input(prepared.clone(), configured_providers, web_url.clone());\n+ // Resolve once: we need both the provider IDs (for the run create input\n+ // and ask-fabro-readiness) and the LLM client itself (for the spawned\n+ // title-generation task). `ready_llm_provider_ids` would otherwise call\n+ // `resolve_llm_client` a second time and discard the client.\n+ let llm_client_for_title = match state.resolve_llm_client().await {\n+ Ok(result) => Some(result),\n+ Err(err) => {\n+ tracing::warn!(error = ?err, \"Failed to resolve LLM client while creating run\");\n+ None\n+ }\n+ };\n+ let ready_provider_ids = llm_client_for_title\n+ .as_ref()\n+ .map(LlmClientResult::provider_ids)\n+ .unwrap_or_default();\n+ let mut create_input = run_manifest::create_run_input(\n+ prepared.clone(),\n+ ready_provider_ids.clone(),\n+ web_url.clone(),\n+ );\n create_input.run_id = Some(run_id);\n create_input.provenance = Some(run_provenance(&headers, &actor));\n create_input.submitted_manifest_bytes = Some(body.to_vec());\n@@ -626,6 +648,7 @@ async fn create_run(\n .into_response();\n }\n };\n+ let deterministic_title = summary.title.clone();\n \n {\n let mut runs = state.runs.lock().expect(\"runs lock poisoned\");\n@@ -641,6 +664,30 @@ async fn create_run(\n );\n }\n \n+ if !explicit_title_supplied && !ready_provider_ids.is_empty() {\n+ if let Some(llm_result) = llm_client_for_title {\n+ let run_spec = created.persisted.run_spec();\n+ let workflow = run_title_generation::workflow_summary(&run_spec.graph);\n+ let run_inputs = run_spec.settings.run.inputs.clone();\n+ let workflow_target = prepared.target_path.to_string();\n+ let title_catalog = state.catalog();\n+ let title_model = title_catalog.small_default_for_configured_ids(&ready_provider_ids);\n+ let title_model_id = title_model.id.clone();\n+ let title_provider_id = title_model.provider.clone();\n+ spawn_generated_title_task(GeneratedTitleTask {\n+ state: Arc::clone(&state),\n+ run_id: created.run_id,\n+ deterministic_title,\n+ workflow_target,\n+ workflow,\n+ run_inputs,\n+ client: llm_result.client,\n+ model_id: title_model_id,\n+ provider_id: title_provider_id,\n+ });\n+ }\n+ }\n+\n (\n StatusCode::CREATED,\n Json(state.decorate_run_summary(summary).await),\n@@ -648,6 +695,77 @@ async fn create_run(\n .into_response()\n }\n \n+struct GeneratedTitleTask {\n+ state: Arc,\n+ run_id: RunId,\n+ deterministic_title: String,\n+ workflow_target: String,\n+ workflow: WorkflowSummary,\n+ run_inputs: std::collections::HashMap,\n+ client: LlmClient,\n+ model_id: String,\n+ provider_id: fabro_model::ProviderId,\n+}\n+\n+fn spawn_generated_title_task(task: GeneratedTitleTask) {\n+ tokio::spawn(async move {\n+ let generated_title = run_title_generation::generate_title_or_current(GenerateTitleInput {\n+ client: Arc::new(task.client),\n+ model_id: task.model_id,\n+ provider_id: task.provider_id,\n+ prompt: TitlePromptInput {\n+ run_id: &task.run_id,\n+ current_title: &task.deterministic_title,\n+ workflow_target: Some(task.workflow_target.as_str()),\n+ run_inputs: &task.run_inputs,\n+ workflow: &task.workflow,\n+ },\n+ })\n+ .await;\n+ if generated_title == task.deterministic_title {\n+ return;\n+ }\n+\n+ let current = match task\n+ .state\n+ .store\n+ .get_cached_summary(&task.run_id, Utc::now())\n+ .await\n+ {\n+ Ok(Some(summary)) => summary,\n+ Ok(None) => return,\n+ Err(err) => {\n+ tracing::debug!(run_id = %task.run_id, error = %err, \"Failed to re-read run summary for title update\");\n+ return;\n+ }\n+ };\n+ if current.title != task.deterministic_title {\n+ return;\n+ }\n+ let run_store = match task.state.store.open_run(&task.run_id).await {\n+ Ok(store) => store,\n+ Err(err) => {\n+ tracing::debug!(run_id = %task.run_id, error = %err, \"Failed to open run store for title update\");\n+ return;\n+ }\n+ };\n+ if let Err(err) = workflow_event::append_event(\n+ &run_store,\n+ &task.run_id,\n+ &workflow_event::Event::RunTitleUpdated {\n+ title: generated_title,\n+ actor: Some(Principal::System {\n+ system_kind: SystemActorKind::Engine,\n+ }),\n+ },\n+ )\n+ .await\n+ {\n+ tracing::debug!(run_id = %task.run_id, error = %err, \"Failed to append generated run title event\");\n+ }\n+ });\n+}\n+\n fn run_provenance(headers: &HeaderMap, subject: &Principal) -> RunProvenance {\n RunProvenance {\n server: Some(RunServerProvenance {\ndiff --git a/lib/crates/fabro-server/src/server/tests.rs b/lib/crates/fabro-server/src/server/tests.rs\nindex 7cee8c7d7..dc6bb885e 100644\n--- a/lib/crates/fabro-server/src/server/tests.rs\n+++ b/lib/crates/fabro-server/src/server/tests.rs\n@@ -2377,6 +2377,254 @@ url = \"http://127.0.0.1:32276\"\n );\n }\n \n+#[tokio::test]\n+async fn create_run_without_explicit_title_returns_deterministic_then_updates_generated_title() {\n+ let llm = MockServer::start_async().await;\n+ let title_mock = mock_openai_title_response(&llm, \"Generated deploy title\", None).await;\n+ let state = TestAppStateBuilder::new()\n+ .provider_base_url(\"openai\", llm.url(\"/v1\"))\n+ .env_lookup(|_| None)\n+ .build();\n+ state\n+ .vault\n+ .write()\n+ .await\n+ .set(\"OPENAI_API_KEY\", \"openai-key\", SecretType::Token, None)\n+ .unwrap();\n+ let app = crate::test_support::build_test_router(Arc::clone(&state));\n+\n+ let body = post_run_manifest(&app, minimal_manifest_json(MINIMAL_DOT)).await;\n+ let run_id: RunId = body[\"id\"].as_str().unwrap().parse().unwrap();\n+\n+ assert_eq!(body[\"title\"], \"Test\");\n+ wait_for_run_title(&state, run_id, \"Generated deploy title\").await;\n+ assert_eq!(title_update_event_count(&state, run_id).await, 1);\n+ title_mock.assert_async().await;\n+}\n+\n+#[tokio::test]\n+async fn create_run_with_explicit_title_skips_generated_title_work() {\n+ let llm = MockServer::start_async().await;\n+ let title_mock = mock_openai_title_response(&llm, \"Generated deploy title\", None).await;\n+ let state = TestAppStateBuilder::new()\n+ .provider_base_url(\"openai\", llm.url(\"/v1\"))\n+ .env_lookup(|_| None)\n+ .build();\n+ state\n+ .vault\n+ .write()\n+ .await\n+ .set(\"OPENAI_API_KEY\", \"openai-key\", SecretType::Token, None)\n+ .unwrap();\n+ let app = crate::test_support::build_test_router(Arc::clone(&state));\n+ let mut manifest = minimal_manifest_json(MINIMAL_DOT);\n+ manifest[\"title\"] = json!(\"Caller title\");\n+\n+ let body = post_run_manifest(&app, manifest).await;\n+ let run_id: RunId = body[\"id\"].as_str().unwrap().parse().unwrap();\n+ // The spawn gate is synchronous in `create_run`, so once the response\n+ // returns we know no title task was scheduled. No sleep needed.\n+\n+ assert_eq!(\n+ state\n+ .store\n+ .get_cached_summary(&run_id, Utc::now())\n+ .await\n+ .unwrap()\n+ .unwrap()\n+ .title,\n+ \"Caller title\"\n+ );\n+ assert_eq!(title_update_event_count(&state, run_id).await, 0);\n+ title_mock.assert_calls_async(0).await;\n+}\n+\n+#[tokio::test]\n+async fn create_run_without_ready_llm_provider_skips_generated_title_work() {\n+ let state = TestAppStateBuilder::new().env_lookup(|_| None).build();\n+ let app = crate::test_support::build_test_router(Arc::clone(&state));\n+\n+ let body = post_run_manifest(&app, minimal_manifest_json(MINIMAL_DOT)).await;\n+ let run_id: RunId = body[\"id\"].as_str().unwrap().parse().unwrap();\n+\n+ assert_eq!(\n+ state\n+ .store\n+ .get_cached_summary(&run_id, Utc::now())\n+ .await\n+ .unwrap()\n+ .unwrap()\n+ .title,\n+ \"Test\"\n+ );\n+ assert_eq!(title_update_event_count(&state, run_id).await, 0);\n+}\n+\n+#[tokio::test]\n+async fn generated_title_failure_leaves_deterministic_title_unchanged() {\n+ let llm = MockServer::start_async().await;\n+ let title_mock = llm\n+ .mock_async(|when, then| {\n+ when.method(POST).path(\"/v1/responses\");\n+ then.status(500)\n+ .header(\"content-type\", \"application/json\")\n+ .json_body(json!({\"error\": {\"message\": \"boom\"}}));\n+ })\n+ .await;\n+ let state = TestAppStateBuilder::new()\n+ .provider_base_url(\"openai\", llm.url(\"/v1\"))\n+ .env_lookup(|_| None)\n+ .build();\n+ state\n+ .vault\n+ .write()\n+ .await\n+ .set(\"OPENAI_API_KEY\", \"openai-key\", SecretType::Token, None)\n+ .unwrap();\n+ let app = crate::test_support::build_test_router(Arc::clone(&state));\n+\n+ let body = post_run_manifest(&app, minimal_manifest_json(MINIMAL_DOT)).await;\n+ let run_id: RunId = body[\"id\"].as_str().unwrap().parse().unwrap();\n+ wait_for_mock_hits(&title_mock, 1).await;\n+ tokio::time::sleep(std::time::Duration::from_millis(25)).await;\n+\n+ assert_eq!(\n+ state\n+ .store\n+ .get_cached_summary(&run_id, Utc::now())\n+ .await\n+ .unwrap()\n+ .unwrap()\n+ .title,\n+ \"Test\"\n+ );\n+ assert_eq!(title_update_event_count(&state, run_id).await, 0);\n+}\n+\n+#[tokio::test]\n+async fn generated_title_does_not_overwrite_user_title_edit() {\n+ let llm = MockServer::start_async().await;\n+ let title_mock = mock_openai_title_response(\n+ &llm,\n+ \"Generated deploy title\",\n+ Some(std::time::Duration::from_millis(150)),\n+ )\n+ .await;\n+ let state = TestAppStateBuilder::new()\n+ .provider_base_url(\"openai\", llm.url(\"/v1\"))\n+ .env_lookup(|_| None)\n+ .build();\n+ state\n+ .vault\n+ .write()\n+ .await\n+ .set(\"OPENAI_API_KEY\", \"openai-key\", SecretType::Token, None)\n+ .unwrap();\n+ let app = crate::test_support::build_test_router(Arc::clone(&state));\n+\n+ let body = post_run_manifest(&app, minimal_manifest_json(MINIMAL_DOT)).await;\n+ let run_id: RunId = body[\"id\"].as_str().unwrap().parse().unwrap();\n+ let patch = Request::builder()\n+ .method(\"PATCH\")\n+ .uri(api(&format!(\"/runs/{run_id}\")))\n+ .header(\"content-type\", \"application/json\")\n+ .body(Body::from(json!({\"title\": \"User title\"}).to_string()))\n+ .unwrap();\n+ let response = app.clone().oneshot(patch).await.unwrap();\n+ response_json!(response, StatusCode::OK).await;\n+\n+ wait_for_mock_hits(&title_mock, 1).await;\n+ tokio::time::sleep(std::time::Duration::from_millis(50)).await;\n+\n+ assert_eq!(\n+ state\n+ .store\n+ .get_cached_summary(&run_id, Utc::now())\n+ .await\n+ .unwrap()\n+ .unwrap()\n+ .title,\n+ \"User title\"\n+ );\n+ assert_eq!(title_update_event_count(&state, run_id).await, 1);\n+}\n+\n+async fn post_run_manifest(app: &Router, manifest: serde_json::Value) -> serde_json::Value {\n+ let response = app\n+ .clone()\n+ .oneshot(\n+ Request::builder()\n+ .method(\"POST\")\n+ .uri(api(\"/runs\"))\n+ .header(\"content-type\", \"application/json\")\n+ .body(Body::from(manifest.to_string()))\n+ .unwrap(),\n+ )\n+ .await\n+ .unwrap();\n+ response_json!(response, StatusCode::CREATED).await\n+}\n+\n+async fn mock_openai_title_response<'a>(\n+ server: &'a MockServer,\n+ title: &str,\n+ delay: Option,\n+) -> httpmock::Mock<'a> {\n+ let title = title.to_string();\n+ server\n+ .mock_async(move |when, then| {\n+ when.method(POST).path(\"/v1/responses\");\n+ let then = then\n+ .status(200)\n+ .header(\"content-type\", \"application/json\")\n+ .json_body(openai_responses_payload(\n+ &json!({ \"title\": title }).to_string(),\n+ ));\n+ if let Some(delay) = delay {\n+ then.delay(delay);\n+ }\n+ })\n+ .await\n+}\n+\n+async fn wait_for_run_title(state: &AppState, run_id: RunId, expected: &str) {\n+ for _ in 0..50 {\n+ let title = state\n+ .store\n+ .get_cached_summary(&run_id, Utc::now())\n+ .await\n+ .unwrap()\n+ .unwrap()\n+ .title;\n+ if title == expected {\n+ return;\n+ }\n+ tokio::time::sleep(std::time::Duration::from_millis(20)).await;\n+ }\n+ panic!(\"run {run_id} title did not become {expected:?}\");\n+}\n+\n+async fn wait_for_mock_hits(mock: &httpmock::Mock<'_>, expected: usize) {\n+ for _ in 0..50 {\n+ if mock.calls_async().await >= expected {\n+ return;\n+ }\n+ tokio::time::sleep(std::time::Duration::from_millis(20)).await;\n+ }\n+ panic!(\"mock did not receive {expected} request(s)\");\n+}\n+\n+async fn title_update_event_count(state: &AppState, run_id: RunId) -> usize {\n+ let run_store = state.store.open_run(&run_id).await.unwrap();\n+ run_store\n+ .list_events()\n+ .await\n+ .unwrap()\n+ .into_iter()\n+ .filter(|event| event.event.event_name() == \"run.title.updated\")\n+ .count()\n+}\n+\n #[tokio::test]\n async fn validate_endpoint_returns_workflow_summary_without_preflight_checks() {\n let app = test_app_with();\ndiff --git a/lib/crates/fabro-types/src/lib.rs b/lib/crates/fabro-types/src/lib.rs\nindex 3c46fc819..b1f5ba2d0 100644\n--- a/lib/crates/fabro-types/src/lib.rs\n+++ b/lib/crates/fabro-types/src/lib.rs\n@@ -112,7 +112,9 @@ pub use run_summary::{\n AskFabro, AskFabroUnavailableReason, AutomationRef, Run, RunBillingSummary, RunError,\n RunLifecycle, RunLinks, RunModel, RunOrigin, RunOriginKind, RunTimestamps, WorkflowRef,\n };\n-pub use run_title::{RunTitleError, infer_run_title, normalize_explicit_run_title};\n+pub use run_title::{\n+ MAX_RUN_TITLE_CHARS, RunTitleError, infer_run_title, normalize_explicit_run_title,\n+};\n pub use sandbox_details::{\n SandboxDetails, SandboxNetwork, SandboxNetworkPolicy, SandboxNetworkPolicyMode,\n SandboxResources, SandboxState, SandboxTimestamps,\ndiff --git a/lib/crates/fabro-types/src/run_title.rs b/lib/crates/fabro-types/src/run_title.rs\nindex 9468e448b..d38b2e7b9 100644\n--- a/lib/crates/fabro-types/src/run_title.rs\n+++ b/lib/crates/fabro-types/src/run_title.rs\n@@ -3,7 +3,7 @@ use std::fmt;\n \n use fabro_util::text::strip_goal_decoration;\n \n-const MAX_RUN_TITLE_CHARS: usize = 100;\n+pub const MAX_RUN_TITLE_CHARS: usize = 100;\n const TRUNCATED_RUN_TITLE_CHARS: usize = MAX_RUN_TITLE_CHARS - 3;\n const UNTITLED_RUN: &str = \"Untitled run\";\n \ndiff --git a/lib/packages/fabro-api-client/src/models/model.ts b/lib/packages/fabro-api-client/src/models/model.ts\nindex 4b03f415b..f02cb0e51 100644\n--- a/lib/packages/fabro-api-client/src/models/model.ts\n+++ b/lib/packages/fabro-api-client/src/models/model.ts\n@@ -66,6 +66,10 @@ export interface Model {\n * Whether this is the default model for its provider.\n */\n 'default': boolean;\n+ /**\n+ * Whether this is the provider\\'s small/default utility model.\n+ */\n+ 'small_default': boolean;\n /**\n * Whether credential material is present for this model\\'s provider on the server (vault entry or environment variable). Does NOT imply the credential is valid or that requests will succeed; call `POST /models/{id}/test` to verify usability.\n */\n", + "summary": { + "files_changed": 214, + "additions": 10363, + "deletions": 2717 + } + } } ], - "conclusion": null, + "conclusion": { + "timestamp": "2026-05-23T18:03:03.257635Z", + "status": "succeeded", + "timing": { + "wall_time_ms": 5582590, + "inference_time_ms": 0, + "tool_time_ms": 0, + "active_time_ms": 0 + }, + "final_git_commit_sha": "106860730369b9970cc8fe82eb47986a06542aa4", + "stages": [ + { + "stage_id": "start", + "stage_label": "start", + "timing": { + "wall_time_ms": 0, + "inference_time_ms": 0, + "tool_time_ms": 0, + "active_time_ms": 0 + }, + "retries": 0 + }, + { + "stage_id": "toolchain", + "stage_label": "toolchain", + "timing": { + "wall_time_ms": 1472, + "inference_time_ms": 0, + "tool_time_ms": 0, + "active_time_ms": 0 + }, + "retries": 0 + }, + { + "stage_id": "preflight_compile", + "stage_label": "preflight_compile", + "timing": { + "wall_time_ms": 124163, + "inference_time_ms": 0, + "tool_time_ms": 0, + "active_time_ms": 0 + }, + "retries": 0 + }, + { + "stage_id": "preflight_lint", + "stage_label": "preflight_lint", + "timing": { + "wall_time_ms": 136380, + "inference_time_ms": 0, + "tool_time_ms": 0, + "active_time_ms": 0 + }, + "retries": 0 + }, + { + "stage_id": "implement", + "stage_label": "implement", + "timing": { + "wall_time_ms": 2368006, + "inference_time_ms": 0, + "tool_time_ms": 0, + "active_time_ms": 0 + }, + "billing_usd_micros": 35951706, + "retries": 0 + }, + { + "stage_id": "simplify_opus", + "stage_label": "simplify_opus", + "timing": { + "wall_time_ms": 1326763, + "inference_time_ms": 0, + "tool_time_ms": 0, + "active_time_ms": 0 + }, + "billing_usd_micros": 8660178, + "retries": 0 + }, + { + "stage_id": "simplify_gpt", + "stage_label": "simplify_gpt", + "timing": { + "wall_time_ms": 438268, + "inference_time_ms": 0, + "tool_time_ms": 0, + "active_time_ms": 0 + }, + "billing_usd_micros": 1661057, + "retries": 0 + }, + { + "stage_id": "verify", + "stage_label": "verify", + "timing": { + "wall_time_ms": 393924, + "inference_time_ms": 0, + "tool_time_ms": 0, + "active_time_ms": 0 + }, + "retries": 0 + }, + { + "stage_id": "fixup", + "stage_label": "fixup", + "timing": { + "wall_time_ms": 754992, + "inference_time_ms": 0, + "tool_time_ms": 0, + "active_time_ms": 0 + }, + "billing_usd_micros": 2290805, + "retries": 0 + } + ], + "billing": { + "input_tokens": 728611, + "output_tokens": 84417, + "total_tokens": 74905847, + "reasoning_tokens": 23223, + "cache_read_tokens": 73227696, + "cache_write_tokens": 841900, + "total_usd_micros": 48563746 + }, + "total_retries": 0, + "diff": {} + }, "sandbox": { "provider": "daytona", "image": "buildpack-deps:noble", @@ -2173,7 +2310,12 @@ "first_event_seq": 2016, "prompt": null, "response": null, - "completion": null, + "completion": { + "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 && ! 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-23T18:02:58.129533Z" + }, "provider_used": null, "diff": null, "script_invocation": { @@ -2181,11 +2323,27 @@ "command": "exec 2>&1\ngit 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 && ! 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", "language": "shell" }, - "script_timing": null, + "script_timing": { + "output": "blob://sha256/b663b197754cdb2bc9c1da19b2877e9a61f47dca0a73612b5487c2da37c63958", + "exit_code": 0, + "duration_ms": 392161, + "termination": "exited", + "output_bytes": 77639, + "live_streaming": true + }, "parallel_results": null, "output": null, + "output_bytes": 77639, + "live_streaming": true, + "termination": "exited", "started_at": "2026-05-23T17:56:25.954731Z", "handler": "command", + "timing": { + "wall_time_ms": 392172, + "inference_time_ms": 0, + "tool_time_ms": 0, + "active_time_ms": 0 + }, "usage": { "input_tokens": 0, "output_tokens": 0, @@ -2194,7 +2352,7 @@ "cache_read_tokens": 0, "cache_write_tokens": 0 }, - "state": "running" + "state": "succeeded" }, "start@1": { "first_event_seq": 16, @@ -2273,6 +2431,40 @@ }, "state": "succeeded" }, + "exit@1": { + "first_event_seq": 2026, + "prompt": null, + "response": null, + "completion": { + "outcome": "succeeded", + "notes": null, + "failure_reason": null, + "timestamp": "2026-05-23T18:03:03.197240Z" + }, + "provider_used": null, + "diff": null, + "script_invocation": null, + "script_timing": null, + "parallel_results": null, + "output": null, + "started_at": "2026-05-23T18:03:03.197197Z", + "handler": "exit", + "timing": { + "wall_time_ms": 0, + "inference_time_ms": 0, + "tool_time_ms": 0, + "active_time_ms": 0 + }, + "usage": { + "input_tokens": 0, + "output_tokens": 0, + "total_tokens": 0, + "reasoning_tokens": 0, + "cache_read_tokens": 0, + "cache_write_tokens": 0 + }, + "state": "succeeded" + }, "simplify_gpt@1": { "first_event_seq": 1598, "prompt": null, diff --git a/stages/010-verify@2/diff.patch b/stages/010-verify@2/diff.patch new file mode 100644 index 000000000..73a3507f2 --- /dev/null +++ b/stages/010-verify@2/diff.patch @@ -0,0 +1,1706 @@ +diff --git a/apps/fabro-web/app/routes/run-detail.test.ts b/apps/fabro-web/app/routes/run-detail.test.ts +index f0782fd97..b6d8d3341 100644 +--- a/apps/fabro-web/app/routes/run-detail.test.ts ++++ b/apps/fabro-web/app/routes/run-detail.test.ts +@@ -382,7 +382,7 @@ describe("RunDetail full-height child routes", () => { + const outletWrappers = renderer.root.findAll( + (node) => + node.type === "div" && +- hasClasses(node.props.className, ["min-h-0", "flex-1", "flex-col"]), ++ hasClasses(node.props.className, ["pt-3", "min-h-0", "flex-1", "flex-col"]), + ); + expect(outletWrappers).toHaveLength(1); + }); +@@ -540,7 +540,10 @@ describe("RunDetail full-height child routes", () => { + const outletWrappers = renderer.root.findAll( + (node) => + node.type === "div" && +- hasClasses(node.props.className, ["pb-[var(--fabro-interview-dock-clearance)]"]), ++ hasClasses(node.props.className, [ ++ "pt-3", ++ "pb-[var(--fabro-interview-dock-clearance)]", ++ ]), + ); + expect(outletWrappers).toHaveLength(1); + }); +diff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml +index 978beb090..dfd45b73a 100644 +--- a/docs/public/api-reference/fabro-api.yaml ++++ b/docs/public/api-reference/fabro-api.yaml +@@ -5881,6 +5881,7 @@ components: + - estimated_output_tps + - aliases + - default ++ - small_default + - configured + properties: + id: +@@ -5924,6 +5925,9 @@ components: + default: + type: boolean + description: Whether this is the default model for its provider. ++ small_default: ++ type: boolean ++ description: Whether this is the provider's small/default utility model. + configured: + type: boolean + description: | +diff --git a/docs/public/core-concepts/models.mdx b/docs/public/core-concepts/models.mdx +index 177f8eb77..61b26419b 100644 +--- a/docs/public/core-concepts/models.mdx ++++ b/docs/public/core-concepts/models.mdx +@@ -64,6 +64,7 @@ agent_profile = "anthropic" + display_name = "Team Code Large" + family = "team-code" + default = true ++small_default = true + aliases = ["team-code"] + estimated_output_tps = 80 + +@@ -119,6 +120,8 @@ reasoning = false + + `api_id` is the model name sent to the provider API. Omit it when the Fabro model ID and provider model ID are the same. + ++Model roles are separate: `default = true` controls normal model selection for workflow execution, while `small_default = true` marks the provider's small/cheap utility model for metadata tasks such as generated run titles. If a provider has no small default, Fabro falls back to that provider's normal default. ++ + Provider auth is declared in `[llm.providers..auth]` with ordered `env:` or `vault:` refs. The primary auth header defaults to `bearer`; override with `header = { custom = "Header-Name" }` for providers like Anthropic that use `x-api-key`. Omit the `[llm.providers..auth]` block entirely for providers that need no API key (e.g. Ollama). Custom headers for any provider — including providers that need only typed headers and no API-key auth — go in `extra_headers` as `{ env = "NAME" }`, `{ vault = "NAME" }`, or `{ literal = "value" }`. + + Provider `agent_profile` defaults from `adapter` and controls profile-specific behavior such as project-memory filenames, CLI/ACP command selection, and native session routing. Valid values are `anthropic`, `openai`, and `gemini`; model-level values override provider-level values. +diff --git a/docs/public/integrations/litellm.mdx b/docs/public/integrations/litellm.mdx +index a19ac43e9..7e9102dde 100644 +--- a/docs/public/integrations/litellm.mdx ++++ b/docs/public/integrations/litellm.mdx +@@ -111,7 +111,7 @@ vision = false + reasoning = false + ``` + +-Only one model for a provider should set `default = true`. ++Only one model for a provider should set `default = true`. You may also mark one small/cheap utility model with `small_default = true`; Fabro uses it for metadata tasks such as generated run titles and falls back to the provider default when it is omitted. + + ## Troubleshooting + +diff --git a/lib/crates/fabro-api/tests/model_round_trip.rs b/lib/crates/fabro-api/tests/model_round_trip.rs +index 6e89a980a..ad896d4c4 100644 +--- a/lib/crates/fabro-api/tests/model_round_trip.rs ++++ b/lib/crates/fabro-api/tests/model_round_trip.rs +@@ -38,6 +38,7 @@ fn model_json_matches_openapi_shape() { + estimated_output_tps: Some(25.0), + aliases: vec!["opus".to_string()], + default: false, ++ small_default: true, + configured: true, + }; + +@@ -48,6 +49,7 @@ fn model_json_matches_openapi_shape() { + assert_eq!(json["features"]["reasoning_effort"], "levels"); + assert_eq!(json["features"]["prompt_cache"], true); + assert_eq!(json["estimated_output_tps"], 25.0); ++ assert_eq!(json["small_default"], true); + assert_eq!(json["configured"], true); + + let round_trip: ApiModel = serde_json::from_value(json).unwrap(); +diff --git a/lib/crates/fabro-api/tests/provider_id_round_trip.rs b/lib/crates/fabro-api/tests/provider_id_round_trip.rs +index b20e15370..21c5227c6 100644 +--- a/lib/crates/fabro-api/tests/provider_id_round_trip.rs ++++ b/lib/crates/fabro-api/tests/provider_id_round_trip.rs +@@ -48,6 +48,7 @@ fn provider_id_json_matches_openapi_shape_through_model() { + estimated_output_tps: None, + aliases: Vec::new(), + default: false, ++ small_default: false, + configured: true, + }; + +diff --git a/lib/crates/fabro-cli/src/commands/model.rs b/lib/crates/fabro-cli/src/commands/model.rs +index 659e4af3d..706cbbf84 100644 +--- a/lib/crates/fabro-cli/src/commands/model.rs ++++ b/lib/crates/fabro-cli/src/commands/model.rs +@@ -503,6 +503,7 @@ mod tests { + estimated_output_tps: Some(100.0), + aliases: vec!["tm".to_string()], + default: false, ++ small_default: false, + configured: false, + }) + .unwrap() +@@ -535,6 +536,7 @@ mod tests { + estimated_output_tps: Some(100.0), + aliases: vec![], + default: false, ++ small_default: false, + configured: true, + }) + .unwrap() +diff --git a/lib/crates/fabro-config/src/builders.rs b/lib/crates/fabro-config/src/builders.rs +index 80017af26..3562328f0 100644 +--- a/lib/crates/fabro-config/src/builders.rs ++++ b/lib/crates/fabro-config/src/builders.rs +@@ -337,6 +337,7 @@ fn model_settings_to_catalog(settings: ModelSettings) -> model_catalog::ModelCat + training, + knowledge_cutoff, + default, ++ small_default, + probe, + enabled, + aliases, +@@ -355,6 +356,7 @@ fn model_settings_to_catalog(settings: ModelSettings) -> model_catalog::ModelCat + training, + knowledge_cutoff, + default, ++ small_default, + probe, + enabled, + aliases, +diff --git a/lib/crates/fabro-config/src/layers/llm.rs b/lib/crates/fabro-config/src/layers/llm.rs +index 94b44ab2a..9afb41d9e 100644 +--- a/lib/crates/fabro-config/src/layers/llm.rs ++++ b/lib/crates/fabro-config/src/layers/llm.rs +@@ -115,6 +115,10 @@ pub struct ModelSettings { + pub knowledge_cutoff: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default: Option, ++ /// Whether this model should be preferred for small/cheap utility tasks. ++ /// Missing or false falls back to the provider default model. ++ #[serde(default, skip_serializing_if = "Option::is_none")] ++ pub small_default: Option, + /// Whether this model should be preferred for provider connectivity + /// probes. Missing or false falls back to the provider default model. + #[serde(default, skip_serializing_if = "Option::is_none")] +diff --git a/lib/crates/fabro-llm/src/model_test.rs b/lib/crates/fabro-llm/src/model_test.rs +index 4fee55a24..e177901a2 100644 +--- a/lib/crates/fabro-llm/src/model_test.rs ++++ b/lib/crates/fabro-llm/src/model_test.rs +@@ -195,6 +195,7 @@ mod tests { + estimated_output_tps: None, + aliases: vec![], + default: false, ++ small_default: false, + configured: false, + } + } +diff --git a/lib/crates/fabro-model/src/catalog.rs b/lib/crates/fabro-model/src/catalog.rs +index 69f687670..d0fa36cb9 100644 +--- a/lib/crates/fabro-model/src/catalog.rs ++++ b/lib/crates/fabro-model/src/catalog.rs +@@ -82,6 +82,8 @@ pub struct ModelCatalogSettings { + #[serde(default)] + pub default: Option, + #[serde(default)] ++ pub small_default: Option, ++ #[serde(default)] + pub probe: Option, + #[serde(default)] + pub enabled: Option, +@@ -564,6 +566,11 @@ pub enum CatalogBuildError { + provider: ProviderId, + models: Vec, + }, ++ #[error("provider '{provider}' has multiple small default models: {models:?}")] ++ MultipleProviderSmallDefaults { ++ provider: ProviderId, ++ models: Vec, ++ }, + #[error("catalog must contain at least one enabled default model")] + NoDefaultModel, + #[error("model '{model}' has invalid reasoning_effort '{value}'")] +@@ -639,6 +646,7 @@ impl Catalog { + let mut models_with_settings = Vec::new(); + let mut model_identifiers = BTreeMap::::new(); + let mut defaults_by_provider = HashMap::>::new(); ++ let mut small_defaults_by_provider = HashMap::>::new(); + + let mut model_ids = settings.models.keys().cloned().collect::>(); + model_ids.sort_unstable(); +@@ -679,6 +687,12 @@ impl Catalog { + .or_default() + .push(model.id.clone()); + } ++ if model.small_default { ++ small_defaults_by_provider ++ .entry(model.provider.clone()) ++ .or_default() ++ .push(model.id.clone()); ++ } + models_with_settings.push((model, resolved_settings)); + } + +@@ -690,6 +704,14 @@ impl Catalog { + }); + } + } ++ for (provider, small_defaults) in small_defaults_by_provider { ++ if small_defaults.len() > 1 { ++ return Err(CatalogBuildError::MultipleProviderSmallDefaults { ++ provider, ++ models: small_defaults, ++ }); ++ } ++ } + if !models_with_settings.iter().any(|(model, _)| model.default) { + return Err(CatalogBuildError::NoDefaultModel); + } +@@ -890,6 +912,18 @@ impl Catalog { + .find(|m| m.provider == provider_id && m.default) + } + ++ /// Small default model for a provider — the small/cheap utility model used ++ /// for metadata enrichment. Falls back to the provider's normal default ++ /// when no explicit small default is configured. ++ #[must_use] ++ pub fn small_default_for_provider(&self, p: &ProviderId) -> Option<&Model> { ++ let provider_id = self.provider(p).map_or(p, |provider| &provider.id); ++ self.models ++ .iter() ++ .find(|m| &m.provider == provider_id && m.small_default) ++ .or_else(|| self.default_for_provider(provider_id)) ++ } ++ + /// Default model for the best-available provider (based on API keys), + /// falling back to the global catalog default. + #[must_use] +@@ -931,6 +965,24 @@ impl Catalog { + .unwrap_or_else(|| self.default_model()) + } + ++ /// Small default model for the best-available built-in provider IDs, ++ /// falling back to the global catalog default. ++ #[must_use] ++ pub fn small_default_for_configured_ids(&self, configured: &[ProviderId]) -> &Model { ++ if configured.is_empty() { ++ return self.default_model(); ++ } ++ let configured = configured ++ .iter() ++ .filter_map(|id| self.provider(id).map(|provider| provider.id.clone())) ++ .collect::>(); ++ self.providers ++ .iter() ++ .filter(|provider| configured.contains(&provider.id)) ++ .find_map(|provider| self.small_default_for_provider(&provider.id)) ++ .unwrap_or_else(|| self.default_model()) ++ } ++ + /// Probe model for a provider — the cheapest model suitable for + /// connectivity checks. Falls back to the provider's default when no + /// explicit override is configured. +@@ -1077,6 +1129,7 @@ fn merge_model_settings( + training: higher.training.or(fallback.training), + knowledge_cutoff: higher.knowledge_cutoff.or(fallback.knowledge_cutoff), + default: higher.default.or(fallback.default), ++ small_default: higher.small_default.or(fallback.small_default), + probe: higher.probe.or(fallback.probe), + enabled: higher.enabled.or(fallback.enabled), + aliases: higher.aliases.or(fallback.aliases), +@@ -1319,6 +1372,7 @@ fn build_model( + estimated_output_tps: settings.estimated_output_tps, + aliases: settings.aliases.clone().unwrap_or_default(), + default: settings.default.unwrap_or_default(), ++ small_default: settings.small_default.unwrap_or_default(), + configured: false, + }; + let catalog_settings = CatalogModelSettings { +@@ -1846,6 +1900,41 @@ enabled = true + assert_eq!(m.id, "gemini-3.1-pro-preview"); + } + ++ #[test] ++ fn builtin_small_defaults_are_marked_per_provider() { ++ let catalog = Catalog::builtin(); ++ ++ assert_eq!( ++ catalog ++ .small_default_for_provider(&ProviderId::anthropic()) ++ .unwrap() ++ .id, ++ "claude-haiku-4-5" ++ ); ++ assert_eq!( ++ catalog ++ .small_default_for_provider(&ProviderId::openai()) ++ .unwrap() ++ .id, ++ "gpt-5.4-mini" ++ ); ++ assert_eq!( ++ catalog ++ .small_default_for_provider(&ProviderId::gemini()) ++ .unwrap() ++ .id, ++ "gemini-3.1-flash-lite-preview" ++ ); ++ assert!(catalog.get("claude-haiku-4-5").unwrap().small_default); ++ assert!(catalog.get("gpt-5.4-mini").unwrap().small_default); ++ assert!( ++ catalog ++ .get("gemini-3.1-flash-lite-preview") ++ .unwrap() ++ .small_default ++ ); ++ } ++ + #[test] + fn builtin_closest_opus_to_gemini() { + let opus = Catalog::builtin().get("claude-opus-4-6").unwrap(); +@@ -2464,6 +2553,365 @@ reasoning = false + ); + } + ++ #[test] ++ fn small_default_for_provider_prefers_enabled_small_default_model_over_provider_default() { ++ let layer = minimal_settings( ++ r#" ++[providers.test] ++display_name = "Test" ++adapter = "openai" ++agent_profile = "openai" ++ ++[models.default_model] ++provider = "test" ++display_name = "Default Model" ++family = "test" ++default = true ++ ++[models.default_model.limits] ++context_window = 1000 ++ ++[models.default_model.features] ++tools = false ++vision = false ++reasoning = false ++ ++[models.small_model] ++provider = "test" ++display_name = "Small Model" ++family = "test" ++small_default = true ++ ++[models.small_model.limits] ++context_window = 1000 ++ ++[models.small_model.features] ++tools = false ++vision = false ++reasoning = false ++"#, ++ ); ++ let catalog = Catalog::from_settings(&layer).unwrap(); ++ ++ assert_eq!( ++ catalog ++ .small_default_for_provider(&ProviderId::new("test")) ++ .unwrap() ++ .id, ++ "small_model" ++ ); ++ } ++ ++ #[test] ++ fn small_default_for_provider_falls_back_to_provider_default_when_no_small_default_marked() { ++ let layer = minimal_settings( ++ r#" ++[providers.test] ++display_name = "Test" ++adapter = "openai" ++agent_profile = "openai" ++ ++[models.default_model] ++provider = "test" ++display_name = "Default Model" ++family = "test" ++default = true ++ ++[models.default_model.limits] ++context_window = 1000 ++ ++[models.default_model.features] ++tools = false ++vision = false ++reasoning = false ++ ++[models.other_model] ++provider = "test" ++display_name = "Other Model" ++family = "test" ++ ++[models.other_model.limits] ++context_window = 1000 ++ ++[models.other_model.features] ++tools = false ++vision = false ++reasoning = false ++"#, ++ ); ++ let catalog = Catalog::from_settings(&layer).unwrap(); ++ ++ assert_eq!( ++ catalog ++ .small_default_for_provider(&ProviderId::new("test")) ++ .unwrap() ++ .id, ++ "default_model" ++ ); ++ } ++ ++ #[test] ++ fn small_default_for_provider_resolves_provider_alias() { ++ let layer = minimal_settings( ++ r#" ++[providers.canonical] ++display_name = "Canonical" ++adapter = "openai" ++agent_profile = "openai" ++aliases = ["alias"] ++ ++[models.default_model] ++provider = "canonical" ++display_name = "Default Model" ++family = "test" ++default = true ++ ++[models.default_model.limits] ++context_window = 1000 ++ ++[models.default_model.features] ++tools = false ++vision = false ++reasoning = false ++ ++[models.small_model] ++provider = "canonical" ++display_name = "Small Model" ++family = "test" ++small_default = true ++ ++[models.small_model.limits] ++context_window = 1000 ++ ++[models.small_model.features] ++tools = false ++vision = false ++reasoning = false ++"#, ++ ); ++ let catalog = Catalog::from_settings(&layer).unwrap(); ++ ++ assert_eq!( ++ catalog ++ .small_default_for_provider(&ProviderId::new("alias")) ++ .unwrap() ++ .id, ++ "small_model" ++ ); ++ } ++ ++ #[test] ++ fn small_default_for_configured_ids_uses_highest_priority_configured_provider() { ++ let layer = minimal_settings( ++ r#" ++[providers.low] ++display_name = "Low" ++adapter = "openai" ++agent_profile = "openai" ++priority = 10 ++ ++[providers.high] ++display_name = "High" ++adapter = "openai" ++agent_profile = "openai" ++priority = 20 ++ ++[models.low_default] ++provider = "low" ++display_name = "Low Default" ++family = "test" ++default = true ++ ++[models.low_default.limits] ++context_window = 1000 ++ ++[models.low_default.features] ++tools = false ++vision = false ++reasoning = false ++ ++[models.low_small] ++provider = "low" ++display_name = "Low Small" ++family = "test" ++small_default = true ++ ++[models.low_small.limits] ++context_window = 1000 ++ ++[models.low_small.features] ++tools = false ++vision = false ++reasoning = false ++ ++[models.high_default] ++provider = "high" ++display_name = "High Default" ++family = "test" ++default = true ++ ++[models.high_default.limits] ++context_window = 1000 ++ ++[models.high_default.features] ++tools = false ++vision = false ++reasoning = false ++ ++[models.high_small] ++provider = "high" ++display_name = "High Small" ++family = "test" ++small_default = true ++ ++[models.high_small.limits] ++context_window = 1000 ++ ++[models.high_small.features] ++tools = false ++vision = false ++reasoning = false ++"#, ++ ); ++ let catalog = Catalog::from_settings(&layer).unwrap(); ++ ++ assert_eq!( ++ catalog ++ .small_default_for_configured_ids(&[ ++ ProviderId::new("low"), ++ ProviderId::new("high") ++ ]) ++ .id, ++ "high_small" ++ ); ++ assert_eq!( ++ catalog ++ .small_default_for_configured_ids(&[ProviderId::new("low")]) ++ .id, ++ "low_small" ++ ); ++ assert_eq!( ++ catalog.small_default_for_configured_ids(&[]).id, ++ catalog.default_model().id ++ ); ++ } ++ ++ #[test] ++ fn small_default_for_configured_ids_falls_back_to_provider_default() { ++ let layer = minimal_settings( ++ r#" ++[providers.test] ++display_name = "Test" ++adapter = "openai" ++agent_profile = "openai" ++ ++[models.default_model] ++provider = "test" ++display_name = "Default Model" ++family = "test" ++default = true ++ ++[models.default_model.limits] ++context_window = 1000 ++ ++[models.default_model.features] ++tools = false ++vision = false ++reasoning = false ++"#, ++ ); ++ let catalog = Catalog::from_settings(&layer).unwrap(); ++ ++ assert_eq!( ++ catalog ++ .small_default_for_configured_ids(&[ProviderId::new("test")]) ++ .id, ++ "default_model" ++ ); ++ } ++ ++ #[test] ++ fn multiple_small_default_models_for_provider_fail_catalog_build() { ++ let layer = minimal_settings( ++ r#" ++[providers.test] ++display_name = "Test" ++adapter = "openai" ++agent_profile = "openai" ++ ++[models.default_model] ++provider = "test" ++display_name = "Default Model" ++family = "test" ++default = true ++ ++[models.default_model.limits] ++context_window = 1000 ++ ++[models.default_model.features] ++tools = false ++vision = false ++reasoning = false ++ ++[models.first_small] ++provider = "test" ++display_name = "First Small" ++family = "test" ++small_default = true ++ ++[models.first_small.limits] ++context_window = 1000 ++ ++[models.first_small.features] ++tools = false ++vision = false ++reasoning = false ++ ++[models.second_small] ++provider = "test" ++display_name = "Second Small" ++family = "test" ++small_default = true ++ ++[models.second_small.limits] ++context_window = 1000 ++ ++[models.second_small.features] ++tools = false ++vision = false ++reasoning = false ++"#, ++ ); ++ ++ let err = Catalog::from_settings(&layer).unwrap_err(); ++ ++ assert!(matches!( ++ err, ++ CatalogBuildError::MultipleProviderSmallDefaults { provider, models } ++ if provider == ProviderId::new("test") ++ && models == vec!["first_small".to_string(), "second_small".to_string()] ++ )); ++ } ++ ++ #[test] ++ fn small_default_false_override_clears_inherited_builtin_small_default_marker() { ++ let catalog = Catalog::from_builtin_with_overrides(&minimal_settings( ++ r#" ++[models."gpt-5.4-mini"] ++small_default = false ++"#, ++ )) ++ .expect("sparse built-in model override should build"); ++ ++ assert_eq!( ++ catalog ++ .small_default_for_provider(&ProviderId::openai()) ++ .unwrap() ++ .id, ++ "gpt-5.4" ++ ); ++ } ++ + #[test] + fn multiple_probe_models_are_non_fatal_and_select_a_probe_model() { + let layer = minimal_settings( +@@ -3215,6 +3663,7 @@ reasoning_effort = "levels" + ), + aliases: [], + default: false, ++ small_default: false, + configured: false, + } + "#); +@@ -3286,6 +3735,7 @@ reasoning_effort = "levels" + "gemini-flash-lite", + ], + default: false, ++ small_default: true, + configured: false, + } + "#); +@@ -3343,6 +3793,7 @@ reasoning_effort = "levels" + "kimi", + ], + default: true, ++ small_default: false, + configured: false, + } + "#); +@@ -3405,6 +3856,7 @@ reasoning_effort = "levels" + "mercury", + ], + default: true, ++ small_default: false, + configured: false, + } + "#); +@@ -3462,6 +3914,7 @@ reasoning_effort = "levels" + "gpt-54", + ], + default: true, ++ small_default: false, + configured: false, + } + "#); +@@ -3514,6 +3967,7 @@ reasoning_effort = "levels" + "gpt-54-pro", + ], + default: false, ++ small_default: false, + configured: false, + } + "#); +@@ -3585,6 +4039,7 @@ reasoning_effort = "levels" + "codex-spark", + ], + default: false, ++ small_default: false, + configured: false, + } + "#); +diff --git a/lib/crates/fabro-model/src/catalog/providers/anthropic.toml b/lib/crates/fabro-model/src/catalog/providers/anthropic.toml +index ed3bd88ff..efa72ded7 100644 +--- a/lib/crates/fabro-model/src/catalog/providers/anthropic.toml ++++ b/lib/crates/fabro-model/src/catalog/providers/anthropic.toml +@@ -140,6 +140,7 @@ knowledge_cutoff = "May 2025" + estimated_output_tps = 100 + aliases = ["haiku", "claude-haiku"] + probe = true ++small_default = true + + [models."claude-haiku-4-5".limits] + context_window = 200000 +diff --git a/lib/crates/fabro-model/src/catalog/providers/gemini.toml b/lib/crates/fabro-model/src/catalog/providers/gemini.toml +index f91ca5c63..fb73d6723 100644 +--- a/lib/crates/fabro-model/src/catalog/providers/gemini.toml ++++ b/lib/crates/fabro-model/src/catalog/providers/gemini.toml +@@ -94,6 +94,7 @@ training = "2025-01-01" + knowledge_cutoff = "January 2025" + estimated_output_tps = 200 + aliases = ["gemini-flash-lite"] ++small_default = true + + [models."gemini-3.1-flash-lite-preview".limits] + context_window = 1048576 +diff --git a/lib/crates/fabro-model/src/catalog/providers/openai.toml b/lib/crates/fabro-model/src/catalog/providers/openai.toml +index 87dd2d8ac..3cdba296e 100644 +--- a/lib/crates/fabro-model/src/catalog/providers/openai.toml ++++ b/lib/crates/fabro-model/src/catalog/providers/openai.toml +@@ -238,6 +238,7 @@ knowledge_cutoff = "April 2025" + estimated_output_tps = 140 + aliases = ["gpt54-mini", "gpt-54-mini"] + probe = true ++small_default = true + + [models."gpt-5.4-mini".limits] + context_window = 400000 +diff --git a/lib/crates/fabro-model/src/types.rs b/lib/crates/fabro-model/src/types.rs +index f6b05d072..764943dd6 100644 +--- a/lib/crates/fabro-model/src/types.rs ++++ b/lib/crates/fabro-model/src/types.rs +@@ -67,6 +67,8 @@ pub struct Model { + pub aliases: Vec, + #[serde(default)] + pub default: bool, ++ #[serde(default)] ++ pub small_default: bool, + /// Whether the server has any credential configured for this model's + /// provider at the time of the response. Always `false` in static catalog + /// data; populated by `GET /models` per request. +@@ -150,6 +152,10 @@ impl Model { + pub fn is_default(&self) -> bool { + self.default + } ++ ++ pub fn is_small_default(&self) -> bool { ++ self.small_default ++ } + } + + #[cfg(test)] +@@ -178,5 +184,6 @@ mod tests { + assert_eq!(info.estimated_output_tps(), Some(25.0)); + assert!(!info.aliases().is_empty()); + assert!(!info.is_default()); ++ assert!(!info.is_small_default()); + } + } +diff --git a/lib/crates/fabro-server/src/lib.rs b/lib/crates/fabro-server/src/lib.rs +index 873482712..5bbad6ea5 100644 +--- a/lib/crates/fabro-server/src/lib.rs ++++ b/lib/crates/fabro-server/src/lib.rs +@@ -31,6 +31,7 @@ mod run_files; + mod run_files_security; + mod run_manifest; + mod run_selector; ++mod run_title_generation; + pub mod run_tool_manifest; + pub mod security_headers; + pub mod serve; +diff --git a/lib/crates/fabro-server/src/run_title_generation.rs b/lib/crates/fabro-server/src/run_title_generation.rs +new file mode 100644 +index 000000000..c9d258cc9 +--- /dev/null ++++ b/lib/crates/fabro-server/src/run_title_generation.rs +@@ -0,0 +1,374 @@ ++use std::collections::HashMap; ++use std::sync::Arc; ++ ++use fabro_llm::client::Client; ++use fabro_llm::generate::{self, GenerateParams}; ++use fabro_llm::types::TimeoutOptions; ++use fabro_model::ProviderId; ++use fabro_types::{Graph, MAX_RUN_TITLE_CHARS, RunId}; ++use serde::Serialize; ++use toml::Value as TomlValue; ++ ++const TRUNCATED_MARKER: &str = "...[truncated]"; ++const MAX_PROMPT_SECTION_CHARS: usize = 4_000; ++ ++pub(crate) struct TitlePromptInput<'a> { ++ pub(crate) run_id: &'a RunId, ++ pub(crate) current_title: &'a str, ++ pub(crate) workflow_target: Option<&'a str>, ++ pub(crate) run_inputs: &'a HashMap, ++ pub(crate) workflow: &'a WorkflowSummary, ++} ++ ++pub(crate) struct GenerateTitleInput<'a> { ++ pub(crate) client: Arc, ++ pub(crate) model_id: String, ++ pub(crate) provider_id: ProviderId, ++ pub(crate) prompt: TitlePromptInput<'a>, ++} ++ ++pub(crate) async fn generate_title_or_current(input: GenerateTitleInput<'_>) -> String { ++ let current_title = input.prompt.current_title.to_string(); ++ let prompt = build_title_prompt(&input.prompt); ++ let params = GenerateParams::new(input.model_id, input.client) ++ .provider(input.provider_id.to_string()) ++ .prompt(prompt) ++ .max_tokens(64) ++ .max_retries(0) ++ .timeout(TimeoutOptions { ++ total: Some(10.0), ++ per_step: Some(5.0), ++ }); ++ ++ let result = match generate::generate_object(params, title_response_schema()).await { ++ Ok(result) => result, ++ Err(err) => { ++ tracing::debug!(error = %err, "Run title generation failed"); ++ return current_title; ++ } ++ }; ++ result ++ .output ++ .as_ref() ++ .and_then(|output| output.get("title")) ++ .and_then(serde_json::Value::as_str) ++ .and_then(normalize_generated_title) ++ .unwrap_or(current_title) ++} ++ ++fn build_title_prompt(input: &TitlePromptInput<'_>) -> String { ++ let workflow_identity = serde_json::json!({ ++ "run_id": input.run_id.to_string(), ++ "current_deterministic_title": input.current_title, ++ "target": input.workflow_target, ++ "name": input.workflow.graph_name, ++ "goal": input.workflow.goal, ++ }); ++ let identity = pretty_json(&workflow_identity); ++ let inputs = pretty_json(input.run_inputs); ++ let workflow = pretty_json(input.workflow); ++ ++ format!( ++ r#"Generate a concise, human-readable title for this Fabro workflow run. ++ ++Base the title on the workflow identity, workflow goal, and run input values. ++Preserve meaningful proper nouns, ticket IDs, repositories, branches, environments, and explicit user goals. ++Return only structured JSON with one field: {{"title":"..."}}. ++The title must be a single line, not blank, and no more than {MAX_RUN_TITLE_CHARS} characters. ++ ++Workflow identity: ++```json ++{} ++``` ++ ++Run inputs (raw values, not redacted): ++```json ++{} ++``` ++ ++Workflow summary: ++```json ++{} ++``` ++"#, ++ truncate_section(&identity, MAX_PROMPT_SECTION_CHARS), ++ truncate_section(&inputs, MAX_PROMPT_SECTION_CHARS), ++ truncate_section(&workflow, MAX_PROMPT_SECTION_CHARS), ++ ) ++} ++ ++fn normalize_generated_title(title: &str) -> Option { ++ let trimmed = title.trim(); ++ if trimmed.is_empty() || trimmed.chars().any(char::is_control) { ++ return None; ++ } ++ Some(trimmed.chars().take(MAX_RUN_TITLE_CHARS).collect()) ++} ++ ++fn title_response_schema() -> serde_json::Value { ++ serde_json::json!({ ++ "type": "object", ++ "additionalProperties": false, ++ "required": ["title"], ++ "properties": { ++ "title": { ++ "type": "string", ++ "description": "Concise single-line run title" ++ } ++ } ++ }) ++} ++ ++#[derive(Serialize)] ++pub(crate) struct WorkflowSummary { ++ pub(crate) graph_name: String, ++ pub(crate) goal: String, ++ pub(crate) stage_count: usize, ++ pub(crate) edge_count: usize, ++ pub(crate) stages: Vec, ++} ++ ++#[derive(Serialize)] ++pub(crate) struct StageSummary { ++ id: String, ++ label: String, ++ handler_type: Option, ++} ++ ++pub(crate) fn workflow_summary(graph: &Graph) -> WorkflowSummary { ++ let mut stages = graph ++ .nodes ++ .values() ++ .map(|node| StageSummary { ++ id: node.id.clone(), ++ label: node.label().to_string(), ++ handler_type: node.handler_type().map(str::to_string), ++ }) ++ .collect::>(); ++ stages.sort_by(|left, right| left.id.cmp(&right.id)); ++ ++ WorkflowSummary { ++ graph_name: graph.name.clone(), ++ goal: graph.goal().to_string(), ++ stage_count: stages.len(), ++ edge_count: graph.edges.len(), ++ stages, ++ } ++} ++ ++fn pretty_json(value: &impl Serialize) -> String { ++ serde_json::to_string_pretty(value).unwrap_or_else(|_| "{}".to_string()) ++} ++ ++fn truncate_section(value: &str, max_chars: usize) -> String { ++ if value.chars().count() <= max_chars { ++ return value.to_string(); ++ } ++ let keep = max_chars.saturating_sub(TRUNCATED_MARKER.chars().count()); ++ let truncated = value.chars().take(keep).collect::(); ++ format!("{truncated}{TRUNCATED_MARKER}") ++} ++ ++#[cfg(test)] ++mod tests { ++ use std::collections::HashMap; ++ use std::pin::Pin; ++ use std::sync::{Arc, Mutex}; ++ ++ use async_trait::async_trait; ++ use fabro_graphviz::parser; ++ use fabro_llm::client::Client; ++ use fabro_llm::error::Error as LlmError; ++ use fabro_llm::provider::{ProviderAdapter, StreamEventStream}; ++ use fabro_llm::token_count::InputTokenCount; ++ use fabro_llm::types::{FinishReason, Message, Request, Response, StreamEvent, TokenCounts}; ++ use fabro_model::ProviderId; ++ use fabro_types::RunId; ++ use futures_util::stream; ++ use toml::Value as TomlValue; ++ ++ use super::*; ++ ++ fn title_test_graph() -> fabro_types::Graph { ++ parser::parse( ++ r#"digraph Ship { ++ graph [goal="Deploy API token SECRET_123 to production"] ++ start [shape=Mdiamond, label="Start"] ++ plan [shape=box, label="Plan rollout"] ++ deploy [shape=parallelogram, label="Deploy"] ++ exit [shape=Msquare, label="Exit"] ++ start -> plan -> deploy -> exit ++ }"#, ++ ) ++ .unwrap() ++ } ++ ++ #[test] ++ fn prompt_includes_goal_inputs_and_workflow_summary_without_redaction() { ++ let run_id = RunId::new(); ++ let graph = title_test_graph(); ++ let summary = workflow_summary(&graph); ++ let inputs = HashMap::from([ ++ ( ++ "api_key".to_string(), ++ TomlValue::String("SECRET_123".to_string()), ++ ), ++ ( ++ "region".to_string(), ++ TomlValue::String("us-east-1".to_string()), ++ ), ++ ]); ++ ++ let prompt = build_title_prompt(&TitlePromptInput { ++ run_id: &run_id, ++ current_title: "Deploy API token SECRET_123 to production", ++ workflow_target: Some("workflows/deploy.fabro"), ++ run_inputs: &inputs, ++ workflow: &summary, ++ }); ++ ++ assert!(prompt.contains("Deploy API token SECRET_123 to production")); ++ assert!(prompt.contains("\"api_key\": \"SECRET_123\"")); ++ assert!(prompt.contains("workflows/deploy.fabro")); ++ assert!(prompt.contains("\"id\": \"deploy\"")); ++ assert!(prompt.contains("\"handler_type\": \"command\"")); ++ assert!(!prompt.contains("[REDACTED]")); ++ } ++ ++ #[test] ++ fn prompt_bounds_large_input_and_workflow_sections() { ++ let run_id = RunId::new(); ++ let graph = title_test_graph(); ++ let summary = workflow_summary(&graph); ++ let inputs = HashMap::from([( ++ "large".to_string(), ++ TomlValue::String("x".repeat(MAX_PROMPT_SECTION_CHARS * 2)), ++ )]); ++ ++ let prompt = build_title_prompt(&TitlePromptInput { ++ run_id: &run_id, ++ current_title: "Current", ++ workflow_target: Some("workflow.fabro"), ++ run_inputs: &inputs, ++ workflow: &summary, ++ }); ++ ++ // Section truncation: per-section budget × 3 + small boilerplate. ++ assert!(prompt.chars().count() < MAX_PROMPT_SECTION_CHARS * 3 + 1_000); ++ assert!(prompt.contains("...[truncated]")); ++ // One section was truncated, the others were not; ensure we don't ++ // emit a doubly-truncated marker run. ++ assert!(!prompt.contains("...[truncated]...[truncated]")); ++ } ++ ++ #[test] ++ fn generated_title_normalization_accepts_and_truncates_valid_titles() { ++ let normalized = normalize_generated_title(" Ship the API rollout ").unwrap(); ++ assert_eq!(normalized, "Ship the API rollout"); ++ ++ let long = normalize_generated_title(&"a".repeat(101)).unwrap(); ++ assert_eq!(long.chars().count(), 100); ++ } ++ ++ #[test] ++ fn generated_title_normalization_rejects_blank_and_control_output() { ++ assert!(normalize_generated_title(" ").is_none()); ++ assert!(normalize_generated_title("first\nsecond").is_none()); ++ assert!(normalize_generated_title("first\tsecond").is_none()); ++ } ++ ++ #[tokio::test] ++ async fn generation_uses_supplied_small_default_model_id() { ++ let (title, captured) = title_with_mocked_response(r#"{"title":"Generated title"}"#).await; ++ ++ assert_eq!(title, "Generated title"); ++ let captured = captured.lock().unwrap(); ++ assert_eq!(captured[0].model, "small-model"); ++ assert_eq!(captured[0].provider.as_deref(), Some("openai")); ++ assert_eq!(captured[0].max_tokens, Some(64)); ++ } ++ ++ #[tokio::test] ++ async fn invalid_or_failed_generation_returns_current_title() { ++ let (invalid, _) = title_with_mocked_response(r#"{"title":"first\nsecond"}"#).await; ++ assert_eq!(invalid, "Current"); ++ ++ let (invalid_shape, _) = title_with_mocked_response(r#"{"name":"missing"}"#).await; ++ assert_eq!(invalid_shape, "Current"); ++ } ++ ++ async fn title_with_mocked_response(response_text: &str) -> (String, Arc>>) { ++ let captured = Arc::new(Mutex::new(Vec::new())); ++ let provider = Arc::new(CapturingProvider { ++ captured: Arc::clone(&captured), ++ response_text: response_text.to_string(), ++ }); ++ let client = Arc::new(Client::new( ++ HashMap::from([("openai".to_string(), provider as Arc)]), ++ Some("openai".to_string()), ++ Vec::new(), ++ )); ++ let run_id = RunId::new(); ++ let graph = title_test_graph(); ++ let summary = workflow_summary(&graph); ++ let inputs = HashMap::new(); ++ let title = generate_title_or_current(GenerateTitleInput { ++ client, ++ model_id: "small-model".to_string(), ++ provider_id: ProviderId::openai(), ++ prompt: TitlePromptInput { ++ run_id: &run_id, ++ current_title: "Current", ++ workflow_target: Some("workflow.fabro"), ++ run_inputs: &inputs, ++ workflow: &summary, ++ }, ++ }) ++ .await; ++ (title, captured) ++ } ++ ++ struct CapturingProvider { ++ captured: Arc>>, ++ response_text: String, ++ } ++ ++ #[async_trait] ++ impl ProviderAdapter for CapturingProvider { ++ #[expect( ++ clippy::unnecessary_literal_bound, ++ reason = "ProviderAdapter trait signature returns &str." ++ )] ++ fn name(&self) -> &str { ++ "openai" ++ } ++ ++ async fn complete(&self, request: &Request) -> Result { ++ self.captured.lock().unwrap().push(request.clone()); ++ Ok(Response { ++ id: "resp_title".to_string(), ++ model: request.model.clone(), ++ provider: "openai".to_string(), ++ message: Message::assistant(self.response_text.clone()), ++ finish_reason: FinishReason::Stop, ++ usage: TokenCounts::default(), ++ raw: None, ++ warnings: Vec::new(), ++ rate_limit: None, ++ }) ++ } ++ ++ async fn stream(&self, _request: &Request) -> Result { ++ Ok(Pin::from(Box::new(stream::empty::< ++ Result, ++ >()))) ++ } ++ ++ async fn count_input_tokens( ++ &self, ++ _request: &Request, ++ ) -> Result, LlmError> { ++ Ok(None) ++ } ++ } ++} +diff --git a/lib/crates/fabro-server/src/server/handler/runs.rs b/lib/crates/fabro-server/src/server/handler/runs.rs +index 0f1057071..a9aab7b05 100644 +--- a/lib/crates/fabro-server/src/server/handler/runs.rs ++++ b/lib/crates/fabro-server/src/server/handler/runs.rs +@@ -18,8 +18,10 @@ use fabro_api::types::{ + }; + use fabro_config::Storage; + use fabro_interview::AnswerSubmission; ++use fabro_llm::client::Client as LlmClient; + use fabro_types::{ +- Principal, RunClientProvenance, RunId, RunProvenance, RunServerProvenance, parse_blob_ref, ++ Principal, RunClientProvenance, RunId, RunProvenance, RunServerProvenance, SystemActorKind, ++ parse_blob_ref, + }; + use fabro_util::version::FABRO_VERSION; + use fabro_workflow::command_log::{command_log_path, read_json_string_blob, read_log_slice}; +@@ -42,6 +44,8 @@ use crate::principal_middleware::{ + use crate::run_files::{list_run_commits, list_run_files}; + use crate::run_manifest; + use crate::run_selector::{ResolveRunError, resolve_run_by_selector}; ++use crate::run_title_generation::{self, GenerateTitleInput, TitlePromptInput, WorkflowSummary}; ++use crate::server_secrets::LlmClientResult; + + pub(super) fn manifest_routes() -> Router> { + Router::new() +@@ -553,6 +557,7 @@ async fn create_run( + Ok(req) => req, + Err(err) => return ApiError::bad_request(err.to_string()).into_response(), + }; ++ let explicit_title_supplied = req.title.is_some(); + let manifest_run_defaults = state.manifest_run_defaults(); + let manifest_environment_defaults = state.manifest_environment_defaults(); + let prepared = match run_manifest::prepare_manifest_with_environment_defaults( +@@ -576,9 +581,26 @@ async fn create_run( + + let web_url = state.run_web_url(&run_id); + let catalog = state.catalog(); +- let configured_providers = state.ready_llm_provider_ids().await; +- let mut create_input = +- run_manifest::create_run_input(prepared.clone(), configured_providers, web_url.clone()); ++ // Resolve once: we need both the provider IDs (for the run create input ++ // and ask-fabro-readiness) and the LLM client itself (for the spawned ++ // title-generation task). `ready_llm_provider_ids` would otherwise call ++ // `resolve_llm_client` a second time and discard the client. ++ let llm_client_for_title = match state.resolve_llm_client().await { ++ Ok(result) => Some(result), ++ Err(err) => { ++ tracing::warn!(error = ?err, "Failed to resolve LLM client while creating run"); ++ None ++ } ++ }; ++ let ready_provider_ids = llm_client_for_title ++ .as_ref() ++ .map(LlmClientResult::provider_ids) ++ .unwrap_or_default(); ++ let mut create_input = run_manifest::create_run_input( ++ prepared.clone(), ++ ready_provider_ids.clone(), ++ web_url.clone(), ++ ); + create_input.run_id = Some(run_id); + create_input.provenance = Some(run_provenance(&headers, &actor)); + create_input.submitted_manifest_bytes = Some(body.to_vec()); +@@ -626,6 +648,7 @@ async fn create_run( + .into_response(); + } + }; ++ let deterministic_title = summary.title.clone(); + + { + let mut runs = state.runs.lock().expect("runs lock poisoned"); +@@ -641,6 +664,30 @@ async fn create_run( + ); + } + ++ if !explicit_title_supplied && !ready_provider_ids.is_empty() { ++ if let Some(llm_result) = llm_client_for_title { ++ let run_spec = created.persisted.run_spec(); ++ let workflow = run_title_generation::workflow_summary(&run_spec.graph); ++ let run_inputs = run_spec.settings.run.inputs.clone(); ++ let workflow_target = prepared.target_path.to_string(); ++ let title_catalog = state.catalog(); ++ let title_model = title_catalog.small_default_for_configured_ids(&ready_provider_ids); ++ let title_model_id = title_model.id.clone(); ++ let title_provider_id = title_model.provider.clone(); ++ spawn_generated_title_task(GeneratedTitleTask { ++ state: Arc::clone(&state), ++ run_id: created.run_id, ++ deterministic_title, ++ workflow_target, ++ workflow, ++ run_inputs, ++ client: llm_result.client, ++ model_id: title_model_id, ++ provider_id: title_provider_id, ++ }); ++ } ++ } ++ + ( + StatusCode::CREATED, + Json(state.decorate_run_summary(summary).await), +@@ -648,6 +695,77 @@ async fn create_run( + .into_response() + } + ++struct GeneratedTitleTask { ++ state: Arc, ++ run_id: RunId, ++ deterministic_title: String, ++ workflow_target: String, ++ workflow: WorkflowSummary, ++ run_inputs: std::collections::HashMap, ++ client: LlmClient, ++ model_id: String, ++ provider_id: fabro_model::ProviderId, ++} ++ ++fn spawn_generated_title_task(task: GeneratedTitleTask) { ++ tokio::spawn(async move { ++ let generated_title = run_title_generation::generate_title_or_current(GenerateTitleInput { ++ client: Arc::new(task.client), ++ model_id: task.model_id, ++ provider_id: task.provider_id, ++ prompt: TitlePromptInput { ++ run_id: &task.run_id, ++ current_title: &task.deterministic_title, ++ workflow_target: Some(task.workflow_target.as_str()), ++ run_inputs: &task.run_inputs, ++ workflow: &task.workflow, ++ }, ++ }) ++ .await; ++ if generated_title == task.deterministic_title { ++ return; ++ } ++ ++ let current = match task ++ .state ++ .store ++ .get_cached_summary(&task.run_id, Utc::now()) ++ .await ++ { ++ Ok(Some(summary)) => summary, ++ Ok(None) => return, ++ Err(err) => { ++ tracing::debug!(run_id = %task.run_id, error = %err, "Failed to re-read run summary for title update"); ++ return; ++ } ++ }; ++ if current.title != task.deterministic_title { ++ return; ++ } ++ let run_store = match task.state.store.open_run(&task.run_id).await { ++ Ok(store) => store, ++ Err(err) => { ++ tracing::debug!(run_id = %task.run_id, error = %err, "Failed to open run store for title update"); ++ return; ++ } ++ }; ++ if let Err(err) = workflow_event::append_event( ++ &run_store, ++ &task.run_id, ++ &workflow_event::Event::RunTitleUpdated { ++ title: generated_title, ++ actor: Some(Principal::System { ++ system_kind: SystemActorKind::Engine, ++ }), ++ }, ++ ) ++ .await ++ { ++ tracing::debug!(run_id = %task.run_id, error = %err, "Failed to append generated run title event"); ++ } ++ }); ++} ++ + fn run_provenance(headers: &HeaderMap, subject: &Principal) -> RunProvenance { + RunProvenance { + server: Some(RunServerProvenance { +diff --git a/lib/crates/fabro-server/src/server/tests.rs b/lib/crates/fabro-server/src/server/tests.rs +index 7cee8c7d7..dc6bb885e 100644 +--- a/lib/crates/fabro-server/src/server/tests.rs ++++ b/lib/crates/fabro-server/src/server/tests.rs +@@ -2377,6 +2377,254 @@ url = "http://127.0.0.1:32276" + ); + } + ++#[tokio::test] ++async fn create_run_without_explicit_title_returns_deterministic_then_updates_generated_title() { ++ let llm = MockServer::start_async().await; ++ let title_mock = mock_openai_title_response(&llm, "Generated deploy title", None).await; ++ let state = TestAppStateBuilder::new() ++ .provider_base_url("openai", llm.url("/v1")) ++ .env_lookup(|_| None) ++ .build(); ++ state ++ .vault ++ .write() ++ .await ++ .set("OPENAI_API_KEY", "openai-key", SecretType::Token, None) ++ .unwrap(); ++ let app = crate::test_support::build_test_router(Arc::clone(&state)); ++ ++ let body = post_run_manifest(&app, minimal_manifest_json(MINIMAL_DOT)).await; ++ let run_id: RunId = body["id"].as_str().unwrap().parse().unwrap(); ++ ++ assert_eq!(body["title"], "Test"); ++ wait_for_run_title(&state, run_id, "Generated deploy title").await; ++ assert_eq!(title_update_event_count(&state, run_id).await, 1); ++ title_mock.assert_async().await; ++} ++ ++#[tokio::test] ++async fn create_run_with_explicit_title_skips_generated_title_work() { ++ let llm = MockServer::start_async().await; ++ let title_mock = mock_openai_title_response(&llm, "Generated deploy title", None).await; ++ let state = TestAppStateBuilder::new() ++ .provider_base_url("openai", llm.url("/v1")) ++ .env_lookup(|_| None) ++ .build(); ++ state ++ .vault ++ .write() ++ .await ++ .set("OPENAI_API_KEY", "openai-key", SecretType::Token, None) ++ .unwrap(); ++ let app = crate::test_support::build_test_router(Arc::clone(&state)); ++ let mut manifest = minimal_manifest_json(MINIMAL_DOT); ++ manifest["title"] = json!("Caller title"); ++ ++ let body = post_run_manifest(&app, manifest).await; ++ let run_id: RunId = body["id"].as_str().unwrap().parse().unwrap(); ++ // The spawn gate is synchronous in `create_run`, so once the response ++ // returns we know no title task was scheduled. No sleep needed. ++ ++ assert_eq!( ++ state ++ .store ++ .get_cached_summary(&run_id, Utc::now()) ++ .await ++ .unwrap() ++ .unwrap() ++ .title, ++ "Caller title" ++ ); ++ assert_eq!(title_update_event_count(&state, run_id).await, 0); ++ title_mock.assert_calls_async(0).await; ++} ++ ++#[tokio::test] ++async fn create_run_without_ready_llm_provider_skips_generated_title_work() { ++ let state = TestAppStateBuilder::new().env_lookup(|_| None).build(); ++ let app = crate::test_support::build_test_router(Arc::clone(&state)); ++ ++ let body = post_run_manifest(&app, minimal_manifest_json(MINIMAL_DOT)).await; ++ let run_id: RunId = body["id"].as_str().unwrap().parse().unwrap(); ++ ++ assert_eq!( ++ state ++ .store ++ .get_cached_summary(&run_id, Utc::now()) ++ .await ++ .unwrap() ++ .unwrap() ++ .title, ++ "Test" ++ ); ++ assert_eq!(title_update_event_count(&state, run_id).await, 0); ++} ++ ++#[tokio::test] ++async fn generated_title_failure_leaves_deterministic_title_unchanged() { ++ let llm = MockServer::start_async().await; ++ let title_mock = llm ++ .mock_async(|when, then| { ++ when.method(POST).path("/v1/responses"); ++ then.status(500) ++ .header("content-type", "application/json") ++ .json_body(json!({"error": {"message": "boom"}})); ++ }) ++ .await; ++ let state = TestAppStateBuilder::new() ++ .provider_base_url("openai", llm.url("/v1")) ++ .env_lookup(|_| None) ++ .build(); ++ state ++ .vault ++ .write() ++ .await ++ .set("OPENAI_API_KEY", "openai-key", SecretType::Token, None) ++ .unwrap(); ++ let app = crate::test_support::build_test_router(Arc::clone(&state)); ++ ++ let body = post_run_manifest(&app, minimal_manifest_json(MINIMAL_DOT)).await; ++ let run_id: RunId = body["id"].as_str().unwrap().parse().unwrap(); ++ wait_for_mock_hits(&title_mock, 1).await; ++ tokio::time::sleep(std::time::Duration::from_millis(25)).await; ++ ++ assert_eq!( ++ state ++ .store ++ .get_cached_summary(&run_id, Utc::now()) ++ .await ++ .unwrap() ++ .unwrap() ++ .title, ++ "Test" ++ ); ++ assert_eq!(title_update_event_count(&state, run_id).await, 0); ++} ++ ++#[tokio::test] ++async fn generated_title_does_not_overwrite_user_title_edit() { ++ let llm = MockServer::start_async().await; ++ let title_mock = mock_openai_title_response( ++ &llm, ++ "Generated deploy title", ++ Some(std::time::Duration::from_millis(150)), ++ ) ++ .await; ++ let state = TestAppStateBuilder::new() ++ .provider_base_url("openai", llm.url("/v1")) ++ .env_lookup(|_| None) ++ .build(); ++ state ++ .vault ++ .write() ++ .await ++ .set("OPENAI_API_KEY", "openai-key", SecretType::Token, None) ++ .unwrap(); ++ let app = crate::test_support::build_test_router(Arc::clone(&state)); ++ ++ let body = post_run_manifest(&app, minimal_manifest_json(MINIMAL_DOT)).await; ++ let run_id: RunId = body["id"].as_str().unwrap().parse().unwrap(); ++ let patch = Request::builder() ++ .method("PATCH") ++ .uri(api(&format!("/runs/{run_id}"))) ++ .header("content-type", "application/json") ++ .body(Body::from(json!({"title": "User title"}).to_string())) ++ .unwrap(); ++ let response = app.clone().oneshot(patch).await.unwrap(); ++ response_json!(response, StatusCode::OK).await; ++ ++ wait_for_mock_hits(&title_mock, 1).await; ++ tokio::time::sleep(std::time::Duration::from_millis(50)).await; ++ ++ assert_eq!( ++ state ++ .store ++ .get_cached_summary(&run_id, Utc::now()) ++ .await ++ .unwrap() ++ .unwrap() ++ .title, ++ "User title" ++ ); ++ assert_eq!(title_update_event_count(&state, run_id).await, 1); ++} ++ ++async fn post_run_manifest(app: &Router, manifest: serde_json::Value) -> serde_json::Value { ++ let response = app ++ .clone() ++ .oneshot( ++ Request::builder() ++ .method("POST") ++ .uri(api("/runs")) ++ .header("content-type", "application/json") ++ .body(Body::from(manifest.to_string())) ++ .unwrap(), ++ ) ++ .await ++ .unwrap(); ++ response_json!(response, StatusCode::CREATED).await ++} ++ ++async fn mock_openai_title_response<'a>( ++ server: &'a MockServer, ++ title: &str, ++ delay: Option, ++) -> httpmock::Mock<'a> { ++ let title = title.to_string(); ++ server ++ .mock_async(move |when, then| { ++ when.method(POST).path("/v1/responses"); ++ let then = then ++ .status(200) ++ .header("content-type", "application/json") ++ .json_body(openai_responses_payload( ++ &json!({ "title": title }).to_string(), ++ )); ++ if let Some(delay) = delay { ++ then.delay(delay); ++ } ++ }) ++ .await ++} ++ ++async fn wait_for_run_title(state: &AppState, run_id: RunId, expected: &str) { ++ for _ in 0..50 { ++ let title = state ++ .store ++ .get_cached_summary(&run_id, Utc::now()) ++ .await ++ .unwrap() ++ .unwrap() ++ .title; ++ if title == expected { ++ return; ++ } ++ tokio::time::sleep(std::time::Duration::from_millis(20)).await; ++ } ++ panic!("run {run_id} title did not become {expected:?}"); ++} ++ ++async fn wait_for_mock_hits(mock: &httpmock::Mock<'_>, expected: usize) { ++ for _ in 0..50 { ++ if mock.calls_async().await >= expected { ++ return; ++ } ++ tokio::time::sleep(std::time::Duration::from_millis(20)).await; ++ } ++ panic!("mock did not receive {expected} request(s)"); ++} ++ ++async fn title_update_event_count(state: &AppState, run_id: RunId) -> usize { ++ let run_store = state.store.open_run(&run_id).await.unwrap(); ++ run_store ++ .list_events() ++ .await ++ .unwrap() ++ .into_iter() ++ .filter(|event| event.event.event_name() == "run.title.updated") ++ .count() ++} ++ + #[tokio::test] + async fn validate_endpoint_returns_workflow_summary_without_preflight_checks() { + let app = test_app_with(); +diff --git a/lib/crates/fabro-types/src/lib.rs b/lib/crates/fabro-types/src/lib.rs +index 3c46fc819..b1f5ba2d0 100644 +--- a/lib/crates/fabro-types/src/lib.rs ++++ b/lib/crates/fabro-types/src/lib.rs +@@ -112,7 +112,9 @@ pub use run_summary::{ + AskFabro, AskFabroUnavailableReason, AutomationRef, Run, RunBillingSummary, RunError, + RunLifecycle, RunLinks, RunModel, RunOrigin, RunOriginKind, RunTimestamps, WorkflowRef, + }; +-pub use run_title::{RunTitleError, infer_run_title, normalize_explicit_run_title}; ++pub use run_title::{ ++ MAX_RUN_TITLE_CHARS, RunTitleError, infer_run_title, normalize_explicit_run_title, ++}; + pub use sandbox_details::{ + SandboxDetails, SandboxNetwork, SandboxNetworkPolicy, SandboxNetworkPolicyMode, + SandboxResources, SandboxState, SandboxTimestamps, +diff --git a/lib/crates/fabro-types/src/run_title.rs b/lib/crates/fabro-types/src/run_title.rs +index 9468e448b..d38b2e7b9 100644 +--- a/lib/crates/fabro-types/src/run_title.rs ++++ b/lib/crates/fabro-types/src/run_title.rs +@@ -3,7 +3,7 @@ use std::fmt; + + use fabro_util::text::strip_goal_decoration; + +-const MAX_RUN_TITLE_CHARS: usize = 100; ++pub const MAX_RUN_TITLE_CHARS: usize = 100; + const TRUNCATED_RUN_TITLE_CHARS: usize = MAX_RUN_TITLE_CHARS - 3; + const UNTITLED_RUN: &str = "Untitled run"; + +diff --git a/lib/packages/fabro-api-client/src/models/model.ts b/lib/packages/fabro-api-client/src/models/model.ts +index 4b03f415b..f02cb0e51 100644 +--- a/lib/packages/fabro-api-client/src/models/model.ts ++++ b/lib/packages/fabro-api-client/src/models/model.ts +@@ -66,6 +66,10 @@ export interface Model { + * Whether this is the default model for its provider. + */ + 'default': boolean; ++ /** ++ * Whether this is the provider\'s small/default utility model. ++ */ ++ 'small_default': boolean; + /** + * Whether credential material is present for this model\'s provider on the server (vault entry or environment variable). Does NOT imply the credential is valid or that requests will succeed; call `POST /models/{id}/test` to verify usability. + */ diff --git a/stages/010-verify@2/output.log b/stages/010-verify@2/output.log new file mode 100644 index 000000000..665f94f67 --- /dev/null +++ b/stages/010-verify@2/output.log @@ -0,0 +1 @@ +blob://sha256/b663b197754cdb2bc9c1da19b2877e9a61f47dca0a73612b5487c2da37c63958 \ No newline at end of file diff --git a/stages/010-verify@2/script_timing.json b/stages/010-verify@2/script_timing.json new file mode 100644 index 000000000..c129f2b65 --- /dev/null +++ b/stages/010-verify@2/script_timing.json @@ -0,0 +1,8 @@ +{ + "output": "blob://sha256/b663b197754cdb2bc9c1da19b2877e9a61f47dca0a73612b5487c2da37c63958", + "exit_code": 0, + "duration_ms": 392161, + "termination": "exited", + "output_bytes": 77639, + "live_streaming": true +} \ No newline at end of file diff --git a/stages/010-verify@2/status.json b/stages/010-verify@2/status.json new file mode 100644 index 000000000..cf10986d2 --- /dev/null +++ b/stages/010-verify@2/status.json @@ -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 && ! 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-23T18:02:58.129533Z" +} \ No newline at end of file diff --git a/stages/011-exit@1/status.json b/stages/011-exit@1/status.json new file mode 100644 index 000000000..376d09086 --- /dev/null +++ b/stages/011-exit@1/status.json @@ -0,0 +1,6 @@ +{ + "outcome": "succeeded", + "notes": null, + "failure_reason": null, + "timestamp": "2026-05-23T18:03:03.197240Z" +} \ No newline at end of file