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:
@@ -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.
|
||||
|
||||
@@ -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" }),
|
||||
|
||||
Reference in New Issue
Block a user