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

@@ -151,6 +151,11 @@ function shouldShowBadge(entry: AgentLogEntry, previousEntry?: AgentLogEntry): b
return !previousEntry || previousEntry.agent !== entry.agent || previousEntry.type !== entry.type;
}
interface RenderEntry {
entry: AgentLogEntry;
hiddenToolBoundaryId: number;
}
type AgentLogRenderGroup =
| {
kind: "single";
@@ -165,28 +170,35 @@ type AgentLogRenderGroup =
showBadge: boolean;
};
function buildRenderGroups(entries: AgentLogEntry[], entryKeys: string[]): AgentLogRenderGroup[] {
function buildRenderGroups(renderEntries: RenderEntry[], entryKeys: string[]): AgentLogRenderGroup[] {
const groups: AgentLogRenderGroup[] = [];
for (let i = 0; i < entries.length; i += 1) {
const entry = entries[i];
for (let i = 0; i < renderEntries.length; i += 1) {
const { entry, hiddenToolBoundaryId } = renderEntries[i];
const rowKey = entryKeys[i] ?? `${getEntrySignature(entry)}|fallback`;
const previousEntry = i > 0 ? entries[i - 1] : undefined;
const showBadge = shouldShowBadge(entry, previousEntry);
const previousRenderEntry = i > 0 ? renderEntries[i - 1] : undefined;
const previousEntry = previousRenderEntry?.entry;
const showBadge = shouldShowBadge(entry, previousEntry)
|| (previousRenderEntry !== undefined && previousRenderEntry.hiddenToolBoundaryId !== hiddenToolBoundaryId);
if (entry.type === "text" || entry.type === "thinking") {
const groupedEntries: AgentLogEntry[] = [entry];
let j = i + 1;
while (j < entries.length) {
const nextEntry = entries[j];
if (nextEntry.type !== entry.type || nextEntry.agent !== entry.agent) {
while (j < renderEntries.length) {
const next = renderEntries[j];
const nextEntry = next.entry;
if (
nextEntry.type !== entry.type
|| nextEntry.agent !== entry.agent
|| next.hiddenToolBoundaryId !== hiddenToolBoundaryId
) {
break;
}
groupedEntries.push(nextEntry);
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({
kind: entry.type,
entries: groupedEntries,
@@ -281,9 +293,26 @@ export function AgentLogViewer({
writeBooleanPref(TOOL_OUTPUT_TOGGLE_STORAGE_KEY, 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(
() => (showToolOutput ? entries : entries.filter((e) => !isToolLikeType(e.type))),
[entries, showToolOutput],
() => renderEntries.map((renderEntry) => renderEntry.entry),
[renderEntries],
);
const chronologicalEntryKeys = useMemo(
@@ -292,8 +321,8 @@ export function AgentLogViewer({
);
const renderGroups = useMemo(
() => buildRenderGroups(visibleEntries, chronologicalEntryKeys),
[visibleEntries, chronologicalEntryKeys],
() => buildRenderGroups(renderEntries, chronologicalEntryKeys),
[renderEntries, chronologicalEntryKeys],
);
// 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();
});
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)", () => {
const entries = [
makeEntry({ text: "Read", type: "tool", agent: "executor", detail: "some/path" }),