diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 147b79a29..aaad859a7 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -14,6 +14,7 @@ DeerFlow is a LangGraph-based AI super agent system with a full-stack architectu **Runtime**: - `make dev`, Docker dev, and production all run the agent runtime in Gateway via `RunManager` + `run_agent()` + `StreamBridge` (`packages/harness/deerflow/runtime/`). Nginx exposes that runtime at `/api/langgraph/*` and rewrites it to Gateway's native `/api/*` routers. +- Gateway streams `write_file` and `str_replace` argument deltas in bounded batches when clients also subscribe to `values`; messages-only consumers retain the original per-chunk contract, while `values` preserves the complete tool call. - Scheduled-task executions must reuse that same Gateway run lifecycle. The scheduler may decide *when* work runs, but it must dispatch through the existing run path rather than introducing a parallel execution stack. **Project Structure**: diff --git a/backend/packages/harness/deerflow/runtime/runs/worker.py b/backend/packages/harness/deerflow/runtime/runs/worker.py index 81b860fb5..bb8fb0342 100644 --- a/backend/packages/harness/deerflow/runtime/runs/worker.py +++ b/backend/packages/harness/deerflow/runtime/runs/worker.py @@ -20,6 +20,7 @@ import copy import inspect import logging import os +import sys import threading import weakref from collections.abc import AsyncIterator @@ -103,6 +104,126 @@ async def _checkpoint_thread_lock(thread_id: str) -> AsyncIterator[None]: # Valid stream_mode values for LangGraph's graph.astream() _VALID_LG_MODES = {"values", "updates", "checkpoints", "tasks", "debug", "messages", "custom"} +# Keep this streaming policy separate from middleware write-authorization sets. +_LARGE_FILE_TOOL_NAMES = frozenset({"str_replace", "write_file"}) +_LARGE_FILE_TOOL_BATCH_SIZE = 32 + + +@dataclass +class _LargeFileToolChunkBatcher: + """Batch file-body argument deltas to avoid quadratic browser parsing. + + Normal assistant text and non-file tool calls remain token-streamed. Large + file arguments still update progressively, but in bounded batches instead + of forcing the browser to reparse the growing JSON on every model token. + """ + + batch_size: int = _LARGE_FILE_TOOL_BATCH_SIZE + tool_names: dict[tuple[str, str, str], str] = field(default_factory=dict) + pending_identity: tuple[str, str, str] | None = None + pending_message: Any | None = None + pending_metadata: dict[str, Any] = field(default_factory=dict) + pending_count: int = 0 + + def push(self, chunk: Any) -> list[Any]: + if not isinstance(chunk, tuple) or len(chunk) != 2: + return [*self.flush(), chunk] + + message, metadata = chunk + message_id = getattr(message, "id", None) + tool_call_chunks = getattr(message, "tool_call_chunks", None) + if not isinstance(message_id, str) or not message_id or not isinstance(tool_call_chunks, list) or len(tool_call_chunks) != 1: + return [*self.flush(), chunk] + + tool_chunk = tool_call_chunks[0] + if not isinstance(tool_chunk, dict): + return [*self.flush(), chunk] + index = tool_chunk.get("index") + tool_call_id = tool_chunk.get("id") + if isinstance(index, int): + discriminator = f"index:{index}" + elif isinstance(tool_call_id, str) and tool_call_id: + discriminator = f"id:{tool_call_id}" + else: + discriminator = "single" + raw_namespace = None + if isinstance(metadata, dict): + raw_namespace = metadata.get("langgraph_checkpoint_ns") or metadata.get("checkpoint_ns") + namespace = raw_namespace if isinstance(raw_namespace, str) else "" + identity = (namespace, message_id, discriminator) + name_fragment = tool_chunk.get("name") + tool_name = self.tool_names.get(identity, "") + if tool_name not in _LARGE_FILE_TOOL_NAMES and isinstance(name_fragment, str) and name_fragment: + tool_name += name_fragment + if any(candidate.startswith(tool_name) for candidate in _LARGE_FILE_TOOL_NAMES): + self.tool_names[identity] = tool_name + else: + self.tool_names.pop(identity, None) + # Batching starts only after the accumulated name matches; split or + # incomplete name fragments stream per-chunk until then. + if tool_name not in _LARGE_FILE_TOOL_NAMES: + return [*self.flush(), chunk] + + model_copy = getattr(message, "model_copy", None) + if not callable(model_copy): + return [*self.flush(), chunk] + additional_kwargs = getattr(message, "additional_kwargs", None) + sanitized_additional_kwargs = additional_kwargs + if isinstance(additional_kwargs, dict) and ("function_call" in additional_kwargs or "tool_calls" in additional_kwargs): + sanitized_additional_kwargs = {key: value for key, value in additional_kwargs.items() if key not in {"function_call", "tool_calls"}} + has_non_tool_payload = bool(getattr(message, "content", None) or sanitized_additional_kwargs or getattr(message, "usage_metadata", None) or getattr(message, "response_metadata", None)) + outputs: list[Any] = [] + if self.pending_identity is not None and self.pending_identity != identity: + outputs.extend(self.flush()) + if has_non_tool_payload: + visible_message = model_copy( + update={ + "additional_kwargs": sanitized_additional_kwargs, + "invalid_tool_calls": [], + "tool_call_chunks": [], + "tool_calls": [], + } + ) + outputs.append((visible_message, metadata)) + + tool_only_message = model_copy( + update={ + "additional_kwargs": {}, + "content": "", + "invalid_tool_calls": [], + "response_metadata": {}, + "tool_calls": [], + "usage_metadata": None, + } + ) + self.pending_identity = identity + self.pending_message = tool_only_message if self.pending_message is None else self.pending_message + tool_only_message + if isinstance(metadata, dict): + self.pending_metadata.update(metadata) + self.pending_count += 1 + if self.pending_count >= self.batch_size: + outputs.extend(self.flush()) + return outputs + + def flush(self) -> list[Any]: + if self.pending_message is None: + return [] + chunk = (self.pending_message, self.pending_metadata) + self.pending_identity = None + self.pending_message = None + self.pending_metadata = {} + self.pending_count = 0 + return [chunk] + + def finish(self) -> list[Any]: + """Flush and release identities at a values or end-of-stream boundary. + + A regular batch-size or interleaved-mode flush must retain identities + because continuation chunks commonly omit the tool name. + """ + chunks = self.flush() + self.tool_names.clear() + return chunks def _build_runtime_context( @@ -539,40 +660,58 @@ async def run_agent( async def _stream_once(input_payload: Any, stream_config: RunnableConfig) -> None: nonlocal llm_error_fallback_message - async with _checkpoint_thread_lock(thread_id): - if len(lg_modes) == 1 and not stream_subgraphs: - # Single mode, no subgraphs: astream yields raw chunks - single_mode = lg_modes[0] - async for chunk in agent.astream(input_payload, config=stream_config, stream_mode=single_mode): + file_tool_chunk_batcher = _LargeFileToolChunkBatcher() if "values" in requested_modes else None + try: + async with _checkpoint_thread_lock(thread_id): + if len(lg_modes) == 1 and not stream_subgraphs: + # Single mode, no subgraphs: astream yields raw chunks + single_mode = lg_modes[0] + async for chunk in agent.astream(input_payload, config=stream_config, stream_mode=single_mode): + if record.abort_event.is_set(): + logger.info("Run %s abort requested — stopping", run_id) + break + llm_error_fallback_message = llm_error_fallback_message or _extract_llm_error_fallback_message(chunk, pre_existing_message_ids) + sse_event = _lg_mode_to_sse_event(single_mode) + await bridge.publish(run_id, sse_event, serialize(chunk, mode=single_mode)) + if single_mode == "custom": + await subagent_events.add(chunk) + return + # Multiple modes or subgraphs: astream yields tuples + async for item in agent.astream( + input_payload, + config=stream_config, + stream_mode=lg_modes, + subgraphs=stream_subgraphs, + ): if record.abort_event.is_set(): logger.info("Run %s abort requested — stopping", run_id) break + + mode, chunk = _unpack_stream_item(item, lg_modes, stream_subgraphs) + if mode is None: + continue + llm_error_fallback_message = llm_error_fallback_message or _extract_llm_error_fallback_message(chunk, pre_existing_message_ids) - sse_event = _lg_mode_to_sse_event(single_mode) - await bridge.publish(run_id, sse_event, serialize(chunk, mode=single_mode)) - if single_mode == "custom": + sse_event = _lg_mode_to_sse_event(mode) + if file_tool_chunk_batcher is not None and mode != "messages": + pending_chunks = file_tool_chunk_batcher.finish() if mode == "values" else file_tool_chunk_batcher.flush() + for publish_chunk in pending_chunks: + await bridge.publish(run_id, "messages", serialize(publish_chunk, mode="messages")) + chunks_to_publish = file_tool_chunk_batcher.push(chunk) if mode == "messages" and file_tool_chunk_batcher is not None else [chunk] + for publish_chunk in chunks_to_publish: + await bridge.publish(run_id, sse_event, serialize(publish_chunk, mode=mode)) + if mode == "custom": await subagent_events.add(chunk) - return - # Multiple modes or subgraphs: astream yields tuples - async for item in agent.astream( - input_payload, - config=stream_config, - stream_mode=lg_modes, - subgraphs=stream_subgraphs, - ): - if record.abort_event.is_set(): - logger.info("Run %s abort requested — stopping", run_id) - break - - mode, chunk = _unpack_stream_item(item, lg_modes, stream_subgraphs) - if mode is None: - continue - - llm_error_fallback_message = llm_error_fallback_message or _extract_llm_error_fallback_message(chunk, pre_existing_message_ids) - sse_event = _lg_mode_to_sse_event(mode) - await bridge.publish(run_id, sse_event, serialize(chunk, mode=mode)) - if mode == "custom": - await subagent_events.add(chunk) + finally: + stream_error = sys.exception() + if file_tool_chunk_batcher is not None: + try: + for publish_chunk in file_tool_chunk_batcher.finish(): + await bridge.publish(run_id, "messages", serialize(publish_chunk, mode="messages")) + except Exception: + if stream_error is None: + raise + logger.debug("Could not flush pending file-tool chunks for run %s", run_id, exc_info=True) # 7. Stream the requested turn, then optionally continue hidden goal turns. # Clear any stale stop_reason before the first (user-visible) turn only. diff --git a/backend/tests/test_run_worker_rollback.py b/backend/tests/test_run_worker_rollback.py index 9d498f84e..9082f2c20 100644 --- a/backend/tests/test_run_worker_rollback.py +++ b/backend/tests/test_run_worker_rollback.py @@ -6,7 +6,7 @@ from typing import Annotated, Any, NotRequired, TypedDict from unittest.mock import AsyncMock, call, patch import pytest -from langchain_core.messages import AIMessage, AnyMessage, HumanMessage +from langchain_core.messages import AIMessage, AIMessageChunk, AnyMessage, HumanMessage from langgraph.channels.delta import DeltaChannel from langgraph.checkpoint.base import empty_checkpoint from langgraph.checkpoint.memory import InMemorySaver @@ -31,6 +31,7 @@ from deerflow.runtime.runs.worker import ( _ensure_interrupted_title, _extract_llm_error_fallback_message, _install_runtime_context, + _LargeFileToolChunkBatcher, _rollback_to_pre_run_checkpoint, _try_extract_from_message, run_agent, @@ -93,6 +94,197 @@ def _build_message_append_graph(state_schema: type, checkpointer: Any): return builder.compile(checkpointer=checkpointer) +@pytest.mark.parametrize("tool_name", ["write_file", "str_replace"]) +def test_large_file_tool_chunk_batcher_streams_bounded_batches(tool_name: str): + batcher = _LargeFileToolChunkBatcher(batch_size=2) + first = AIMessageChunk( + content="", + id="ai-1", + tool_call_chunks=[ + { + "id": "call-1", + "index": 0, + "name": tool_name, + "args": '{"path":"/mnt/user-data/outputs/report.md","content":"Hel', + } + ], + ) + continuation = AIMessageChunk( + content="", + id="ai-1", + tool_call_chunks=[{"index": 0, "name": None, "args": 'lo"}'}], + ) + + assert batcher.push((first, {})) == [] + published = batcher.push((continuation, {})) + assert len(published) == 1 + message, metadata = published[0] + assert metadata == {} + assert message.tool_calls[0]["args"]["content"] == "Hello" + assert batcher.flush() == [] + + +def test_large_file_tool_chunk_batcher_preserves_visible_and_non_file_chunks(): + batcher = _LargeFileToolChunkBatcher() + visible_text = AIMessageChunk(content="Writing the report now.", id="ai-1") + search_tool = AIMessageChunk( + content="", + id="ai-2", + tool_call_chunks=[ + { + "id": "call-2", + "index": 0, + "name": "web_search", + "args": '{"query":"vector databases"}', + } + ], + ) + write_with_reasoning = AIMessageChunk( + content="", + id="ai-3", + additional_kwargs={"reasoning_content": "Choosing a filename."}, + tool_call_chunks=[ + { + "id": "call-3", + "index": 0, + "name": "write_file", + "args": '{"path":"/mnt/user-data/outputs/report.md"}', + } + ], + ) + + assert batcher.push((visible_text, {})) == [(visible_text, {})] + assert batcher.push((search_tool, {})) == [(search_tool, {})] + visible_reasoning = batcher.push((write_with_reasoning, {})) + assert len(visible_reasoning) == 1 + filtered_message, filtered_metadata = visible_reasoning[0] + assert filtered_metadata == {} + assert filtered_message.additional_kwargs == {"reasoning_content": "Choosing a filename."} + assert filtered_message.tool_call_chunks == [] + pending_file_chunks = batcher.flush() + assert len(pending_file_chunks) == 1 + assert pending_file_chunks[0][0].tool_call_chunks[0]["name"] == "write_file" + + +def test_large_file_tool_chunk_batcher_separates_subgraph_namespaces(): + batcher = _LargeFileToolChunkBatcher() + first = AIMessageChunk( + content="", + id="shared-ai-id", + tool_call_chunks=[{"id": "call-a", "index": 0, "name": "write_file", "args": '{"path":"a.md","content":"A'}], + ) + second = AIMessageChunk( + content="", + id="shared-ai-id", + tool_call_chunks=[{"id": "call-b", "index": 0, "name": "write_file", "args": '{"path":"b.md","content":"B'}], + ) + + assert batcher.push((first, {"langgraph_checkpoint_ns": "task-a"})) == [] + published = batcher.push((second, {"langgraph_checkpoint_ns": "task-b"})) + + assert len(published) == 1 + assert published[0][1]["langgraph_checkpoint_ns"] == "task-a" + assert batcher.flush()[0][1]["langgraph_checkpoint_ns"] == "task-b" + + +@pytest.mark.parametrize("metadata", [None, "not-a-dict"]) +def test_large_file_tool_chunk_batcher_accepts_non_dict_metadata(metadata: Any): + batcher = _LargeFileToolChunkBatcher(batch_size=1) + message = AIMessageChunk( + content="", + id="ai-file", + tool_call_chunks=[ + { + "id": "call-file", + "index": 0, + "name": "write_file", + "args": '{"path":"report.md","content":"draft"}', + } + ], + ) + + published = batcher.push((message, metadata)) + + assert len(published) == 1 + assert published[0][0].tool_call_chunks[0]["args"] == '{"path":"report.md","content":"draft"}' + assert published[0][1] == {} + + +def test_large_file_tool_chunk_batcher_does_not_retain_non_file_names(): + batcher = _LargeFileToolChunkBatcher() + + for index in range(100): + message = AIMessageChunk( + content="", + id=f"ai-{index}", + tool_call_chunks=[ + { + "id": f"call-{index}", + "index": 0, + "name": "web_search", + "args": '{"query":"deerflow"}', + } + ], + ) + + assert batcher.push((message, {})) == [(message, {})] + + assert batcher.tool_names == {} + + +def test_large_file_tool_chunk_batcher_starts_batching_after_split_name_matches(): + batcher = _LargeFileToolChunkBatcher(batch_size=1) + name_prefix = AIMessageChunk( + content="", + id="ai-file", + tool_call_chunks=[{"id": "call-file", "index": 0, "name": "write_", "args": ""}], + ) + name_suffix = AIMessageChunk( + content="", + id="ai-file", + tool_call_chunks=[ + { + "index": 0, + "name": "file", + "args": '{"path":"report.md","content":"draft"}', + } + ], + ) + + assert batcher.push((name_prefix, {})) == [(name_prefix, {})] + assert set(batcher.tool_names.values()) == {"write_"} + assert len(batcher.push((name_suffix, {}))) == 1 + assert set(batcher.tool_names.values()) == {"write_file"} + + +def test_large_file_tool_chunk_batcher_keeps_identity_across_batches_then_releases_it(): + batcher = _LargeFileToolChunkBatcher(batch_size=1) + first = AIMessageChunk( + content="", + id="ai-file", + tool_call_chunks=[ + { + "id": "call-file", + "index": 0, + "name": "write_file", + "args": '{"path":"report.md","content":"Hel', + } + ], + ) + continuation = AIMessageChunk( + content="", + id="ai-file", + tool_call_chunks=[{"index": 0, "name": None, "args": 'lo"}'}], + ) + + assert len(batcher.push((first, {}))) == 1 + assert len(batcher.push((continuation, {}))) == 1 + assert set(batcher.tool_names.values()) == {"write_file"} + + assert batcher.finish() == [] + assert batcher.tool_names == {} + + def test_build_runtime_context_includes_app_config_when_present(): app_config = object() @@ -136,6 +328,200 @@ def test_install_runtime_context_overrides_internal_pre_existing_message_ids(): assert config["context"][CURRENT_RUN_PRE_EXISTING_MESSAGE_IDS_KEY] == frozenset({"old-ai"}) +@pytest.mark.anyio +async def test_run_agent_batches_incremental_file_args_and_keeps_complete_values(): + run_manager = RunManager() + record = await run_manager.create("thread-file-stream") + bridge = SimpleNamespace( + publish=AsyncMock(), + publish_end=AsyncMock(), + cleanup=AsyncMock(), + ) + complete_message = AIMessage( + content="", + id="ai-file", + tool_calls=[ + { + "id": "call-file", + "name": "write_file", + "args": { + "path": "/mnt/user-data/outputs/report.md", + "content": "Hello world", + }, + "type": "tool_call", + } + ], + ) + + class DummyAgent: + async def astream(self, graph_input, config=None, stream_mode=None, subgraphs=False): + del graph_input, config, stream_mode, subgraphs + yield ( + "messages", + ( + AIMessageChunk( + content="", + id="ai-file", + tool_call_chunks=[ + { + "id": "call-file", + "index": 0, + "name": "write_file", + "args": '{"path":"/mnt/user-data/outputs/report.md","content":"Hello', + } + ], + ), + {}, + ), + ) + yield ( + "messages", + ( + AIMessageChunk( + content="", + id="ai-file", + tool_call_chunks=[{"index": 0, "name": None, "args": ' world"}'}], + ), + {}, + ), + ) + yield ("values", {"messages": [complete_message]}) + + await run_agent( + bridge, + run_manager, + record, + ctx=RunContext(checkpointer=None), + agent_factory=lambda **_kwargs: DummyAgent(), + graph_input={}, + config={}, + stream_modes=["messages-tuple", "values"], + ) + + message_events = [call.args for call in bridge.publish.await_args_list if call.args[1] == "messages"] + assert len(message_events) == 1 + assert message_events[0][2][0]["tool_calls"][0]["args"]["content"] == "Hello world" + values_events = [call.args[2] for call in bridge.publish.await_args_list if call.args[1] == "values"] + assert any(event["messages"][0]["tool_calls"][0]["args"]["content"] == "Hello world" for event in values_events) + + +@pytest.mark.parametrize( + ("stream_error", "flush_publish_error", "expected_error"), + [ + (True, False, "stream failed"), + (True, True, "stream failed"), + (False, True, "flush publish failed"), + ], +) +@pytest.mark.anyio +async def test_run_agent_handles_pending_file_args_when_stream_or_flush_raises(stream_error: bool, flush_publish_error: bool, expected_error: str): + run_manager = RunManager() + record = await run_manager.create("thread-file-stream-error") + + async def publish(_run_id: str, event: str, _data: Any): + if flush_publish_error and event == "messages": + raise RuntimeError("flush publish failed") + + bridge = SimpleNamespace( + publish=AsyncMock(side_effect=publish), + publish_end=AsyncMock(), + cleanup=AsyncMock(), + ) + + class DummyAgent: + async def astream(self, graph_input, config=None, stream_mode=None, subgraphs=False): + del graph_input, config, stream_mode, subgraphs + yield ( + "messages", + ( + AIMessageChunk( + content="", + id="ai-file", + tool_call_chunks=[ + { + "id": "call-file", + "index": 0, + "name": "write_file", + "args": '{"path":"report.md","content":"partial', + } + ], + ), + {}, + ), + ) + if stream_error: + raise RuntimeError("stream failed") + + await run_agent( + bridge, + run_manager, + record, + ctx=RunContext(checkpointer=None), + agent_factory=lambda **_kwargs: DummyAgent(), + graph_input={}, + config={}, + stream_modes=["messages-tuple", "values"], + ) + + message_events = [call.args for call in bridge.publish.await_args_list if call.args[1] == "messages"] + assert len(message_events) == 1 + assert message_events[0][2][0]["tool_call_chunks"][0]["args"].endswith('"content":"partial') + error_events = [call.args for call in bridge.publish.await_args_list if call.args[1] == "error"] + assert error_events[0][2]["message"] == expected_error + + +@pytest.mark.anyio +async def test_run_agent_keeps_file_chunks_unbatched_without_values_mode(): + run_manager = RunManager() + record = await run_manager.create("thread-file-messages-only") + bridge = SimpleNamespace( + publish=AsyncMock(), + publish_end=AsyncMock(), + cleanup=AsyncMock(), + ) + chunks = [ + AIMessageChunk( + content="", + id="ai-file", + tool_call_chunks=[ + { + "id": "call-file", + "index": 0, + "name": "write_file", + "args": '{"path":"report.md","content":"Hel', + } + ], + ), + AIMessageChunk( + content="", + id="ai-file", + tool_call_chunks=[{"index": 0, "name": None, "args": 'lo"}'}], + ), + ] + + class DummyAgent: + async def astream(self, graph_input, config=None, stream_mode=None, subgraphs=False): + del graph_input, config, stream_mode, subgraphs + for chunk in chunks: + yield (chunk, {}) + + await run_agent( + bridge, + run_manager, + record, + ctx=RunContext(checkpointer=None), + agent_factory=lambda **_kwargs: DummyAgent(), + graph_input={}, + config={}, + stream_modes=["messages-tuple"], + ) + + message_events = [call.args[2] for call in bridge.publish.await_args_list if call.args[1] == "messages"] + assert len(message_events) == 2 + assert message_events[0][0]["tool_call_chunks"][0]["args"].endswith('"content":"Hel') + assert message_events[1][0]["tool_call_chunks"][0]["args"] == 'lo"}' + + @pytest.mark.anyio async def test_run_agent_threads_explicit_app_config_into_config_only_factory(): run_manager = RunManager() diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index e0864427d..dd960b549 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -65,6 +65,7 @@ The frontend is a stateful chat application. Users create **threads** (conversat 1. Optional composer helpers such as `core/input-polish` can rewrite the local draft before submission, and `core/voice-input` can transcribe browser microphone input into that same local draft; confirmed user input then flows to thread hooks (`core/threads/hooks.ts`) → LangGraph SDK streaming 2. Stream events update thread state (messages, artifacts, todos, goal) + File-tool artifact auto-open work must run in an effect with timer cleanup; never schedule timers while rendering streamed `write_file` or `str_replace` updates. 3. `useThreadHistory` loads persisted conversation pages from `GET /api/threads/{id}/messages/page`, preserving the backend's thread-global event `seq`; rendering overlays checkpoint/live copies at their matching canonical identities (a summarized checkpoint may contain a protected early input plus a recent tail), suppresses checkpoint/transient prefixes whose canonical position is still behind an unloaded cursor page instead of collapsing that unknown gap before a recent anchor, then adds optimistic messages without timestamp re-sorting. History invalidation preserves already-loaded pages so their established ordering positions are not discarded. 4. Stop actions call the LangGraph SDK stream stop path; `core/threads/hooks.ts` invalidates current-thread, thread-history, token-usage, and sidebar/search caches immediately and schedules one follow-up refetch because SDK stop may finish via abort + fire-and-forget cancel before backend title finalization commits 5. TanStack Query manages server state; localStorage stores user settings diff --git a/frontend/src/components/workspace/messages/message-group.tsx b/frontend/src/components/workspace/messages/message-group.tsx index e75106b60..a12096c00 100644 --- a/frontend/src/components/workspace/messages/message-group.tsx +++ b/frontend/src/components/workspace/messages/message-group.tsx @@ -15,7 +15,7 @@ import { SquareTerminalIcon, WrenchIcon, } from "lucide-react"; -import { memo, useMemo, useState } from "react"; +import { memo, useEffect, useMemo, useState } from "react"; import { ChainOfThought, @@ -26,7 +26,10 @@ import { } from "@/components/ai-elements/chain-of-thought"; import { CodeBlock } from "@/components/ai-elements/code-block"; import { Button } from "@/components/ui/button"; -import { resolveArtifactURL } from "@/core/artifacts/utils"; +import { + buildWriteFileArtifactURL, + resolveArtifactURL, +} from "@/core/artifacts/utils"; import { useI18n } from "@/core/i18n/hooks"; import { formatTokenCount } from "@/core/messages/usage"; import type { TokenDebugStep } from "@/core/messages/usage-model"; @@ -554,6 +557,40 @@ function ToolCall({ ) : ( fallback ); + const writeFilePath = + (name === "write_file" || name === "str_replace") && + typeof args.path === "string" + ? args.path + : undefined; + const writeFileArtifactUrl = writeFilePath + ? buildWriteFileArtifactURL({ + filepath: writeFilePath, + messageId, + toolCallId: id, + }) + : null; + const autoOpenArtifactUrl = + isLoading && + isLast && + autoOpen && + autoSelect && + writeFileArtifactUrl && + !result + ? writeFileArtifactUrl + : null; + + useEffect(() => { + if (!autoOpenArtifactUrl || selectedArtifact === autoOpenArtifactUrl) { + return; + } + + const timeout = window.setTimeout(() => { + select(autoOpenArtifactUrl, true); + setOpen(true); + }, 100); + + return () => window.clearTimeout(timeout); + }, [autoOpenArtifactUrl, select, selectedArtifact, setOpen]); if (name.startsWith("browser_")) { const shot = browserView?.screenshot; @@ -749,38 +786,24 @@ function ToolCall({ if (!description) { description = t.toolCalls.writeFile; } - const path: string | undefined = (args as { path: string })?.path; - if (isLoading && isLast && autoOpen && autoSelect && path && !result) { - setTimeout(() => { - const url = new URL( - `write-file:${path}?message_id=${messageId}&tool_call_id=${id}`, - ).toString(); - if (selectedArtifact === url) { - return; - } - select(url, true); - setOpen(true); - }, 100); - } return ( { - select( - new URL( - `write-file:${path}?message_id=${messageId}&tool_call_id=${id}`, - ).toString(), - ); + if (!writeFileArtifactUrl) { + return; + } + select(writeFileArtifactUrl); setOpen(true); }} > - {path && ( + {writeFilePath && ( - {path} + {writeFilePath} )} diff --git a/frontend/src/core/artifacts/utils.ts b/frontend/src/core/artifacts/utils.ts index 324d51925..eb31d3256 100644 --- a/frontend/src/core/artifacts/utils.ts +++ b/frontend/src/core/artifacts/utils.ts @@ -27,6 +27,26 @@ function encodeArtifactPath(filepath: string) { .join("/"); } +export function buildWriteFileArtifactURL({ + filepath, + messageId, + toolCallId, +}: { + filepath: string; + messageId?: string; + toolCallId?: string; +}) { + const url = new URL("write-file:/"); + url.pathname = filepath.replaceAll("%", "%25"); + if (messageId) { + url.searchParams.set("message_id", messageId); + } + if (toolCallId) { + url.searchParams.set("tool_call_id", toolCallId); + } + return url.toString(); +} + function decodeRelativeArtifactPath(filepath: string) { return filepath.split("/").map(decodePathSegment).join("/"); } diff --git a/frontend/tests/e2e/artifact-batched-stream.spec.ts b/frontend/tests/e2e/artifact-batched-stream.spec.ts new file mode 100644 index 000000000..e705a934a --- /dev/null +++ b/frontend/tests/e2e/artifact-batched-stream.spec.ts @@ -0,0 +1,234 @@ +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; + +import { expect, test } from "@playwright/test"; + +import { mockLangGraphAPI } from "./utils/mock-api"; + +const THREAD_ID = "00000000-0000-0000-0000-000000004354"; +const RUN_ID = "00000000-0000-0000-0000-000000004355"; +const MISSING_PATH_THREAD_ID = "00000000-0000-0000-0000-000000004356"; +const ARTIFACT_PATH = "/artifact-fixtures/batched-report.md"; + +const INITIAL_MESSAGES = [ + { + type: "human", + id: "msg-human-batched-artifact", + content: [{ type: "text", text: "Create a batched markdown report" }], + }, +]; + +function batchedWriteFileStreamFrames() { + const chunks = [ + { + content: "", + additional_kwargs: {}, + response_metadata: {}, + type: "AIMessageChunk", + name: null, + id: "msg-ai-batched-artifact", + tool_calls: [ + { + name: "write_file", + args: { path: ARTIFACT_PATH, content: "Hello " }, + id: "call-batched-artifact", + type: "tool_call", + }, + ], + invalid_tool_calls: [], + usage_metadata: null, + tool_call_chunks: [ + { + name: "write_file", + args: `{"path":"${ARTIFACT_PATH}","content":"Hello `, + id: "call-batched-artifact", + index: 0, + type: "tool_call_chunk", + }, + ], + chunk_position: null, + }, + { + content: "", + additional_kwargs: {}, + response_metadata: {}, + type: "AIMessageChunk", + name: null, + id: "msg-ai-batched-artifact", + tool_calls: [], + invalid_tool_calls: [ + { + name: null, + args: 'world"}', + id: null, + error: null, + type: "invalid_tool_call", + }, + ], + usage_metadata: null, + tool_call_chunks: [ + { + name: null, + args: 'world"}', + id: null, + index: 0, + type: "tool_call_chunk", + }, + ], + chunk_position: null, + }, + ]; + const events = [ + { + event: "metadata", + data: { run_id: RUN_ID, thread_id: THREAD_ID }, + }, + { + event: "values", + data: { + messages: [ + ...INITIAL_MESSAGES, + { + type: "human", + id: "msg-human-batched-artifact-follow-up", + content: [{ type: "text", text: "Continue the report" }], + }, + ], + }, + }, + ...chunks.map((chunk) => ({ event: "messages", data: [chunk, {}] })), + ]; + + return events.map( + (event) => `event: ${event.event}\ndata: ${JSON.stringify(event.data)}\n\n`, + ); +} + +async function startBatchedWriteFileStreamServer() { + const frames = batchedWriteFileStreamFrames(); + const server = createServer((_request, response) => { + response.writeHead(200, { + "Access-Control-Allow-Origin": "*", + "Cache-Control": "no-cache", + "Content-Type": "text/event-stream", + }); + response.write(frames.slice(0, 3).join("")); + + const nextBatch = setTimeout(() => { + response.write(frames[3]); + }, 300); + const finishStream = setTimeout(() => { + response.end(); + }, 2_000); + response.once("close", () => { + clearTimeout(nextBatch); + clearTimeout(finishStream); + }); + }); + + await new Promise((resolve, reject) => { + const handleError = (error: Error) => reject(error); + server.once("error", handleError); + server.listen(0, "127.0.0.1", () => { + server.off("error", handleError); + resolve(); + }); + }); + + const { port } = server.address() as AddressInfo; + return { + url: `http://127.0.0.1:${port}/runs/stream`, + async close() { + server.closeAllConnections(); + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + }, + }; +} + +test("assembles streamed write-file argument deltas in the artifact preview", async ({ + page, +}) => { + let streamStarted = false; + let releasePostStreamHistory!: () => void; + const postStreamHistoryReleased = new Promise((resolve) => { + releasePostStreamHistory = resolve; + }); + const streamServer = await startBatchedWriteFileStreamServer(); + mockLangGraphAPI(page, { + threads: [ + { + thread_id: THREAD_ID, + title: "Batched artifact streaming", + messages: INITIAL_MESSAGES, + }, + ], + }); + await page.route("**/api/langgraph/threads/*/history", async (route) => { + if (streamStarted) { + await postStreamHistoryReleased; + } + return route.fallback(); + }); + await page.route("**/api/langgraph/threads/*/runs/stream", (route) => { + streamStarted = true; + return route.continue({ url: streamServer.url }); + }); + + try { + await page.goto(`/workspace/chats/${THREAD_ID}`); + + const textarea = page.getByPlaceholder(/how can i assist you/i); + await expect(textarea).toBeVisible({ timeout: 15_000 }); + await textarea.fill("Continue the report"); + await textarea.press("Enter"); + + await expect(page.getByText(ARTIFACT_PATH)).toBeVisible({ + timeout: 10_000, + }); + + const artifactsPanel = page.locator("#artifacts"); + await expect(artifactsPanel).toBeVisible(); + await expect(artifactsPanel.getByText("batched-report.md")).toBeVisible(); + await expect(artifactsPanel.getByText("Hello world")).toBeVisible(); + } finally { + releasePostStreamHistory(); + await streamServer.close(); + } +}); + +test("does not open an artifact for a file tool call without a path", async ({ + page, +}) => { + mockLangGraphAPI(page, { + threads: [ + { + thread_id: MISSING_PATH_THREAD_ID, + title: "File tool without a path", + messages: [ + ...INITIAL_MESSAGES, + { + type: "ai", + id: "msg-ai-missing-path", + content: "", + tool_calls: [ + { + id: "call-missing-path", + name: "write_file", + args: { description: "Write file" }, + }, + ], + }, + ], + }, + ], + }); + + await page.goto(`/workspace/chats/${MISSING_PATH_THREAD_ID}`); + + const writeFileStep = page.getByText("Write file", { exact: true }); + await expect(writeFileStep).toBeVisible({ timeout: 15_000 }); + await writeFileStep.click(); + await expect(page.locator("#artifacts")).toBeHidden(); +}); diff --git a/frontend/tests/unit/components/workspace/messages/message-group.test.ts b/frontend/tests/unit/components/workspace/messages/message-group.test.ts index 4b21c97b1..ad2eec510 100644 --- a/frontend/tests/unit/components/workspace/messages/message-group.test.ts +++ b/frontend/tests/unit/components/workspace/messages/message-group.test.ts @@ -1,25 +1,36 @@ import type { Message } from "@langchain/langgraph-sdk"; -import { describe, expect, it, rs } from "@rstest/core"; +import { afterEach, describe, expect, it, rs } from "@rstest/core"; import { createElement, type ComponentProps } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { MessageGroup } from "@/components/workspace/messages/message-group"; import { I18nContext } from "@/core/i18n/context"; +const artifactsMockState = rs.hoisted(() => ({ + autoOpen: false, + autoSelect: false, +})); + rs.mock("@/components/workspace/artifacts", () => ({ useArtifacts: () => ({ artifacts: [], setArtifacts: () => undefined, selectedArtifact: null, - autoSelect: false, + autoSelect: artifactsMockState.autoSelect, select: () => undefined, deselect: () => undefined, open: false, - autoOpen: false, + autoOpen: artifactsMockState.autoOpen, setOpen: () => undefined, }), })); +afterEach(() => { + artifactsMockState.autoOpen = false; + artifactsMockState.autoSelect = false; + rs.restoreAllMocks(); +}); + describe("MessageGroup", () => { it("renders assistant text attached to a tool-calling processing message", () => { const html = renderGroup([ @@ -92,6 +103,35 @@ describe("MessageGroup", () => { expect(html).toContain("1 more step"); }); + it("does not schedule artifact auto-open during render", () => { + artifactsMockState.autoOpen = true; + artifactsMockState.autoSelect = true; + const timeoutSpy = rs.spyOn(globalThis, "setTimeout"); + const html = renderGroup( + [ + { + id: "ai-write", + type: "ai", + content: "", + tool_calls: [ + { + id: "call-write", + name: "write_file", + args: { + path: "/mnt/user-data/outputs/report.md", + content: "# Report", + }, + }, + ], + } as Message, + ], + { isLoading: true }, + ); + + expect(html).toContain("/mnt/user-data/outputs/report.md"); + expect(timeoutSpy).not.toHaveBeenCalled(); + }); + it("keeps tool-calling assistant text visible when reasoning is also present", () => { const html = renderGroup([ { diff --git a/frontend/tests/unit/core/artifacts/utils.test.ts b/frontend/tests/unit/core/artifacts/utils.test.ts index 8c2aeced3..edb8b35dd 100644 --- a/frontend/tests/unit/core/artifacts/utils.test.ts +++ b/frontend/tests/unit/core/artifacts/utils.test.ts @@ -223,4 +223,33 @@ describe("artifact URL helpers", () => { ]), ).toBe("https://example.com/image.png"); }); + + test("builds encoded write-file URLs without undefined query parameters", async () => { + const { buildWriteFileArtifactURL } = await loadFreshArtifactUtils(); + const filepath = "/mnt/user-data/outputs/a b#c?%20.md"; + expect( + buildWriteFileArtifactURL({ + filepath: "/mnt/user-data/outputs/report.md", + messageId: "ai-1", + toolCallId: "call-1", + }), + ).toBe( + "write-file:/mnt/user-data/outputs/report.md?message_id=ai-1&tool_call_id=call-1", + ); + + const withIds = new URL( + buildWriteFileArtifactURL({ + filepath, + messageId: "message #1", + toolCallId: "call ?1", + }), + ); + expect(decodeURIComponent(withIds.pathname)).toBe(filepath); + expect(withIds.searchParams.get("message_id")).toBe("message #1"); + expect(withIds.searchParams.get("tool_call_id")).toBe("call ?1"); + + const withoutIds = buildWriteFileArtifactURL({ filepath }); + expect(withoutIds).not.toContain("undefined"); + expect(new URL(withoutIds).search).toBe(""); + }); });