fix(webui): tighten turn completion handling
Keep the new turn-end signal scoped to WebSocket clients, preserve pending tool-call state across trailing tool result rows, and drop the accidental npm lockfile from the Bun-based WebUI. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
committed by
Xubin Ren
co-authored by
Cursor
parent
be83525f99
commit
96da6d8190
@@ -796,13 +796,14 @@ class AgentLoop:
|
|||||||
channel=msg.channel, chat_id=msg.chat_id,
|
channel=msg.channel, chat_id=msg.chat_id,
|
||||||
content="", metadata=msg.metadata or {},
|
content="", metadata=msg.metadata or {},
|
||||||
))
|
))
|
||||||
# Signal that the turn is fully complete (all tools executed,
|
if msg.channel == "websocket":
|
||||||
# final text streamed). This lets WS clients know when to
|
# Signal that the turn is fully complete (all tools executed,
|
||||||
# definitively stop the loading indicator.
|
# final text streamed). This lets WS clients know when to
|
||||||
await self.bus.publish_outbound(OutboundMessage(
|
# definitively stop the loading indicator.
|
||||||
channel=msg.channel, chat_id=msg.chat_id,
|
await self.bus.publish_outbound(OutboundMessage(
|
||||||
content="", metadata={**msg.metadata, "_turn_end": True},
|
channel=msg.channel, chat_id=msg.chat_id,
|
||||||
))
|
content="", metadata={**msg.metadata, "_turn_end": True},
|
||||||
|
))
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
logger.info("Task cancelled for session {}", session_key)
|
logger.info("Task cancelled for session {}", session_key)
|
||||||
# Preserve partial context from the interrupted turn so
|
# Preserve partial context from the interrupted turn so
|
||||||
|
|||||||
@@ -243,3 +243,28 @@ class TestToolEventProgress:
|
|||||||
assert outbound[-1].content == ""
|
assert outbound[-1].content == ""
|
||||||
assert (outbound[-1].metadata or {}).get("_turn_end") is True
|
assert (outbound[-1].metadata or {}).get("_turn_end") is True
|
||||||
assert outbound[-1].chat_id == "chat1"
|
assert outbound[-1].chat_id == "chat1"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_non_websocket_dispatch_does_not_publish_turn_end_marker(self, tmp_path: Path) -> None:
|
||||||
|
bus = MessageBus()
|
||||||
|
provider = MagicMock()
|
||||||
|
provider.get_default_model.return_value = "test-model"
|
||||||
|
provider.chat_with_retry = AsyncMock(return_value=LLMResponse(content="Done", tool_calls=[]))
|
||||||
|
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model")
|
||||||
|
loop.tools.get_definitions = MagicMock(return_value=[])
|
||||||
|
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
|
||||||
|
|
||||||
|
await loop._dispatch(InboundMessage(
|
||||||
|
channel="slack",
|
||||||
|
sender_id="u1",
|
||||||
|
chat_id="chat1",
|
||||||
|
content="say hello",
|
||||||
|
))
|
||||||
|
|
||||||
|
outbound = []
|
||||||
|
while bus.outbound_size > 0:
|
||||||
|
outbound.append(await bus.consume_outbound())
|
||||||
|
|
||||||
|
assert len(outbound) == 1
|
||||||
|
assert outbound[0].content == "Done"
|
||||||
|
assert (outbound[0].metadata or {}).get("_turn_end") is not True
|
||||||
|
|||||||
Generated
-6020
File diff suppressed because it is too large
Load Diff
@@ -84,7 +84,7 @@ export function useSessionHistory(key: string | null): {
|
|||||||
messages: UIMessage[];
|
messages: UIMessage[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
/** ``true`` when the last persisted message has ``tool_calls`` but no
|
/** ``true`` when the last persisted assistant turn has ``tool_calls`` but no
|
||||||
* final text yet — the model was still processing when the page loaded. */
|
* final text yet — the model was still processing when the page loaded. */
|
||||||
hasPendingToolCalls: boolean;
|
hasPendingToolCalls: boolean;
|
||||||
} {
|
} {
|
||||||
@@ -153,9 +153,11 @@ export function useSessionHistory(key: string | null): {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
// Check if the last persisted message has tool_calls but no final
|
// Tool result rows can trail the assistant tool-call row while the turn
|
||||||
// text yet — the model was still processing when the page loaded.
|
// is still running, so check the last conversational row.
|
||||||
const lastRaw = body.messages[body.messages.length - 1];
|
const lastRaw = [...body.messages]
|
||||||
|
.reverse()
|
||||||
|
.find((m) => m.role === "user" || m.role === "assistant");
|
||||||
const hasPending =
|
const hasPending =
|
||||||
lastRaw?.role === "assistant" &&
|
lastRaw?.role === "assistant" &&
|
||||||
Array.isArray(lastRaw.tool_calls) &&
|
Array.isArray(lastRaw.tool_calls) &&
|
||||||
|
|||||||
@@ -194,6 +194,36 @@ describe("useSessions", () => {
|
|||||||
expect(result.current.hasPendingToolCalls).toBe(true);
|
expect(result.current.hasPendingToolCalls).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps pending when tool result rows trail assistant tool calls", async () => {
|
||||||
|
vi.mocked(api.fetchSessionMessages).mockResolvedValue({
|
||||||
|
key: "websocket:chat-pending-tool-result",
|
||||||
|
created_at: "2026-04-20T10:00:00Z",
|
||||||
|
updated_at: "2026-04-20T10:05:00Z",
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: "Using 1 tool",
|
||||||
|
timestamp: "2026-04-20T10:00:01Z",
|
||||||
|
tool_calls: [{ id: "call-1" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "tool",
|
||||||
|
content: "tool output",
|
||||||
|
timestamp: "2026-04-20T10:00:02Z",
|
||||||
|
tool_call_id: "call-1",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useSessionHistory("websocket:chat-pending-tool-result"), {
|
||||||
|
wrapper: wrap(fakeClient()),
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
|
||||||
|
expect(result.current.hasPendingToolCalls).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("does not flag history as pending once the assistant turn has no tool calls", async () => {
|
it("does not flag history as pending once the assistant turn has no tool calls", async () => {
|
||||||
vi.mocked(api.fetchSessionMessages).mockResolvedValue({
|
vi.mocked(api.fetchSessionMessages).mockResolvedValue({
|
||||||
key: "websocket:chat-done",
|
key: "websocket:chat-done",
|
||||||
|
|||||||
Reference in New Issue
Block a user