story-kit: start 59_story_current_work_panel
This commit is contained in:
25
.story_kit/work/2_current/59_story_current_work_panel.md
Normal file
25
.story_kit/work/2_current/59_story_current_work_panel.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
---
|
||||||
|
name: Animated Agent Work Assignment UI
|
||||||
|
test_plan: pending
|
||||||
|
---
|
||||||
|
|
||||||
|
# Story 59: Animated Agent Work Assignment UI
|
||||||
|
|
||||||
|
## User Story
|
||||||
|
|
||||||
|
As a user watching the web UI, I want to see which agent is working on which work item across all active pipeline stages, with agents visually animating between idle and assigned states, so the pipeline feels like a living system I can watch in real time.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] Work items in current/qa/merge panels each show which agent (if any) is working on them
|
||||||
|
- [ ] Agent lozenges (e.g. "coder-1 sonnet") visually animate from the agents panel to the work item they are assigned to
|
||||||
|
- [ ] When an agent completes, its lozenge animates back to idle in the agents panel (or to the next work item if immediately reassigned)
|
||||||
|
- [ ] Idle agents are visible in the agents panel with an idle state indicator
|
||||||
|
- [ ] Active agents show a subtle activity indicator (pulse, shimmer, etc.) on the work item they are docked to
|
||||||
|
- [ ] Pipeline state and agent assignments update in real time via WebSocket — no manual refresh needed
|
||||||
|
- [ ] The backend exposes agent-to-story assignments as part of the pipeline state (agent pool already tracks this)
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- Actions from the UI (stop agent, reassign, start agent) — future story
|
||||||
|
- Agent output streaming in the work item card — existing agent panel handles this
|
||||||
@@ -196,3 +196,33 @@ body,
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Agent activity indicator pulse */
|
||||||
|
@keyframes pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.4;
|
||||||
|
transform: scale(0.85);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Agent lozenge appearance animation (simulates arriving from agents panel) */
|
||||||
|
@keyframes agentAppear {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-4px) scale(0.95);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Thinking/loading pulse for text */
|
||||||
|
.pulse {
|
||||||
|
animation: pulse 1.5s infinite;
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,10 +8,17 @@ export type WsRequest =
|
|||||||
type: "cancel";
|
type: "cancel";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface AgentAssignment {
|
||||||
|
agent_name: string;
|
||||||
|
model: string | null;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PipelineStageItem {
|
export interface PipelineStageItem {
|
||||||
story_id: string;
|
story_id: string;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
agent: AgentAssignment | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PipelineState {
|
export interface PipelineState {
|
||||||
|
|||||||
@@ -73,7 +73,16 @@ function StatusBadge({ status }: { status: AgentStatusValue }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function RosterBadge({ agent }: { agent: AgentConfigInfo }) {
|
function RosterBadge({
|
||||||
|
agent,
|
||||||
|
activeStoryId,
|
||||||
|
}: {
|
||||||
|
agent: AgentConfigInfo;
|
||||||
|
activeStoryId: string | null;
|
||||||
|
}) {
|
||||||
|
const isActive = activeStoryId !== null;
|
||||||
|
const storyNumber = activeStoryId?.match(/^(\d+)/)?.[1];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
@@ -83,14 +92,56 @@ function RosterBadge({ agent }: { agent: AgentConfigInfo }) {
|
|||||||
padding: "2px 8px",
|
padding: "2px 8px",
|
||||||
borderRadius: "6px",
|
borderRadius: "6px",
|
||||||
fontSize: "0.7em",
|
fontSize: "0.7em",
|
||||||
background: "#ffffff08",
|
background: isActive ? "#58a6ff18" : "#ffffff08",
|
||||||
color: "#888",
|
color: isActive ? "#58a6ff" : "#888",
|
||||||
border: "1px solid #333",
|
border: isActive ? "1px solid #58a6ff44" : "1px solid #333",
|
||||||
|
transition: "background 0.3s, color 0.3s, border-color 0.3s",
|
||||||
}}
|
}}
|
||||||
title={agent.role || agent.name}
|
title={
|
||||||
|
isActive
|
||||||
|
? `Working on #${storyNumber ?? activeStoryId}`
|
||||||
|
: `${agent.role || agent.name} — idle`
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<span style={{ fontWeight: 600, color: "#aaa" }}>{agent.name}</span>
|
{isActive && (
|
||||||
{agent.model && <span style={{ color: "#666" }}>{agent.model}</span>}
|
<span
|
||||||
|
style={{
|
||||||
|
width: "5px",
|
||||||
|
height: "5px",
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: "#58a6ff",
|
||||||
|
animation: "pulse 1.5s infinite",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!isActive && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: "5px",
|
||||||
|
height: "5px",
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: "#555",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span style={{ fontWeight: 600, color: isActive ? "#58a6ff" : "#aaa" }}>
|
||||||
|
{agent.name}
|
||||||
|
</span>
|
||||||
|
{agent.model && (
|
||||||
|
<span style={{ color: isActive ? "#7ab8ff" : "#666" }}>
|
||||||
|
{agent.model}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isActive && storyNumber && (
|
||||||
|
<span style={{ color: "#7ab8ff", marginLeft: "2px" }}>
|
||||||
|
#{storyNumber}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!isActive && (
|
||||||
|
<span style={{ color: "#444", fontStyle: "italic" }}>idle</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -519,7 +570,7 @@ export function AgentPanel() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Roster badges */}
|
{/* Roster badges — show all configured agents with idle/active state */}
|
||||||
{roster.length > 0 && (
|
{roster.length > 0 && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -528,9 +579,24 @@ export function AgentPanel() {
|
|||||||
gap: "4px",
|
gap: "4px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{roster.map((a) => (
|
{roster.map((a) => {
|
||||||
<RosterBadge key={`roster-${a.name}`} agent={a} />
|
// Find the story this roster agent is currently working on (if any)
|
||||||
))}
|
const activeEntry = Object.entries(agents).find(
|
||||||
|
([, state]) =>
|
||||||
|
state.agentName === a.name &&
|
||||||
|
(state.status === "running" || state.status === "pending"),
|
||||||
|
);
|
||||||
|
const activeStoryId = activeEntry
|
||||||
|
? activeEntry[0].split(":")[0]
|
||||||
|
: null;
|
||||||
|
return (
|
||||||
|
<RosterBadge
|
||||||
|
key={`roster-${a.name}`}
|
||||||
|
agent={a}
|
||||||
|
activeStoryId={activeStoryId}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
104
frontend/src/components/StagePanel.test.tsx
Normal file
104
frontend/src/components/StagePanel.test.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { PipelineStageItem } from "../api/client";
|
||||||
|
import { StagePanel } from "./StagePanel";
|
||||||
|
|
||||||
|
describe("StagePanel", () => {
|
||||||
|
it("renders empty message when no items", () => {
|
||||||
|
render(<StagePanel title="Current" items={[]} />);
|
||||||
|
expect(screen.getByText("Empty.")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders story item without agent lozenge when agent is null", () => {
|
||||||
|
const items: PipelineStageItem[] = [
|
||||||
|
{
|
||||||
|
story_id: "42_story_no_agent",
|
||||||
|
name: "No Agent Story",
|
||||||
|
error: null,
|
||||||
|
agent: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
render(<StagePanel title="Current" items={items} />);
|
||||||
|
expect(screen.getByText("No Agent Story")).toBeInTheDocument();
|
||||||
|
// No agent lozenge
|
||||||
|
expect(screen.queryByText(/coder-/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows agent lozenge with agent name and model when agent is running", () => {
|
||||||
|
const items: PipelineStageItem[] = [
|
||||||
|
{
|
||||||
|
story_id: "43_story_with_agent",
|
||||||
|
name: "Active Story",
|
||||||
|
error: null,
|
||||||
|
agent: {
|
||||||
|
agent_name: "coder-1",
|
||||||
|
model: "sonnet",
|
||||||
|
status: "running",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
render(<StagePanel title="Current" items={items} />);
|
||||||
|
expect(screen.getByText("Active Story")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("coder-1 sonnet")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows agent lozenge with only agent name when model is null", () => {
|
||||||
|
const items: PipelineStageItem[] = [
|
||||||
|
{
|
||||||
|
story_id: "44_story_no_model",
|
||||||
|
name: "No Model Story",
|
||||||
|
error: null,
|
||||||
|
agent: {
|
||||||
|
agent_name: "coder-2",
|
||||||
|
model: null,
|
||||||
|
status: "running",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
render(<StagePanel title="Current" items={items} />);
|
||||||
|
expect(screen.getByText("coder-2")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows agent lozenge for pending agent", () => {
|
||||||
|
const items: PipelineStageItem[] = [
|
||||||
|
{
|
||||||
|
story_id: "45_story_pending",
|
||||||
|
name: "Pending Story",
|
||||||
|
error: null,
|
||||||
|
agent: {
|
||||||
|
agent_name: "coder-1",
|
||||||
|
model: "haiku",
|
||||||
|
status: "pending",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
render(<StagePanel title="QA" items={items} />);
|
||||||
|
expect(screen.getByText("coder-1 haiku")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows story number extracted from story_id", () => {
|
||||||
|
const items: PipelineStageItem[] = [
|
||||||
|
{
|
||||||
|
story_id: "59_story_current_work_panel",
|
||||||
|
name: "Current Work Panel",
|
||||||
|
error: null,
|
||||||
|
agent: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
render(<StagePanel title="Current" items={items} />);
|
||||||
|
expect(screen.getByText("#59")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows error message when item has an error", () => {
|
||||||
|
const items: PipelineStageItem[] = [
|
||||||
|
{
|
||||||
|
story_id: "1_story_bad",
|
||||||
|
name: null,
|
||||||
|
error: "Missing front matter",
|
||||||
|
agent: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
render(<StagePanel title="Upcoming" items={items} />);
|
||||||
|
expect(screen.getByText("Missing front matter")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { PipelineStageItem } from "../api/client";
|
import type { AgentAssignment, PipelineStageItem } from "../api/client";
|
||||||
|
|
||||||
interface StagePanelProps {
|
interface StagePanelProps {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -6,6 +6,61 @@ interface StagePanelProps {
|
|||||||
emptyMessage?: string;
|
emptyMessage?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AgentLozenge({ agent }: { agent: AgentAssignment }) {
|
||||||
|
const isRunning = agent.status === "running";
|
||||||
|
const isPending = agent.status === "pending";
|
||||||
|
const color = isRunning ? "#58a6ff" : isPending ? "#e3b341" : "#aaa";
|
||||||
|
const label = agent.model
|
||||||
|
? `${agent.agent_name} ${agent.model}`
|
||||||
|
: agent.agent_name;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="agent-lozenge"
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "5px",
|
||||||
|
padding: "2px 8px",
|
||||||
|
borderRadius: "999px",
|
||||||
|
fontSize: "0.72em",
|
||||||
|
fontWeight: 600,
|
||||||
|
background: `${color}18`,
|
||||||
|
color,
|
||||||
|
border: `1px solid ${color}44`,
|
||||||
|
marginTop: "4px",
|
||||||
|
animation: "agentAppear 0.3s ease-out",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isRunning && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: "5px",
|
||||||
|
height: "5px",
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: color,
|
||||||
|
animation: "pulse 1.5s infinite",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isPending && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: "5px",
|
||||||
|
height: "5px",
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: color,
|
||||||
|
opacity: 0.7,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function StagePanel({
|
export function StagePanel({
|
||||||
title,
|
title,
|
||||||
items,
|
items,
|
||||||
@@ -58,13 +113,15 @@ export function StagePanel({
|
|||||||
<div
|
<div
|
||||||
key={`${title}-${item.story_id}`}
|
key={`${title}-${item.story_id}`}
|
||||||
style={{
|
style={{
|
||||||
border: "1px solid #2a2a2a",
|
border: item.agent
|
||||||
|
? "1px solid #2a3a4a"
|
||||||
|
: "1px solid #2a2a2a",
|
||||||
borderRadius: "8px",
|
borderRadius: "8px",
|
||||||
padding: "8px 12px",
|
padding: "8px 12px",
|
||||||
background: "#191919",
|
background: item.agent ? "#161e2a" : "#191919",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
flexDirection: "column",
|
||||||
gap: "8px",
|
gap: "2px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
@@ -94,6 +151,7 @@ export function StagePanel({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{item.agent && <AgentLozenge agent={item.agent} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,14 +1,26 @@
|
|||||||
|
use crate::agents::AgentStatus;
|
||||||
use crate::http::context::AppContext;
|
use crate::http::context::AppContext;
|
||||||
use crate::io::story_metadata::parse_front_matter;
|
use crate::io::story_metadata::parse_front_matter;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
/// Agent assignment embedded in a pipeline stage item.
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
pub struct AgentAssignment {
|
||||||
|
pub agent_name: String,
|
||||||
|
pub model: Option<String>,
|
||||||
|
pub status: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
#[derive(Clone, Debug, Serialize)]
|
||||||
pub struct UpcomingStory {
|
pub struct UpcomingStory {
|
||||||
pub story_id: String,
|
pub story_id: String,
|
||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
pub error: Option<String>,
|
pub error: Option<String>,
|
||||||
|
/// Active agent working on this item, if any.
|
||||||
|
pub agent: Option<AgentAssignment>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct StoryValidationResult {
|
pub struct StoryValidationResult {
|
||||||
@@ -28,16 +40,55 @@ pub struct PipelineState {
|
|||||||
|
|
||||||
/// Load the full pipeline state (all 4 active stages).
|
/// Load the full pipeline state (all 4 active stages).
|
||||||
pub fn load_pipeline_state(ctx: &AppContext) -> Result<PipelineState, String> {
|
pub fn load_pipeline_state(ctx: &AppContext) -> Result<PipelineState, String> {
|
||||||
|
let agent_map = build_active_agent_map(ctx);
|
||||||
Ok(PipelineState {
|
Ok(PipelineState {
|
||||||
upcoming: load_stage_items(ctx, "1_upcoming")?,
|
upcoming: load_stage_items(ctx, "1_upcoming", &HashMap::new())?,
|
||||||
current: load_stage_items(ctx, "2_current")?,
|
current: load_stage_items(ctx, "2_current", &agent_map)?,
|
||||||
qa: load_stage_items(ctx, "3_qa")?,
|
qa: load_stage_items(ctx, "3_qa", &agent_map)?,
|
||||||
merge: load_stage_items(ctx, "4_merge")?,
|
merge: load_stage_items(ctx, "4_merge", &agent_map)?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build a map from story_id → AgentAssignment for all pending/running agents.
|
||||||
|
fn build_active_agent_map(ctx: &AppContext) -> HashMap<String, AgentAssignment> {
|
||||||
|
let agents = match ctx.agents.list_agents() {
|
||||||
|
Ok(a) => a,
|
||||||
|
Err(_) => return HashMap::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let config_opt = ctx
|
||||||
|
.state
|
||||||
|
.get_project_root()
|
||||||
|
.ok()
|
||||||
|
.and_then(|root| crate::config::ProjectConfig::load(&root).ok());
|
||||||
|
|
||||||
|
let mut map = HashMap::new();
|
||||||
|
for agent in agents {
|
||||||
|
if !matches!(agent.status, AgentStatus::Pending | AgentStatus::Running) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let model = config_opt
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|cfg| cfg.find_agent(&agent.agent_name))
|
||||||
|
.and_then(|ac| ac.model.clone());
|
||||||
|
map.insert(
|
||||||
|
agent.story_id.clone(),
|
||||||
|
AgentAssignment {
|
||||||
|
agent_name: agent.agent_name,
|
||||||
|
model,
|
||||||
|
status: agent.status.to_string(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
map
|
||||||
|
}
|
||||||
|
|
||||||
/// Load work items from any pipeline stage directory.
|
/// Load work items from any pipeline stage directory.
|
||||||
fn load_stage_items(ctx: &AppContext, stage_dir: &str) -> Result<Vec<UpcomingStory>, String> {
|
fn load_stage_items(
|
||||||
|
ctx: &AppContext,
|
||||||
|
stage_dir: &str,
|
||||||
|
agent_map: &HashMap<String, AgentAssignment>,
|
||||||
|
) -> Result<Vec<UpcomingStory>, String> {
|
||||||
let root = ctx.state.get_project_root()?;
|
let root = ctx.state.get_project_root()?;
|
||||||
let dir = root.join(".story_kit").join("work").join(stage_dir);
|
let dir = root.join(".story_kit").join("work").join(stage_dir);
|
||||||
|
|
||||||
@@ -65,7 +116,8 @@ fn load_stage_items(ctx: &AppContext, stage_dir: &str) -> Result<Vec<UpcomingSto
|
|||||||
Ok(meta) => (meta.name, None),
|
Ok(meta) => (meta.name, None),
|
||||||
Err(e) => (None, Some(e.to_string())),
|
Err(e) => (None, Some(e.to_string())),
|
||||||
};
|
};
|
||||||
stories.push(UpcomingStory { story_id, name, error });
|
let agent = agent_map.get(&story_id).cloned();
|
||||||
|
stories.push(UpcomingStory { story_id, name, error, agent });
|
||||||
}
|
}
|
||||||
|
|
||||||
stories.sort_by(|a, b| a.story_id.cmp(&b.story_id));
|
stories.sort_by(|a, b| a.story_id.cmp(&b.story_id));
|
||||||
@@ -73,7 +125,7 @@ fn load_stage_items(ctx: &AppContext, stage_dir: &str) -> Result<Vec<UpcomingSto
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn load_upcoming_stories(ctx: &AppContext) -> Result<Vec<UpcomingStory>, String> {
|
pub fn load_upcoming_stories(ctx: &AppContext) -> Result<Vec<UpcomingStory>, String> {
|
||||||
load_stage_items(ctx, "1_upcoming")
|
load_stage_items(ctx, "1_upcoming", &HashMap::new())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shared create-story logic used by both the OpenApi and MCP handlers.
|
/// Shared create-story logic used by both the OpenApi and MCP handlers.
|
||||||
@@ -546,6 +598,81 @@ mod tests {
|
|||||||
assert!(result.is_empty());
|
assert!(result.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pipeline_state_includes_agent_for_running_story() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let root = tmp.path().to_path_buf();
|
||||||
|
|
||||||
|
let current = root.join(".story_kit/work/2_current");
|
||||||
|
fs::create_dir_all(¤t).unwrap();
|
||||||
|
fs::write(
|
||||||
|
current.join("10_story_test.md"),
|
||||||
|
"---\nname: Test Story\ntest_plan: approved\n---\n# Story\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let ctx = crate::http::context::AppContext::new_test(root);
|
||||||
|
ctx.agents.inject_test_agent("10_story_test", "coder-1", crate::agents::AgentStatus::Running);
|
||||||
|
|
||||||
|
let state = load_pipeline_state(&ctx).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(state.current.len(), 1);
|
||||||
|
let item = &state.current[0];
|
||||||
|
assert!(item.agent.is_some(), "running agent should appear on work item");
|
||||||
|
let agent = item.agent.as_ref().unwrap();
|
||||||
|
assert_eq!(agent.agent_name, "coder-1");
|
||||||
|
assert_eq!(agent.status, "running");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pipeline_state_no_agent_for_completed_story() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let root = tmp.path().to_path_buf();
|
||||||
|
|
||||||
|
let current = root.join(".story_kit/work/2_current");
|
||||||
|
fs::create_dir_all(¤t).unwrap();
|
||||||
|
fs::write(
|
||||||
|
current.join("11_story_done.md"),
|
||||||
|
"---\nname: Done Story\ntest_plan: approved\n---\n# Story\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let ctx = crate::http::context::AppContext::new_test(root);
|
||||||
|
ctx.agents.inject_test_agent("11_story_done", "coder-1", crate::agents::AgentStatus::Completed);
|
||||||
|
|
||||||
|
let state = load_pipeline_state(&ctx).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(state.current.len(), 1);
|
||||||
|
assert!(
|
||||||
|
state.current[0].agent.is_none(),
|
||||||
|
"completed agent should not appear on work item"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pipeline_state_pending_agent_included() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let root = tmp.path().to_path_buf();
|
||||||
|
|
||||||
|
let current = root.join(".story_kit/work/2_current");
|
||||||
|
fs::create_dir_all(¤t).unwrap();
|
||||||
|
fs::write(
|
||||||
|
current.join("12_story_pending.md"),
|
||||||
|
"---\nname: Pending Story\ntest_plan: approved\n---\n# Story\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let ctx = crate::http::context::AppContext::new_test(root);
|
||||||
|
ctx.agents.inject_test_agent("12_story_pending", "coder-1", crate::agents::AgentStatus::Pending);
|
||||||
|
|
||||||
|
let state = load_pipeline_state(&ctx).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(state.current.len(), 1);
|
||||||
|
let item = &state.current[0];
|
||||||
|
assert!(item.agent.is_some(), "pending agent should appear on work item");
|
||||||
|
assert_eq!(item.agent.as_ref().unwrap().status, "pending");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn load_upcoming_parses_metadata() {
|
fn load_upcoming_parses_metadata() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
Reference in New Issue
Block a user