fix(cli): préserve l'affichage des progress legacy sans progressId via clés d'occurrence dédiées — #236
This commit is contained in:
@ -1342,8 +1342,8 @@ describe("CustomAgentChatView", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await screen.findByText("done");
|
await screen.findByText("done");
|
||||||
expect(screen.getAllByText("Worker ← QA")).toHaveLength(1);
|
expect(screen.getAllByText("Worker ← QA")).toHaveLength(2);
|
||||||
expect(screen.getAllByText("validate")).toHaveLength(1);
|
expect(screen.getAllByText("validate")).toHaveLength(2);
|
||||||
expect(screen.queryByText("Appel inter-agent")).toBeNull();
|
expect(screen.queryByText("Appel inter-agent")).toBeNull();
|
||||||
expect(screen.queryByText("reply delivered")).toBeNull();
|
expect(screen.queryByText("reply delivered")).toBeNull();
|
||||||
expect(screen.queryByText("Tool")).toBeNull();
|
expect(screen.queryByText("Tool")).toBeNull();
|
||||||
@ -1353,6 +1353,99 @@ describe("CustomAgentChatView", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps repeated MCP cycles visible when legacy progress has no progressId", async () => {
|
||||||
|
let emitChunk: ((chunk: unknown) => void) | null = null;
|
||||||
|
const agent = {
|
||||||
|
launchAgentChat: vi.fn(),
|
||||||
|
reattachAgentChat: vi.fn(async (sessionId: string) => ({
|
||||||
|
sessionId,
|
||||||
|
scrollback: [],
|
||||||
|
})),
|
||||||
|
sendAgentChat: vi.fn(
|
||||||
|
(_sessionId: string, _prompt: string, onChunk: (chunk: unknown) => void) =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
emitChunk = (chunk: unknown) => {
|
||||||
|
onChunk(chunk);
|
||||||
|
if ((chunk as { kind?: string }).kind === "final") resolve();
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
cancelAgentChat: vi.fn(async () => {}),
|
||||||
|
closeAgentChat: vi.fn(async () => {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<DIProvider
|
||||||
|
gateways={{
|
||||||
|
agent,
|
||||||
|
system: { pickFile: vi.fn(async () => null) },
|
||||||
|
} as unknown as Gateways}
|
||||||
|
>
|
||||||
|
<CustomAgentChatView
|
||||||
|
projectId="project-1"
|
||||||
|
agentId="agent-1"
|
||||||
|
agentName="Worker"
|
||||||
|
profile={profile}
|
||||||
|
cwd="/repo"
|
||||||
|
nodeId="node-1"
|
||||||
|
sessionId="chat-session-1"
|
||||||
|
conversationId="conversation-1"
|
||||||
|
onSessionId={vi.fn()}
|
||||||
|
onConversationId={vi.fn()}
|
||||||
|
/>
|
||||||
|
</DIProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(agent.reattachAgentChat).toHaveBeenCalledWith(
|
||||||
|
"chat-session-1",
|
||||||
|
expect.any(Function),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText(/message CLI custom/), {
|
||||||
|
target: { value: "read many tickets" },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||||||
|
await waitFor(() => expect(emitChunk).not.toBeNull());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
for (let index = 1; index <= 8; index += 1) {
|
||||||
|
emitChunk?.({
|
||||||
|
kind: "progress" as const,
|
||||||
|
progress: {
|
||||||
|
source: "ideaLocal" as const,
|
||||||
|
kind: "mcp" as const,
|
||||||
|
stage: "started" as const,
|
||||||
|
label: "idea_ticket_read",
|
||||||
|
toolName: "idea_ticket_read",
|
||||||
|
text: `reading ticket #${index}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
emitChunk?.({
|
||||||
|
kind: "progress" as const,
|
||||||
|
progress: {
|
||||||
|
source: "ideaLocal" as const,
|
||||||
|
kind: "mcp" as const,
|
||||||
|
stage: "completed" as const,
|
||||||
|
label: "idea_ticket_read",
|
||||||
|
toolName: "idea_ticket_read",
|
||||||
|
text: `read ticket #${index}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
emitChunk?.({ kind: "final", content: "done" });
|
||||||
|
});
|
||||||
|
|
||||||
|
await screen.findByText("done");
|
||||||
|
expect(screen.getAllByTestId("chat-tool-progress")).toHaveLength(8);
|
||||||
|
expect(screen.getAllByText("MCP")).toHaveLength(8);
|
||||||
|
expect(screen.getAllByText("idea_ticket_read")).toHaveLength(8);
|
||||||
|
expect(screen.getByText("read ticket #1")).toBeTruthy();
|
||||||
|
expect(screen.getByText("read ticket #8")).toBeTruthy();
|
||||||
|
expect(screen.queryByText("reading ticket #1")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("deduplicates identical entity progress chunks", async () => {
|
it("deduplicates identical entity progress chunks", async () => {
|
||||||
let emitChunk: ((chunk: unknown) => void) | null = null;
|
let emitChunk: ((chunk: unknown) => void) | null = null;
|
||||||
const agent = {
|
const agent = {
|
||||||
|
|||||||
@ -64,7 +64,7 @@ type ChatTurn =
|
|||||||
| { role: "user"; text: string; attachments?: string[]; receivedAtMs?: number }
|
| { role: "user"; text: string; attachments?: string[]; receivedAtMs?: number }
|
||||||
| { role: "agent"; text: string; pending?: boolean; receivedAtMs?: number }
|
| { role: "agent"; text: string; pending?: boolean; receivedAtMs?: number }
|
||||||
| { role: "tool"; label: string; receivedAtMs?: number }
|
| { role: "tool"; label: string; receivedAtMs?: number }
|
||||||
| { role: "progress"; progress: ReplyProgress; settled?: boolean }
|
| { role: "progress"; progress: ReplyProgress; settled?: boolean; progressKey?: string }
|
||||||
| { role: "final"; text: string; receivedAtMs?: number }
|
| { role: "final"; text: string; receivedAtMs?: number }
|
||||||
| { role: "error"; text: string; receivedAtMs?: number }
|
| { role: "error"; text: string; receivedAtMs?: number }
|
||||||
| { role: "unknown"; text: string; receivedAtMs?: number };
|
| { role: "unknown"; text: string; receivedAtMs?: number };
|
||||||
@ -291,11 +291,13 @@ function progressKey(progress: ReplyProgress): string {
|
|||||||
function progressUpsertKey(progress: ReplyProgress): string {
|
function progressUpsertKey(progress: ReplyProgress): string {
|
||||||
const progressId = progress.progressId?.trim();
|
const progressId = progress.progressId?.trim();
|
||||||
if (progressId) return progressId;
|
if (progressId) return progressId;
|
||||||
const entity = progressEntityKey(progress);
|
|
||||||
if (entity) return `legacy-entity:${entity}`;
|
|
||||||
return `legacy:${progressDisplayKey(progress)}`;
|
return `legacy:${progressDisplayKey(progress)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function turnProgressKey(turn: Extract<ChatTurn, { role: "progress" }>): string {
|
||||||
|
return turn.progressKey ?? progressUpsertKey(turn.progress);
|
||||||
|
}
|
||||||
|
|
||||||
function normalizedProgressToolName(progress: ReplyProgress): string | null {
|
function normalizedProgressToolName(progress: ReplyProgress): string | null {
|
||||||
const name = progress.toolName ?? progress.label;
|
const name = progress.toolName ?? progress.label;
|
||||||
const normalized = name.trim().toLowerCase();
|
const normalized = name.trim().toLowerCase();
|
||||||
@ -328,6 +330,38 @@ function progressDisplayKey(progress: ReplyProgress): string {
|
|||||||
return progressKey(progress);
|
return progressKey(progress);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function legacyActiveProgressKey(progress: ReplyProgress): string | null {
|
||||||
|
const entity = progressEntityKey(progress);
|
||||||
|
if (!entity) return null;
|
||||||
|
if (progress.stage !== "started" && progress.stage !== "delta") return null;
|
||||||
|
return `legacy:${entity}\u0000started`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function legacyCompletedDedupKey(progress: ReplyProgress): string | null {
|
||||||
|
const entity = progressEntityKey(progress);
|
||||||
|
if (!entity || progress.stage !== "completed") return null;
|
||||||
|
if (isDelegationProgress(progress)) return entity;
|
||||||
|
return `${entity}\u0000${progress.text ?? ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findLastProgressIndex(
|
||||||
|
turns: ChatTurn[],
|
||||||
|
predicate: (turn: Extract<ChatTurn, { role: "progress" }>) => boolean,
|
||||||
|
): number {
|
||||||
|
for (let index = turns.length - 1; index >= 0; index -= 1) {
|
||||||
|
const turn = turns[index];
|
||||||
|
if (turn.role === "progress" && predicate(turn)) return index;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function legacyCompletedOccurrenceKey(
|
||||||
|
progress: ReplyProgress,
|
||||||
|
entry: ProgressIndexEntry,
|
||||||
|
): string {
|
||||||
|
return `legacy-completed:${progressDisplayKey(progress)}\u0000${entry.firstOccurredAtMs}\u0000${entry.sequence}`;
|
||||||
|
}
|
||||||
|
|
||||||
interface ChatViewState {
|
interface ChatViewState {
|
||||||
turns: ChatTurn[];
|
turns: ChatTurn[];
|
||||||
progressIndex: Map<string, ProgressIndexEntry>;
|
progressIndex: Map<string, ProgressIndexEntry>;
|
||||||
@ -346,13 +380,41 @@ function conversationCacheKey(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function appendProgress(state: ChatViewState, progress: ReplyProgress): ChatViewState {
|
function appendProgress(state: ChatViewState, progress: ReplyProgress): ChatViewState {
|
||||||
const key = progressUpsertKey(progress);
|
let key = progressUpsertKey(progress);
|
||||||
const existingIndex = state.turns.findIndex(
|
let existingIndex = state.turns.findIndex(
|
||||||
(turn) => turn.role === "progress" && progressUpsertKey(turn.progress) === key,
|
(turn) => turn.role === "progress" && turnProgressKey(turn) === key,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const activeKey =
|
||||||
|
!progress.progressId && progress.stage === "completed"
|
||||||
|
? legacyActiveProgressKey({ ...progress, stage: "started" })
|
||||||
|
: null;
|
||||||
|
if (activeKey) {
|
||||||
|
const activeIndex = state.turns.findIndex(
|
||||||
|
(turn) => turn.role === "progress" && turnProgressKey(turn) === activeKey,
|
||||||
|
);
|
||||||
|
if (activeIndex >= 0) {
|
||||||
|
existingIndex = activeIndex;
|
||||||
|
key = activeKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const completedDedupKey = existingIndex < 0 ? legacyCompletedDedupKey(progress) : null;
|
||||||
|
if (completedDedupKey) {
|
||||||
|
existingIndex = findLastProgressIndex(
|
||||||
|
state.turns,
|
||||||
|
(turn) => legacyCompletedDedupKey(turn.progress) === completedDedupKey,
|
||||||
|
);
|
||||||
|
const existingTurn = existingIndex >= 0 ? state.turns[existingIndex] : null;
|
||||||
|
if (existingTurn?.role === "progress") {
|
||||||
|
key = turnProgressKey(existingTurn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingTurn = existingIndex >= 0 ? state.turns[existingIndex] : null;
|
||||||
const existingProgress =
|
const existingProgress =
|
||||||
existingIndex >= 0 && state.turns[existingIndex].role === "progress"
|
existingTurn?.role === "progress"
|
||||||
? state.turns[existingIndex].progress
|
? existingTurn.progress
|
||||||
: null;
|
: null;
|
||||||
const merged =
|
const merged =
|
||||||
existingProgress && progress.stage === "completed"
|
existingProgress && progress.stage === "completed"
|
||||||
@ -365,24 +427,30 @@ function appendProgress(state: ChatViewState, progress: ReplyProgress): ChatView
|
|||||||
progress.occurredAtMs ??
|
progress.occurredAtMs ??
|
||||||
Date.now();
|
Date.now();
|
||||||
const sequence = existingEntry?.sequence ?? nextIndex.size;
|
const sequence = existingEntry?.sequence ?? nextIndex.size;
|
||||||
nextIndex.set(key, { firstOccurredAtMs, sequence });
|
const nextEntry = { firstOccurredAtMs, sequence };
|
||||||
|
let nextKey = key;
|
||||||
|
if (!progress.progressId && progress.stage === "completed" && activeKey && key === activeKey) {
|
||||||
|
nextKey = legacyCompletedOccurrenceKey(merged, nextEntry);
|
||||||
|
nextIndex.delete(activeKey);
|
||||||
|
}
|
||||||
|
nextIndex.set(nextKey, nextEntry);
|
||||||
|
|
||||||
if (existingIndex >= 0) {
|
if (existingIndex >= 0) {
|
||||||
const nextTurns = [...state.turns];
|
const nextTurns = [...state.turns];
|
||||||
nextTurns[existingIndex] = { role: "progress", progress: merged };
|
nextTurns[existingIndex] = { role: "progress", progress: merged, progressKey: nextKey };
|
||||||
return { turns: nextTurns, progressIndex: nextIndex };
|
return { turns: nextTurns, progressIndex: nextIndex };
|
||||||
}
|
}
|
||||||
|
|
||||||
const insertAt = state.turns.findIndex((turn) => {
|
const insertAt = state.turns.findIndex((turn) => {
|
||||||
if (turn.role !== "progress") return false;
|
if (turn.role !== "progress") return false;
|
||||||
const candidate = state.progressIndex.get(progressUpsertKey(turn.progress));
|
const candidate = state.progressIndex.get(turnProgressKey(turn));
|
||||||
if (!candidate) return false;
|
if (!candidate) return false;
|
||||||
if (candidate.firstOccurredAtMs !== firstOccurredAtMs) {
|
if (candidate.firstOccurredAtMs !== firstOccurredAtMs) {
|
||||||
return candidate.firstOccurredAtMs > firstOccurredAtMs;
|
return candidate.firstOccurredAtMs > firstOccurredAtMs;
|
||||||
}
|
}
|
||||||
return candidate.sequence > sequence;
|
return candidate.sequence > sequence;
|
||||||
});
|
});
|
||||||
const nextTurn = { role: "progress" as const, progress: merged };
|
const nextTurn = { role: "progress" as const, progress: merged, progressKey: nextKey };
|
||||||
if (insertAt < 0) {
|
if (insertAt < 0) {
|
||||||
return { turns: [...state.turns, nextTurn], progressIndex: nextIndex };
|
return { turns: [...state.turns, nextTurn], progressIndex: nextIndex };
|
||||||
}
|
}
|
||||||
@ -408,9 +476,32 @@ function mergeCompletedProgress(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function completeRunningProgress(turns: ChatTurn[]): ChatTurn[] {
|
function progressShouldSettle(
|
||||||
|
turn: Extract<ChatTurn, { role: "progress" }>,
|
||||||
|
completedEntities: Set<string | null>,
|
||||||
|
): boolean {
|
||||||
|
if (
|
||||||
|
turn.progress.kind === "turn" ||
|
||||||
|
(turn.progress.stage !== "started" && turn.progress.stage !== "delta")
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const entity = progressEntityKey(turn.progress);
|
||||||
|
return Boolean(entity && completedEntities.has(entity));
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldCompleteProgress(
|
||||||
|
turn: Extract<ChatTurn, { role: "progress" }>,
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
turn.progress.kind !== "turn" &&
|
||||||
|
(turn.progress.stage === "started" || turn.progress.stage === "delta")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function completeChatViewProgress(state: ChatViewState): ChatViewState {
|
||||||
const completedEntities = new Set(
|
const completedEntities = new Set(
|
||||||
turns
|
state.turns
|
||||||
.filter(
|
.filter(
|
||||||
(turn): turn is Extract<ChatTurn, { role: "progress" }> =>
|
(turn): turn is Extract<ChatTurn, { role: "progress" }> =>
|
||||||
turn.role === "progress" &&
|
turn.role === "progress" &&
|
||||||
@ -419,40 +510,44 @@ function completeRunningProgress(turns: ChatTurn[]): ChatTurn[] {
|
|||||||
)
|
)
|
||||||
.map((turn) => progressEntityKey(turn.progress)),
|
.map((turn) => progressEntityKey(turn.progress)),
|
||||||
);
|
);
|
||||||
return turns.map((turn) => {
|
|
||||||
if (
|
const nextIndex = new Map(state.progressIndex);
|
||||||
turn.role !== "progress" ||
|
const turns = state.turns.map((turn) => {
|
||||||
turn.progress.kind === "turn" ||
|
if (turn.role !== "progress" || !shouldCompleteProgress(turn)) return turn;
|
||||||
(turn.progress.stage !== "started" && turn.progress.stage !== "delta")
|
|
||||||
) {
|
const previousKey = turnProgressKey(turn);
|
||||||
return turn;
|
const previousEntry = nextIndex.get(previousKey);
|
||||||
}
|
const entry =
|
||||||
const entity = progressEntityKey(turn.progress);
|
previousEntry ?? {
|
||||||
if (entity && completedEntities.has(entity)) {
|
firstOccurredAtMs: turn.progress.occurredAtMs ?? Date.now(),
|
||||||
|
sequence: nextIndex.size,
|
||||||
|
};
|
||||||
|
const completedProgress = {
|
||||||
|
...turn.progress,
|
||||||
|
stage: "completed" as const,
|
||||||
|
};
|
||||||
|
const nextKey = turn.progress.progressId
|
||||||
|
? progressUpsertKey(completedProgress)
|
||||||
|
: legacyCompletedOccurrenceKey(completedProgress, entry);
|
||||||
|
if (previousKey !== nextKey) nextIndex.delete(previousKey);
|
||||||
|
nextIndex.set(nextKey, entry);
|
||||||
|
|
||||||
|
if (progressShouldSettle(turn, completedEntities)) {
|
||||||
return {
|
return {
|
||||||
...turn,
|
...turn,
|
||||||
settled: true,
|
settled: true,
|
||||||
progress: {
|
progress: completedProgress,
|
||||||
...turn.progress,
|
progressKey: nextKey,
|
||||||
stage: "completed",
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...turn,
|
...turn,
|
||||||
progress: {
|
progress: completedProgress,
|
||||||
...turn.progress,
|
progressKey: nextKey,
|
||||||
stage: "completed",
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
function completeChatViewProgress(state: ChatViewState): ChatViewState {
|
return { turns, progressIndex: nextIndex };
|
||||||
return {
|
|
||||||
turns: completeRunningProgress(state.turns),
|
|
||||||
progressIndex: state.progressIndex,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isRecoverableNoResponseTurn(turn: ChatTurn | undefined): boolean {
|
function isRecoverableNoResponseTurn(turn: ChatTurn | undefined): boolean {
|
||||||
@ -2206,7 +2301,7 @@ function ChatBubble({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (turn.role === "progress") {
|
if (turn.role === "progress") {
|
||||||
const key = progressUpsertKey(turn.progress);
|
const key = turnProgressKey(turn);
|
||||||
return (
|
return (
|
||||||
<ProgressBubble
|
<ProgressBubble
|
||||||
progress={turn.progress}
|
progress={turn.progress}
|
||||||
|
|||||||
Reference in New Issue
Block a user