-
Notifications
You must be signed in to change notification settings - Fork 0
feat(harness): rail only #216
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: local/amicode
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -31,10 +31,12 @@ import { Button } from "@opencode-ai/ui/button" | |
| import { Card } from "@opencode-ai/ui/card" | ||
| import { | ||
| ContextToolGroup, | ||
| EditToolGroup, | ||
| Message, | ||
| MessageDivider, | ||
| Part as MessagePart, | ||
| partDefaultOpen, | ||
| ShellToolGroup, | ||
| type UserActions, | ||
| } from "@opencode-ai/session-ui/message-part" | ||
| import { DiffChanges } from "@opencode-ai/ui/diff-changes" | ||
|
|
@@ -1396,6 +1398,115 @@ export function MessageTimeline(props: { | |
| </TimelineRowFrame> | ||
| ) | ||
| } | ||
| case "StepFrame": { | ||
| const stepFrameRow = row as Accessor<TimelineRowByTag<"StepFrame">> | ||
| const frame = () => stepFrameRow() | ||
| const isRunning = () => frame().state === "running" | ||
| const isError = () => frame().state === "error" | ||
| const dotActive = () => isRunning() && frame().lastStep | ||
| return ( | ||
| <TimelineRowFrame row={stepFrameRow as unknown as Accessor<FramedTimelineRow>}> | ||
| <div data-slot="session-turn-message-container" class="w-full px-3 md:px-4 min-w-0 overflow-hidden"> | ||
| <div | ||
| data-slot="harness-step-frame" | ||
| data-state={frame().state} | ||
| data-step-index={frame().stepIndex} | ||
| class="relative pl-4 border-l min-w-0 max-w-full overflow-hidden break-words transition-colors" | ||
| style={{ | ||
| "border-color": isError() ? "var(--v2-state-border-danger)" : isRunning() ? "var(--accent-edge)" : "var(--v2-text-text-base)", | ||
| }} | ||
| > | ||
| <span | ||
| data-slot="harness-step-dot" | ||
| data-active={dotActive() ? "true" : undefined} | ||
| class="absolute -left-[6px] top-[14px] size-2.5 rounded-full border-2 shrink-0" | ||
| style={{ | ||
| "border-color": isError() ? "var(--v2-state-fg-danger)" : dotActive() ? "var(--accent-fill-strong)" : "var(--v2-text-text-base)", | ||
| background: dotActive() ? "var(--accent-fill-strong)" : "transparent", | ||
| }} | ||
| /> | ||
| <div class="flex items-center gap-2 py-1.5 min-w-0"> | ||
| <span class="text-[11px] font-medium tracking-wide text-v2-text-text-faint shrink-0">Step {frame().stepIndex + 1}</span> | ||
| <span class="text-[11px] text-v2-text-text-muted truncate min-w-0"> | ||
| {frame().reasoningHeading ? frame().reasoningHeading : frame().groups.length === 1 && frame().groups[0]?.type === "part" ? "response" : `${frame().groups.length} groups`} | ||
| </span> | ||
|
|
||
| </div> | ||
| <Show when={frame().reasoningHeading}> | ||
| <div class="pb-2 text-[12px] italic leading-5 text-v2-text-text-muted break-words whitespace-normal max-w-full overflow-hidden">{frame().reasoningHeading}</div> | ||
| </Show> | ||
| <div class="flex flex-col gap-1.5 pb-3 min-w-0 max-w-full overflow-hidden"> | ||
| <For each={frame().groups}> | ||
| {(group) => { | ||
| if (group.type === "context") { | ||
| const parts = () => | ||
| group.refs | ||
| .map((ref) => getMsgPart(ref.messageID, ref.partID)) | ||
| .filter((p): p is ToolPart => p?.type === "tool") | ||
| const key = () => `context:${group.key}` | ||
| const open = () => toolOpen[key()] === true | ||
| return ( | ||
| <ContextToolGroup | ||
| parts={parts()} | ||
| open={open()} | ||
| onOpenChange={(v) => setToolOpen(key(), v)} | ||
| busy={workingTurn(frame().userMessageID) && frame().state === "running"} | ||
| onSizeChange={onSizeChange} | ||
| /> | ||
| ) | ||
| } | ||
| if (group.type === "shell") { | ||
| const parts = () => | ||
| group.refs | ||
| .map((ref) => getMsgPart(ref.messageID, ref.partID)) | ||
| .filter((p): p is ToolPart => p?.type === "tool") | ||
| return <ShellToolGroup parts={parts()} busy={workingTurn(frame().userMessageID) && frame().state === "running"} onSizeChange={onSizeChange} /> | ||
| } | ||
| if (group.type === "edit") { | ||
| const parts = () => | ||
| group.refs | ||
| .map((ref) => getMsgPart(ref.messageID, ref.partID)) | ||
| .filter((p): p is ToolPart => p?.type === "tool") | ||
| return <EditToolGroup parts={parts()} busy={workingTurn(frame().userMessageID) && frame().state === "running"} onSizeChange={onSizeChange} /> | ||
| } | ||
|
Comment on lines
+1438
to
+1471
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Confirm ShellToolGroup and EditToolGroup expose no controlled open prop.
set -euo pipefail
ast-grep run --pattern 'export function ShellToolGroup($_) { $$$ }' --lang tsx packages/session-ui/src/components/message-part.tsx | head -40
ast-grep run --pattern 'export function EditToolGroup($_) { $$$ }' --lang tsx packages/session-ui/src/components/message-part.tsx | head -40Repository: harmoniqs/opencode Length of output: 7870 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- timeline group rendering ---'
sed -n '1400,1490p' packages/app/src/pages/session/timeline/message-timeline.tsx
printf '%s\n' '--- groupParts and projection callers ---'
rg -n -C 12 'groupParts|frame\(\)\.groups|PartGroup' packages/app/src/pages/session/timeline packages -g '*.ts' -g '*.tsx' | head -240
printf '%s\n' '--- relevant package versions and For implementation references ---'
rg -n '"solid-js"|<For|<Index|function For|const For' package.json packages/*/package.json packages/app packages/session-ui -g '*.json' -g '*.ts' -g '*.tsx' | head -200Repository: harmoniqs/opencode Length of output: 50374 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- StepFrame construction and row reconciliation ---'
sed -n '160,225p' packages/app/src/pages/session/timeline/rows.ts
fd -i 'row-reconciliation' packages/app/src/pages/session/timeline --type f --exec sh -c 'echo "--- $1"; cat -n "$1"' sh {} \;
rg -n -C 8 'constructSessionMessageRows|reuseTimelineRows|row\(\)|set.*rows|rows\(' packages/app/src/pages/session/timeline packages/app/src/pages/session -g '*.ts' -g '*.tsx' | head -240
printf '%s\n' '--- groupParts implementation and PartGroup identity fields ---'
rg -n -C 30 'function groupParts|export function groupParts|type PartGroup|interface PartGroup' packages/session-ui/src/components/message-part.tsx packages/session-ui/src -g '*.ts' -g '*.tsx' | head -240Repository: harmoniqs/opencode Length of output: 50374 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- all row-reconciliation callers ---'
rg -n -C 15 'reuseTimelineRows' packages/app/src -g '*.ts' -g '*.tsx'
printf '%s\n' '--- TimelineRow equality and key logic ---'
rg -n -C 20 'export namespace TimelineRow|function equals|equals\(|function key|key\(' packages/app/src/pages/session/timeline/timeline-row.ts packages/app/src/pages/session/timeline -g '*.ts' | head -220
printf '%s\n' '--- focused groupParts remainder ---'
sed -n '78,190p' packages/session-ui/src/components/message-part-groups.ts
printf '%s\n' '--- StepFrame type and row projection types ---'
rg -n -C 12 'StepFrame|timelineRowByKey|TimelineRowView|constructSessionMessageRows' packages/app/src/pages/session/timeline/message-timeline.tsx packages/app/src/pages/session/timeline -g '*.ts' -g '*.tsx' | head -220Repository: harmoniqs/opencode Length of output: 50374 🏁 Script executed: #!/bin/bash
set -euo pipefail
python3 - <<'PY'
from pathlib import Path
source = Path("packages/session-ui/src/components/message-part-groups.ts").read_text()
timeline = Path("packages/app/src/pages/session/timeline/message-timeline.tsx").read_text()
checks = {
"groupParts creates a fresh result array": "const result: PartGroup[] = []" in source,
"groupParts creates fresh group objects": "result.push({" in source and "refs: slice.map" in source,
"StepFrame calls groupParts": "const groups = groupParts(" in Path("packages/app/src/pages/session/timeline/rows.ts").read_text(),
"shell group has no controlled open prop": "export function ShellToolGroup(props: { parts: ToolPart[]; busy?: boolean; onSizeChange?: () => void })" in Path("packages/session-ui/src/components/message-part.tsx").read_text(),
"edit group has no controlled open prop": "export function EditToolGroup(props: { parts: ToolPart[]; busy?: boolean; onSizeChange?: () => void })" in Path("packages/session-ui/src/components/message-part.tsx").read_text(),
"StepFrame uses For": "<For each={frame().groups}>" in timeline,
"proposed Index branch snapshots the accessor": "const group = groupAccessor()" in timeline,
}
for name, value in checks.items():
print(f"{name}: {'yes' if value else 'no'}")
# Show whether the proposed Index callback reads the accessor only in the
# callback body, rather than using it in a reactive JSX expression.
start = timeline.index("<For each={frame().groups}>")
end = timeline.index("</For>", start)
block = timeline[start:end]
print("proposed Index callback uses groupAccessor() in branch selection:",
"const group = groupAccessor()" in block)
PYRepository: harmoniqs/opencode Length of output: 511 🌐 Web query:
💡 Result: In SolidJS 1.9, the Citations:
Preserve shell and edit expansion state across streaming group changes. When a tool is appended, 🤖 Prompt for AI Agents |
||
| const msg = () => messageByID().get(group.ref.messageID) | ||
| const part = () => getMsgPart(group.ref.messageID, group.ref.partID) | ||
| const defaultOpen = () => { | ||
| const item = part() | ||
| if (!item) return | ||
| return partDefaultOpen(item, settings.general.shellToolPartsExpanded(), settings.general.editToolPartsExpanded()) | ||
| } | ||
| return ( | ||
| <Show when={msg()}> | ||
| {(message) => ( | ||
| <Show when={part()}> | ||
| {(part) => ( | ||
| <MessagePart | ||
| part={part()} | ||
| message={message()} | ||
| showAssistantCopyPartID={assistantCopyPartID(frame().userMessageID)} | ||
| turnDurationMs={turnDurationMs(frame().userMessageID)} | ||
| useV2Actions={settings.general.newLayoutDesigns()} | ||
| defaultOpen={defaultOpen()} | ||
| toolOpen={toolOpen[part().id] ?? defaultOpen()} | ||
| onToolOpenChange={(open) => setToolOpen(part().id, open)} | ||
| deferToolContent | ||
| virtualizeDiff={false} | ||
| onContentRendered={onSizeChange} | ||
| /> | ||
| )} | ||
| </Show> | ||
| )} | ||
| </Show> | ||
| ) | ||
| }} | ||
| </For> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| </TimelineRowFrame> | ||
| ) | ||
| } | ||
| case "Thinking": { | ||
| const thinkingRow = row as Accessor<TimelineRowByTag<"Thinking">> | ||
| return ( | ||
|
|
@@ -2067,6 +2178,7 @@ export function MessageTimeline(props: { | |
| )} | ||
| </Show> | ||
| </div> | ||
|
|
||
| {/* amicode: problem-header rail (renders only when the session has | ||
| amicode_* parts) + ask/ui bridges — ported from the pre-merge | ||
| message-timeline (AMICODE-PATCHES.md "Upstream sync 2026-08-01") */} | ||
|
|
@@ -2171,6 +2283,7 @@ export function MessageTimeline(props: { | |
| </Show> | ||
| </div> | ||
| </Show> | ||
|
|
||
| {/* amicode#271: no-header fallback (new untitled sessions only) */} | ||
| <Show when={!showHeader() && activeBubble()}> | ||
| {(_) => { | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,5 +1,6 @@ | ||||||||||||||||||||||||||||||||||||||||||||||
| import { describe, expect, mock, test } from "bun:test" | ||||||||||||||||||||||||||||||||||||||||||||||
| import type { SessionMessageInfo } from "@opencode-ai/client/promise" | ||||||||||||||||||||||||||||||||||||||||||||||
| import type { UserMessage } from "@opencode-ai/sdk/v2" | ||||||||||||||||||||||||||||||||||||||||||||||
| import { normalizeSessionMessages } from "@/utils/session-message" | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| mock.module("@opencode-ai/session-ui/message-part", () => ({ | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -168,6 +169,201 @@ describe("current session timeline rows", () => { | |||||||||||||||||||||||||||||||||||||||||||||
| ]) | ||||||||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| test("step rail: all steps in a busy turn show running state", () => { | ||||||||||||||||||||||||||||||||||||||||||||||
| // When session is busy (agent working after user's message), ALL steps | ||||||||||||||||||||||||||||||||||||||||||||||
| // show "running" — the entire rail is yellow. Once session goes idle, all flip to white. | ||||||||||||||||||||||||||||||||||||||||||||||
| const userMsg = { id: "msg_u", type: "user" as const, text: "hello", time: { created: 1 } } | ||||||||||||||||||||||||||||||||||||||||||||||
| const assistantMsg = { | ||||||||||||||||||||||||||||||||||||||||||||||
| id: "msg_a", | ||||||||||||||||||||||||||||||||||||||||||||||
| type: "assistant" as const, | ||||||||||||||||||||||||||||||||||||||||||||||
| agent: "build", | ||||||||||||||||||||||||||||||||||||||||||||||
| model: { id: "model", providerID: "provider" }, | ||||||||||||||||||||||||||||||||||||||||||||||
| content: [{ type: "text", text: "working on it" }], | ||||||||||||||||||||||||||||||||||||||||||||||
| time: { created: 2 }, | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
| const source = [userMsg, assistantMsg] as unknown as SessionMessageInfo[] | ||||||||||||||||||||||||||||||||||||||||||||||
| const normalized = normalizeSessionMessages("ses_1", source) | ||||||||||||||||||||||||||||||||||||||||||||||
| const messages = new Map(normalized.messages.map((m) => [m.id, m])) | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| // Inject step-start before the text part (simulating the event reducer) | ||||||||||||||||||||||||||||||||||||||||||||||
| const baseParts = normalized.parts.get("msg_a") ?? [] | ||||||||||||||||||||||||||||||||||||||||||||||
| const partsWithStep = [ | ||||||||||||||||||||||||||||||||||||||||||||||
| { id: "step_0", sessionID: "ses_1", messageID: "msg_a", type: "step-start" as const }, | ||||||||||||||||||||||||||||||||||||||||||||||
| ...baseParts, | ||||||||||||||||||||||||||||||||||||||||||||||
| ] | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| const result = Timeline.constructMessageRows( | ||||||||||||||||||||||||||||||||||||||||||||||
| messages.get("msg_u")! as UserMessage, | ||||||||||||||||||||||||||||||||||||||||||||||
| (messageID) => (messageID === "msg_a" ? partsWithStep : normalized.parts.get(messageID) ?? []), | ||||||||||||||||||||||||||||||||||||||||||||||
| [messages.get("msg_a")!] as any, | ||||||||||||||||||||||||||||||||||||||||||||||
| 0, | ||||||||||||||||||||||||||||||||||||||||||||||
| true, | ||||||||||||||||||||||||||||||||||||||||||||||
| "busy", | ||||||||||||||||||||||||||||||||||||||||||||||
| true, | ||||||||||||||||||||||||||||||||||||||||||||||
| true, | ||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+195
to
+204
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win Replace the Line 198 casts the assistant message array to As per coding guidelines: "Avoid using the ♻️ Proposed fix for one site; apply the same pattern to the other four+import type { AssistantMessage, UserMessage } from "`@opencode-ai/sdk/v2`"+ const assistant = messages.get("msg_a")
+ if (assistant?.role !== "assistant") throw new Error("expected assistant message")
const result = Timeline.constructMessageRows(
messages.get("msg_u")! as UserMessage,
(messageID) => (messageID === "msg_a" ? partsWithStep : normalized.parts.get(messageID) ?? []),
- [messages.get("msg_a")!] as any,
+ [assistant satisfies AssistantMessage],
0,📝 Committable suggestion
Suggested change
🤖 Prompt for AI AgentsSource: Coding guidelines |
||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| const stepFrames = result.filter((row) => row._tag === "StepFrame") | ||||||||||||||||||||||||||||||||||||||||||||||
| expect(stepFrames.length).toBeGreaterThan(0) | ||||||||||||||||||||||||||||||||||||||||||||||
| expect(stepFrames[0]!.state).toBe("running") | ||||||||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| test("step rail: busy turn with tool step also shows running state", () => { | ||||||||||||||||||||||||||||||||||||||||||||||
| const userMsg = { id: "msg_u", type: "user" as const, text: "hello", time: { created: 1 } } | ||||||||||||||||||||||||||||||||||||||||||||||
| const assistantMsg = { | ||||||||||||||||||||||||||||||||||||||||||||||
| id: "msg_a", | ||||||||||||||||||||||||||||||||||||||||||||||
| type: "assistant" as const, | ||||||||||||||||||||||||||||||||||||||||||||||
| agent: "build", | ||||||||||||||||||||||||||||||||||||||||||||||
| model: { id: "model", providerID: "provider" }, | ||||||||||||||||||||||||||||||||||||||||||||||
| content: [{ type: "tool", id: "tool_1", name: "bash", time: { created: 2 }, state: { status: "running", input: { command: "ls" }, metadata: {} } }], | ||||||||||||||||||||||||||||||||||||||||||||||
| time: { created: 2 }, | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
| const source = [userMsg, assistantMsg] as unknown as SessionMessageInfo[] | ||||||||||||||||||||||||||||||||||||||||||||||
| const normalized = normalizeSessionMessages("ses_1", source) | ||||||||||||||||||||||||||||||||||||||||||||||
| const messages = new Map(normalized.messages.map((m) => [m.id, m])) | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| const baseParts = normalized.parts.get("msg_a") ?? [] | ||||||||||||||||||||||||||||||||||||||||||||||
| const partsWithStep = [ | ||||||||||||||||||||||||||||||||||||||||||||||
| { id: "step_0", sessionID: "ses_1", messageID: "msg_a", type: "step-start" as const }, | ||||||||||||||||||||||||||||||||||||||||||||||
| ...baseParts, | ||||||||||||||||||||||||||||||||||||||||||||||
| ] | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| const result = Timeline.constructMessageRows( | ||||||||||||||||||||||||||||||||||||||||||||||
| messages.get("msg_u")! as UserMessage, | ||||||||||||||||||||||||||||||||||||||||||||||
| (messageID) => (messageID === "msg_a" ? partsWithStep : normalized.parts.get(messageID) ?? []), | ||||||||||||||||||||||||||||||||||||||||||||||
| [messages.get("msg_a")!] as any, | ||||||||||||||||||||||||||||||||||||||||||||||
| 0, | ||||||||||||||||||||||||||||||||||||||||||||||
| true, | ||||||||||||||||||||||||||||||||||||||||||||||
| "busy", | ||||||||||||||||||||||||||||||||||||||||||||||
| true, | ||||||||||||||||||||||||||||||||||||||||||||||
| true, | ||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| const stepFrames = result.filter((row) => row._tag === "StepFrame") | ||||||||||||||||||||||||||||||||||||||||||||||
| expect(stepFrames.length).toBeGreaterThan(0) | ||||||||||||||||||||||||||||||||||||||||||||||
| expect(stepFrames[0]!.state).toBe("running") | ||||||||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| test("step rail: completed step shows done state", () => { | ||||||||||||||||||||||||||||||||||||||||||||||
| const userMsg = { id: "msg_u", type: "user" as const, text: "hello", time: { created: 1 } } | ||||||||||||||||||||||||||||||||||||||||||||||
| const assistantMsg = { | ||||||||||||||||||||||||||||||||||||||||||||||
| id: "msg_a", | ||||||||||||||||||||||||||||||||||||||||||||||
| type: "assistant" as const, | ||||||||||||||||||||||||||||||||||||||||||||||
| agent: "build", | ||||||||||||||||||||||||||||||||||||||||||||||
| model: { id: "model", providerID: "provider" }, | ||||||||||||||||||||||||||||||||||||||||||||||
| content: [{ type: "text", text: "done" }], | ||||||||||||||||||||||||||||||||||||||||||||||
| time: { created: 2, completed: 3 }, | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
| const source = [userMsg, assistantMsg] as unknown as SessionMessageInfo[] | ||||||||||||||||||||||||||||||||||||||||||||||
| const normalized = normalizeSessionMessages("ses_1", source) | ||||||||||||||||||||||||||||||||||||||||||||||
| const messages = new Map(normalized.messages.map((m) => [m.id, m])) | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| const baseParts = normalized.parts.get("msg_a") ?? [] | ||||||||||||||||||||||||||||||||||||||||||||||
| const partsWithStep = [ | ||||||||||||||||||||||||||||||||||||||||||||||
| { id: "step_0", sessionID: "ses_1", messageID: "msg_a", type: "step-start" as const }, | ||||||||||||||||||||||||||||||||||||||||||||||
| ...baseParts, | ||||||||||||||||||||||||||||||||||||||||||||||
| { id: "step_0_end", sessionID: "ses_1", messageID: "msg_a", type: "step-finish" as const, reason: "end_turn", cost: 0, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } }, | ||||||||||||||||||||||||||||||||||||||||||||||
| ] | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| // Session is idle — the step should be "done" | ||||||||||||||||||||||||||||||||||||||||||||||
| const result = Timeline.constructMessageRows( | ||||||||||||||||||||||||||||||||||||||||||||||
| messages.get("msg_u")! as UserMessage, | ||||||||||||||||||||||||||||||||||||||||||||||
| (messageID) => (messageID === "msg_a" ? partsWithStep : normalized.parts.get(messageID) ?? []), | ||||||||||||||||||||||||||||||||||||||||||||||
| [messages.get("msg_a")!] as any, | ||||||||||||||||||||||||||||||||||||||||||||||
| 0, | ||||||||||||||||||||||||||||||||||||||||||||||
| true, | ||||||||||||||||||||||||||||||||||||||||||||||
| "idle", | ||||||||||||||||||||||||||||||||||||||||||||||
| true, | ||||||||||||||||||||||||||||||||||||||||||||||
| true, | ||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| const stepFrames = result.filter((row) => row._tag === "StepFrame") | ||||||||||||||||||||||||||||||||||||||||||||||
| expect(stepFrames.length).toBeGreaterThan(0) | ||||||||||||||||||||||||||||||||||||||||||||||
| expect(stepFrames[0]!.state).toBe("done") | ||||||||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+247
to
+283
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win The The test name says "completed step shows done state" and the input adds a 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| test("step rail: step with errored tool shows error state", () => { | ||||||||||||||||||||||||||||||||||||||||||||||
| const userMsg = { id: "msg_u", type: "user" as const, text: "hello", time: { created: 1 } } | ||||||||||||||||||||||||||||||||||||||||||||||
| const assistantMsg = { | ||||||||||||||||||||||||||||||||||||||||||||||
| id: "msg_a", | ||||||||||||||||||||||||||||||||||||||||||||||
| type: "assistant" as const, | ||||||||||||||||||||||||||||||||||||||||||||||
| agent: "build", | ||||||||||||||||||||||||||||||||||||||||||||||
| model: { id: "model", providerID: "provider" }, | ||||||||||||||||||||||||||||||||||||||||||||||
| content: [{ type: "tool", id: "tool_1", name: "bash", time: { created: 2, completed: 3 }, state: { status: "error", input: { command: "fail" }, error: { message: "failed" }, metadata: {} } }], | ||||||||||||||||||||||||||||||||||||||||||||||
| time: { created: 2 }, | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
| const source = [userMsg, assistantMsg] as unknown as SessionMessageInfo[] | ||||||||||||||||||||||||||||||||||||||||||||||
| const normalized = normalizeSessionMessages("ses_1", source) | ||||||||||||||||||||||||||||||||||||||||||||||
| const messages = new Map(normalized.messages.map((m) => [m.id, m])) | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| const baseParts = normalized.parts.get("msg_a") ?? [] | ||||||||||||||||||||||||||||||||||||||||||||||
| const partsWithStep = [ | ||||||||||||||||||||||||||||||||||||||||||||||
| { id: "step_0", sessionID: "ses_1", messageID: "msg_a", type: "step-start" as const }, | ||||||||||||||||||||||||||||||||||||||||||||||
| ...baseParts, | ||||||||||||||||||||||||||||||||||||||||||||||
| ] | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| const result = Timeline.constructMessageRows( | ||||||||||||||||||||||||||||||||||||||||||||||
| messages.get("msg_u")! as UserMessage, | ||||||||||||||||||||||||||||||||||||||||||||||
| (messageID) => (messageID === "msg_a" ? partsWithStep : normalized.parts.get(messageID) ?? []), | ||||||||||||||||||||||||||||||||||||||||||||||
| [messages.get("msg_a")!] as any, | ||||||||||||||||||||||||||||||||||||||||||||||
| 0, | ||||||||||||||||||||||||||||||||||||||||||||||
| true, | ||||||||||||||||||||||||||||||||||||||||||||||
| "busy", | ||||||||||||||||||||||||||||||||||||||||||||||
| true, | ||||||||||||||||||||||||||||||||||||||||||||||
| true, | ||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| const stepFrames = result.filter((row) => row._tag === "StepFrame") | ||||||||||||||||||||||||||||||||||||||||||||||
| expect(stepFrames.length).toBeGreaterThan(0) | ||||||||||||||||||||||||||||||||||||||||||||||
| expect(stepFrames[0]!.state).toBe("error") | ||||||||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| test("step rail: all steps in a busy turn show running state (yellow rail)", () => { | ||||||||||||||||||||||||||||||||||||||||||||||
| // Two steps: step 0 (text, finished) and step 1 (text, still open). | ||||||||||||||||||||||||||||||||||||||||||||||
| // Both should be "running" because the session is busy — the entire turn's | ||||||||||||||||||||||||||||||||||||||||||||||
| // rail is yellow until the agent finishes and the session goes idle. | ||||||||||||||||||||||||||||||||||||||||||||||
| const userMsg = { id: "msg_u", type: "user" as const, text: "hello", time: { created: 1 } } | ||||||||||||||||||||||||||||||||||||||||||||||
| const assistantMsg = { | ||||||||||||||||||||||||||||||||||||||||||||||
| id: "msg_a", | ||||||||||||||||||||||||||||||||||||||||||||||
| type: "assistant" as const, | ||||||||||||||||||||||||||||||||||||||||||||||
| agent: "build", | ||||||||||||||||||||||||||||||||||||||||||||||
| model: { id: "model", providerID: "provider" }, | ||||||||||||||||||||||||||||||||||||||||||||||
| content: [{ type: "text", text: "first" }, { type: "text", text: "second" }], | ||||||||||||||||||||||||||||||||||||||||||||||
| time: { created: 2 }, | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
| const source = [userMsg, assistantMsg] as unknown as SessionMessageInfo[] | ||||||||||||||||||||||||||||||||||||||||||||||
| const normalized = normalizeSessionMessages("ses_1", source) | ||||||||||||||||||||||||||||||||||||||||||||||
| const messages = new Map(normalized.messages.map((m) => [m.id, m])) | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| const baseParts = normalized.parts.get("msg_a") ?? [] | ||||||||||||||||||||||||||||||||||||||||||||||
| // Two step slices: step 0 has part[0], step 1 has part[1] | ||||||||||||||||||||||||||||||||||||||||||||||
| const partsWithSteps = [ | ||||||||||||||||||||||||||||||||||||||||||||||
| { id: "step_0", sessionID: "ses_1", messageID: "msg_a", type: "step-start" as const }, | ||||||||||||||||||||||||||||||||||||||||||||||
| baseParts[0]!, | ||||||||||||||||||||||||||||||||||||||||||||||
| { id: "step_0_end", sessionID: "ses_1", messageID: "msg_a", type: "step-finish" as const, reason: "end_turn", cost: 0, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } }, | ||||||||||||||||||||||||||||||||||||||||||||||
| { id: "step_1", sessionID: "ses_1", messageID: "msg_a", type: "step-start" as const }, | ||||||||||||||||||||||||||||||||||||||||||||||
| baseParts[1]!, | ||||||||||||||||||||||||||||||||||||||||||||||
| ] | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| const result = Timeline.constructMessageRows( | ||||||||||||||||||||||||||||||||||||||||||||||
| messages.get("msg_u")! as UserMessage, | ||||||||||||||||||||||||||||||||||||||||||||||
| (messageID) => (messageID === "msg_a" ? partsWithSteps : normalized.parts.get(messageID) ?? []), | ||||||||||||||||||||||||||||||||||||||||||||||
| [messages.get("msg_a")!] as any, | ||||||||||||||||||||||||||||||||||||||||||||||
| 0, | ||||||||||||||||||||||||||||||||||||||||||||||
| true, | ||||||||||||||||||||||||||||||||||||||||||||||
| "busy", | ||||||||||||||||||||||||||||||||||||||||||||||
| true, | ||||||||||||||||||||||||||||||||||||||||||||||
| true, | ||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| const stepFrames = result.filter((row) => row._tag === "StepFrame") | ||||||||||||||||||||||||||||||||||||||||||||||
| expect(stepFrames.length).toBe(2) | ||||||||||||||||||||||||||||||||||||||||||||||
| expect(stepFrames[0]!.state).toBe("running") | ||||||||||||||||||||||||||||||||||||||||||||||
| expect(stepFrames[0]!.lastStep).toBe(false) | ||||||||||||||||||||||||||||||||||||||||||||||
| expect(stepFrames[1]!.state).toBe("running") | ||||||||||||||||||||||||||||||||||||||||||||||
| expect(stepFrames[1]!.lastStep).toBe(true) | ||||||||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| test("removes a failed assistant error when the turn continues streaming", () => { | ||||||||||||||||||||||||||||||||||||||||||||||
| const source = [ | ||||||||||||||||||||||||||||||||||||||||||||||
| { id: "msg_user", type: "user", text: "recover", time: { created: 1 } }, | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
The
busyflag marks every group in a running step as active.workingTurn(frame().userMessageID) && frame().state === "running"is the same value for all groups in the frame.ContextToolGroup,ShellToolGroup, andEditToolGrouptreatbusyas pending and render the active title, for example "Gathering context" and "Working in shell". Groups that already completed inside the running step keep the active title until the turn ends.The legacy path at Line 1215 restricts
busyto the last group of the turn. Apply the same restriction here, for example by marking only the last group of the last step.Also applies to: 1463-1463, 1470-1470
🤖 Prompt for AI Agents