diff --git a/run.json b/run.json index 655e5bdde..4c8374574 100644 --- a/run.json +++ b/run.json @@ -492,7 +492,7 @@ "kind": "running" }, "status_updated_at": "2026-05-24T17:13:10.096457Z", - "last_event_at": "2026-05-24T18:01:10.177955Z", + "last_event_at": "2026-05-24T18:09:48.016057Z", "pending_control": null, "checkpoints": [ { @@ -1225,9 +1225,9 @@ } }, { - "seq": 0, + "seq": 2707, "checkpoint": { - "timestamp": "2026-05-24T18:09:44.086717Z", + "timestamp": "2026-05-24T18:09:48.015941Z", "current_node": "verify", "completed_nodes": [ "start", @@ -1241,75 +1241,41 @@ ], "node_retries": {}, "context_values": { - "graph.rankdir": "LR", - "graph.model_stylesheet": "\n * { model: claude-opus-4-7; }\n ", - "internal.retry_count.start": 0, - "internal.fidelity": "compact", - "failure_signature": "", - "internal.retry_count.toolchain": 0, - "internal.retry_count.preflight_compile": 0, - "thread.simplify_gpt.current_node": "verify", - "thread.implement.current_node": "simplify_opus", - "internal.retry_count.simplify_gpt": 0, - "outcome": "succeeded", - "internal.retry_count.verify": 0, - "failure_class": "", - "response.simplify_opus": "## Summary\n\nAggregated three review agents' findings and applied the consensus high-value fixes:\n\n**Quality / reuse fixes:**\n- `tool_permissions::known_tool_category` now returns `Option` directly. Deleted the brittle `Some(\"read\")` → `Read` string match in `handler/llm/api.rs`. `is_auto_approved` and `tool_category` are now enum-typed end-to-end; cli.rs and its tests updated accordingly. Adding a new category is now a compile-time exhaustiveness error instead of a silent `Other`.\n- `ToolSource::Mcp` now carries `original_name` populated from `info.original_tool_name` in `make_mcp_tools`. Workflow code no longer re-parses qualified MCP names (eliminates the silent fallback bug where a malformed prefix would store the qualified name as \"original\").\n- Moved `agent_tool_source` + `agent_tool_summaries_from_definitions` out of `handler/llm/api.rs`. New `ToolDefinitionWithSource::to_agent_tool_summary()` + `Session::agent_tool_summaries()` colocate the conversion next to the source type. Tests moved to `tool_registry.rs`.\n- Collapsed `Session::available_tools` and the duplicate private `effective_tools` into one public `effective_tools()` method.\n- Merged `ToolAvailableIcon` and `TodoPendingIcon` into a single `EmptyCircleIcon` component in the sidebar.\n\n**Efficiency fix:**\n- Gated `emit_agent_tools_available` on `!is_reused` in the primary path. Reused steerable sessions had been re-emitting the full tool list (with descriptions cloned into the event payload) on every prompt even though registry, access policy, and exposure mode are immutable for the session's lifetime. Failover path keeps its emit since it always builds a fresh session.\n\n**Skipped (cost > benefit):** `mcp_server_from_tool_name` dedup in `fabro-store` would require pulling `fabro-mcp` (with `rmcp`) into the store crate or relocating the parse helper into `fabro-types`. The duplicate is 5 lines using the same delimiter convention — not worth the dependency surface.\n\n**Verification:** `cargo check --workspace --all-targets`, `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings`, `cargo +nightly-2026-04-14 fmt --check --all`, `cargo nextest run -p fabro-types -p fabro-workflow -p fabro-store -p fabro-api -p fabro-agent` (2250+464 tests), `bun test stage-insights-sidebar` (10/10), and `bun run typecheck` all pass. The 14 unrelated `run-actions.test.ts` failures pre-exist on the parent commit.", - "internal.retry_count.simplify_opus": 0, - "response.implement": "The TS generator is pinned now, but this sandbox lacks Java, which OpenAPI Generator needs. I’ll install a headless JRE and rerun generation.", - "internal.retry_count.preflight_lint": 0, - "graph.goal": "---\ntitle: feat: StageProjection agent tools API\ntype: feat\nstatus: active\ndate: 2026-05-24\n---\n\n# feat: StageProjection Agent Tools API\n\n## Overview\n\nExpose the complete effective tool list for each agent-backed stage through `StageProjection`, so UI and API consumers can show actual tools such as `apply_patch`, `grep`, `glob`, `read_file`, MCP tools, skill tools, and subagent tools without inferring them from `permission_level`.\n\nThe API should expose tool summaries with `name`, `description`, `source`, `category`, and `invoked`. It must not expose full JSON parameter schemas in the run projection.\n\n## Problem Frame\n\nThe stage sidebar currently has permission metadata such as \"Full access\", but `permission_level` is only an access mode. It does not tell consumers which tools were actually exposed to the model after provider profile setup, optional tool registration, MCP integration, and tool access policy filtering.\n\nThe authoritative list already exists at request-build time in the agent session registry. The API needs to capture that effective list once per stage session and project it onto the stage.\n\n## Requirements Trace\n\n- R1. Add a StageProjection API field containing the complete effective tools for a stage session.\n- R2. Include `name`, `description`, `source`, `category`, and `invoked` for each tool.\n- R3. Do not infer tools from `permission_level` in backend or frontend code.\n- R4. Do not expose full tool parameter schemas through StageProjection.\n- R5. Preserve existing `permission_level` and `mcp_servers` fields for compatibility.\n- R6. Mark individual tools as invoked when matching `agent.tool.started` events are projected.\n- R7. Keep legacy runs backward compatible by defaulting missing tool lists to empty.\n\n## Scope Boundaries\n\n- Do not remove or rename `StageProjection.permission_level`.\n- Do not remove the existing MCP server projection or `AgentMcpToolSummary`.\n- Do not change completion API tool definitions.\n- Do not add ACP-native tool discovery in this pass unless an ACP path already has an equivalent effective tool list available.\n- Do not render parameter schemas in the web UI.\n\n## Context & Research\n\n### Relevant Code and Patterns\n\n- OpenAPI is the source of truth for HTTP contracts in `docs/public/api-reference/fabro-api.yaml`.\n- Shared API/projection DTOs should live in `fabro-types`, with `fabro-api/build.rs` replacements to avoid duplicate generated Rust types.\n- `StageProjection` lives in `lib/crates/fabro-types/src/run_projection.rs`.\n- Durable run event props live in `lib/crates/fabro-types/src/run_event/agent.rs` and `lib/crates/fabro-types/src/run_event/mod.rs`.\n- Workflow event conversion and event names live in `lib/crates/fabro-workflow/src/event/convert.rs`, `events.rs`, and `names.rs`.\n- Projection replay lives in `lib/crates/fabro-store/src/run_state.rs`.\n- The effective request tool list is built in `lib/crates/fabro-agent/src/session.rs` from `ToolRegistry::definitions_with_source_for_policy`.\n- Tool source metadata already exists as `ToolSource` and `ToolDefinitionWithSource` in `lib/crates/fabro-agent/src/tool_registry.rs`.\n- Tool category mapping already exists in `lib/crates/fabro-agent/src/tool_permissions.rs`.\n- The sidebar display lives in `apps/fabro-web/app/components/stage-insights-sidebar.tsx`.\n\n### Strategy Docs\n\n- Read `docs/internal/events-strategy.md` before adding the new durable event.\n- Read `docs/internal/testing-strategy.md` before adding or reorganizing tests.\n- Follow the OpenAPI type ownership guidance in `AGENTS.md`: reuse `fabro-types` through `fabro-api/build.rs` replacements when the API schema has the same product meaning and serde shape.\n\n## Key Technical Decisions\n\n- Add a new StageProjection field named `agent_tools`, not `tools`, to avoid ambiguity with MCP nested tools and completion tool definitions.\n- Add a dedicated durable event named `agent.tools.available` instead of overloading `agent.session.activated`.\n- Capture the effective tool list after session setup and filtering, using the same path as model request construction.\n- Store descriptions in the summary because they are useful API/UI metadata; omit parameter schemas to keep projection payloads small and avoid leaking full implementation detail.\n- Keep `AgentMcpToolSummary` MCP-only. Add a new general-purpose `AgentToolSummary` instead of stretching the MCP type beyond its meaning.\n- Treat `invoked` as projected state. The availability event should emit tools with `invoked: false`; replay of `agent.tool.started` flips matching tools to true.\n\n## API Contract\n\nAdd these schemas to OpenAPI and map them to `fabro_types` replacements:\n\n- `AgentToolSummary`\n - required: `name`, `description`, `source`, `category`, `invoked`\n - `name`: exposed tool name, e.g. `apply_patch` or `mcp__filesystem__read_file`\n - `description`: model-facing tool description\n - `source`: `AgentToolSource`\n - `category`: `AgentToolCategory`\n - `invoked`: boolean\n- `AgentToolSource`\n - tagged by `kind`\n - `native`\n - `mcp` with `server_name` and `original_name`\n - `skill`\n- `AgentToolCategory`\n - enum: `read`, `write`, `shell`, `subagent`, `other`\n- `AgentToolsAvailableProps`\n - required: `tools`, `visit`\n - `tools`: array of `AgentToolSummary`\n - `visit`: stage visit number\n\nAdd to `StageProjection`:\n\n- `agent_tools`: array of `AgentToolSummary`\n- Default to an empty array when omitted.\n- Skip serializing when empty, matching existing projection optional-list style.\n\nAdd event body:\n\n- Serialized event name: `agent.tools.available`\n- Event body type: `AgentToolsAvailableProps`\n\n## Implementation Units\n\n- [ ] **Unit 1: Add shared tool summary types**\n\n**Goal:** Define the canonical API/projection DTOs in `fabro-types`.\n\n**Files:**\n- Modify: `lib/crates/fabro-types/src/run_event/agent.rs`\n- Modify: `lib/crates/fabro-types/src/run_event/mod.rs`\n- Modify: `lib/crates/fabro-types/src/run_projection.rs`\n- Modify: `lib/crates/fabro-types/src/lib.rs`\n\n**Work:**\n- Add `AgentToolSummary`, `AgentToolSource`, `AgentToolCategory`, and `AgentToolsAvailableProps`.\n- Add `EventBody::AgentToolsAvailable` serialized as `agent.tools.available`.\n- Add `agent_tools: Vec` to `StageProjection`.\n- Ensure all new fields default cleanly for older persisted events/projections.\n- Export the new public types from `fabro-types`.\n\n- [ ] **Unit 2: Capture effective session tools**\n\n**Goal:** Provide an authoritative one-time source for the list that the model can actually call.\n\n**Files:**\n- Modify: `lib/crates/fabro-agent/src/session.rs`\n- Modify: `lib/crates/fabro-workflow/src/handler/llm/api.rs`\n- Modify: `lib/crates/fabro-workflow/src/event/events.rs`\n- Modify: `lib/crates/fabro-workflow/src/event/names.rs`\n- Modify: `lib/crates/fabro-workflow/src/event/convert.rs`\n- Modify: `lib/crates/fabro-workflow/src/event/stored_fields.rs` only if the new event needs non-standard stored fields.\n\n**Work:**\n- Add `Session::available_tools()` that returns the same effective `ToolDefinitionWithSource` list as `build_request()`.\n- Map `ToolDefinitionWithSource` to `AgentToolSummary` at the workflow boundary.\n- Populate `description` from `ToolDefinition.description`.\n- Populate `source` from `ToolSource`.\n- For MCP tools, include the server name from `ToolSource::Mcp` and derive `original_name` from the qualified exposed name using the existing MCP naming convention.\n- Populate `category` from the existing tool category mapping for known exposed names; use `other` when no category mapping applies.\n- Emit `agent.tools.available` once for the stage session after session setup and filtering are complete.\n\n- [ ] **Unit 3: Project available and invoked tools**\n\n**Goal:** Make `StageProjection.agent_tools` replay-authoritative.\n\n**Files:**\n- Modify: `lib/crates/fabro-store/src/run_state.rs`\n\n**Work:**\n- On `EventBody::AgentToolsAvailable`, replace the current stage visit's `agent_tools` with the event tools.\n- On `EventBody::AgentToolStarted`, find a matching `agent_tools` entry by exposed `tool_name` and set `invoked = true`.\n- Keep the existing MCP server `invoked` update unchanged.\n- If a legacy run has no availability event, do not synthesize a full list from permissions.\n\n- [ ] **Unit 4: Update OpenAPI and generated clients**\n\n**Goal:** Expose the new projection field and event contract through public API clients without duplicate Rust API types.\n\n**Files:**\n- Modify: `docs/public/api-reference/fabro-api.yaml`\n- Modify: `lib/crates/fabro-api/build.rs`\n- Modify: `lib/crates/fabro-api/src/lib.rs`\n- Modify generated files under `lib/crates/fabro-api/src/generated.rs` via `cargo build -p fabro-api`.\n- Modify generated files under `lib/packages/fabro-api-client/src` via TypeScript client generation.\n\n**Work:**\n- Add the OpenAPI schemas listed in the API Contract section.\n- Add `StageProjection.agent_tools`.\n- Add `fabro-api/build.rs` replacements for the new `fabro-types` types.\n- Regenerate Rust API code.\n- Regenerate TypeScript API client code.\n\n- [ ] **Unit 5: Render tools in the web sidebar**\n\n**Goal:** Replace the permission-derived sidebar display with the actual projected tool list.\n\n**Files:**\n- Modify: `apps/fabro-web/app/components/stage-insights-sidebar.tsx`\n- Modify: `apps/fabro-web/app/components/stage-insights-sidebar.test.tsx`\n\n**Work:**\n- Render `stage.agent_tools` when present.\n- Show each tool's name, description, source/category, and invoked state.\n- Keep permission level as secondary metadata or fallback for legacy stages with no `agent_tools`.\n- Do not infer tool availability from permission level.\n\n## Test Plan\n\n- `fabro-types`\n - JSON round-trip for `agent.tools.available`.\n - Serialization checks for `AgentToolSource` and `AgentToolCategory`.\n - Backward compatibility check that missing `agent_tools` deserializes to an empty list.\n\n- `fabro-workflow`\n - Event name and conversion tests for `agent.tools.available`.\n - Capture test proving native tools such as `apply_patch`, `grep`, and `glob` are emitted from the effective registry path.\n - MCP source mapping test for a qualified MCP tool name.\n\n- `fabro-store`\n - Projection test that `agent.tools.available` populates `StageProjection.agent_tools`.\n - Projection test that `agent.tool.started` marks only the matching tool as invoked.\n - Regression test that MCP server `invoked` status still updates as before.\n\n- `fabro-api`\n - Type identity/parity tests confirming API types reuse `fabro_types::AgentToolSummary`, `AgentToolSource`, `AgentToolCategory`, and `AgentToolsAvailableProps`.\n - StageProjection round-trip test including `agent_tools`.\n\n- `fabro-web`\n - Sidebar test rendering tool names and descriptions from `stage.agent_tools`.\n - Sidebar test showing invoked state.\n - Legacy fallback test for stages without `agent_tools`.\n\n## Run Checks\n\n- `cargo +nightly-2026-04-14 fmt --check --all`\n- `cargo nextest run -p fabro-types -p fabro-workflow -p fabro-store -p fabro-api`\n- `cargo build -p fabro-api`\n- `cd lib/packages/fabro-api-client && bun run generate`\n- `cd apps/fabro-web && bun test && bun run typecheck`\n\n## Assumptions\n\n- The first implementation targets normal API-backed agent sessions, not ACP-native sessions.\n- `description` is safe to expose because it is already model-facing tool metadata, but parameter schemas remain out of scope for StageProjection.\n- `agent_tools` is a complete list only for runs that emit `agent.tools.available`; legacy runs return an empty list and may still show existing permission/MCP metadata.\n- If tool registration becomes mutable later, the event contract can be re-emitted and projection replacement semantics will still work.\n", - "command.output": "blob://sha256/4a229e77c774df9e4587431eec40dc119a6d55f115aaf1205d37c54bc78d1c8f", - "last_stage": "simplify_gpt", - "internal.thread_id": "simplify_gpt", - "thread.start.current_node": "toolchain", - "thread.preflight_compile.current_node": "preflight_lint", - "internal.node_visit_count": 1, - "thread.toolchain.current_node": "preflight_compile", - "thread.preflight_lint.current_node": "implement", - "last_response": "Reviewed the feature branch diff against `origin/main...HEAD` and ran the requested parallel review pass. I didn’t find any actionable reuse/quality/efficiency issues that warranted code changes, so", "internal.retry_count.implement": 0, - "thread.simplify_opus.current_node": "simplify_gpt", + "internal.run_id": "01KSDFKD9JJS7D2ZJ6JQYXV58Z", + "last_response": "Reviewed the feature branch diff against `origin/main...HEAD` and ran the requested parallel review pass. I didn’t find any actionable reuse/quality/efficiency issues that warranted code changes, so", + "graph.rankdir": "LR", + "internal.retry_count.preflight_compile": 0, + "internal.thread_id": "simplify_gpt", + "outcome": "succeeded", + "failure_signature": "", + "response.implement": "The TS generator is pinned now, but this sandbox lacks Java, which OpenAPI Generator needs. I’ll install a headless JRE and rerun generation.", + "thread.simplify_gpt.current_node": "verify", + "failure_class": "", + "internal.retry_count.toolchain": 0, + "thread.implement.current_node": "simplify_opus", "response.simplify_gpt": "Reviewed the feature branch diff against `origin/main...HEAD` and ran the requested parallel review pass. I didn’t find any actionable reuse/quality/efficiency issues that warranted code changes, so the working tree remains clean.\n\nValidation run:\n\n- `cargo nextest run -p fabro-types -p fabro-store -p fabro-agent -p fabro-workflow -p fabro-api agent_tools_available stage_projection_round_trip to_agent_tool_summary effective_tools --no-tests=pass` ✅\n- `cd apps/fabro-web && bun test stage-insights-sidebar.test.tsx` ✅\n- `cargo +nightly-2026-04-14 fmt --check --all` ✅\n- `cd apps/fabro-web && bun run typecheck` ✅\n- `git status --short` clean ✅", - "current_node": "verify", + "graph.model_stylesheet": "\n * { model: claude-opus-4-7; }\n ", + "internal.node_visit_count": 1, + "internal.retry_count.simplify_opus": 0, + "internal.retry_count.verify": 0, "internal.work_dir": "/home/daytona/workspace/fabro", - "internal.run_id": "01KSDFKD9JJS7D2ZJ6JQYXV58Z" + "thread.preflight_compile.current_node": "preflight_lint", + "thread.toolchain.current_node": "preflight_compile", + "internal.retry_count.simplify_gpt": 0, + "response.simplify_opus": "## Summary\n\nAggregated three review agents' findings and applied the consensus high-value fixes:\n\n**Quality / reuse fixes:**\n- `tool_permissions::known_tool_category` now returns `Option` directly. Deleted the brittle `Some(\"read\")` → `Read` string match in `handler/llm/api.rs`. `is_auto_approved` and `tool_category` are now enum-typed end-to-end; cli.rs and its tests updated accordingly. Adding a new category is now a compile-time exhaustiveness error instead of a silent `Other`.\n- `ToolSource::Mcp` now carries `original_name` populated from `info.original_tool_name` in `make_mcp_tools`. Workflow code no longer re-parses qualified MCP names (eliminates the silent fallback bug where a malformed prefix would store the qualified name as \"original\").\n- Moved `agent_tool_source` + `agent_tool_summaries_from_definitions` out of `handler/llm/api.rs`. New `ToolDefinitionWithSource::to_agent_tool_summary()` + `Session::agent_tool_summaries()` colocate the conversion next to the source type. Tests moved to `tool_registry.rs`.\n- Collapsed `Session::available_tools` and the duplicate private `effective_tools` into one public `effective_tools()` method.\n- Merged `ToolAvailableIcon` and `TodoPendingIcon` into a single `EmptyCircleIcon` component in the sidebar.\n\n**Efficiency fix:**\n- Gated `emit_agent_tools_available` on `!is_reused` in the primary path. Reused steerable sessions had been re-emitting the full tool list (with descriptions cloned into the event payload) on every prompt even though registry, access policy, and exposure mode are immutable for the session's lifetime. Failover path keeps its emit since it always builds a fresh session.\n\n**Skipped (cost > benefit):** `mcp_server_from_tool_name` dedup in `fabro-store` would require pulling `fabro-mcp` (with `rmcp`) into the store crate or relocating the parse helper into `fabro-types`. The duplicate is 5 lines using the same delimiter convention — not worth the dependency surface.\n\n**Verification:** `cargo check --workspace --all-targets`, `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings`, `cargo +nightly-2026-04-14 fmt --check --all`, `cargo nextest run -p fabro-types -p fabro-workflow -p fabro-store -p fabro-api -p fabro-agent` (2250+464 tests), `bun test stage-insights-sidebar` (10/10), and `bun run typecheck` all pass. The 14 unrelated `run-actions.test.ts` failures pre-exist on the parent commit.", + "internal.retry_count.start": 0, + "command.output": "blob://sha256/4a229e77c774df9e4587431eec40dc119a6d55f115aaf1205d37c54bc78d1c8f", + "thread.simplify_opus.current_node": "simplify_gpt", + "last_stage": "simplify_gpt", + "thread.preflight_lint.current_node": "implement", + "graph.goal": "---\ntitle: feat: StageProjection agent tools API\ntype: feat\nstatus: active\ndate: 2026-05-24\n---\n\n# feat: StageProjection Agent Tools API\n\n## Overview\n\nExpose the complete effective tool list for each agent-backed stage through `StageProjection`, so UI and API consumers can show actual tools such as `apply_patch`, `grep`, `glob`, `read_file`, MCP tools, skill tools, and subagent tools without inferring them from `permission_level`.\n\nThe API should expose tool summaries with `name`, `description`, `source`, `category`, and `invoked`. It must not expose full JSON parameter schemas in the run projection.\n\n## Problem Frame\n\nThe stage sidebar currently has permission metadata such as \"Full access\", but `permission_level` is only an access mode. It does not tell consumers which tools were actually exposed to the model after provider profile setup, optional tool registration, MCP integration, and tool access policy filtering.\n\nThe authoritative list already exists at request-build time in the agent session registry. The API needs to capture that effective list once per stage session and project it onto the stage.\n\n## Requirements Trace\n\n- R1. Add a StageProjection API field containing the complete effective tools for a stage session.\n- R2. Include `name`, `description`, `source`, `category`, and `invoked` for each tool.\n- R3. Do not infer tools from `permission_level` in backend or frontend code.\n- R4. Do not expose full tool parameter schemas through StageProjection.\n- R5. Preserve existing `permission_level` and `mcp_servers` fields for compatibility.\n- R6. Mark individual tools as invoked when matching `agent.tool.started` events are projected.\n- R7. Keep legacy runs backward compatible by defaulting missing tool lists to empty.\n\n## Scope Boundaries\n\n- Do not remove or rename `StageProjection.permission_level`.\n- Do not remove the existing MCP server projection or `AgentMcpToolSummary`.\n- Do not change completion API tool definitions.\n- Do not add ACP-native tool discovery in this pass unless an ACP path already has an equivalent effective tool list available.\n- Do not render parameter schemas in the web UI.\n\n## Context & Research\n\n### Relevant Code and Patterns\n\n- OpenAPI is the source of truth for HTTP contracts in `docs/public/api-reference/fabro-api.yaml`.\n- Shared API/projection DTOs should live in `fabro-types`, with `fabro-api/build.rs` replacements to avoid duplicate generated Rust types.\n- `StageProjection` lives in `lib/crates/fabro-types/src/run_projection.rs`.\n- Durable run event props live in `lib/crates/fabro-types/src/run_event/agent.rs` and `lib/crates/fabro-types/src/run_event/mod.rs`.\n- Workflow event conversion and event names live in `lib/crates/fabro-workflow/src/event/convert.rs`, `events.rs`, and `names.rs`.\n- Projection replay lives in `lib/crates/fabro-store/src/run_state.rs`.\n- The effective request tool list is built in `lib/crates/fabro-agent/src/session.rs` from `ToolRegistry::definitions_with_source_for_policy`.\n- Tool source metadata already exists as `ToolSource` and `ToolDefinitionWithSource` in `lib/crates/fabro-agent/src/tool_registry.rs`.\n- Tool category mapping already exists in `lib/crates/fabro-agent/src/tool_permissions.rs`.\n- The sidebar display lives in `apps/fabro-web/app/components/stage-insights-sidebar.tsx`.\n\n### Strategy Docs\n\n- Read `docs/internal/events-strategy.md` before adding the new durable event.\n- Read `docs/internal/testing-strategy.md` before adding or reorganizing tests.\n- Follow the OpenAPI type ownership guidance in `AGENTS.md`: reuse `fabro-types` through `fabro-api/build.rs` replacements when the API schema has the same product meaning and serde shape.\n\n## Key Technical Decisions\n\n- Add a new StageProjection field named `agent_tools`, not `tools`, to avoid ambiguity with MCP nested tools and completion tool definitions.\n- Add a dedicated durable event named `agent.tools.available` instead of overloading `agent.session.activated`.\n- Capture the effective tool list after session setup and filtering, using the same path as model request construction.\n- Store descriptions in the summary because they are useful API/UI metadata; omit parameter schemas to keep projection payloads small and avoid leaking full implementation detail.\n- Keep `AgentMcpToolSummary` MCP-only. Add a new general-purpose `AgentToolSummary` instead of stretching the MCP type beyond its meaning.\n- Treat `invoked` as projected state. The availability event should emit tools with `invoked: false`; replay of `agent.tool.started` flips matching tools to true.\n\n## API Contract\n\nAdd these schemas to OpenAPI and map them to `fabro_types` replacements:\n\n- `AgentToolSummary`\n - required: `name`, `description`, `source`, `category`, `invoked`\n - `name`: exposed tool name, e.g. `apply_patch` or `mcp__filesystem__read_file`\n - `description`: model-facing tool description\n - `source`: `AgentToolSource`\n - `category`: `AgentToolCategory`\n - `invoked`: boolean\n- `AgentToolSource`\n - tagged by `kind`\n - `native`\n - `mcp` with `server_name` and `original_name`\n - `skill`\n- `AgentToolCategory`\n - enum: `read`, `write`, `shell`, `subagent`, `other`\n- `AgentToolsAvailableProps`\n - required: `tools`, `visit`\n - `tools`: array of `AgentToolSummary`\n - `visit`: stage visit number\n\nAdd to `StageProjection`:\n\n- `agent_tools`: array of `AgentToolSummary`\n- Default to an empty array when omitted.\n- Skip serializing when empty, matching existing projection optional-list style.\n\nAdd event body:\n\n- Serialized event name: `agent.tools.available`\n- Event body type: `AgentToolsAvailableProps`\n\n## Implementation Units\n\n- [ ] **Unit 1: Add shared tool summary types**\n\n**Goal:** Define the canonical API/projection DTOs in `fabro-types`.\n\n**Files:**\n- Modify: `lib/crates/fabro-types/src/run_event/agent.rs`\n- Modify: `lib/crates/fabro-types/src/run_event/mod.rs`\n- Modify: `lib/crates/fabro-types/src/run_projection.rs`\n- Modify: `lib/crates/fabro-types/src/lib.rs`\n\n**Work:**\n- Add `AgentToolSummary`, `AgentToolSource`, `AgentToolCategory`, and `AgentToolsAvailableProps`.\n- Add `EventBody::AgentToolsAvailable` serialized as `agent.tools.available`.\n- Add `agent_tools: Vec` to `StageProjection`.\n- Ensure all new fields default cleanly for older persisted events/projections.\n- Export the new public types from `fabro-types`.\n\n- [ ] **Unit 2: Capture effective session tools**\n\n**Goal:** Provide an authoritative one-time source for the list that the model can actually call.\n\n**Files:**\n- Modify: `lib/crates/fabro-agent/src/session.rs`\n- Modify: `lib/crates/fabro-workflow/src/handler/llm/api.rs`\n- Modify: `lib/crates/fabro-workflow/src/event/events.rs`\n- Modify: `lib/crates/fabro-workflow/src/event/names.rs`\n- Modify: `lib/crates/fabro-workflow/src/event/convert.rs`\n- Modify: `lib/crates/fabro-workflow/src/event/stored_fields.rs` only if the new event needs non-standard stored fields.\n\n**Work:**\n- Add `Session::available_tools()` that returns the same effective `ToolDefinitionWithSource` list as `build_request()`.\n- Map `ToolDefinitionWithSource` to `AgentToolSummary` at the workflow boundary.\n- Populate `description` from `ToolDefinition.description`.\n- Populate `source` from `ToolSource`.\n- For MCP tools, include the server name from `ToolSource::Mcp` and derive `original_name` from the qualified exposed name using the existing MCP naming convention.\n- Populate `category` from the existing tool category mapping for known exposed names; use `other` when no category mapping applies.\n- Emit `agent.tools.available` once for the stage session after session setup and filtering are complete.\n\n- [ ] **Unit 3: Project available and invoked tools**\n\n**Goal:** Make `StageProjection.agent_tools` replay-authoritative.\n\n**Files:**\n- Modify: `lib/crates/fabro-store/src/run_state.rs`\n\n**Work:**\n- On `EventBody::AgentToolsAvailable`, replace the current stage visit's `agent_tools` with the event tools.\n- On `EventBody::AgentToolStarted`, find a matching `agent_tools` entry by exposed `tool_name` and set `invoked = true`.\n- Keep the existing MCP server `invoked` update unchanged.\n- If a legacy run has no availability event, do not synthesize a full list from permissions.\n\n- [ ] **Unit 4: Update OpenAPI and generated clients**\n\n**Goal:** Expose the new projection field and event contract through public API clients without duplicate Rust API types.\n\n**Files:**\n- Modify: `docs/public/api-reference/fabro-api.yaml`\n- Modify: `lib/crates/fabro-api/build.rs`\n- Modify: `lib/crates/fabro-api/src/lib.rs`\n- Modify generated files under `lib/crates/fabro-api/src/generated.rs` via `cargo build -p fabro-api`.\n- Modify generated files under `lib/packages/fabro-api-client/src` via TypeScript client generation.\n\n**Work:**\n- Add the OpenAPI schemas listed in the API Contract section.\n- Add `StageProjection.agent_tools`.\n- Add `fabro-api/build.rs` replacements for the new `fabro-types` types.\n- Regenerate Rust API code.\n- Regenerate TypeScript API client code.\n\n- [ ] **Unit 5: Render tools in the web sidebar**\n\n**Goal:** Replace the permission-derived sidebar display with the actual projected tool list.\n\n**Files:**\n- Modify: `apps/fabro-web/app/components/stage-insights-sidebar.tsx`\n- Modify: `apps/fabro-web/app/components/stage-insights-sidebar.test.tsx`\n\n**Work:**\n- Render `stage.agent_tools` when present.\n- Show each tool's name, description, source/category, and invoked state.\n- Keep permission level as secondary metadata or fallback for legacy stages with no `agent_tools`.\n- Do not infer tool availability from permission level.\n\n## Test Plan\n\n- `fabro-types`\n - JSON round-trip for `agent.tools.available`.\n - Serialization checks for `AgentToolSource` and `AgentToolCategory`.\n - Backward compatibility check that missing `agent_tools` deserializes to an empty list.\n\n- `fabro-workflow`\n - Event name and conversion tests for `agent.tools.available`.\n - Capture test proving native tools such as `apply_patch`, `grep`, and `glob` are emitted from the effective registry path.\n - MCP source mapping test for a qualified MCP tool name.\n\n- `fabro-store`\n - Projection test that `agent.tools.available` populates `StageProjection.agent_tools`.\n - Projection test that `agent.tool.started` marks only the matching tool as invoked.\n - Regression test that MCP server `invoked` status still updates as before.\n\n- `fabro-api`\n - Type identity/parity tests confirming API types reuse `fabro_types::AgentToolSummary`, `AgentToolSource`, `AgentToolCategory`, and `AgentToolsAvailableProps`.\n - StageProjection round-trip test including `agent_tools`.\n\n- `fabro-web`\n - Sidebar test rendering tool names and descriptions from `stage.agent_tools`.\n - Sidebar test showing invoked state.\n - Legacy fallback test for stages without `agent_tools`.\n\n## Run Checks\n\n- `cargo +nightly-2026-04-14 fmt --check --all`\n- `cargo nextest run -p fabro-types -p fabro-workflow -p fabro-store -p fabro-api`\n- `cargo build -p fabro-api`\n- `cd lib/packages/fabro-api-client && bun run generate`\n- `cd apps/fabro-web && bun test && bun run typecheck`\n\n## Assumptions\n\n- The first implementation targets normal API-backed agent sessions, not ACP-native sessions.\n- `description` is safe to expose because it is already model-facing tool metadata, but parameter schemas remain out of scope for StageProjection.\n- `agent_tools` is a complete list only for runs that emit `agent.tools.available`; legacy runs return an empty list and may still show existing permission/MCP metadata.\n- If tool registration becomes mutable later, the event contract can be re-emitted and projection replacement semantics will still work.\n", + "thread.start.current_node": "toolchain", + "internal.retry_count.preflight_lint": 0, + "internal.fidelity": "compact", + "current_node": "verify" }, "node_outcomes": { - "start": { - "status": "succeeded", - "usage": null - }, - "simplify_gpt": { - "status": "succeeded", - "context_updates": { - "last_response": "Reviewed the feature branch diff against `origin/main...HEAD` and ran the requested parallel review pass. I didn’t find any actionable reuse/quality/efficiency issues that warranted code changes, so", - "last_stage": "simplify_gpt", - "response.simplify_gpt": "Reviewed the feature branch diff against `origin/main...HEAD` and ran the requested parallel review pass. I didn’t find any actionable reuse/quality/efficiency issues that warranted code changes, so the working tree remains clean.\n\nValidation run:\n\n- `cargo nextest run -p fabro-types -p fabro-store -p fabro-agent -p fabro-workflow -p fabro-api agent_tools_available stage_projection_round_trip to_agent_tool_summary effective_tools --no-tests=pass` ✅\n- `cd apps/fabro-web && bun test stage-insights-sidebar.test.tsx` ✅\n- `cargo +nightly-2026-04-14 fmt --check --all` ✅\n- `cd apps/fabro-web && bun run typecheck` ✅\n- `git status --short` clean ✅" - }, - "notes": "Stage completed: simplify_gpt", - "usage": { - "input": { - "usage": { - "model": { - "provider": "openai", - "model_id": "gpt-5.5" - }, - "tokens": { - "input_tokens": 1008114, - "output_tokens": 3387, - "reasoning_tokens": 2181, - "cache_read_tokens": 569856, - "cache_write_tokens": 0 - } - }, - "facts": { - "algorithm": "openai" - } - }, - "total_usd_micros": 5492538 - } - }, "preflight_compile": { "status": "succeeded", "context_updates": { @@ -1318,60 +1284,6 @@ "notes": "Script completed: cargo check -q --workspace 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_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 - }, - "verify": { - "status": "succeeded", - "context_updates": { - "command.output": "blob://sha256/4a229e77c774df9e4587431eec40dc119a6d55f115aaf1205d37c54bc78d1c8f" - }, - "notes": "Script completed: git fetch origin main 2>&1 && git merge --no-edit --no-stat origin/main 2>&1 && cargo +nightly-2026-04-14 fmt --all 2>&1 && cargo dev docs refresh 2>&1 && cargo +nightly-2026-04-14 fmt --check --all 2>&1 && { command -v rg >/dev/null 2>&1 || { echo 'rg is required for verify'; exit 127; }; } && ! rg -n 'AuthMode::Disabled|RunAuthMethod|RunSubjectProvenance|\\bActorRef\\b|\\bActorKind\\b|AuthenticatedSubject|AuthenticatedService|AuthorizeRunScoped|AuthorizeRunBlob|AuthorizeStageArtifact|AuthorizeCommandLog|auth_method\\s*==\\s*\"disabled\"' lib/crates apps lib/packages docs/public/api-reference/fabro-api.yaml 2>&1 && cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --workspace --status-level slow --profile ci 2>&1 && cargo dev docs check 2>&1 && bun install --frozen-lockfile 2>&1 && (cd apps/fabro-web && bun run typecheck) 2>&1 && (cd apps/fabro-web && bun run test) 2>&1 && (cd lib/packages/fabro-api-client && bun run typecheck) 2>&1 && cargo dev build -- -p fabro-cli --release 2>&1", - "usage": null - }, - "implement": { - "status": "succeeded", - "context_updates": { - "last_stage": "implement", - "last_response": "The TS generator is pinned now, but this sandbox lacks Java, which OpenAPI Generator needs. I’ll install a headless JRE and rerun generation.", - "response.implement": "The TS generator is pinned now, but this sandbox lacks Java, which OpenAPI Generator needs. I’ll install a headless JRE and rerun generation." - }, - "notes": "Stage completed: implement", - "usage": { - "input": { - "usage": { - "model": { - "provider": "openai", - "model_id": "gpt-5.5" - }, - "tokens": { - "input_tokens": 6711598, - "output_tokens": 22676, - "reasoning_tokens": 11522, - "cache_read_tokens": 16107008, - "cache_write_tokens": 0 - } - }, - "facts": { - "algorithm": "openai" - } - }, - "total_usd_micros": 42637434 - } - }, "simplify_opus": { "status": "succeeded", "context_updates": { @@ -1413,24 +1325,234 @@ "/home/daytona/workspace/fabro/lib/crates/fabro-agent/src/tool_registry.rs", "/home/daytona/workspace/fabro/lib/crates/fabro-workflow/src/handler/llm/api.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 + }, + "simplify_gpt": { + "status": "succeeded", + "context_updates": { + "last_response": "Reviewed the feature branch diff against `origin/main...HEAD` and ran the requested parallel review pass. I didn’t find any actionable reuse/quality/efficiency issues that warranted code changes, so", + "last_stage": "simplify_gpt", + "response.simplify_gpt": "Reviewed the feature branch diff against `origin/main...HEAD` and ran the requested parallel review pass. I didn’t find any actionable reuse/quality/efficiency issues that warranted code changes, so the working tree remains clean.\n\nValidation run:\n\n- `cargo nextest run -p fabro-types -p fabro-store -p fabro-agent -p fabro-workflow -p fabro-api agent_tools_available stage_projection_round_trip to_agent_tool_summary effective_tools --no-tests=pass` ✅\n- `cd apps/fabro-web && bun test stage-insights-sidebar.test.tsx` ✅\n- `cargo +nightly-2026-04-14 fmt --check --all` ✅\n- `cd apps/fabro-web && bun run typecheck` ✅\n- `git status --short` clean ✅" + }, + "notes": "Stage completed: simplify_gpt", + "usage": { + "input": { + "usage": { + "model": { + "provider": "openai", + "model_id": "gpt-5.5" + }, + "tokens": { + "input_tokens": 1008114, + "output_tokens": 3387, + "reasoning_tokens": 2181, + "cache_read_tokens": 569856, + "cache_write_tokens": 0 + } + }, + "facts": { + "algorithm": "openai" + } + }, + "total_usd_micros": 5492538 + } + }, + "verify": { + "status": "succeeded", + "context_updates": { + "command.output": "blob://sha256/4a229e77c774df9e4587431eec40dc119a6d55f115aaf1205d37c54bc78d1c8f" + }, + "notes": "Script completed: git fetch origin main 2>&1 && git merge --no-edit --no-stat origin/main 2>&1 && cargo +nightly-2026-04-14 fmt --all 2>&1 && cargo dev docs refresh 2>&1 && cargo +nightly-2026-04-14 fmt --check --all 2>&1 && { command -v rg >/dev/null 2>&1 || { echo 'rg is required for verify'; exit 127; }; } && ! rg -n 'AuthMode::Disabled|RunAuthMethod|RunSubjectProvenance|\\bActorRef\\b|\\bActorKind\\b|AuthenticatedSubject|AuthenticatedService|AuthorizeRunScoped|AuthorizeRunBlob|AuthorizeStageArtifact|AuthorizeCommandLog|auth_method\\s*==\\s*\"disabled\"' lib/crates apps lib/packages docs/public/api-reference/fabro-api.yaml 2>&1 && cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --workspace --status-level slow --profile ci 2>&1 && cargo dev docs check 2>&1 && bun install --frozen-lockfile 2>&1 && (cd apps/fabro-web && bun run typecheck) 2>&1 && (cd apps/fabro-web && bun run test) 2>&1 && (cd lib/packages/fabro-api-client && bun run typecheck) 2>&1 && cargo dev build -- -p fabro-cli --release 2>&1", + "usage": null + }, + "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 + }, + "implement": { + "status": "succeeded", + "context_updates": { + "last_stage": "implement", + "last_response": "The TS generator is pinned now, but this sandbox lacks Java, which OpenAPI Generator needs. I’ll install a headless JRE and rerun generation.", + "response.implement": "The TS generator is pinned now, but this sandbox lacks Java, which OpenAPI Generator needs. I’ll install a headless JRE and rerun generation." + }, + "notes": "Stage completed: implement", + "usage": { + "input": { + "usage": { + "model": { + "provider": "openai", + "model_id": "gpt-5.5" + }, + "tokens": { + "input_tokens": 6711598, + "output_tokens": 22676, + "reasoning_tokens": 11522, + "cache_read_tokens": 16107008, + "cache_write_tokens": 0 + } + }, + "facts": { + "algorithm": "openai" + } + }, + "total_usd_micros": 42637434 + } } }, "next_node_id": "exit", + "git_commit_sha": "bca30391ce0ac99cc2e4ea38e9f74ca1725b85d9", "node_visits": { - "toolchain": 1, + "simplify_opus": 1, + "verify": 1, "preflight_compile": 1, + "toolchain": 1, "start": 1, "preflight_lint": 1, - "implement": 1, "simplify_gpt": 1, - "simplify_opus": 1, - "verify": 1 + "implement": 1 } }, - "diff": {} + "diff": { + "patch": "diff --git a/apps/fabro-web/app/components/stage-insights-sidebar.tsx b/apps/fabro-web/app/components/stage-insights-sidebar.tsx\nindex 1b534c181..cc6e496a0 100644\n--- a/apps/fabro-web/app/components/stage-insights-sidebar.tsx\n+++ b/apps/fabro-web/app/components/stage-insights-sidebar.tsx\n@@ -263,22 +263,22 @@ function TodoSection({ todos }: { todos: TodoListProjection | null }) {\n }\n \n function TodoRow({ todo }: { todo: TodoProjection }) {\n- const { Icon, color, srLabel } = todoStatusVisual(todo.status);\n+ const { Icon, color, srLabel, spin } = todoStatusVisual(todo.status);\n const muted = todo.status === TodoStatus.COMPLETED;\n return (\n
  • \n- \n+ \n {todo.subject}\n
  • \n );\n }\n \n-function todoStatusVisual(status: TodoStatus): { Icon: IconType; color: string; srLabel: string } {\n+function todoStatusVisual(status: TodoStatus): { Icon: IconType; color: string; srLabel: string; spin?: boolean } {\n switch (status) {\n case TodoStatus.COMPLETED:\n return { Icon: CheckCircleIcon, color: \"text-mint\", srLabel: \"Completed\" };\n case TodoStatus.IN_PROGRESS:\n- return { Icon: ArrowPathIcon, color: \"text-teal-500\", srLabel: \"In progress\" };\n+ return { Icon: ArrowPathIcon, color: \"text-teal-500\", srLabel: \"In progress\", spin: true };\n case TodoStatus.DELETED:\n return { Icon: XCircleIcon, color: \"text-fg-muted\", srLabel: \"Deleted\" };\n case TodoStatus.PENDING:\ndiff --git a/apps/fabro-web/app/routes/runs.preferences.test.tsx b/apps/fabro-web/app/routes/runs.preferences.test.tsx\nindex 003e44ae1..d3ad1cc1f 100644\n--- a/apps/fabro-web/app/routes/runs.preferences.test.tsx\n+++ b/apps/fabro-web/app/routes/runs.preferences.test.tsx\n@@ -244,6 +244,28 @@ describe(\"Runs workspace preference restoration\", () => {\n expect(JSON.parse(storage.getItem(RUNS_PREFERENCES_STORAGE_KEY) ?? \"{}\").view).toBe(\"columns\");\n });\n \n+ test(\"clicking a sort header in list view updates the URL while preserving other params\", async () => {\n+ const { renderer, router } = await renderRuns(\"/runs?view=list&archived=1\");\n+\n+ await act(async () => {\n+ compositeByName(renderer, \"SortHeader\", (props) => props.sortKey === \"status\").props.onClick(\"status\");\n+ });\n+\n+ expect(router.state.location.search).toContain(\"sort=status\");\n+ expect(router.state.location.search).toContain(\"view=list\");\n+ expect(router.state.location.search).toContain(\"archived=1\");\n+\n+ // Clicking the same header again toggles direction to ascending.\n+ await act(async () => {\n+ compositeByName(renderer, \"SortHeader\", (props) => props.sortKey === \"status\").props.onClick(\"status\");\n+ });\n+\n+ expect(router.state.location.search).toContain(\"sort=status\");\n+ expect(router.state.location.search).toContain(\"direction=asc\");\n+ expect(router.state.location.search).toContain(\"view=list\");\n+ expect(router.state.location.search).toContain(\"archived=1\");\n+ });\n+\n test(\"changing filters and hidden columns persists them\", async () => {\n const { renderer } = await renderRuns(\"/runs?view=list\");\n \ndiff --git a/apps/fabro-web/app/routes/runs.test.tsx b/apps/fabro-web/app/routes/runs.test.tsx\nindex 85e9a4dd0..0cea3dca4 100644\n--- a/apps/fabro-web/app/routes/runs.test.tsx\n+++ b/apps/fabro-web/app/routes/runs.test.tsx\n@@ -5,7 +5,7 @@ import {\n buildBoardColumns,\n loadStoredRunsWorkspaceSearchParams,\n placeArchivedColumnLast,\n- persistRunsWorkspaceSearchParams,\n+ persistRunsWorkspacePreferences,\n RUNS_PREFERENCES_STORAGE_KEY,\n runsQuickStartCommands,\n shouldRefreshBoardForEvent,\n@@ -279,11 +279,24 @@ describe(\"runs route workspace preferences\", () => {\n \n test(\"persisting preferences omits page and stores canonical values\", () => {\n const storage = new MemoryStorage();\n- const params = new URLSearchParams(\n- \"view=columns&search=abc&created=1d&sort=made-up&direction=asc&size=100&page=9&hide=unknown,workflow,repo\",\n- );\n \n- persistRunsWorkspaceSearchParams(params, storage);\n+ persistRunsWorkspacePreferences(\n+ {\n+ version: 1,\n+ view: \"columns\",\n+ search: \"abc\",\n+ repo: \"all\",\n+ workflow: \"all\",\n+ created: \"1d\",\n+ archived: false,\n+ sort: \"created_at\",\n+ direction: \"asc\",\n+ size: 100,\n+ hide: \"repo,workflow\",\n+ page: 9,\n+ },\n+ storage,\n+ );\n \n expect(JSON.parse(storage.getItem(RUNS_PREFERENCES_STORAGE_KEY) ?? \"{}\")).toEqual({\n version: 1,\ndiff --git a/apps/fabro-web/app/routes/runs.tsx b/apps/fabro-web/app/routes/runs.tsx\nindex 7d5878b86..9f6a2aec3 100644\n--- a/apps/fabro-web/app/routes/runs.tsx\n+++ b/apps/fabro-web/app/routes/runs.tsx\n@@ -693,6 +693,8 @@ interface RunsWorkspacePreferences {\n direction: ListRunsDirectionEnum;\n size: number;\n hide: string;\n+ // URL-only: never persisted to localStorage.\n+ page: number;\n }\n \n function defaultRunsWorkspacePreferences(): RunsWorkspacePreferences {\n@@ -708,6 +710,7 @@ function defaultRunsWorkspacePreferences(): RunsWorkspacePreferences {\n direction: \"desc\",\n size: DEFAULT_LIST_PAGE_SIZE,\n hide: \"\",\n+ page: 1,\n };\n }\n \n@@ -754,6 +757,7 @@ function normalizeStoredRunsWorkspacePreferences(value: unknown): RunsWorkspaceP\n direction: parseDirection(stringValue(record.direction)),\n size: parsePageSize(typeof size === \"number\" || typeof size === \"string\" ? String(size) : null),\n hide: serializeHiddenColumns(hiddenColumns) ?? \"\",\n+ page: 1,\n };\n }\n \n@@ -770,6 +774,7 @@ function runsWorkspacePreferencesFromSearchParams(searchParams: URLSearchParams)\n direction: parseDirection(searchParams.get(\"direction\")),\n size: parsePageSize(searchParams.get(\"size\")),\n hide: serializeHiddenColumns(parseHiddenColumns(searchParams.get(\"hide\"))) ?? \"\",\n+ page: parsePage(searchParams.get(\"page\")),\n };\n }\n \n@@ -785,6 +790,7 @@ function runsWorkspacePreferencesToSearchParams(preferences: RunsWorkspacePrefer\n if (preferences.direction === \"asc\") params.set(\"direction\", \"asc\");\n if (preferences.size !== DEFAULT_LIST_PAGE_SIZE) params.set(\"size\", String(preferences.size));\n if (preferences.hide !== \"\") params.set(\"hide\", preferences.hide);\n+ if (preferences.page > 1) params.set(\"page\", String(preferences.page));\n return params;\n }\n \n@@ -821,16 +827,15 @@ export function resolveRunsWorkspaceSearchParams(\n return stored.toString() === \"\" ? urlSearchParams : stored;\n }\n \n-export function persistRunsWorkspaceSearchParams(\n- searchParams: URLSearchParams,\n+export function persistRunsWorkspacePreferences(\n+ preferences: RunsWorkspacePreferences,\n storage: Pick | null = runsPreferencesStorage(),\n ) {\n if (storage == null) return;\n+ // `page` is URL-only ephemeral view state; strip it before persisting.\n+ const { page: _page, ...storable } = preferences;\n try {\n- storage.setItem(\n- RUNS_PREFERENCES_STORAGE_KEY,\n- JSON.stringify(runsWorkspacePreferencesFromSearchParams(searchParams)),\n- );\n+ storage.setItem(RUNS_PREFERENCES_STORAGE_KEY, JSON.stringify(storable));\n } catch {\n // localStorage persistence is best effort only.\n }\n@@ -1821,55 +1826,59 @@ export default function Runs() {\n [searchParams],\n );\n \n- const updateParam = useCallback(\n- (key: string, value: string | null) => {\n- const next = new URLSearchParams(searchParams);\n- if (value == null || value === \"\") {\n- next.delete(key);\n- } else {\n- next.set(key, value);\n- }\n- persistRunsWorkspaceSearchParams(next);\n- setSearchParams(next, { replace: true });\n+ const updatePreferences = useCallback(\n+ (updater: (prev: RunsWorkspacePreferences) => RunsWorkspacePreferences) => {\n+ setSearchParams(\n+ (prevParams) => {\n+ const next = updater(runsWorkspacePreferencesFromSearchParams(prevParams));\n+ persistRunsWorkspacePreferences(next);\n+ return runsWorkspacePreferencesToSearchParams(next);\n+ },\n+ { replace: true },\n+ );\n },\n- [searchParams, setSearchParams],\n+ [setSearchParams],\n );\n \n- const setQuery = (value: string) => updateParam(\"search\", value || null);\n- const setRepoFilter = (value: string) => updateParam(\"repo\", value === \"all\" ? null : value);\n- const setWorkflowFilter = (value: string) => updateParam(\"workflow\", value === \"all\" ? null : value);\n- const setCreatedFilter = (value: CreatedFilter) => updateParam(\"created\", value === \"all\" ? null : value);\n- const setIncludeArchived = (value: boolean) => updateParam(\"archived\", value ? \"1\" : null);\n- const setView = (value: ViewMode) => updateParam(\"view\", value === \"columns\" ? null : value);\n+ const setQuery = (value: string) =>\n+ updatePreferences((prev) => ({ ...prev, search: value }));\n+ const setRepoFilter = (value: string) =>\n+ updatePreferences((prev) => ({ ...prev, repo: value }));\n+ const setWorkflowFilter = (value: string) =>\n+ updatePreferences((prev) => ({ ...prev, workflow: value }));\n+ const setCreatedFilter = (value: CreatedFilter) =>\n+ updatePreferences((prev) => ({ ...prev, created: value }));\n+ const setIncludeArchived = (value: boolean) =>\n+ updatePreferences((prev) => ({ ...prev, archived: value }));\n+ const setView = (value: ViewMode) =>\n+ updatePreferences((prev) => ({ ...prev, view: value }));\n const setPage = useCallback(\n- (next: number) => updateParam(\"page\", next > 1 ? String(next) : null),\n- [updateParam],\n+ (next: number) => updatePreferences((prev) => ({ ...prev, page: next })),\n+ [updatePreferences],\n );\n const setPageSize = useCallback(\n- (next: number) => {\n- updateParam(\"size\", next === DEFAULT_LIST_PAGE_SIZE ? null : String(next));\n- updateParam(\"page\", null);\n- },\n- [updateParam],\n+ (next: number) => updatePreferences((prev) => ({ ...prev, size: next, page: 1 })),\n+ [updatePreferences],\n );\n const setHiddenColumns = useCallback(\n- (next: Set) => updateParam(\"hide\", serializeHiddenColumns(next)),\n- [updateParam],\n+ (next: Set) =>\n+ updatePreferences((prev) => ({ ...prev, hide: serializeHiddenColumns(next) ?? \"\" })),\n+ [updatePreferences],\n );\n const handleSortClick = useCallback(\n- (key: ListRunsSortEnum) => {\n- if (sort === key) {\n- updateParam(\"direction\", direction === \"asc\" ? null : \"asc\");\n- } else {\n- updateParam(\"sort\", key === \"created_at\" ? null : key);\n- updateParam(\"direction\", null);\n- }\n- updateParam(\"page\", null);\n- },\n- [sort, direction, updateParam],\n+ (key: ListRunsSortEnum) =>\n+ updatePreferences((prev) =>\n+ prev.sort === key\n+ ? { ...prev, direction: prev.direction === \"asc\" ? \"desc\" : \"asc\", page: 1 }\n+ : { ...prev, sort: key, direction: \"desc\", page: 1 },\n+ ),\n+ [updatePreferences],\n );\n \n+ const hydratedFromStorage = useRef(false);\n useEffect(() => {\n+ if (hydratedFromStorage.current) return;\n+ hydratedFromStorage.current = true;\n if (searchParams === urlSearchParams) return;\n setSearchParams(searchParams, { replace: true });\n }, [searchParams, urlSearchParams, setSearchParams]);\ndiff --git a/lib/crates/fabro-agent/src/session.rs b/lib/crates/fabro-agent/src/session.rs\nindex f6921361b..9c3a5e4d9 100644\n--- a/lib/crates/fabro-agent/src/session.rs\n+++ b/lib/crates/fabro-agent/src/session.rs\n@@ -592,7 +592,7 @@ impl Session {\n } else {\n let skills_dir = fabro_util::Home::from_env().skills_dir();\n let skills_str = skills_dir.to_string_lossy().to_string();\n- default_skill_dirs(Some(&skills_str), self.config.git_root.as_deref())\n+ default_skill_dirs(Some(&skills_str), Some(&doc_root))\n };\n self.skills = discover_skills(self.sandbox.as_ref(), &skill_dirs, &cancel_token).await?;\n debug!(skill_count = self.skills.len(), \"Skills discovered\");\ndiff --git a/lib/crates/fabro-server/tests/it/scenario/run_completion.rs b/lib/crates/fabro-server/tests/it/scenario/run_completion.rs\nindex fa0f6a4a7..4dfec9f30 100644\n--- a/lib/crates/fabro-server/tests/it/scenario/run_completion.rs\n+++ b/lib/crates/fabro-server/tests/it/scenario/run_completion.rs\n@@ -1,14 +1,83 @@\n+use std::sync::Arc;\n+\n use axum::body::Body;\n use axum::http::{Request, StatusCode};\n+use fabro_auth::EnvCredentialSource;\n+use fabro_model::{Catalog, ProviderId};\n+use fabro_test::{TwinScenario, TwinScenarios, twin_openai};\n+use fabro_types::RunId;\n use tokio::time::sleep;\n use tower::ServiceExt;\n \n use crate::helpers::{\n- MINIMAL_DOT, api, checked_response, create_and_start_run_from_manifest,\n+ MINIMAL_DOT, api, checked_response, create_and_start_run_from_manifest, minimal_manifest_json,\n minimal_manifest_json_with_dry_run, response_text, test_app_state_with_options,\n test_app_with_scheduler, test_settings, wait_for_run_status,\n };\n \n+const OPENAI_AGENT_MODEL: &str = \"gpt-5.4\";\n+\n+const PROJECT_SKILL_AGENT_DOT: &str = r#\"digraph ProjectSkillAgent {\n+ graph [goal=\"Verify project skills are visible to agent runs\"]\n+ rankdir=LR\n+\n+ start [shape=Mdiamond, label=\"Start\"]\n+ exit [shape=Msquare, label=\"Exit\"]\n+\n+ work [shape=box, label=\"Work\", prompt=\"Respond with done.\"]\n+\n+ start -> work -> exit\n+}\"#;\n+\n+fn test_app_with_openai_agent_backend(openai_base_url: String, api_key: String) -> axum::Router {\n+ let settings = test_settings();\n+ let llm_catalog_settings =\n+ fabro_server::test_support::llm_catalog_settings_with_provider_base_url(\n+ \"openai\",\n+ openai_base_url,\n+ );\n+ let catalog = Arc::new(\n+ Catalog::from_builtin_with_overrides(&llm_catalog_settings)\n+ .expect(\"test catalog should build\"),\n+ );\n+ let source_api_key = api_key.clone();\n+ let env_api_key = api_key;\n+ let llm_source: Arc = Arc::new(\n+ EnvCredentialSource::with_env_lookup(Arc::new(move |name| match name {\n+ \"OPENAI_API_KEY\" => Some(source_api_key.clone()),\n+ _ => None,\n+ })),\n+ );\n+ let state = fabro_server::test_support::TestAppStateBuilder::new()\n+ .runtime_settings(settings.server_settings, settings.manifest_run_defaults)\n+ .max_concurrent_runs(5)\n+ .llm_catalog_settings(llm_catalog_settings)\n+ .registry_factory(move |interviewer| {\n+ let catalog = Arc::clone(&catalog);\n+ let llm_source = Arc::clone(&llm_source);\n+ let emitter = Arc::new(fabro_workflow::event::Emitter::new(RunId::new()));\n+ let steering_hub = Arc::new(fabro_workflow::SteeringHub::new(emitter));\n+ fabro_workflow::handler::default_registry(interviewer, move || {\n+ Some(Box::new(\n+ fabro_workflow::handler::llm::AgentApiBackend::new_with_catalog(\n+ OPENAI_AGENT_MODEL.to_string(),\n+ ProviderId::openai(),\n+ Vec::new(),\n+ Arc::clone(&llm_source),\n+ Arc::clone(&steering_hub),\n+ Arc::clone(&catalog),\n+ ),\n+ ))\n+ })\n+ })\n+ .env_lookup(move |name| match name {\n+ \"OPENAI_API_KEY\" => Some(env_api_key.clone()),\n+ _ => None,\n+ })\n+ .build();\n+ test_app_with_scheduler(state)\n+}\n+\n #[tokio::test(flavor = \"multi_thread\", worker_threads = 2)]\n async fn run_completes_and_status_is_completed() {\n let state = test_app_state_with_options(test_settings(), 5);\n@@ -22,6 +91,62 @@ async fn run_completes_and_status_is_completed() {\n assert_eq!(status, \"succeeded\");\n }\n \n+#[tokio::test(flavor = \"multi_thread\", worker_threads = 2)]\n+async fn agent_run_includes_project_skills_from_local_sandbox_working_directory() {\n+ let project = tempfile::tempdir().expect(\"project tempdir should create\");\n+ let skill_dir = project\n+ .path()\n+ .join(\".fabro\")\n+ .join(\"skills\")\n+ .join(\"local-server-project-skill\");\n+ tokio::fs::create_dir_all(&skill_dir)\n+ .await\n+ .expect(\"project skill dir should create\");\n+ tokio::fs::write(\n+ skill_dir.join(\"SKILL.md\"),\n+ \"---\\nname: local-server-project-skill\\ndescription: Project-only skill\\n---\\nUse the project skill.\\n\",\n+ )\n+ .await\n+ .expect(\"project skill should write\");\n+\n+ let twin = twin_openai().await;\n+ let namespace = format!(\"{}::{}\", module_path!(), line!());\n+ TwinScenarios::new(&namespace)\n+ .scenario(\n+ TwinScenario::responses(OPENAI_AGENT_MODEL)\n+ .stream(true)\n+ .text(\"Done\"),\n+ )\n+ .load(twin)\n+ .await;\n+ let app = test_app_with_openai_agent_backend(twin.base_url.clone(), namespace.clone());\n+\n+ let mut manifest = minimal_manifest_json(PROJECT_SKILL_AGENT_DOT);\n+ manifest[\"title\"] = serde_json::Value::String(\"Project skill agent\".to_string());\n+ manifest[\"cwd\"] = serde_json::Value::String(project.path().display().to_string());\n+ let run_id = create_and_start_run_from_manifest(&app, manifest).await;\n+\n+ let status = wait_for_run_status(&app, &run_id, &[\"succeeded\", \"failed\"]).await;\n+ assert_eq!(status, \"succeeded\");\n+ let logs = twin.request_logs(&namespace).await;\n+ let requests = logs[\"requests\"]\n+ .as_array()\n+ .expect(\"twin-openai request logs should be an array\");\n+ let instructions = requests\n+ .iter()\n+ .find(|request| request[\"model\"] == OPENAI_AGENT_MODEL)\n+ .and_then(|request| request[\"instructions_text\"].as_str())\n+ .unwrap_or_default();\n+ assert!(\n+ instructions.contains(\"local-server-project-skill\"),\n+ \"expected project skill name in OpenAI instructions, got logs: {logs}\"\n+ );\n+ assert!(\n+ instructions.contains(\"Project-only skill\"),\n+ \"expected project skill description in OpenAI instructions, got logs: {logs}\"\n+ );\n+}\n+\n #[tokio::test(flavor = \"multi_thread\", worker_threads = 2)]\n async fn attach_run_events_returns_sse_stream() {\n let state = test_app_state_with_options(test_settings(), 5);\ndiff --git a/lib/crates/fabro-test/src/lib.rs b/lib/crates/fabro-test/src/lib.rs\nindex 9dd283d68..48c6ef4fc 100644\n--- a/lib/crates/fabro-test/src/lib.rs\n+++ b/lib/crates/fabro-test/src/lib.rs\n@@ -2067,6 +2067,22 @@ impl TwinOpenAi {\n .expect(\"reset twin-openai namespace\");\n assert_reqwest_status(response, fabro_http::StatusCode::OK, \"POST /__admin/reset\").await;\n }\n+\n+ pub async fn request_logs(&self, namespace: &str) -> serde_json::Value {\n+ let response = test_http_client()\n+ .get(format!(\"{}/__admin/requests\", self.admin_url()))\n+ .bearer_auth(namespace)\n+ .send()\n+ .await\n+ .expect(\"fetch twin-openai request logs\");\n+ let response = expect_reqwest_status(\n+ response,\n+ fabro_http::StatusCode::OK,\n+ \"GET /__admin/requests\",\n+ )\n+ .await;\n+ response.json().await.expect(\"request logs should be JSON\")\n+ }\n }\n \n #[derive(Debug, Default, Clone)]\ndiff --git a/test/twin/openai/src/engine/mod.rs b/test/twin/openai/src/engine/mod.rs\nindex 56359ecd6..867735baa 100644\n--- a/test/twin/openai/src/engine/mod.rs\n+++ b/test/twin/openai/src/engine/mod.rs\n@@ -19,11 +19,12 @@ pub fn execute_responses_request(\n ) -> Result {\n request.validate()?;\n let context = RequestContext {\n- endpoint: \"responses\".to_owned(),\n- model: request.model.clone(),\n- stream: request.stream,\n- metadata: request.metadata.clone(),\n- input_text: request.extract_user_text(),\n+ endpoint: \"responses\".to_owned(),\n+ model: request.model.clone(),\n+ stream: request.stream,\n+ metadata: request.metadata.clone(),\n+ input_text: request.extract_user_text(),\n+ instructions_text: request.extract_instruction_text(),\n };\n state.log_request(namespace, context.clone());\n \n@@ -52,11 +53,12 @@ pub fn execute_chat_request(\n ) -> Result {\n request.validate()?;\n let context = RequestContext {\n- endpoint: \"chat.completions\".to_owned(),\n- model: request.model.clone(),\n- stream: request.stream,\n- metadata: serde_json::Map::new(),\n- input_text: request.extract_user_text(),\n+ endpoint: \"chat.completions\".to_owned(),\n+ model: request.model.clone(),\n+ stream: request.stream,\n+ metadata: serde_json::Map::new(),\n+ input_text: request.extract_user_text(),\n+ instructions_text: request.extract_instruction_text(),\n };\n state.log_request(namespace, context.clone());\n \ndiff --git a/test/twin/openai/src/engine/scenario.rs b/test/twin/openai/src/engine/scenario.rs\nindex 59d8fd642..ee7ff7377 100644\n--- a/test/twin/openai/src/engine/scenario.rs\n+++ b/test/twin/openai/src/engine/scenario.rs\n@@ -62,11 +62,12 @@ pub struct ToolCallTemplate {\n \n #[derive(Clone, Debug)]\n pub struct RequestContext {\n- pub endpoint: String,\n- pub model: String,\n- pub stream: bool,\n- pub metadata: Map,\n- pub input_text: String,\n+ pub endpoint: String,\n+ pub model: String,\n+ pub stream: bool,\n+ pub metadata: Map,\n+ pub input_text: String,\n+ pub instructions_text: String,\n }\n \n impl ScenarioScript {\ndiff --git a/test/twin/openai/src/logs.rs b/test/twin/openai/src/logs.rs\nindex 1edc6cd8b..0fce6eafd 100644\n--- a/test/twin/openai/src/logs.rs\n+++ b/test/twin/openai/src/logs.rs\n@@ -3,9 +3,10 @@ use serde_json::{Map, Value};\n \n #[derive(Clone, Debug, Serialize)]\n pub struct RequestLog {\n- pub endpoint: String,\n- pub model: String,\n- pub stream: bool,\n- pub input_text: String,\n- pub metadata: Map,\n+ pub endpoint: String,\n+ pub model: String,\n+ pub stream: bool,\n+ pub input_text: String,\n+ pub instructions_text: String,\n+ pub metadata: Map,\n }\ndiff --git a/test/twin/openai/src/openai/models.rs b/test/twin/openai/src/openai/models.rs\nindex 42fa186f5..bd7996891 100644\n--- a/test/twin/openai/src/openai/models.rs\n+++ b/test/twin/openai/src/openai/models.rs\n@@ -11,6 +11,7 @@ pub struct ResponsesRequest {\n pub model: String,\n #[serde(default)]\n pub input: ResponseInput,\n+ pub instructions: Option,\n #[serde(default)]\n pub stream: bool,\n #[serde(default)]\n@@ -40,6 +41,13 @@ impl ResponsesRequest {\n }\n }\n \n+ pub fn extract_instruction_text(&self) -> String {\n+ self.instructions\n+ .as_deref()\n+ .map(normalize_whitespace)\n+ .unwrap_or_default()\n+ }\n+\n pub fn response_format(&self) -> Option {\n let format = self.text.as_ref()?.format.as_ref()?;\n response_format_from_kind(\n@@ -476,6 +484,16 @@ impl ChatCompletionsRequest {\n }\n }\n \n+ pub fn extract_instruction_text(&self) -> String {\n+ let pieces: Vec = self\n+ .messages\n+ .iter()\n+ .filter(|message| message.role == \"system\" || message.role == \"developer\")\n+ .flat_map(ChatMessage::extract_texts)\n+ .collect();\n+ normalize_whitespace(&pieces.join(\" \"))\n+ }\n+\n pub fn response_format(&self) -> Option {\n let format = self.response_format.as_ref()?;\n response_format_from_kind(\n@@ -721,18 +739,26 @@ fn validate_tools(\n return Err(OpenAiError::invalid_request(param, \"tool type is required\"));\n };\n \n- if tool_type != \"function\" {\n- return Err(OpenAiError::invalid_request(\n- param,\n- \"only function tools are supported\",\n- ));\n- }\n-\n- if function_tool_name(tool, surface).is_none() {\n- return Err(OpenAiError::invalid_request(\n- param,\n- \"function tool name is required\",\n- ));\n+ match tool_type {\n+ \"function\" => {\n+ if function_tool_name(tool, surface).is_none() {\n+ return Err(OpenAiError::invalid_request(\n+ param,\n+ \"function tool name is required\",\n+ ));\n+ }\n+ }\n+ \"custom\" if surface == ToolSurface::Responses => {\n+ if function_tool_name(tool, surface).is_none() {\n+ return Err(OpenAiError::invalid_request(\n+ param,\n+ \"custom tool name is required\",\n+ ));\n+ }\n+ }\n+ _ => {\n+ return Err(OpenAiError::invalid_request(param, \"unsupported tool type\"));\n+ }\n }\n }\n \ndiff --git a/test/twin/openai/src/state.rs b/test/twin/openai/src/state.rs\nindex d456ab6c5..29cece646 100644\n--- a/test/twin/openai/src/state.rs\n+++ b/test/twin/openai/src/state.rs\n@@ -125,11 +125,12 @@ impl AppState {\n .or_default()\n .request_logs\n .push(RequestLog {\n- endpoint: request.endpoint,\n- model: request.model,\n- stream: request.stream,\n- input_text: request.input_text,\n- metadata: request.metadata,\n+ endpoint: request.endpoint,\n+ model: request.model,\n+ stream: request.stream,\n+ input_text: request.input_text,\n+ instructions_text: request.instructions_text,\n+ metadata: request.metadata,\n });\n }\n \n", + "summary": { + "files_changed": 43, + "additions": 1493, + "deletions": 169 + } + } } ], - "conclusion": null, + "conclusion": { + "timestamp": "2026-05-24T18:09:48.070960Z", + "status": "succeeded", + "timing": { + "wall_time_ms": 3397906, + "inference_time_ms": 0, + "tool_time_ms": 0, + "active_time_ms": 0 + }, + "final_git_commit_sha": "bca30391ce0ac99cc2e4ea38e9f74ca1725b85d9", + "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": 1338, + "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": 120854, + "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": 135071, + "inference_time_ms": 0, + "tool_time_ms": 0, + "active_time_ms": 0 + }, + "retries": 0 + }, + { + "stage_id": "implement", + "stage_label": "implement", + "timing": { + "wall_time_ms": 1374106, + "inference_time_ms": 0, + "tool_time_ms": 0, + "active_time_ms": 0 + }, + "billing_usd_micros": 42637434, + "retries": 0 + }, + { + "stage_id": "simplify_opus", + "stage_label": "simplify_opus", + "timing": { + "wall_time_ms": 1001385, + "inference_time_ms": 0, + "tool_time_ms": 0, + "active_time_ms": 0 + }, + "billing_usd_micros": 10621308, + "retries": 0 + }, + { + "stage_id": "simplify_gpt", + "stage_label": "simplify_gpt", + "timing": { + "wall_time_ms": 223542, + "inference_time_ms": 0, + "tool_time_ms": 0, + "active_time_ms": 0 + }, + "billing_usd_micros": 5492538, + "retries": 0 + }, + { + "stage_id": "verify", + "stage_label": "verify", + "timing": { + "wall_time_ms": 513906, + "inference_time_ms": 0, + "tool_time_ms": 0, + "active_time_ms": 0 + }, + "retries": 0 + } + ], + "billing": { + "input_tokens": 7833405, + "output_tokens": 61033, + "total_tokens": 34562016, + "reasoning_tokens": 13703, + "cache_read_tokens": 25925164, + "cache_write_tokens": 728711, + "total_usd_micros": 58751280 + }, + "total_retries": 0, + "diff": {} + }, "sandbox": { "provider": "daytona", "snapshot": "fabro-v12", @@ -1785,23 +1907,30 @@ }, "state": "succeeded" }, - "verify@1": { - "first_event_seq": 2700, + "exit@1": { + "first_event_seq": 2710, "prompt": null, "response": null, - "completion": null, + "completion": { + "outcome": "succeeded", + "notes": null, + "failure_reason": null, + "timestamp": "2026-05-24T18:09:48.016057Z" + }, "provider_used": null, "diff": null, - "script_invocation": { - "script": "git fetch origin main 2>&1 && git merge --no-edit --no-stat origin/main 2>&1 && cargo +nightly-2026-04-14 fmt --all 2>&1 && cargo dev docs refresh 2>&1 && cargo +nightly-2026-04-14 fmt --check --all 2>&1 && { command -v rg >/dev/null 2>&1 || { echo 'rg is required for verify'; exit 127; }; } && ! rg -n 'AuthMode::Disabled|RunAuthMethod|RunSubjectProvenance|\\bActorRef\\b|\\bActorKind\\b|AuthenticatedSubject|AuthenticatedService|AuthorizeRunScoped|AuthorizeRunBlob|AuthorizeStageArtifact|AuthorizeCommandLog|auth_method\\s*==\\s*\"disabled\"' lib/crates apps lib/packages docs/public/api-reference/fabro-api.yaml 2>&1 && cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --workspace --status-level slow --profile ci 2>&1 && cargo dev docs check 2>&1 && bun install --frozen-lockfile 2>&1 && (cd apps/fabro-web && bun run typecheck) 2>&1 && (cd apps/fabro-web && bun run test) 2>&1 && (cd lib/packages/fabro-api-client && bun run typecheck) 2>&1 && cargo dev build -- -p fabro-cli --release 2>&1", - "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 && { command -v rg >/dev/null 2>&1 || { echo 'rg is required for verify'; exit 127; }; } && ! rg -n 'AuthMode::Disabled|RunAuthMethod|RunSubjectProvenance|\\bActorRef\\b|\\bActorKind\\b|AuthenticatedSubject|AuthenticatedService|AuthorizeRunScoped|AuthorizeRunBlob|AuthorizeStageArtifact|AuthorizeCommandLog|auth_method\\s*==\\s*\"disabled\"' lib/crates apps lib/packages docs/public/api-reference/fabro-api.yaml 2>&1 && cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --workspace --status-level slow --profile ci 2>&1 && cargo dev docs check 2>&1 && bun install --frozen-lockfile 2>&1 && (cd apps/fabro-web && bun run typecheck) 2>&1 && (cd apps/fabro-web && bun run test) 2>&1 && (cd lib/packages/fabro-api-client && bun run typecheck) 2>&1 && cargo dev build -- -p fabro-cli --release 2>&1", - "language": "shell" - }, + "script_invocation": null, "script_timing": null, "parallel_results": null, "output": null, - "started_at": "2026-05-24T18:01:10.177355Z", - "handler": "command", + "started_at": "2026-05-24T18:09:48.016037Z", + "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, @@ -1810,7 +1939,55 @@ "cache_read_tokens": 0, "cache_write_tokens": 0 }, - "state": "running" + "state": "succeeded" + }, + "verify@1": { + "first_event_seq": 2700, + "prompt": null, + "response": 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 && { command -v rg >/dev/null 2>&1 || { echo 'rg is required for verify'; exit 127; }; } && ! rg -n 'AuthMode::Disabled|RunAuthMethod|RunSubjectProvenance|\\bActorRef\\b|\\bActorKind\\b|AuthenticatedSubject|AuthenticatedService|AuthorizeRunScoped|AuthorizeRunBlob|AuthorizeStageArtifact|AuthorizeCommandLog|auth_method\\s*==\\s*\"disabled\"' lib/crates apps lib/packages docs/public/api-reference/fabro-api.yaml 2>&1 && cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --workspace --status-level slow --profile ci 2>&1 && cargo dev docs check 2>&1 && bun install --frozen-lockfile 2>&1 && (cd apps/fabro-web && bun run typecheck) 2>&1 && (cd apps/fabro-web && bun run test) 2>&1 && (cd lib/packages/fabro-api-client && bun run typecheck) 2>&1 && cargo dev build -- -p fabro-cli --release 2>&1", + "failure_reason": null, + "timestamp": "2026-05-24T18:09:44.085765Z" + }, + "provider_used": null, + "diff": null, + "script_invocation": { + "script": "git fetch origin main 2>&1 && git merge --no-edit --no-stat origin/main 2>&1 && cargo +nightly-2026-04-14 fmt --all 2>&1 && cargo dev docs refresh 2>&1 && cargo +nightly-2026-04-14 fmt --check --all 2>&1 && { command -v rg >/dev/null 2>&1 || { echo 'rg is required for verify'; exit 127; }; } && ! rg -n 'AuthMode::Disabled|RunAuthMethod|RunSubjectProvenance|\\bActorRef\\b|\\bActorKind\\b|AuthenticatedSubject|AuthenticatedService|AuthorizeRunScoped|AuthorizeRunBlob|AuthorizeStageArtifact|AuthorizeCommandLog|auth_method\\s*==\\s*\"disabled\"' lib/crates apps lib/packages docs/public/api-reference/fabro-api.yaml 2>&1 && cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --workspace --status-level slow --profile ci 2>&1 && cargo dev docs check 2>&1 && bun install --frozen-lockfile 2>&1 && (cd apps/fabro-web && bun run typecheck) 2>&1 && (cd apps/fabro-web && bun run test) 2>&1 && (cd lib/packages/fabro-api-client && bun run typecheck) 2>&1 && cargo dev build -- -p fabro-cli --release 2>&1", + "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 && { command -v rg >/dev/null 2>&1 || { echo 'rg is required for verify'; exit 127; }; } && ! rg -n 'AuthMode::Disabled|RunAuthMethod|RunSubjectProvenance|\\bActorRef\\b|\\bActorKind\\b|AuthenticatedSubject|AuthenticatedService|AuthorizeRunScoped|AuthorizeRunBlob|AuthorizeStageArtifact|AuthorizeCommandLog|auth_method\\s*==\\s*\"disabled\"' lib/crates apps lib/packages docs/public/api-reference/fabro-api.yaml 2>&1 && cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --workspace --status-level slow --profile ci 2>&1 && cargo dev docs check 2>&1 && bun install --frozen-lockfile 2>&1 && (cd apps/fabro-web && bun run typecheck) 2>&1 && (cd apps/fabro-web && bun run test) 2>&1 && (cd lib/packages/fabro-api-client && bun run typecheck) 2>&1 && cargo dev build -- -p fabro-cli --release 2>&1", + "language": "shell" + }, + "script_timing": { + "output": "blob://sha256/4a229e77c774df9e4587431eec40dc119a6d55f115aaf1205d37c54bc78d1c8f", + "exit_code": 0, + "duration_ms": 513889, + "termination": "exited", + "output_bytes": 207579, + "live_streaming": true + }, + "parallel_results": null, + "output": null, + "output_bytes": 207579, + "live_streaming": true, + "termination": "exited", + "started_at": "2026-05-24T18:01:10.177355Z", + "handler": "command", + "timing": { + "wall_time_ms": 513906, + "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" }, "preflight_compile@1": { "first_event_seq": 32, diff --git a/stages/008-verify@1/diff.patch b/stages/008-verify@1/diff.patch new file mode 100644 index 000000000..837f438be --- /dev/null +++ b/stages/008-verify@1/diff.patch @@ -0,0 +1,647 @@ +diff --git a/apps/fabro-web/app/components/stage-insights-sidebar.tsx b/apps/fabro-web/app/components/stage-insights-sidebar.tsx +index 1b534c181..cc6e496a0 100644 +--- a/apps/fabro-web/app/components/stage-insights-sidebar.tsx ++++ b/apps/fabro-web/app/components/stage-insights-sidebar.tsx +@@ -263,22 +263,22 @@ function TodoSection({ todos }: { todos: TodoListProjection | null }) { + } + + function TodoRow({ todo }: { todo: TodoProjection }) { +- const { Icon, color, srLabel } = todoStatusVisual(todo.status); ++ const { Icon, color, srLabel, spin } = todoStatusVisual(todo.status); + const muted = todo.status === TodoStatus.COMPLETED; + return ( +
  • +- ++ + {todo.subject} +
  • + ); + } + +-function todoStatusVisual(status: TodoStatus): { Icon: IconType; color: string; srLabel: string } { ++function todoStatusVisual(status: TodoStatus): { Icon: IconType; color: string; srLabel: string; spin?: boolean } { + switch (status) { + case TodoStatus.COMPLETED: + return { Icon: CheckCircleIcon, color: "text-mint", srLabel: "Completed" }; + case TodoStatus.IN_PROGRESS: +- return { Icon: ArrowPathIcon, color: "text-teal-500", srLabel: "In progress" }; ++ return { Icon: ArrowPathIcon, color: "text-teal-500", srLabel: "In progress", spin: true }; + case TodoStatus.DELETED: + return { Icon: XCircleIcon, color: "text-fg-muted", srLabel: "Deleted" }; + case TodoStatus.PENDING: +diff --git a/apps/fabro-web/app/routes/runs.preferences.test.tsx b/apps/fabro-web/app/routes/runs.preferences.test.tsx +index 003e44ae1..d3ad1cc1f 100644 +--- a/apps/fabro-web/app/routes/runs.preferences.test.tsx ++++ b/apps/fabro-web/app/routes/runs.preferences.test.tsx +@@ -244,6 +244,28 @@ describe("Runs workspace preference restoration", () => { + expect(JSON.parse(storage.getItem(RUNS_PREFERENCES_STORAGE_KEY) ?? "{}").view).toBe("columns"); + }); + ++ test("clicking a sort header in list view updates the URL while preserving other params", async () => { ++ const { renderer, router } = await renderRuns("/runs?view=list&archived=1"); ++ ++ await act(async () => { ++ compositeByName(renderer, "SortHeader", (props) => props.sortKey === "status").props.onClick("status"); ++ }); ++ ++ expect(router.state.location.search).toContain("sort=status"); ++ expect(router.state.location.search).toContain("view=list"); ++ expect(router.state.location.search).toContain("archived=1"); ++ ++ // Clicking the same header again toggles direction to ascending. ++ await act(async () => { ++ compositeByName(renderer, "SortHeader", (props) => props.sortKey === "status").props.onClick("status"); ++ }); ++ ++ expect(router.state.location.search).toContain("sort=status"); ++ expect(router.state.location.search).toContain("direction=asc"); ++ expect(router.state.location.search).toContain("view=list"); ++ expect(router.state.location.search).toContain("archived=1"); ++ }); ++ + test("changing filters and hidden columns persists them", async () => { + const { renderer } = await renderRuns("/runs?view=list"); + +diff --git a/apps/fabro-web/app/routes/runs.test.tsx b/apps/fabro-web/app/routes/runs.test.tsx +index 85e9a4dd0..0cea3dca4 100644 +--- a/apps/fabro-web/app/routes/runs.test.tsx ++++ b/apps/fabro-web/app/routes/runs.test.tsx +@@ -5,7 +5,7 @@ import { + buildBoardColumns, + loadStoredRunsWorkspaceSearchParams, + placeArchivedColumnLast, +- persistRunsWorkspaceSearchParams, ++ persistRunsWorkspacePreferences, + RUNS_PREFERENCES_STORAGE_KEY, + runsQuickStartCommands, + shouldRefreshBoardForEvent, +@@ -279,11 +279,24 @@ describe("runs route workspace preferences", () => { + + test("persisting preferences omits page and stores canonical values", () => { + const storage = new MemoryStorage(); +- const params = new URLSearchParams( +- "view=columns&search=abc&created=1d&sort=made-up&direction=asc&size=100&page=9&hide=unknown,workflow,repo", +- ); + +- persistRunsWorkspaceSearchParams(params, storage); ++ persistRunsWorkspacePreferences( ++ { ++ version: 1, ++ view: "columns", ++ search: "abc", ++ repo: "all", ++ workflow: "all", ++ created: "1d", ++ archived: false, ++ sort: "created_at", ++ direction: "asc", ++ size: 100, ++ hide: "repo,workflow", ++ page: 9, ++ }, ++ storage, ++ ); + + expect(JSON.parse(storage.getItem(RUNS_PREFERENCES_STORAGE_KEY) ?? "{}")).toEqual({ + version: 1, +diff --git a/apps/fabro-web/app/routes/runs.tsx b/apps/fabro-web/app/routes/runs.tsx +index 7d5878b86..9f6a2aec3 100644 +--- a/apps/fabro-web/app/routes/runs.tsx ++++ b/apps/fabro-web/app/routes/runs.tsx +@@ -693,6 +693,8 @@ interface RunsWorkspacePreferences { + direction: ListRunsDirectionEnum; + size: number; + hide: string; ++ // URL-only: never persisted to localStorage. ++ page: number; + } + + function defaultRunsWorkspacePreferences(): RunsWorkspacePreferences { +@@ -708,6 +710,7 @@ function defaultRunsWorkspacePreferences(): RunsWorkspacePreferences { + direction: "desc", + size: DEFAULT_LIST_PAGE_SIZE, + hide: "", ++ page: 1, + }; + } + +@@ -754,6 +757,7 @@ function normalizeStoredRunsWorkspacePreferences(value: unknown): RunsWorkspaceP + direction: parseDirection(stringValue(record.direction)), + size: parsePageSize(typeof size === "number" || typeof size === "string" ? String(size) : null), + hide: serializeHiddenColumns(hiddenColumns) ?? "", ++ page: 1, + }; + } + +@@ -770,6 +774,7 @@ function runsWorkspacePreferencesFromSearchParams(searchParams: URLSearchParams) + direction: parseDirection(searchParams.get("direction")), + size: parsePageSize(searchParams.get("size")), + hide: serializeHiddenColumns(parseHiddenColumns(searchParams.get("hide"))) ?? "", ++ page: parsePage(searchParams.get("page")), + }; + } + +@@ -785,6 +790,7 @@ function runsWorkspacePreferencesToSearchParams(preferences: RunsWorkspacePrefer + if (preferences.direction === "asc") params.set("direction", "asc"); + if (preferences.size !== DEFAULT_LIST_PAGE_SIZE) params.set("size", String(preferences.size)); + if (preferences.hide !== "") params.set("hide", preferences.hide); ++ if (preferences.page > 1) params.set("page", String(preferences.page)); + return params; + } + +@@ -821,16 +827,15 @@ export function resolveRunsWorkspaceSearchParams( + return stored.toString() === "" ? urlSearchParams : stored; + } + +-export function persistRunsWorkspaceSearchParams( +- searchParams: URLSearchParams, ++export function persistRunsWorkspacePreferences( ++ preferences: RunsWorkspacePreferences, + storage: Pick | null = runsPreferencesStorage(), + ) { + if (storage == null) return; ++ // `page` is URL-only ephemeral view state; strip it before persisting. ++ const { page: _page, ...storable } = preferences; + try { +- storage.setItem( +- RUNS_PREFERENCES_STORAGE_KEY, +- JSON.stringify(runsWorkspacePreferencesFromSearchParams(searchParams)), +- ); ++ storage.setItem(RUNS_PREFERENCES_STORAGE_KEY, JSON.stringify(storable)); + } catch { + // localStorage persistence is best effort only. + } +@@ -1821,55 +1826,59 @@ export default function Runs() { + [searchParams], + ); + +- const updateParam = useCallback( +- (key: string, value: string | null) => { +- const next = new URLSearchParams(searchParams); +- if (value == null || value === "") { +- next.delete(key); +- } else { +- next.set(key, value); +- } +- persistRunsWorkspaceSearchParams(next); +- setSearchParams(next, { replace: true }); ++ const updatePreferences = useCallback( ++ (updater: (prev: RunsWorkspacePreferences) => RunsWorkspacePreferences) => { ++ setSearchParams( ++ (prevParams) => { ++ const next = updater(runsWorkspacePreferencesFromSearchParams(prevParams)); ++ persistRunsWorkspacePreferences(next); ++ return runsWorkspacePreferencesToSearchParams(next); ++ }, ++ { replace: true }, ++ ); + }, +- [searchParams, setSearchParams], ++ [setSearchParams], + ); + +- const setQuery = (value: string) => updateParam("search", value || null); +- const setRepoFilter = (value: string) => updateParam("repo", value === "all" ? null : value); +- const setWorkflowFilter = (value: string) => updateParam("workflow", value === "all" ? null : value); +- const setCreatedFilter = (value: CreatedFilter) => updateParam("created", value === "all" ? null : value); +- const setIncludeArchived = (value: boolean) => updateParam("archived", value ? "1" : null); +- const setView = (value: ViewMode) => updateParam("view", value === "columns" ? null : value); ++ const setQuery = (value: string) => ++ updatePreferences((prev) => ({ ...prev, search: value })); ++ const setRepoFilter = (value: string) => ++ updatePreferences((prev) => ({ ...prev, repo: value })); ++ const setWorkflowFilter = (value: string) => ++ updatePreferences((prev) => ({ ...prev, workflow: value })); ++ const setCreatedFilter = (value: CreatedFilter) => ++ updatePreferences((prev) => ({ ...prev, created: value })); ++ const setIncludeArchived = (value: boolean) => ++ updatePreferences((prev) => ({ ...prev, archived: value })); ++ const setView = (value: ViewMode) => ++ updatePreferences((prev) => ({ ...prev, view: value })); + const setPage = useCallback( +- (next: number) => updateParam("page", next > 1 ? String(next) : null), +- [updateParam], ++ (next: number) => updatePreferences((prev) => ({ ...prev, page: next })), ++ [updatePreferences], + ); + const setPageSize = useCallback( +- (next: number) => { +- updateParam("size", next === DEFAULT_LIST_PAGE_SIZE ? null : String(next)); +- updateParam("page", null); +- }, +- [updateParam], ++ (next: number) => updatePreferences((prev) => ({ ...prev, size: next, page: 1 })), ++ [updatePreferences], + ); + const setHiddenColumns = useCallback( +- (next: Set) => updateParam("hide", serializeHiddenColumns(next)), +- [updateParam], ++ (next: Set) => ++ updatePreferences((prev) => ({ ...prev, hide: serializeHiddenColumns(next) ?? "" })), ++ [updatePreferences], + ); + const handleSortClick = useCallback( +- (key: ListRunsSortEnum) => { +- if (sort === key) { +- updateParam("direction", direction === "asc" ? null : "asc"); +- } else { +- updateParam("sort", key === "created_at" ? null : key); +- updateParam("direction", null); +- } +- updateParam("page", null); +- }, +- [sort, direction, updateParam], ++ (key: ListRunsSortEnum) => ++ updatePreferences((prev) => ++ prev.sort === key ++ ? { ...prev, direction: prev.direction === "asc" ? "desc" : "asc", page: 1 } ++ : { ...prev, sort: key, direction: "desc", page: 1 }, ++ ), ++ [updatePreferences], + ); + ++ const hydratedFromStorage = useRef(false); + useEffect(() => { ++ if (hydratedFromStorage.current) return; ++ hydratedFromStorage.current = true; + if (searchParams === urlSearchParams) return; + setSearchParams(searchParams, { replace: true }); + }, [searchParams, urlSearchParams, setSearchParams]); +diff --git a/lib/crates/fabro-agent/src/session.rs b/lib/crates/fabro-agent/src/session.rs +index f6921361b..9c3a5e4d9 100644 +--- a/lib/crates/fabro-agent/src/session.rs ++++ b/lib/crates/fabro-agent/src/session.rs +@@ -592,7 +592,7 @@ impl Session { + } else { + let skills_dir = fabro_util::Home::from_env().skills_dir(); + let skills_str = skills_dir.to_string_lossy().to_string(); +- default_skill_dirs(Some(&skills_str), self.config.git_root.as_deref()) ++ default_skill_dirs(Some(&skills_str), Some(&doc_root)) + }; + self.skills = discover_skills(self.sandbox.as_ref(), &skill_dirs, &cancel_token).await?; + debug!(skill_count = self.skills.len(), "Skills discovered"); +diff --git a/lib/crates/fabro-server/tests/it/scenario/run_completion.rs b/lib/crates/fabro-server/tests/it/scenario/run_completion.rs +index fa0f6a4a7..4dfec9f30 100644 +--- a/lib/crates/fabro-server/tests/it/scenario/run_completion.rs ++++ b/lib/crates/fabro-server/tests/it/scenario/run_completion.rs +@@ -1,14 +1,83 @@ ++use std::sync::Arc; ++ + use axum::body::Body; + use axum::http::{Request, StatusCode}; ++use fabro_auth::EnvCredentialSource; ++use fabro_model::{Catalog, ProviderId}; ++use fabro_test::{TwinScenario, TwinScenarios, twin_openai}; ++use fabro_types::RunId; + use tokio::time::sleep; + use tower::ServiceExt; + + use crate::helpers::{ +- MINIMAL_DOT, api, checked_response, create_and_start_run_from_manifest, ++ MINIMAL_DOT, api, checked_response, create_and_start_run_from_manifest, minimal_manifest_json, + minimal_manifest_json_with_dry_run, response_text, test_app_state_with_options, + test_app_with_scheduler, test_settings, wait_for_run_status, + }; + ++const OPENAI_AGENT_MODEL: &str = "gpt-5.4"; ++ ++const PROJECT_SKILL_AGENT_DOT: &str = r#"digraph ProjectSkillAgent { ++ graph [goal="Verify project skills are visible to agent runs"] ++ rankdir=LR ++ ++ start [shape=Mdiamond, label="Start"] ++ exit [shape=Msquare, label="Exit"] ++ ++ work [shape=box, label="Work", prompt="Respond with done."] ++ ++ start -> work -> exit ++}"#; ++ ++fn test_app_with_openai_agent_backend(openai_base_url: String, api_key: String) -> axum::Router { ++ let settings = test_settings(); ++ let llm_catalog_settings = ++ fabro_server::test_support::llm_catalog_settings_with_provider_base_url( ++ "openai", ++ openai_base_url, ++ ); ++ let catalog = Arc::new( ++ Catalog::from_builtin_with_overrides(&llm_catalog_settings) ++ .expect("test catalog should build"), ++ ); ++ let source_api_key = api_key.clone(); ++ let env_api_key = api_key; ++ let llm_source: Arc = Arc::new( ++ EnvCredentialSource::with_env_lookup(Arc::new(move |name| match name { ++ "OPENAI_API_KEY" => Some(source_api_key.clone()), ++ _ => None, ++ })), ++ ); ++ let state = fabro_server::test_support::TestAppStateBuilder::new() ++ .runtime_settings(settings.server_settings, settings.manifest_run_defaults) ++ .max_concurrent_runs(5) ++ .llm_catalog_settings(llm_catalog_settings) ++ .registry_factory(move |interviewer| { ++ let catalog = Arc::clone(&catalog); ++ let llm_source = Arc::clone(&llm_source); ++ let emitter = Arc::new(fabro_workflow::event::Emitter::new(RunId::new())); ++ let steering_hub = Arc::new(fabro_workflow::SteeringHub::new(emitter)); ++ fabro_workflow::handler::default_registry(interviewer, move || { ++ Some(Box::new( ++ fabro_workflow::handler::llm::AgentApiBackend::new_with_catalog( ++ OPENAI_AGENT_MODEL.to_string(), ++ ProviderId::openai(), ++ Vec::new(), ++ Arc::clone(&llm_source), ++ Arc::clone(&steering_hub), ++ Arc::clone(&catalog), ++ ), ++ )) ++ }) ++ }) ++ .env_lookup(move |name| match name { ++ "OPENAI_API_KEY" => Some(env_api_key.clone()), ++ _ => None, ++ }) ++ .build(); ++ test_app_with_scheduler(state) ++} ++ + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn run_completes_and_status_is_completed() { + let state = test_app_state_with_options(test_settings(), 5); +@@ -22,6 +91,62 @@ async fn run_completes_and_status_is_completed() { + assert_eq!(status, "succeeded"); + } + ++#[tokio::test(flavor = "multi_thread", worker_threads = 2)] ++async fn agent_run_includes_project_skills_from_local_sandbox_working_directory() { ++ let project = tempfile::tempdir().expect("project tempdir should create"); ++ let skill_dir = project ++ .path() ++ .join(".fabro") ++ .join("skills") ++ .join("local-server-project-skill"); ++ tokio::fs::create_dir_all(&skill_dir) ++ .await ++ .expect("project skill dir should create"); ++ tokio::fs::write( ++ skill_dir.join("SKILL.md"), ++ "---\nname: local-server-project-skill\ndescription: Project-only skill\n---\nUse the project skill.\n", ++ ) ++ .await ++ .expect("project skill should write"); ++ ++ let twin = twin_openai().await; ++ let namespace = format!("{}::{}", module_path!(), line!()); ++ TwinScenarios::new(&namespace) ++ .scenario( ++ TwinScenario::responses(OPENAI_AGENT_MODEL) ++ .stream(true) ++ .text("Done"), ++ ) ++ .load(twin) ++ .await; ++ let app = test_app_with_openai_agent_backend(twin.base_url.clone(), namespace.clone()); ++ ++ let mut manifest = minimal_manifest_json(PROJECT_SKILL_AGENT_DOT); ++ manifest["title"] = serde_json::Value::String("Project skill agent".to_string()); ++ manifest["cwd"] = serde_json::Value::String(project.path().display().to_string()); ++ let run_id = create_and_start_run_from_manifest(&app, manifest).await; ++ ++ let status = wait_for_run_status(&app, &run_id, &["succeeded", "failed"]).await; ++ assert_eq!(status, "succeeded"); ++ let logs = twin.request_logs(&namespace).await; ++ let requests = logs["requests"] ++ .as_array() ++ .expect("twin-openai request logs should be an array"); ++ let instructions = requests ++ .iter() ++ .find(|request| request["model"] == OPENAI_AGENT_MODEL) ++ .and_then(|request| request["instructions_text"].as_str()) ++ .unwrap_or_default(); ++ assert!( ++ instructions.contains("local-server-project-skill"), ++ "expected project skill name in OpenAI instructions, got logs: {logs}" ++ ); ++ assert!( ++ instructions.contains("Project-only skill"), ++ "expected project skill description in OpenAI instructions, got logs: {logs}" ++ ); ++} ++ + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn attach_run_events_returns_sse_stream() { + let state = test_app_state_with_options(test_settings(), 5); +diff --git a/lib/crates/fabro-test/src/lib.rs b/lib/crates/fabro-test/src/lib.rs +index 9dd283d68..48c6ef4fc 100644 +--- a/lib/crates/fabro-test/src/lib.rs ++++ b/lib/crates/fabro-test/src/lib.rs +@@ -2067,6 +2067,22 @@ impl TwinOpenAi { + .expect("reset twin-openai namespace"); + assert_reqwest_status(response, fabro_http::StatusCode::OK, "POST /__admin/reset").await; + } ++ ++ pub async fn request_logs(&self, namespace: &str) -> serde_json::Value { ++ let response = test_http_client() ++ .get(format!("{}/__admin/requests", self.admin_url())) ++ .bearer_auth(namespace) ++ .send() ++ .await ++ .expect("fetch twin-openai request logs"); ++ let response = expect_reqwest_status( ++ response, ++ fabro_http::StatusCode::OK, ++ "GET /__admin/requests", ++ ) ++ .await; ++ response.json().await.expect("request logs should be JSON") ++ } + } + + #[derive(Debug, Default, Clone)] +diff --git a/test/twin/openai/src/engine/mod.rs b/test/twin/openai/src/engine/mod.rs +index 56359ecd6..867735baa 100644 +--- a/test/twin/openai/src/engine/mod.rs ++++ b/test/twin/openai/src/engine/mod.rs +@@ -19,11 +19,12 @@ pub fn execute_responses_request( + ) -> Result { + request.validate()?; + let context = RequestContext { +- endpoint: "responses".to_owned(), +- model: request.model.clone(), +- stream: request.stream, +- metadata: request.metadata.clone(), +- input_text: request.extract_user_text(), ++ endpoint: "responses".to_owned(), ++ model: request.model.clone(), ++ stream: request.stream, ++ metadata: request.metadata.clone(), ++ input_text: request.extract_user_text(), ++ instructions_text: request.extract_instruction_text(), + }; + state.log_request(namespace, context.clone()); + +@@ -52,11 +53,12 @@ pub fn execute_chat_request( + ) -> Result { + request.validate()?; + let context = RequestContext { +- endpoint: "chat.completions".to_owned(), +- model: request.model.clone(), +- stream: request.stream, +- metadata: serde_json::Map::new(), +- input_text: request.extract_user_text(), ++ endpoint: "chat.completions".to_owned(), ++ model: request.model.clone(), ++ stream: request.stream, ++ metadata: serde_json::Map::new(), ++ input_text: request.extract_user_text(), ++ instructions_text: request.extract_instruction_text(), + }; + state.log_request(namespace, context.clone()); + +diff --git a/test/twin/openai/src/engine/scenario.rs b/test/twin/openai/src/engine/scenario.rs +index 59d8fd642..ee7ff7377 100644 +--- a/test/twin/openai/src/engine/scenario.rs ++++ b/test/twin/openai/src/engine/scenario.rs +@@ -62,11 +62,12 @@ pub struct ToolCallTemplate { + + #[derive(Clone, Debug)] + pub struct RequestContext { +- pub endpoint: String, +- pub model: String, +- pub stream: bool, +- pub metadata: Map, +- pub input_text: String, ++ pub endpoint: String, ++ pub model: String, ++ pub stream: bool, ++ pub metadata: Map, ++ pub input_text: String, ++ pub instructions_text: String, + } + + impl ScenarioScript { +diff --git a/test/twin/openai/src/logs.rs b/test/twin/openai/src/logs.rs +index 1edc6cd8b..0fce6eafd 100644 +--- a/test/twin/openai/src/logs.rs ++++ b/test/twin/openai/src/logs.rs +@@ -3,9 +3,10 @@ use serde_json::{Map, Value}; + + #[derive(Clone, Debug, Serialize)] + pub struct RequestLog { +- pub endpoint: String, +- pub model: String, +- pub stream: bool, +- pub input_text: String, +- pub metadata: Map, ++ pub endpoint: String, ++ pub model: String, ++ pub stream: bool, ++ pub input_text: String, ++ pub instructions_text: String, ++ pub metadata: Map, + } +diff --git a/test/twin/openai/src/openai/models.rs b/test/twin/openai/src/openai/models.rs +index 42fa186f5..bd7996891 100644 +--- a/test/twin/openai/src/openai/models.rs ++++ b/test/twin/openai/src/openai/models.rs +@@ -11,6 +11,7 @@ pub struct ResponsesRequest { + pub model: String, + #[serde(default)] + pub input: ResponseInput, ++ pub instructions: Option, + #[serde(default)] + pub stream: bool, + #[serde(default)] +@@ -40,6 +41,13 @@ impl ResponsesRequest { + } + } + ++ pub fn extract_instruction_text(&self) -> String { ++ self.instructions ++ .as_deref() ++ .map(normalize_whitespace) ++ .unwrap_or_default() ++ } ++ + pub fn response_format(&self) -> Option { + let format = self.text.as_ref()?.format.as_ref()?; + response_format_from_kind( +@@ -476,6 +484,16 @@ impl ChatCompletionsRequest { + } + } + ++ pub fn extract_instruction_text(&self) -> String { ++ let pieces: Vec = self ++ .messages ++ .iter() ++ .filter(|message| message.role == "system" || message.role == "developer") ++ .flat_map(ChatMessage::extract_texts) ++ .collect(); ++ normalize_whitespace(&pieces.join(" ")) ++ } ++ + pub fn response_format(&self) -> Option { + let format = self.response_format.as_ref()?; + response_format_from_kind( +@@ -721,18 +739,26 @@ fn validate_tools( + return Err(OpenAiError::invalid_request(param, "tool type is required")); + }; + +- if tool_type != "function" { +- return Err(OpenAiError::invalid_request( +- param, +- "only function tools are supported", +- )); +- } +- +- if function_tool_name(tool, surface).is_none() { +- return Err(OpenAiError::invalid_request( +- param, +- "function tool name is required", +- )); ++ match tool_type { ++ "function" => { ++ if function_tool_name(tool, surface).is_none() { ++ return Err(OpenAiError::invalid_request( ++ param, ++ "function tool name is required", ++ )); ++ } ++ } ++ "custom" if surface == ToolSurface::Responses => { ++ if function_tool_name(tool, surface).is_none() { ++ return Err(OpenAiError::invalid_request( ++ param, ++ "custom tool name is required", ++ )); ++ } ++ } ++ _ => { ++ return Err(OpenAiError::invalid_request(param, "unsupported tool type")); ++ } + } + } + +diff --git a/test/twin/openai/src/state.rs b/test/twin/openai/src/state.rs +index d456ab6c5..29cece646 100644 +--- a/test/twin/openai/src/state.rs ++++ b/test/twin/openai/src/state.rs +@@ -125,11 +125,12 @@ impl AppState { + .or_default() + .request_logs + .push(RequestLog { +- endpoint: request.endpoint, +- model: request.model, +- stream: request.stream, +- input_text: request.input_text, +- metadata: request.metadata, ++ endpoint: request.endpoint, ++ model: request.model, ++ stream: request.stream, ++ input_text: request.input_text, ++ instructions_text: request.instructions_text, ++ metadata: request.metadata, + }); + } + diff --git a/stages/008-verify@1/output.log b/stages/008-verify@1/output.log new file mode 100644 index 000000000..6e687e8bd --- /dev/null +++ b/stages/008-verify@1/output.log @@ -0,0 +1 @@ +blob://sha256/4a229e77c774df9e4587431eec40dc119a6d55f115aaf1205d37c54bc78d1c8f \ No newline at end of file diff --git a/stages/008-verify@1/script_timing.json b/stages/008-verify@1/script_timing.json new file mode 100644 index 000000000..b09fee6bd --- /dev/null +++ b/stages/008-verify@1/script_timing.json @@ -0,0 +1,8 @@ +{ + "output": "blob://sha256/4a229e77c774df9e4587431eec40dc119a6d55f115aaf1205d37c54bc78d1c8f", + "exit_code": 0, + "duration_ms": 513889, + "termination": "exited", + "output_bytes": 207579, + "live_streaming": true +} \ No newline at end of file diff --git a/stages/008-verify@1/status.json b/stages/008-verify@1/status.json new file mode 100644 index 000000000..9da0dab60 --- /dev/null +++ b/stages/008-verify@1/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 && { command -v rg >/dev/null 2>&1 || { echo 'rg is required for verify'; exit 127; }; } && ! rg -n 'AuthMode::Disabled|RunAuthMethod|RunSubjectProvenance|\\bActorRef\\b|\\bActorKind\\b|AuthenticatedSubject|AuthenticatedService|AuthorizeRunScoped|AuthorizeRunBlob|AuthorizeStageArtifact|AuthorizeCommandLog|auth_method\\s*==\\s*\"disabled\"' lib/crates apps lib/packages docs/public/api-reference/fabro-api.yaml 2>&1 && cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --workspace --status-level slow --profile ci 2>&1 && cargo dev docs check 2>&1 && bun install --frozen-lockfile 2>&1 && (cd apps/fabro-web && bun run typecheck) 2>&1 && (cd apps/fabro-web && bun run test) 2>&1 && (cd lib/packages/fabro-api-client && bun run typecheck) 2>&1 && cargo dev build -- -p fabro-cli --release 2>&1", + "failure_reason": null, + "timestamp": "2026-05-24T18:09:44.085765Z" +} \ No newline at end of file diff --git a/stages/009-exit@1/status.json b/stages/009-exit@1/status.json new file mode 100644 index 000000000..36aede5b7 --- /dev/null +++ b/stages/009-exit@1/status.json @@ -0,0 +1,6 @@ +{ + "outcome": "succeeded", + "notes": null, + "failure_reason": null, + "timestamp": "2026-05-24T18:09:48.016057Z" +} \ No newline at end of file