Fix Assistant terminal idle lifecycle

This commit is contained in:
rcourtman
2026-06-06 04:49:42 +01:00
parent 4e2ae3109e
commit f0c602541e
3 changed files with 75 additions and 7 deletions
@@ -268,6 +268,17 @@ runtime cost control, and shared AI transport surfaces.
that as an active-turn status strip: no idle diagnostics, but while a turn is
loading it must show waiting, current tool/workflow progress, or generating
status even when the transcript already contains an in-flight assistant row.
The referenced OpenCode source at fetched `origin/dev` commit
`09d9cf01f93798939c1284fbe974b6e1f4d2759d` resolves the direct-run wait when
`packages/opencode/src/cli/cmd/run/stream.transport.ts` verifies the session
is idle, maps `turn.idle` to an idle footer phase in
`packages/opencode/src/cli/cmd/run/footer.ts`, and then flushes scrollback
separately from later state sync. Pulse's Assistant drawer adapts that
completion boundary by clearing the visible active turn as soon as the chat
stream has processed its terminal `done` or `error` event; async
conversation/session-list refresh may continue afterward, but it must not
keep the composer footer or transcript row saying the assistant is still
generating.
When multiple governed tools are pending, completed tools must not blank the
status while another tool is still running, and the status heartbeat should
follow the latest progressed pending tool without reordering the transcript's
@@ -197,6 +197,54 @@ describe('useChat', () => {
dispose();
});
it('clears the visible active turn before conversation refresh finishes', async () => {
let resolveRefresh!: () => void;
let sendResolved = false;
mockChat.mockImplementation(
(
_prompt: string,
_session: string | undefined,
_model: string | undefined,
onEvent: (event: StreamEvent) => void,
) => {
onEvent({
type: 'content',
data: { text: 'Pulse currently sees 33 compute resources.' },
} as StreamEvent);
onEvent({
type: 'done',
data: { model: 'pulse:local-inventory' },
} as StreamEvent);
return Promise.resolve();
},
);
const onConversationChanged = vi.fn(
() =>
new Promise<void>((resolve) => {
resolveRefresh = resolve;
}),
);
const { value: chat, dispose } = withRoot(() => useChat({ onConversationChanged }));
const sendPromise = chat.sendMessage('how many devices in this').then((result) => {
sendResolved = true;
return result;
});
await new Promise((resolve) => setTimeout(resolve, 0));
expect(chat.isLoading()).toBe(false);
expect(chat.messages()[1]).toMatchObject({
isStreaming: false,
model: 'pulse:local-inventory',
});
expect(sendResolved).toBe(false);
resolveRefresh();
await expect(sendPromise).resolves.toBe(true);
dispose();
});
it('uses the configured default model route for the request and assistant turn', async () => {
mockChat.mockResolvedValue(undefined);
@@ -1134,6 +1134,19 @@ export function useChat(options: UseChatOptions = {}) {
const abortController = new AbortController();
abortControllerRef = abortController;
let visibleTurnCompleted = false;
const completeVisibleTurn = () => {
if (requestId !== activeRequestId || visibleTurnCompleted) return;
visibleTurnCompleted = true;
abortControllerRef = null;
setIsLoading(false);
if (options?.drainAfter !== false) {
queueMicrotask(() => {
void drainQueuedFollowUps();
});
}
};
try {
await AIChatAPI.chat(
@@ -1155,6 +1168,7 @@ export function useChat(options: UseChatOptions = {}) {
if (requestId !== activeRequestId) {
return false;
}
completeVisibleTurn();
await notifyConversationChanged();
return true;
} catch (error) {
@@ -1177,17 +1191,12 @@ export function useChat(options: UseChatOptions = {}) {
: msg,
),
);
completeVisibleTurn();
await notifyConversationChanged();
return false;
} finally {
if (requestId === activeRequestId) {
abortControllerRef = null;
setIsLoading(false);
if (options?.drainAfter !== false) {
queueMicrotask(() => {
void drainQueuedFollowUps();
});
}
completeVisibleTurn();
}
}
};