story-kit: merge 160_story_constrain_thinking_trace_height_in_agent_stream_ui
This commit is contained in:
@@ -11,7 +11,14 @@ export interface AgentInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface AgentEvent {
|
export interface AgentEvent {
|
||||||
type: "status" | "output" | "agent_json" | "done" | "error" | "warning";
|
type:
|
||||||
|
| "status"
|
||||||
|
| "output"
|
||||||
|
| "thinking"
|
||||||
|
| "agent_json"
|
||||||
|
| "done"
|
||||||
|
| "error"
|
||||||
|
| "warning";
|
||||||
story_id?: string;
|
story_id?: string;
|
||||||
agent_name?: string;
|
agent_name?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { render, screen } from "@testing-library/react";
|
import { act, render, screen } from "@testing-library/react";
|
||||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { AgentConfigInfo, AgentInfo } from "../api/agents";
|
import type { AgentConfigInfo, AgentEvent, AgentInfo } from "../api/agents";
|
||||||
import { agentsApi } from "../api/agents";
|
import { agentsApi, subscribeAgentStream } from "../api/agents";
|
||||||
|
|
||||||
vi.mock("../api/agents", () => {
|
vi.mock("../api/agents", () => {
|
||||||
const agentsApi = {
|
const agentsApi = {
|
||||||
@@ -17,6 +17,8 @@ vi.mock("../api/agents", () => {
|
|||||||
// Dynamic import so the mock is in place before the module loads
|
// Dynamic import so the mock is in place before the module loads
|
||||||
const { AgentPanel } = await import("./AgentPanel");
|
const { AgentPanel } = await import("./AgentPanel");
|
||||||
|
|
||||||
|
const mockedSubscribeAgentStream = vi.mocked(subscribeAgentStream);
|
||||||
|
|
||||||
const mockedAgents = {
|
const mockedAgents = {
|
||||||
listAgents: vi.mocked(agentsApi.listAgents),
|
listAgents: vi.mocked(agentsApi.listAgents),
|
||||||
getAgentConfig: vi.mocked(agentsApi.getAgentConfig),
|
getAgentConfig: vi.mocked(agentsApi.getAgentConfig),
|
||||||
@@ -210,3 +212,214 @@ describe("RosterBadge availability state", () => {
|
|||||||
expect(dot.style.animation).toBe("");
|
expect(dot.style.animation).toBe("");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Thinking trace block in agent stream UI", () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
Element.prototype.scrollIntoView = vi.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockedAgents.getAgentConfig.mockResolvedValue(ROSTER);
|
||||||
|
mockedAgents.listAgents.mockResolvedValue([]);
|
||||||
|
mockedSubscribeAgentStream.mockReturnValue(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
// AC1+AC2: thinking block renders with fixed max-height and is visually distinct
|
||||||
|
it("renders thinking block with max-height 96px when thinking event arrives", async () => {
|
||||||
|
let emitEvent: ((e: AgentEvent) => void) | null = null;
|
||||||
|
mockedSubscribeAgentStream.mockImplementation(
|
||||||
|
(_storyId, _agentName, onEvent) => {
|
||||||
|
emitEvent = onEvent;
|
||||||
|
return () => {};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const agentList: AgentInfo[] = [
|
||||||
|
{
|
||||||
|
story_id: "160_thinking",
|
||||||
|
agent_name: "coder-1",
|
||||||
|
status: "running",
|
||||||
|
session_id: null,
|
||||||
|
worktree_path: "/tmp/wt",
|
||||||
|
base_branch: "master",
|
||||||
|
log_session_id: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
mockedAgents.listAgents.mockResolvedValue(agentList);
|
||||||
|
|
||||||
|
render(<AgentPanel />);
|
||||||
|
|
||||||
|
// Wait for the subscription to be set up
|
||||||
|
await screen.findByTestId("roster-badge-coder-1");
|
||||||
|
|
||||||
|
// Fire a thinking event
|
||||||
|
await act(async () => {
|
||||||
|
emitEvent?.({
|
||||||
|
type: "thinking",
|
||||||
|
story_id: "160_thinking",
|
||||||
|
agent_name: "coder-1",
|
||||||
|
text: "Let me consider the problem carefully...",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const block = screen.getByTestId("thinking-block");
|
||||||
|
expect(block).toBeInTheDocument();
|
||||||
|
// AC2: fixed max-height
|
||||||
|
expect(block.style.maxHeight).toBe("96px");
|
||||||
|
// AC2: overflow scrolling
|
||||||
|
expect(block.style.overflowY).toBe("auto");
|
||||||
|
// AC1: visually distinct — italic monospace font
|
||||||
|
expect(block.style.fontStyle).toBe("italic");
|
||||||
|
expect(block.style.fontFamily).toBe("monospace");
|
||||||
|
// Contains the thinking text
|
||||||
|
expect(block.textContent).toContain(
|
||||||
|
"Let me consider the problem carefully...",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// AC3: thinking block renders the "thinking" label
|
||||||
|
it("shows a thinking label in the block header", async () => {
|
||||||
|
let emitEvent: ((e: AgentEvent) => void) | null = null;
|
||||||
|
mockedSubscribeAgentStream.mockImplementation(
|
||||||
|
(_storyId, _agentName, onEvent) => {
|
||||||
|
emitEvent = onEvent;
|
||||||
|
return () => {};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const agentList: AgentInfo[] = [
|
||||||
|
{
|
||||||
|
story_id: "160_label",
|
||||||
|
agent_name: "coder-1",
|
||||||
|
status: "running",
|
||||||
|
session_id: null,
|
||||||
|
worktree_path: "/tmp/wt",
|
||||||
|
base_branch: "master",
|
||||||
|
log_session_id: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
mockedAgents.listAgents.mockResolvedValue(agentList);
|
||||||
|
|
||||||
|
render(<AgentPanel />);
|
||||||
|
await screen.findByTestId("roster-badge-coder-1");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
emitEvent?.({
|
||||||
|
type: "thinking",
|
||||||
|
story_id: "160_label",
|
||||||
|
agent_name: "coder-1",
|
||||||
|
text: "thinking...",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const block = screen.getByTestId("thinking-block");
|
||||||
|
expect(block.textContent).toContain("thinking");
|
||||||
|
});
|
||||||
|
|
||||||
|
// AC4: regular text output renders outside the thinking container
|
||||||
|
it("renders regular output outside the thinking block", async () => {
|
||||||
|
let emitEvent: ((e: AgentEvent) => void) | null = null;
|
||||||
|
mockedSubscribeAgentStream.mockImplementation(
|
||||||
|
(_storyId, _agentName, onEvent) => {
|
||||||
|
emitEvent = onEvent;
|
||||||
|
return () => {};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const agentList: AgentInfo[] = [
|
||||||
|
{
|
||||||
|
story_id: "160_output",
|
||||||
|
agent_name: "coder-1",
|
||||||
|
status: "running",
|
||||||
|
session_id: null,
|
||||||
|
worktree_path: "/tmp/wt",
|
||||||
|
base_branch: "master",
|
||||||
|
log_session_id: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
mockedAgents.listAgents.mockResolvedValue(agentList);
|
||||||
|
|
||||||
|
render(<AgentPanel />);
|
||||||
|
await screen.findByTestId("roster-badge-coder-1");
|
||||||
|
|
||||||
|
// First: thinking event
|
||||||
|
await act(async () => {
|
||||||
|
emitEvent?.({
|
||||||
|
type: "thinking",
|
||||||
|
story_id: "160_output",
|
||||||
|
agent_name: "coder-1",
|
||||||
|
text: "thinking deeply",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Then: text output event
|
||||||
|
await act(async () => {
|
||||||
|
emitEvent?.({
|
||||||
|
type: "output",
|
||||||
|
story_id: "160_output",
|
||||||
|
agent_name: "coder-1",
|
||||||
|
text: "Here is the result.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const thinkingBlock = screen.getByTestId("thinking-block");
|
||||||
|
const outputArea = screen.getByTestId("agent-output-coder-1");
|
||||||
|
|
||||||
|
// Thinking still visible
|
||||||
|
expect(thinkingBlock).toBeInTheDocument();
|
||||||
|
expect(thinkingBlock.textContent).toContain("thinking deeply");
|
||||||
|
|
||||||
|
// Output renders in a separate element, not inside the thinking block
|
||||||
|
expect(outputArea).toBeInTheDocument();
|
||||||
|
expect(outputArea.textContent).toContain("Here is the result.");
|
||||||
|
expect(thinkingBlock.contains(outputArea)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// AC5: thinking block remains visible when text starts
|
||||||
|
it("keeps thinking block visible after output arrives", async () => {
|
||||||
|
let emitEvent: ((e: AgentEvent) => void) | null = null;
|
||||||
|
mockedSubscribeAgentStream.mockImplementation(
|
||||||
|
(_storyId, _agentName, onEvent) => {
|
||||||
|
emitEvent = onEvent;
|
||||||
|
return () => {};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const agentList: AgentInfo[] = [
|
||||||
|
{
|
||||||
|
story_id: "160_persist",
|
||||||
|
agent_name: "coder-1",
|
||||||
|
status: "running",
|
||||||
|
session_id: null,
|
||||||
|
worktree_path: "/tmp/wt",
|
||||||
|
base_branch: "master",
|
||||||
|
log_session_id: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
mockedAgents.listAgents.mockResolvedValue(agentList);
|
||||||
|
|
||||||
|
render(<AgentPanel />);
|
||||||
|
await screen.findByTestId("roster-badge-coder-1");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
emitEvent?.({
|
||||||
|
type: "thinking",
|
||||||
|
story_id: "160_persist",
|
||||||
|
agent_name: "coder-1",
|
||||||
|
text: "my thoughts",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
emitEvent?.({
|
||||||
|
type: "output",
|
||||||
|
story_id: "160_persist",
|
||||||
|
agent_name: "coder-1",
|
||||||
|
text: "final answer",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Thinking block still in the DOM after output arrives
|
||||||
|
expect(screen.getByTestId("thinking-block")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -14,12 +14,66 @@ interface AgentState {
|
|||||||
agentName: string;
|
agentName: string;
|
||||||
status: AgentStatusValue;
|
status: AgentStatusValue;
|
||||||
log: string[];
|
log: string[];
|
||||||
|
/** Accumulated thinking text for the current turn. */
|
||||||
|
thinking: string;
|
||||||
|
/** True once regular output has been received after thinking started. */
|
||||||
|
thinkingDone: boolean;
|
||||||
sessionId: string | null;
|
sessionId: string | null;
|
||||||
worktreePath: string | null;
|
worktreePath: string | null;
|
||||||
baseBranch: string | null;
|
baseBranch: string | null;
|
||||||
terminalAt: number | null;
|
terminalAt: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Fixed-height thinking trace block that auto-scrolls to bottom as text arrives. */
|
||||||
|
function ThinkingBlock({ text }: { text: string }) {
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Auto-scroll to bottom whenever text grows
|
||||||
|
useEffect(() => {
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (el) {
|
||||||
|
el.scrollTop = el.scrollHeight;
|
||||||
|
}
|
||||||
|
}, [text]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="thinking-block"
|
||||||
|
ref={scrollRef}
|
||||||
|
style={{
|
||||||
|
maxHeight: "96px",
|
||||||
|
overflowY: "auto",
|
||||||
|
background: "#161b22",
|
||||||
|
border: "1px solid #2d333b",
|
||||||
|
borderRadius: "6px",
|
||||||
|
padding: "6px 10px",
|
||||||
|
fontSize: "0.78em",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
color: "#6e7681",
|
||||||
|
fontStyle: "italic",
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
wordBreak: "break-word",
|
||||||
|
lineHeight: "1.4",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
fontSize: "0.8em",
|
||||||
|
color: "#444c56",
|
||||||
|
marginBottom: "4px",
|
||||||
|
fontStyle: "normal",
|
||||||
|
letterSpacing: "0.04em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
thinking
|
||||||
|
</span>
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const formatTimestamp = (value: Date | null): string => {
|
const formatTimestamp = (value: Date | null): string => {
|
||||||
if (!value) return "";
|
if (!value) return "";
|
||||||
return value.toLocaleTimeString([], {
|
return value.toLocaleTimeString([], {
|
||||||
@@ -117,6 +171,8 @@ export function AgentPanel({ configVersion = 0 }: AgentPanelProps) {
|
|||||||
agentName: a.agent_name,
|
agentName: a.agent_name,
|
||||||
status: a.status,
|
status: a.status,
|
||||||
log: [],
|
log: [],
|
||||||
|
thinking: "",
|
||||||
|
thinkingDone: false,
|
||||||
sessionId: a.session_id,
|
sessionId: a.session_id,
|
||||||
worktreePath: a.worktree_path,
|
worktreePath: a.worktree_path,
|
||||||
baseBranch: a.base_branch,
|
baseBranch: a.base_branch,
|
||||||
@@ -160,6 +216,8 @@ export function AgentPanel({ configVersion = 0 }: AgentPanelProps) {
|
|||||||
agentName,
|
agentName,
|
||||||
status: "pending" as AgentStatusValue,
|
status: "pending" as AgentStatusValue,
|
||||||
log: [],
|
log: [],
|
||||||
|
thinking: "",
|
||||||
|
thinkingDone: false,
|
||||||
sessionId: null,
|
sessionId: null,
|
||||||
worktreePath: null,
|
worktreePath: null,
|
||||||
baseBranch: null,
|
baseBranch: null,
|
||||||
@@ -183,12 +241,23 @@ export function AgentPanel({ configVersion = 0 }: AgentPanelProps) {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
case "thinking":
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
[key]: {
|
||||||
|
...current,
|
||||||
|
thinking: current.thinking + (event.text ?? ""),
|
||||||
|
},
|
||||||
|
};
|
||||||
case "output":
|
case "output":
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
[key]: {
|
[key]: {
|
||||||
...current,
|
...current,
|
||||||
log: [...current.log, event.text ?? ""],
|
log: [...current.log, event.text ?? ""],
|
||||||
|
// Receiving text output signals thinking phase is over
|
||||||
|
thinkingDone:
|
||||||
|
current.thinking.length > 0 ? true : current.thinkingDone,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
case "done":
|
case "done":
|
||||||
@@ -240,6 +309,11 @@ export function AgentPanel({ configVersion = 0 }: AgentPanelProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Agents that have streaming content to show (thinking or log)
|
||||||
|
const activeAgents = Object.values(agents).filter(
|
||||||
|
(a) => a.thinking.length > 0 || a.log.length > 0,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -396,6 +470,36 @@ export function AgentPanel({ configVersion = 0 }: AgentPanelProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Per-agent streaming output: thinking trace + regular text */}
|
||||||
|
{activeAgents.map((agent) => (
|
||||||
|
<div
|
||||||
|
key={`stream-${agent.agentName}`}
|
||||||
|
data-testid={`agent-stream-${agent.agentName}`}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "4px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{agent.thinking.length > 0 && <ThinkingBlock text={agent.thinking} />}
|
||||||
|
{agent.log.length > 0 && (
|
||||||
|
<div
|
||||||
|
data-testid={`agent-output-${agent.agentName}`}
|
||||||
|
style={{
|
||||||
|
fontSize: "0.8em",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
color: "#ccc",
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
wordBreak: "break-word",
|
||||||
|
lineHeight: "1.5",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{agent.log.join("")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
{actionError && (
|
{actionError && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -115,6 +115,12 @@ pub enum AgentEvent {
|
|||||||
agent_name: String,
|
agent_name: String,
|
||||||
message: String,
|
message: String,
|
||||||
},
|
},
|
||||||
|
/// Thinking tokens from an extended-thinking block.
|
||||||
|
Thinking {
|
||||||
|
story_id: String,
|
||||||
|
agent_name: String,
|
||||||
|
text: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||||
@@ -3357,7 +3363,23 @@ fn run_agent_pty_blocking(
|
|||||||
&& let Some(content) = message.get("content").and_then(|c| c.as_array())
|
&& let Some(content) = message.get("content").and_then(|c| c.as_array())
|
||||||
{
|
{
|
||||||
for block in content {
|
for block in content {
|
||||||
if let Some(text) = block.get("text").and_then(|t| t.as_str()) {
|
let block_type = block.get("type").and_then(|t| t.as_str()).unwrap_or("");
|
||||||
|
if block_type == "thinking" {
|
||||||
|
if let Some(thinking) =
|
||||||
|
block.get("thinking").and_then(|t| t.as_str())
|
||||||
|
{
|
||||||
|
emit_event(
|
||||||
|
AgentEvent::Thinking {
|
||||||
|
story_id: story_id.to_string(),
|
||||||
|
agent_name: agent_name.to_string(),
|
||||||
|
text: thinking.to_string(),
|
||||||
|
},
|
||||||
|
tx,
|
||||||
|
event_log,
|
||||||
|
log_writer,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if let Some(text) = block.get("text").and_then(|t| t.as_str()) {
|
||||||
emit_event(
|
emit_event(
|
||||||
AgentEvent::Output {
|
AgentEvent::Output {
|
||||||
story_id: story_id.to_string(),
|
story_id: story_id.to_string(),
|
||||||
|
|||||||
Reference in New Issue
Block a user