feat(FN-3583): prevent worktree collisions on manual moves and make heartbe

The merge lands five commits: a fix (FN-3583) that preserves non-tool message boundaries when tools are hidden in CLI bundles, plus hardened test coverage for that regression and bundle asset bootstrapping; worktree collision prevention on manual task moves via a new `worktree-names.ts` module and s

Fusion-Task-Id: FN-3583
This commit is contained in:
Fusion
2026-05-06 07:31:42 -07:00
committed by gsxdsm
parent 4866f341bd
commit 39623ff015
3 changed files with 70 additions and 13 deletions

View File

@@ -46,6 +46,15 @@ export function buildCliWithRealDashboardAssets() {
runBuildCommand("pnpm --filter @fusion/dashboard build:client", workspaceRoot); runBuildCommand("pnpm --filter @fusion/dashboard build:client", workspaceRoot);
runBuildCommand("pnpm build", cliRoot); runBuildCommand("pnpm build", cliRoot);
if (hasBuiltDashboardAssets()) {
return;
}
// Fallback for environments where build:client alone does not refresh the
// dashboard dist/client bundle consumed by the CLI copy step.
runBuildCommand("pnpm --filter @fusion/dashboard build", workspaceRoot);
runBuildCommand("pnpm build", cliRoot);
} }
export function readClientIndexHtml() { export function readClientIndexHtml() {

View File

@@ -151,6 +151,11 @@ function shouldShowBadge(entry: AgentLogEntry, previousEntry?: AgentLogEntry): b
return !previousEntry || previousEntry.agent !== entry.agent || previousEntry.type !== entry.type; return !previousEntry || previousEntry.agent !== entry.agent || previousEntry.type !== entry.type;
} }
interface RenderEntry {
entry: AgentLogEntry;
hiddenToolBoundaryId: number;
}
type AgentLogRenderGroup = type AgentLogRenderGroup =
| { | {
kind: "single"; kind: "single";
@@ -165,28 +170,35 @@ type AgentLogRenderGroup =
showBadge: boolean; showBadge: boolean;
}; };
function buildRenderGroups(entries: AgentLogEntry[], entryKeys: string[]): AgentLogRenderGroup[] { function buildRenderGroups(renderEntries: RenderEntry[], entryKeys: string[]): AgentLogRenderGroup[] {
const groups: AgentLogRenderGroup[] = []; const groups: AgentLogRenderGroup[] = [];
for (let i = 0; i < entries.length; i += 1) { for (let i = 0; i < renderEntries.length; i += 1) {
const entry = entries[i]; const { entry, hiddenToolBoundaryId } = renderEntries[i];
const rowKey = entryKeys[i] ?? `${getEntrySignature(entry)}|fallback`; const rowKey = entryKeys[i] ?? `${getEntrySignature(entry)}|fallback`;
const previousEntry = i > 0 ? entries[i - 1] : undefined; const previousRenderEntry = i > 0 ? renderEntries[i - 1] : undefined;
const showBadge = shouldShowBadge(entry, previousEntry); const previousEntry = previousRenderEntry?.entry;
const showBadge = shouldShowBadge(entry, previousEntry)
|| (previousRenderEntry !== undefined && previousRenderEntry.hiddenToolBoundaryId !== hiddenToolBoundaryId);
if (entry.type === "text" || entry.type === "thinking") { if (entry.type === "text" || entry.type === "thinking") {
const groupedEntries: AgentLogEntry[] = [entry]; const groupedEntries: AgentLogEntry[] = [entry];
let j = i + 1; let j = i + 1;
while (j < entries.length) { while (j < renderEntries.length) {
const nextEntry = entries[j]; const next = renderEntries[j];
if (nextEntry.type !== entry.type || nextEntry.agent !== entry.agent) { const nextEntry = next.entry;
if (
nextEntry.type !== entry.type
|| nextEntry.agent !== entry.agent
|| next.hiddenToolBoundaryId !== hiddenToolBoundaryId
) {
break; break;
} }
groupedEntries.push(nextEntry); groupedEntries.push(nextEntry);
j += 1; j += 1;
} }
const endKey = entryKeys[j - 1] ?? `${getEntrySignature(entries[j - 1])}|fallback`; const endKey = entryKeys[j - 1] ?? `${getEntrySignature(renderEntries[j - 1].entry)}|fallback`;
groups.push({ groups.push({
kind: entry.type, kind: entry.type,
entries: groupedEntries, entries: groupedEntries,
@@ -281,9 +293,26 @@ export function AgentLogViewer({
writeBooleanPref(TOOL_OUTPUT_TOGGLE_STORAGE_KEY, showToolOutput); writeBooleanPref(TOOL_OUTPUT_TOGGLE_STORAGE_KEY, showToolOutput);
}, [showToolOutput]); }, [showToolOutput]);
const renderEntries = useMemo(() => {
if (showToolOutput) {
return entries.map((entry) => ({ entry, hiddenToolBoundaryId: 0 }));
}
const filtered: RenderEntry[] = [];
let hiddenToolBoundaryId = 0;
for (const entry of entries) {
if (isToolLikeType(entry.type)) {
hiddenToolBoundaryId += 1;
continue;
}
filtered.push({ entry, hiddenToolBoundaryId });
}
return filtered;
}, [entries, showToolOutput]);
const visibleEntries = useMemo( const visibleEntries = useMemo(
() => (showToolOutput ? entries : entries.filter((e) => !isToolLikeType(e.type))), () => renderEntries.map((renderEntry) => renderEntry.entry),
[entries, showToolOutput], [renderEntries],
); );
const chronologicalEntryKeys = useMemo( const chronologicalEntryKeys = useMemo(
@@ -292,8 +321,8 @@ export function AgentLogViewer({
); );
const renderGroups = useMemo( const renderGroups = useMemo(
() => buildRenderGroups(visibleEntries, chronologicalEntryKeys), () => buildRenderGroups(renderEntries, chronologicalEntryKeys),
[visibleEntries, chronologicalEntryKeys], [renderEntries, chronologicalEntryKeys],
); );
// Keep live-follow pinned to the bottom when new streamed entries append. // Keep live-follow pinned to the bottom when new streamed entries append.

View File

@@ -1623,6 +1623,25 @@ describe("AgentLogViewer", () => {
expect(container.querySelector(".agent-log-tool-error")).toBeTruthy(); expect(container.querySelector(".agent-log-tool-error")).toBeTruthy();
}); });
it("keeps the latest non-tool message visible as its own row when tools are hidden", () => {
const entries = [
makeEntry({ text: "Starting plan", type: "text", agent: "executor", timestamp: "2026-01-01T00:00:00Z" }),
makeEntry({ text: "read file", type: "tool", agent: "executor", timestamp: "2026-01-01T00:00:01Z" }),
makeEntry({ text: "Final answer", type: "text", agent: "executor", timestamp: "2026-01-01T00:00:02Z" }),
];
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
const toggle = container.querySelector("[data-testid='agent-log-tool-output-toggle']") as HTMLButtonElement;
fireEvent.click(toggle);
const textRows = container.querySelectorAll(".agent-log-text");
expect(textRows).toHaveLength(2);
expect(textRows[0].textContent).toContain("Starting plan");
expect(textRows[1].textContent).toContain("Final answer");
expect(container.querySelectorAll(".agent-log-agent-badge")).toHaveLength(2);
expect(container.querySelectorAll(".agent-log-timestamp")).toHaveLength(2);
});
it("does not render any tool log entries when off (only agent text)", () => { it("does not render any tool log entries when off (only agent text)", () => {
const entries = [ const entries = [
makeEntry({ text: "Read", type: "tool", agent: "executor", detail: "some/path" }), makeEntry({ text: "Read", type: "tool", agent: "executor", detail: "some/path" }),