fix(chat): reprise robuste au retour d'un layout non conversationnel (composer verrouillé selon l'état réel, replay complet du scrollback, plus de fausse terminaison 'no reply') (#190)
This commit is contained in:
@ -2415,6 +2415,7 @@ pub async fn agent_send(
|
|||||||
let meta = state.structured_sessions.meta_for_session(&sid);
|
let meta = state.structured_sessions.meta_for_session(&sid);
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let mut terminal_visible = false;
|
let mut terminal_visible = false;
|
||||||
|
let mut streamed_text = String::new();
|
||||||
let mut settler = crate::chat::ProgressSettler::default();
|
let mut settler = crate::chat::ProgressSettler::default();
|
||||||
for event in stream {
|
for event in stream {
|
||||||
// Non-terminal, sans contenu chat : un signal de limite alimente le service
|
// Non-terminal, sans contenu chat : un signal de limite alimente le service
|
||||||
@ -2445,6 +2446,9 @@ pub async fn agent_send(
|
|||||||
let Some(chunk) = crate::chat::chunk_from_event(event) else {
|
let Some(chunk) = crate::chat::chunk_from_event(event) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
if let ReplyChunk::TextDelta { text } = &chunk {
|
||||||
|
streamed_text.push_str(text);
|
||||||
|
}
|
||||||
if matches!(chunk, ReplyChunk::Final { .. } | ReplyChunk::Error { .. }) {
|
if matches!(chunk, ReplyChunk::Final { .. } | ReplyChunk::Error { .. }) {
|
||||||
terminal_visible = true;
|
terminal_visible = true;
|
||||||
}
|
}
|
||||||
@ -2455,13 +2459,16 @@ pub async fn agent_send(
|
|||||||
let _ = bridge.send_output_settled(&sid, &mut settler, chunk);
|
let _ = bridge.send_output_settled(&sid, &mut settler, chunk);
|
||||||
}
|
}
|
||||||
if !terminal_visible {
|
if !terminal_visible {
|
||||||
let _ = bridge.send_output_settled(
|
let chunk = if streamed_text.trim().is_empty() {
|
||||||
&sid,
|
|
||||||
&mut settler,
|
|
||||||
ReplyChunk::Error {
|
ReplyChunk::Error {
|
||||||
message: "Le modèle n'a renvoyé aucune réponse.".to_owned(),
|
message: "Le modèle n'a renvoyé aucune réponse.".to_owned(),
|
||||||
},
|
}
|
||||||
);
|
} else {
|
||||||
|
ReplyChunk::Final {
|
||||||
|
content: streamed_text,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let _ = bridge.send_output_settled(&sid, &mut settler, chunk);
|
||||||
}
|
}
|
||||||
bridge.detach_if(&sid, gen);
|
bridge.detach_if(&sid, gen);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -197,9 +197,13 @@ fn chunk_from_event_drops_heartbeat() {
|
|||||||
/// end. This is the body of the spawned pump thread, run inline.
|
/// end. This is the body of the spawned pump thread, run inline.
|
||||||
fn pump_turn(bridge: &ChatBridge, session: &SessionId, gen: u64, events: Vec<ReplyEvent>) {
|
fn pump_turn(bridge: &ChatBridge, session: &SessionId, gen: u64, events: Vec<ReplyEvent>) {
|
||||||
let mut terminal_visible = false;
|
let mut terminal_visible = false;
|
||||||
|
let mut streamed_text = String::new();
|
||||||
for event in events {
|
for event in events {
|
||||||
// Mirror the real pump: heartbeats map to no chunk and are skipped.
|
// Mirror the real pump: heartbeats map to no chunk and are skipped.
|
||||||
if let Some(chunk) = chunk_from_event(event) {
|
if let Some(chunk) = chunk_from_event(event) {
|
||||||
|
if let ReplyChunk::TextDelta { text } = &chunk {
|
||||||
|
streamed_text.push_str(text);
|
||||||
|
}
|
||||||
if matches!(chunk, ReplyChunk::Final { .. } | ReplyChunk::Error { .. }) {
|
if matches!(chunk, ReplyChunk::Final { .. } | ReplyChunk::Error { .. }) {
|
||||||
terminal_visible = true;
|
terminal_visible = true;
|
||||||
}
|
}
|
||||||
@ -207,10 +211,12 @@ fn pump_turn(bridge: &ChatBridge, session: &SessionId, gen: u64, events: Vec<Rep
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !terminal_visible {
|
if !terminal_visible {
|
||||||
let _ = bridge.send_output(
|
let chunk = if streamed_text.trim().is_empty() {
|
||||||
session,
|
error_chunk("Le modèle n'a renvoyé aucune réponse.")
|
||||||
error_chunk("Le modèle n'a renvoyé aucune réponse."),
|
} else {
|
||||||
);
|
final_chunk(&streamed_text)
|
||||||
|
};
|
||||||
|
let _ = bridge.send_output(session, chunk);
|
||||||
}
|
}
|
||||||
bridge.detach_if(session, gen);
|
bridge.detach_if(session, gen);
|
||||||
}
|
}
|
||||||
@ -270,6 +276,34 @@ fn pump_with_only_a_final_delivers_just_the_final() {
|
|||||||
assert_eq!(sink.lock().unwrap().as_slice(), &[final_chunk("ok")]);
|
assert_eq!(sink.lock().unwrap().as_slice(), &[final_chunk("ok")]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pump_with_text_deltas_then_eof_synthesizes_final_not_no_reply() {
|
||||||
|
let bridge = ChatBridge::new();
|
||||||
|
let session = sid();
|
||||||
|
let sink = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let gen = bridge.register(session, capturing_channel(Arc::clone(&sink)));
|
||||||
|
|
||||||
|
pump_turn(
|
||||||
|
&bridge,
|
||||||
|
&session,
|
||||||
|
gen,
|
||||||
|
vec![
|
||||||
|
ReplyEvent::TextDelta { text: "Hel".into() },
|
||||||
|
ReplyEvent::TextDelta { text: "lo".into() },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
sink.lock().unwrap().as_slice(),
|
||||||
|
&[delta("Hel"), delta("lo"), final_chunk("Hello")]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
bridge.scrollback(&session),
|
||||||
|
vec![delta("Hel"), delta("lo"), final_chunk("Hello")],
|
||||||
|
"reattach replays the synthesized terminal chunk instead of no-reply"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pump_with_heartbeat_then_eof_emits_visible_error() {
|
fn pump_with_heartbeat_then_eof_emits_visible_error() {
|
||||||
let bridge = ChatBridge::new();
|
let bridge = ChatBridge::new();
|
||||||
@ -522,6 +556,42 @@ fn detach_view_keeps_session_scrollback_and_later_reattach_replays_absent_chunks
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detached_turn_with_text_eof_replays_final_without_no_reply_on_reattach() {
|
||||||
|
let bridge = ChatBridge::new();
|
||||||
|
let session = sid();
|
||||||
|
let first_sink = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let gen = bridge.register(session, capturing_channel(Arc::clone(&first_sink)));
|
||||||
|
|
||||||
|
bridge.detach(&session);
|
||||||
|
pump_turn(
|
||||||
|
&bridge,
|
||||||
|
&session,
|
||||||
|
gen,
|
||||||
|
vec![
|
||||||
|
ReplyEvent::TextDelta { text: "off".into() },
|
||||||
|
ReplyEvent::TextDelta {
|
||||||
|
text: "screen".into(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
first_sink.lock().unwrap().is_empty(),
|
||||||
|
"detached view receives no live chunks"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
bridge.scrollback(&session),
|
||||||
|
vec![delta("off"), delta("screen"), final_chunk("offscreen")]
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!bridge
|
||||||
|
.scrollback(&session)
|
||||||
|
.iter()
|
||||||
|
.any(|chunk| matches!(chunk, ReplyChunk::Error { message } if message == "Le modèle n'a renvoyé aucune réponse."))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn scrollback_of_unknown_session_is_empty() {
|
fn scrollback_of_unknown_session_is_empty() {
|
||||||
let bridge = ChatBridge::new();
|
let bridge = ChatBridge::new();
|
||||||
|
|||||||
@ -59,6 +59,14 @@ function addPluginCommand(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function deferred<T>() {
|
||||||
|
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||||
|
const promise = new Promise<T>((res) => {
|
||||||
|
resolve = res;
|
||||||
|
});
|
||||||
|
return { promise, resolve };
|
||||||
|
}
|
||||||
|
|
||||||
describe("CustomAgentChatView", () => {
|
describe("CustomAgentChatView", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@ -174,8 +182,136 @@ describe("CustomAgentChatView", () => {
|
|||||||
expect(await screen.findByText("Background tool")).toBeTruthy();
|
expect(await screen.findByText("Background tool")).toBeTruthy();
|
||||||
expect(screen.getByText("Completed away")).toBeTruthy();
|
expect(screen.getByText("Completed away")).toBeTruthy();
|
||||||
expect(
|
expect(
|
||||||
screen.getByText("Reprise effectuée: 2 événements rattrapés pendant l'absence."),
|
screen.queryByText(/Reprise effectuée:/),
|
||||||
).toBeTruthy();
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the composer locked on layout return until reattach replay settles (#190)", async () => {
|
||||||
|
let scrollback: ReplyChunk[] = [
|
||||||
|
{ kind: "userPrompt" as const, text: "Initial prompt" },
|
||||||
|
{ kind: "final" as const, content: "Initial reply" },
|
||||||
|
];
|
||||||
|
const secondReattach = deferred<{ sessionId: string; scrollback: ReplyChunk[] }>();
|
||||||
|
const agent = {
|
||||||
|
launchAgentChat: vi.fn(),
|
||||||
|
reattachAgentChat: vi
|
||||||
|
.fn()
|
||||||
|
.mockImplementationOnce(async (sessionId: string) => ({
|
||||||
|
sessionId,
|
||||||
|
scrollback,
|
||||||
|
}))
|
||||||
|
.mockImplementationOnce(() => secondReattach.promise),
|
||||||
|
detachAgentChat: vi.fn(async () => {}),
|
||||||
|
sendAgentChat: vi.fn(async () => {}),
|
||||||
|
cancelAgentChat: vi.fn(async () => {}),
|
||||||
|
closeAgentChat: vi.fn(async () => {}),
|
||||||
|
};
|
||||||
|
const props = {
|
||||||
|
projectId: "project-1",
|
||||||
|
agentId: "agent-1",
|
||||||
|
agentName: "Worker",
|
||||||
|
profile,
|
||||||
|
cwd: "/repo",
|
||||||
|
nodeId: "node-1",
|
||||||
|
sessionId: "chat-session-1",
|
||||||
|
conversationId: "conversation-1",
|
||||||
|
onSessionId: vi.fn(),
|
||||||
|
onConversationId: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const first = render(
|
||||||
|
<DIProvider
|
||||||
|
gateways={{
|
||||||
|
agent,
|
||||||
|
system: { pickFile: vi.fn(async () => null) },
|
||||||
|
} as unknown as Gateways}
|
||||||
|
>
|
||||||
|
<CustomAgentChatView {...props} />
|
||||||
|
</DIProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await screen.findByText("Initial reply")).toBeTruthy();
|
||||||
|
first.unmount();
|
||||||
|
await waitFor(() => expect(agent.detachAgentChat).toHaveBeenCalledWith("chat-session-1"));
|
||||||
|
|
||||||
|
scrollback = [
|
||||||
|
{ kind: "userPrompt" as const, text: "Initial prompt" },
|
||||||
|
{ kind: "final" as const, content: "Initial reply" },
|
||||||
|
{ kind: "userPrompt" as const, text: "Question during graph view" },
|
||||||
|
{ kind: "textDelta" as const, text: "Partial answer while away" },
|
||||||
|
];
|
||||||
|
|
||||||
|
render(
|
||||||
|
<DIProvider
|
||||||
|
gateways={{
|
||||||
|
agent,
|
||||||
|
system: { pickFile: vi.fn(async () => null) },
|
||||||
|
} as unknown as Gateways}
|
||||||
|
>
|
||||||
|
<CustomAgentChatView {...props} />
|
||||||
|
</DIProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("Initial reply")).toBeTruthy();
|
||||||
|
expect(screen.getByRole("button", { name: "Envoyer" }).hasAttribute("disabled")).toBe(true);
|
||||||
|
expect(screen.getByLabelText(/message CLI custom/).hasAttribute("disabled")).toBe(true);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
secondReattach.resolve({ sessionId: "chat-session-1", scrollback });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await screen.findByText("Question during graph view")).toBeTruthy();
|
||||||
|
expect(screen.getByText("Partial answer while away")).toBeTruthy();
|
||||||
|
expect(screen.queryByText(/Reprise effectuée:/)).toBeNull();
|
||||||
|
expect(screen.getByRole("button", { name: "Stop" })).toBeTruthy();
|
||||||
|
expect(screen.getByLabelText(/message CLI custom/).hasAttribute("disabled")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops a stale no-response chunk when replay contains a later real answer (#190)", async () => {
|
||||||
|
const agent = {
|
||||||
|
launchAgentChat: vi.fn(),
|
||||||
|
reattachAgentChat: vi.fn(async (sessionId: string) => ({
|
||||||
|
sessionId,
|
||||||
|
scrollback: [
|
||||||
|
{ kind: "userPrompt" as const, text: "resume the turn" },
|
||||||
|
{
|
||||||
|
kind: "error" as const,
|
||||||
|
message: "Le modèle n'a renvoyé aucune réponse.",
|
||||||
|
},
|
||||||
|
{ kind: "textDelta" as const, text: "Real answer " },
|
||||||
|
{ kind: "final" as const, content: "Real answer completed" },
|
||||||
|
],
|
||||||
|
})),
|
||||||
|
sendAgentChat: vi.fn(async () => {}),
|
||||||
|
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>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await screen.findByText("Real answer completed")).toBeTruthy();
|
||||||
|
expect(screen.getByText("Real answer")).toBeTruthy();
|
||||||
|
expect(screen.queryByText("Le modèle n'a renvoyé aucune réponse.")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("stops the live agent with cascade feedback and releases the structured session", async () => {
|
it("stops the live agent with cascade feedback and releases the structured session", async () => {
|
||||||
|
|||||||
@ -59,6 +59,7 @@ interface CachedChatView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const chatSessionViewCache = new Map<string, CachedChatView>();
|
const chatSessionViewCache = new Map<string, CachedChatView>();
|
||||||
|
const EMPTY_MODEL_RESPONSE_MESSAGE = "Le modèle n'a renvoyé aucune réponse.";
|
||||||
|
|
||||||
export function __clearCustomAgentChatViewCacheForTests() {
|
export function __clearCustomAgentChatViewCacheForTests() {
|
||||||
chatSessionViewCache.clear();
|
chatSessionViewCache.clear();
|
||||||
@ -299,17 +300,6 @@ function appendProgress(turns: ChatTurn[], progress: ReplyProgress): ChatTurn[]
|
|||||||
return [...turns, { role: "progress", progress }];
|
return [...turns, { role: "progress", progress }];
|
||||||
}
|
}
|
||||||
|
|
||||||
function reattachNotice(missedChunks: number): ChatTurn | null {
|
|
||||||
if (missedChunks <= 0) return null;
|
|
||||||
return {
|
|
||||||
role: "tool",
|
|
||||||
label:
|
|
||||||
missedChunks === 1
|
|
||||||
? "Reprise effectuée: 1 événement rattrapé pendant l'absence."
|
|
||||||
: `Reprise effectuée: ${missedChunks} événements rattrapés pendant l'absence.`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function mergeCompletedProgress(
|
function mergeCompletedProgress(
|
||||||
running: ReplyProgress,
|
running: ReplyProgress,
|
||||||
completed: ReplyProgress,
|
completed: ReplyProgress,
|
||||||
@ -362,6 +352,26 @@ function completeRunningProgress(turns: ChatTurn[]): ChatTurn[] {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isRecoverableNoResponseTurn(turn: ChatTurn | undefined): boolean {
|
||||||
|
return turn?.role === "error" && turn.text === EMPTY_MODEL_RESPONSE_MESSAGE;
|
||||||
|
}
|
||||||
|
|
||||||
|
function turnsIndicateBusy(turns: ChatTurn[]): boolean {
|
||||||
|
for (let index = turns.length - 1; index >= 0; index -= 1) {
|
||||||
|
const turn = turns[index];
|
||||||
|
if (turn.role === "final" || turn.role === "error") return false;
|
||||||
|
if (turn.role === "agent" && turn.pending) return true;
|
||||||
|
if (
|
||||||
|
turn.role === "progress" &&
|
||||||
|
!turn.settled &&
|
||||||
|
progressStageBucket(turn.progress) !== "completed"
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
function fileExtension(mime: string): string {
|
function fileExtension(mime: string): string {
|
||||||
if (mime === "image/png") return "png";
|
if (mime === "image/png") return "png";
|
||||||
if (mime === "image/jpeg") return "jpg";
|
if (mime === "image/jpeg") return "jpg";
|
||||||
@ -490,6 +500,9 @@ function foldChunk(turns: ChatTurn[], raw: unknown): ChatTurn[] {
|
|||||||
raw.kind !== "userPrompt" &&
|
raw.kind !== "userPrompt" &&
|
||||||
raw.kind !== "UserPrompt"
|
raw.kind !== "UserPrompt"
|
||||||
) {
|
) {
|
||||||
|
if (isRecoverableNoResponseTurn(last) && raw.kind !== "error") {
|
||||||
|
return foldChunk(turns.slice(0, -1), raw);
|
||||||
|
}
|
||||||
return turns;
|
return turns;
|
||||||
}
|
}
|
||||||
switch (raw.kind) {
|
switch (raw.kind) {
|
||||||
@ -556,7 +569,7 @@ export function CustomAgentChatView({
|
|||||||
const [slashActiveIndex, setSlashActiveIndex] = useState(0);
|
const [slashActiveIndex, setSlashActiveIndex] = useState(0);
|
||||||
const [profileDialog, setProfileDialog] =
|
const [profileDialog, setProfileDialog] =
|
||||||
useState<ProfileCommandDialogState | null>(null);
|
useState<ProfileCommandDialogState | null>(null);
|
||||||
const [opening, setOpening] = useState(false);
|
const [opening, setOpening] = useState(Boolean(sessionId));
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [stopping, setStopping] = useState(false);
|
const [stopping, setStopping] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@ -793,14 +806,10 @@ export function CustomAgentChatView({
|
|||||||
if (!mountedRef.current) return reattached.sessionId;
|
if (!mountedRef.current) return reattached.sessionId;
|
||||||
setCurrentSession(reattached.sessionId);
|
setCurrentSession(reattached.sessionId);
|
||||||
if (options.applyScrollback) {
|
if (options.applyScrollback) {
|
||||||
const cachedCount = chatSessionViewCache.get(sid)?.chunkCount ?? 0;
|
scrollbackChunkCountRef.current = reattached.scrollback.length;
|
||||||
scrollbackChunkCountRef.current = Math.max(
|
|
||||||
scrollbackChunkCountRef.current,
|
|
||||||
reattached.scrollback.length,
|
|
||||||
);
|
|
||||||
const replayed = reattached.scrollback.reduce(foldChunk, [] as ChatTurn[]);
|
const replayed = reattached.scrollback.reduce(foldChunk, [] as ChatTurn[]);
|
||||||
const notice = reattachNotice(reattached.scrollback.length - cachedCount);
|
setTurns(replayed);
|
||||||
setTurns(notice ? [...replayed, notice] : replayed);
|
setBusy(turnsIndicateBusy(replayed));
|
||||||
}
|
}
|
||||||
return reattached.sessionId;
|
return reattached.sessionId;
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user