From f851ef17a82e308f06e47f07d6fbac91c3c36eec Mon Sep 17 00:00:00 2001 From: Sandro Date: Thu, 26 Feb 2026 22:59:43 +0000 Subject: [PATCH 01/50] Integrated copilotkit to integrate with chatui. --- CHANGELOG.md | 1 + daiv/automation/agent/graph.py | 5 ++ daiv/chat/api/schemas.py | 27 ------- daiv/chat/api/utils.py | 98 -------------------------- daiv/chat/api/views.py | 125 ++++++++++++++++----------------- docker/local/app/config.env | 2 +- pyproject.toml | 2 + uv.lock | 99 ++++++++++++++++++++++++-- 8 files changed, 163 insertions(+), 196 deletions(-) delete mode 100644 daiv/chat/api/utils.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b1aa4157..42bbf6d84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Fixed chat streaming crashes caused by AG-UI `messages_in_process` state transitions where a `None` sentinel was merged as a mapping, preventing `TypeError: 'NoneType' object is not a mapping` during ASGI responses. - Fixed unit tests that still referenced the removed `create_changelog_subagent` by migrating them to `create_docs_research_subagent` expectations and `/agents` output assertions. - Fixed duplicate agent launches when issue labels are added, removed, and re-added by checking if DAIV has already reacted to the issue before processing label events. - Fixed sandbox archive layout to avoid adding the repository root folder; repository contents are now archived at the top level (while still excluding `.git`). diff --git a/daiv/automation/agent/graph.py b/daiv/automation/agent/graph.py index 9da099c40..c4ed6b30a 100644 --- a/daiv/automation/agent/graph.py +++ b/daiv/automation/agent/graph.py @@ -12,6 +12,7 @@ from deepagents.middleware.summarization import _compute_summarization_defaults from langchain.agents import create_agent from langchain.agents.middleware import ( + AgentMiddleware, HumanInTheLoopMiddleware, InterruptOnConfig, ModelFallbackMiddleware, @@ -114,6 +115,7 @@ async def create_daiv_agent( store: BaseStore | None = None, debug: bool = False, interrupt_on: dict[str, bool | InterruptOnConfig] | None = None, + middleware: list[AgentMiddleware] | None = None, # Flags to override the default settings sandbox_enabled: bool | None = None, web_fetch_enabled: bool | None = None, @@ -131,6 +133,7 @@ async def create_daiv_agent( store: The store to use for the agent. debug: Whether to enable debug mode for the agent. interrupt_on: The interrupt on configuration for the agent. + middleware: The middleware to use for the agent. sandbox_enabled: Whether to enable the sandbox for the agent. If None, fallback to the config default. web_fetch_enabled: Whether to enable web fetch for the agent. If None, fallback to the config default. web_search_enabled: Whether to enable web search for the agent. @@ -178,6 +181,8 @@ async def create_daiv_agent( agent_conditional_middlewares.append(SandboxMiddleware()) if fallback_models: agent_conditional_middlewares.append(ModelFallbackMiddleware(fallback_models[0], *fallback_models[1:])) + if middleware: + agent_conditional_middlewares += middleware if interrupt_on is not None: agent_conditional_middlewares.append(HumanInTheLoopMiddleware(interrupt_on=interrupt_on)) diff --git a/daiv/chat/api/schemas.py b/daiv/chat/api/schemas.py index 0455b2179..91ef63b21 100644 --- a/daiv/chat/api/schemas.py +++ b/daiv/chat/api/schemas.py @@ -13,30 +13,3 @@ class ModelSchema(Schema): class ModelListSchema(Schema): object: Literal["list"] data: list[ModelSchema] - - -class MessageSchema(Schema): - role: Literal["user", "assistant", "system"] - content: str - name: str | None = None - - -class ChatCompletionRequest(Schema): - model: str | None - messages: list[MessageSchema] - stream: bool = False - - -class ChatCompletionResponse(Schema): - id: str - object: Literal["chat.completion"] = "chat.completion" - created: int - choices: list[dict[str, int | dict | str]] - - -class ChatCompletionChunk(Schema): - id: str - object: Literal["chat.completion.chunk"] = "chat.completion.chunk" - created: int - model: str | None = None - choices: list[dict[str, int | dict | str | None]] diff --git a/daiv/chat/api/utils.py b/daiv/chat/api/utils.py deleted file mode 100644 index 9e644ad81..000000000 --- a/daiv/chat/api/utils.py +++ /dev/null @@ -1,98 +0,0 @@ -from __future__ import annotations - -import json -import logging -import uuid -from datetime import datetime -from typing import TYPE_CHECKING - -from automation.agent.graph import create_daiv_agent -from chat.api.schemas import ChatCompletionChunk -from codebase.base import Scope -from codebase.context import set_runtime_ctx - -if TYPE_CHECKING: - from collections.abc import AsyncGenerator - - from langchain_core.runnables import RunnableConfig - from langchain_core.runnables.schema import StreamEvent - - -logger = logging.getLogger("daiv.chat") - - -def extract_text_from_event_data(event_data: StreamEvent) -> str: - """ - Extract the text from the event data. - - Args: - event_data: The event data. - - Returns: - The extracted text. - """ - if isinstance(event_data["data"]["chunk"].content, list) and len(event_data["data"]["chunk"].content) > 0: - if event_data["data"]["chunk"].content[0]["type"] == "text": - return event_data["data"]["chunk"].content[0]["text"] - elif isinstance(event_data["data"]["chunk"].content, str): - return event_data["data"]["chunk"].content - return "" - - -async def generate_stream( - input_data: dict, model_id: str, *, repo_id: str, ref: str, config: RunnableConfig -) -> AsyncGenerator[str]: - """ - Generate a stream of chat completion events. - - Args: - input_data: The input data. - model_id: The model ID. - repo_id: The repository ID. - ref: The reference. - config: The config. - - Returns: - The stream of chat completion events. - """ - chunk_uuid = str(uuid.uuid4()) - created = int(datetime.now().timestamp()) - - async with set_runtime_ctx(repo_id=repo_id, scope=Scope.GLOBAL, ref=ref) as runtime_ctx: - try: - daiv_agent = await create_daiv_agent(ctx=runtime_ctx) - - async for message_chunk, _metadata in daiv_agent.astream(input_data, config=config, context=runtime_ctx): - if message_chunk and message_chunk.content: - chat_chunk = ChatCompletionChunk( - id=chunk_uuid, - created=created, - model=model_id, - choices=[ - { - "index": 0, - "finish_reason": None, - "delta": {"content": message_chunk.content, "role": "assistant"}, - } - ], - ) - yield f"data: {chat_chunk.model_dump_json()}\n\n" - - chat_chunk = ChatCompletionChunk( - id=chunk_uuid, - created=created, - model=model_id, - choices=[{"index": 0, "finish_reason": "stop", "delta": {"content": "", "role": "assistant"}}], - ) - yield f"data: {chat_chunk.model_dump_json()}\n\n" - - except Exception: - logger.exception("Error generating stream.") - chat_chunk = ChatCompletionChunk( - id=chunk_uuid, - created=created, - model=model_id, - choices=[{"index": 0, "finish_reason": "stop", "delta": {"content": "", "role": "assistant"}}], - ) - yield f"data: {chat_chunk.model_dump_json()}\n\n" - yield f"data: {json.dumps({'error': 'An internal error has occurred.'})}\n\n" diff --git a/daiv/chat/api/views.py b/daiv/chat/api/views.py index 280a26e0a..eec287094 100644 --- a/daiv/chat/api/views.py +++ b/daiv/chat/api/views.py @@ -1,36 +1,62 @@ import logging -import uuid -from datetime import datetime +from typing import TYPE_CHECKING, Any, cast +from django.conf import settings as django_settings from django.http import Http404, HttpRequest, StreamingHttpResponse -from langchain_core.runnables import RunnableConfig +from ag_ui.core import RunAgentInput # noqa: TC002 +from ag_ui.encoder import EventEncoder +from copilotkit import LangGraphAGUIAgent +from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver +from langgraph.store.memory import InMemoryStore from ninja import Router from automation.agent.graph import create_daiv_agent -from automation.agent.utils import extract_text_content from codebase.base import Scope from codebase.context import set_runtime_ctx -from core.constants import BOT_NAME -from .schemas import ChatCompletionRequest, ChatCompletionResponse, ModelListSchema, ModelSchema from .security import AuthBearer -from .utils import generate_stream + +if TYPE_CHECKING: + from ag_ui.core.events import BaseEvent # noqa: TC002 logger = logging.getLogger("daiv.chat") -MODEL_ID = "DAIV" HEADER_REPO_ID = "X-Repo-ID" HEADER_REF = "X-Ref" -chat_router = Router(auth=AuthBearer(), tags=["chat"]) +class RuntimeContextLangGraphAGUIAgent(LangGraphAGUIAgent): + """Inject runtime context into AG-UI stream kwargs.""" + + def __init__(self, *, runtime_context: Any, **kwargs: Any): + super().__init__(**kwargs) + self._runtime_context = runtime_context + + def set_message_in_progress(self, run_id: str, data: dict[str, Any]) -> None: + """ + Merge in-progress message data while tolerating upstream None sentinels. + + ag-ui-langgraph stores `None` when a stream segment is finished. If a new + message/tool-call segment starts in the same run, the default merge logic + attempts to unpack that `None` as a mapping and crashes. + """ + current_message_in_progress = self.messages_in_process.get(run_id) or {} + self.messages_in_process[run_id] = {**current_message_in_progress, **data} + + def get_stream_kwargs(self, *args: Any, **kwargs: Any) -> dict[str, Any]: + stream_kwargs = super().get_stream_kwargs(*args, **kwargs) + stream_kwargs.setdefault("context", self._runtime_context) + return stream_kwargs + + +chat_router = Router(tags=["chat"]) models_router = Router(auth=AuthBearer(), tags=["models"]) @chat_router.post( "/completions", - response=ChatCompletionResponse | dict, + response=dict, openapi_extra={ "parameters": [ {"in": "header", "name": HEADER_REPO_ID, "schema": {"type": "string"}, "required": True}, @@ -38,61 +64,34 @@ ] }, ) -async def create_chat_completion(request: HttpRequest, payload: ChatCompletionRequest): +async def create_chat_completion(request: HttpRequest, input_data: RunAgentInput): """ This endpoint is used to create a chat completion for a given set of messages within the indexed codebase. The main goal is to have an OpenAI compatible API to allow seamless integration with existing tools and services. """ - repo_id, ref = request.headers.get(HEADER_REPO_ID), request.headers.get(HEADER_REF) - - input_data = {"messages": [msg.dict() for msg in payload.messages]} - config = RunnableConfig( - metadata={"model_id": MODEL_ID, "chat_stream": payload.stream, "repo_id": repo_id, "ref": ref} - ) - - if payload.stream: - return StreamingHttpResponse( - generate_stream(input_data, MODEL_ID, repo_id=repo_id, ref=ref, config=config), - content_type="text/event-stream", - ) - try: - async with set_runtime_ctx(repo_id=repo_id, scope=Scope.GLOBAL, ref=ref) as runtime_ctx: - daiv_agent = await create_daiv_agent(ctx=runtime_ctx) - result = await daiv_agent.ainvoke(input_data, config=config, context=runtime_ctx) - - return ChatCompletionResponse( - id=str(uuid.uuid4()), - created=int(datetime.now().timestamp()), - choices=[ - { - "index": 1, - "message": { - "content": extract_text_content(result["messages"][-1].content), - "role": "assistant", - "tool_calls": [], - }, - "finish_reason": "stop", - } - ], - ) - except Exception as e: - return {"error": str(e)} - - -@models_router.get("", response={200: ModelListSchema}) -async def get_models(request: HttpRequest): - """ - This endpoint is used to get the list of models available for the chat completion. - """ - return ModelListSchema(object="list", data=[await get_model(request, MODEL_ID)]) - - -@models_router.get("/{model_id}", response={200: ModelSchema}) -async def get_model(request: HttpRequest, model_id: str): - """ - This endpoint is used to get the model information. - """ - if model_id != MODEL_ID: - raise Http404("Model not found") - return ModelSchema(id=MODEL_ID, object="model", created=None, owned_by=BOT_NAME) + repo_id = request.headers.get(HEADER_REPO_ID) + ref = request.headers.get(HEADER_REF) + + if not repo_id or not ref: + raise Http404("Repository ID or reference not found") + + encoder = EventEncoder(accept=request.headers.get("accept")) + + async def event_generator(): + async with ( + AsyncPostgresSaver.from_conn_string(django_settings.DB_URI) as checkpointer, + set_runtime_ctx(repo_id=repo_id, scope=Scope.GLOBAL, ref=ref) as runtime_ctx, + ): + agent = await create_daiv_agent(ctx=runtime_ctx, checkpointer=checkpointer, store=InMemoryStore()) + langgraph_agent = RuntimeContextLangGraphAGUIAgent( + name="DAIV", + description="DAIV agent", + graph=agent, + config={"recursion_limit": 500}, + runtime_context=runtime_ctx, + ) + async for event in langgraph_agent.run(input_data): + yield encoder.encode(cast("BaseEvent", event)) + + return StreamingHttpResponse(event_generator(), content_type=encoder.get_content_type()) diff --git a/docker/local/app/config.env b/docker/local/app/config.env index 9af1298fe..2d0e0acd7 100644 --- a/docker/local/app/config.env +++ b/docker/local/app/config.env @@ -27,4 +27,4 @@ LANGCHAIN_PROJECT=default DAIV_SANDBOX_NETWORK_ENABLED=True # AUTOMATION -DAIV_AGENT_MODEL_NAME=openrouter:minimax/minimax-m2.5:nitro +DAIV_AGENT_MODEL_NAME=openrouter:moonshotai/kimi-k2.5 diff --git a/pyproject.toml b/pyproject.toml index 280172ed8..3f5c60e67 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,8 @@ classifiers = [ "Programming Language :: Python :: 3.14", ] dependencies = [ + "ag-ui-langgraph==0.0.25", + "copilotkit==0.1.78", "ddgs==9.10.0", "deepagents==0.4.3", "django==6.0.2", diff --git a/uv.lock b/uv.lock index bc0a647c5..c2f72ef9d 100644 --- a/uv.lock +++ b/uv.lock @@ -7,6 +7,39 @@ resolution-markers = [ "sys_platform != 'emscripten' and sys_platform != 'win32'", ] +[[package]] +name = "ag-ui-langgraph" +version = "0.0.25" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ag-ui-protocol" }, + { name = "langchain" }, + { name = "langchain-core" }, + { name = "langgraph" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f9/c6/48bf48fb20eb128ca87058ba7cc22785c272ca5d162236f534a8fdb4b8cb/ag_ui_langgraph-0.0.25.tar.gz", hash = "sha256:ee100631fe57026d331f695c939826d470f3f9564e0956ff46be391f87d9498c", size = 13198, upload-time = "2026-02-10T16:07:11.484Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/d5/041803505ec2258790b82d45090a3e38aa5f32a60026f97718822c9bc86e/ag_ui_langgraph-0.0.25-py3-none-any.whl", hash = "sha256:a48cde3723578c32a6610e5f5e2bcf1f31ddb711ec1dd1b2c6486a7a64abe1cd", size = 14943, upload-time = "2026-02-10T16:07:10.561Z" }, +] + +[package.optional-dependencies] +fastapi = [ + { name = "fastapi" }, +] + +[[package]] +name = "ag-ui-protocol" +version = "0.1.13" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/b5/fc0b65b561d00d88811c8a7d98ee735833f81554be244340950e7b65820c/ag_ui_protocol-0.1.13.tar.gz", hash = "sha256:811d7d7dcce4783dec252918f40b717ebfa559399bf6b071c4ba47c0c1e21bcb", size = 5671, upload-time = "2026-02-19T18:40:38.602Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/9f/b833c1ab1999da35ebad54841ae85d2c2764c931da9a6f52d8541b6901b2/ag_ui_protocol-0.1.13-py3-none-any.whl", hash = "sha256:1393fa894c1e8416efe184168a50689e760d05b32f4646eebb8ff423dddf8e8f", size = 8053, upload-time = "2026-02-19T18:40:37.27Z" }, +] + [[package]] name = "aiohappyeyeballs" version = "2.6.1" @@ -334,6 +367,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "copilotkit" +version = "0.1.78" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ag-ui-langgraph", extra = ["fastapi"] }, + { name = "fastapi" }, + { name = "langchain" }, + { name = "langgraph" }, + { name = "partialjson" }, + { name = "toml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d4/33/4ff1f1d732f89f8a08f08a20c6288af8d407438221b44748967f10df88f8/copilotkit-0.1.78.tar.gz", hash = "sha256:d27c303d61539eab3dc168ada6ec0a0ecb02f770da8aa4f1f9bbd9488235c556", size = 37578, upload-time = "2026-02-06T11:56:04.523Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/5c/81bcea99f0da7d6b43f5bbdce13943da260ea94cd5d61c0d8bb1d047e50c/copilotkit-0.1.78-py3-none-any.whl", hash = "sha256:d4230094c96de708a58c9d5da82258a4bfda6ce85846b2fb154a1837ed0a92f5", size = 46919, upload-time = "2026-02-06T11:56:03.318Z" }, +] + [[package]] name = "coverage" version = "7.13.4" @@ -431,6 +481,8 @@ name = "daiv" version = "1.1.0" source = { virtual = "." } dependencies = [ + { name = "ag-ui-langgraph" }, + { name = "copilotkit" }, { name = "ddgs" }, { name = "deepagents" }, { name = "django" }, @@ -496,6 +548,8 @@ docs = [ [package.metadata] requires-dist = [ + { name = "ag-ui-langgraph", specifier = "==0.0.25" }, + { name = "copilotkit", specifier = "==0.1.78" }, { name = "ddgs", specifier = "==9.10.0" }, { name = "deepagents", specifier = "==0.4.3" }, { name = "django", specifier = "==6.0.2" }, @@ -795,6 +849,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/51/37/b3ea9cd5558ff4cb51957caca2193981c6b0ff30bd0d2630ac62505d99d0/fake_useragent-2.2.0-py3-none-any.whl", hash = "sha256:67f35ca4d847b0d298187443aaf020413746e56acd985a611908c73dba2daa24", size = 161695, upload-time = "2025-04-14T15:32:17.732Z" }, ] +[[package]] +name = "fastapi" +version = "0.115.14" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ca/53/8c38a874844a8b0fa10dd8adf3836ac154082cf88d3f22b544e9ceea0a15/fastapi-0.115.14.tar.gz", hash = "sha256:b1de15cdc1c499a4da47914db35d0e4ef8f1ce62b624e94e0e5824421df99739", size = 296263, upload-time = "2025-06-26T15:29:08.21Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/50/b1222562c6d270fea83e9c9075b8e8600b8479150a18e4516a6138b980d1/fastapi-0.115.14-py3-none-any.whl", hash = "sha256:6c0c8bf9420bd58f565e585036d971872472b4f7d3f6c73b698e10cffdefb3ca", size = 95514, upload-time = "2025-06-26T15:29:06.49Z" }, +] + [[package]] name = "filelock" version = "3.24.3" @@ -2086,6 +2154,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b6/61/fae042894f4296ec49e3f193aff5d7c18440da9e48102c3315e1bc4519a7/parso-0.8.6-py2.py3-none-any.whl", hash = "sha256:2c549f800b70a5c4952197248825584cb00f033b29c692671d3bf08bf380baff", size = 106894, upload-time = "2026-02-09T15:45:21.391Z" }, ] +[[package]] +name = "partialjson" +version = "0.0.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5d/b2/59669fdc3ecbc724a077c598c1c9b4068549af0cd8c3b5add9337bd4d93a/partialjson-0.0.8.tar.gz", hash = "sha256:91217e19a15049332df534477f56420065ad1729cedee7d8c7433e1d2acc7dca", size = 4142, upload-time = "2024-08-03T18:03:15.798Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/fb/453af21468774dbd0954853735a4fc7841544c3022ff86e5d93252d7ea72/partialjson-0.0.8-py3-none-any.whl", hash = "sha256:22c6c60944137f931a7033fa0eeee2d74b49114f3d45c25a560b07a6ebf22b76", size = 4549, upload-time = "2024-08-03T18:03:14.447Z" }, +] + [[package]] name = "pathspec" version = "1.0.4" @@ -3004,15 +3081,14 @@ wheels = [ [[package]] name = "sse-starlette" -version = "3.2.0" +version = "3.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, - { name = "starlette" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8b/8d/00d280c03ffd39aaee0e86ec81e2d3b9253036a0f93f51d10503adef0e65/sse_starlette-3.2.0.tar.gz", hash = "sha256:8127594edfb51abe44eac9c49e59b0b01f1039d0c7461c6fd91d4e03b70da422", size = 27253, upload-time = "2026-01-17T13:11:05.62Z" } +sdist = { url = "https://files.pythonhosted.org/packages/db/3c/fa6517610dc641262b77cc7bf994ecd17465812c1b0585fe33e11be758ab/sse_starlette-3.0.3.tar.gz", hash = "sha256:88cfb08747e16200ea990c8ca876b03910a23b547ab3bd764c0d8eb81019b971", size = 21943, upload-time = "2025-10-30T18:44:20.117Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/96/7f/832f015020844a8b8f7a9cbc103dd76ba8e3875004c41e08440ea3a2b41a/sse_starlette-3.2.0-py3-none-any.whl", hash = "sha256:5876954bd51920fc2cd51baee47a080eb88a37b5b784e615abb0b283f801cdbf", size = 12763, upload-time = "2026-01-17T13:11:03.775Z" }, + { url = "https://files.pythonhosted.org/packages/23/a0/984525d19ca5c8a6c33911a0c164b11490dd0f90ff7fd689f704f84e9a11/sse_starlette-3.0.3-py3-none-any.whl", hash = "sha256:af5bf5a6f3933df1d9c7f8539633dc8444ca6a97ab2e2a7cd3b6e431ac03a431", size = 11765, upload-time = "2025-10-30T18:44:18.834Z" }, ] [[package]] @@ -3031,14 +3107,14 @@ wheels = [ [[package]] name = "starlette" -version = "0.52.1" +version = "0.46.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/20/08dfcd9c983f6a6f4a1000d934b9e6d626cff8d2eeb77a89a68eef20a2b7/starlette-0.46.2.tar.gz", hash = "sha256:7f7361f34eed179294600af672f565727419830b54b7b084efe44bb82d2fccd5", size = 2580846, upload-time = "2025-04-13T13:56:17.942Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" }, + { url = "https://files.pythonhosted.org/packages/8b/0c/9d30a4ebeb6db2b25a841afbb80f6ef9a854fc3b41be131d249a977b4959/starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35", size = 72037, upload-time = "2025-04-13T13:56:16.21Z" }, ] [[package]] @@ -3076,6 +3152,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/af/df/c7891ef9d2712ad774777271d39fdef63941ffba0a9d59b7ad1fd2765e57/tiktoken-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f61c0aea5565ac82e2ec50a05e02a6c44734e91b51c10510b084ea1b8e633a71", size = 920667, upload-time = "2025-10-06T20:22:34.444Z" }, ] +[[package]] +name = "toml" +version = "0.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253, upload-time = "2020-11-01T01:40:22.204Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588, upload-time = "2020-11-01T01:40:20.672Z" }, +] + [[package]] name = "toml-fmt-common" version = "1.2.0" From e51bdb780032fee74fc26836fae40ef249e75f91 Mon Sep 17 00:00:00 2001 From: Sandro Date: Thu, 26 Feb 2026 23:08:58 +0000 Subject: [PATCH 02/50] Cleaned changelog. --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 42bbf6d84..7b1aa4157 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,7 +46,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Fixed chat streaming crashes caused by AG-UI `messages_in_process` state transitions where a `None` sentinel was merged as a mapping, preventing `TypeError: 'NoneType' object is not a mapping` during ASGI responses. - Fixed unit tests that still referenced the removed `create_changelog_subagent` by migrating them to `create_docs_research_subagent` expectations and `/agents` output assertions. - Fixed duplicate agent launches when issue labels are added, removed, and re-added by checking if DAIV has already reacted to the issue before processing label events. - Fixed sandbox archive layout to avoid adding the repository root folder; repository contents are now archived at the top level (while still excluding `.git`). From 80e6689e19936f8c66b0827bbdf2df9f6a8fa73c Mon Sep 17 00:00:00 2001 From: Sandro Date: Thu, 23 Apr 2026 19:32:42 +0100 Subject: [PATCH 03/50] build(deps): bump ag-ui-langgraph from 0.0.31 to 0.0.34 Drop the now-redundant set_message_in_progress override on RuntimeContextLangGraphAGUIAgent: upstream already guards the None sentinel merge with or {}, so the override was a no-op (and was triggering Liskov type-check errors because its signature widened MessageInProgress to dict[str, Any]). The get_stream_kwargs override is kept because upstream still dict- merges config/context via .update(), which is incompatible with our frozen RuntimeCtx dataclass passed to LangGraph as context_schema. --- daiv/chat/api/views.py | 19 +++++++------------ pyproject.toml | 2 +- uv.lock | 8 ++++---- 3 files changed, 12 insertions(+), 17 deletions(-) diff --git a/daiv/chat/api/views.py b/daiv/chat/api/views.py index 35ca4673c..9254d7cf9 100644 --- a/daiv/chat/api/views.py +++ b/daiv/chat/api/views.py @@ -29,23 +29,18 @@ class RuntimeContextLangGraphAGUIAgent(LangGraphAGUIAgent): - """Inject runtime context into AG-UI stream kwargs.""" + """ + Forward the daiv RuntimeCtx dataclass as LangGraph's typed `context=` kwarg. + + Upstream's `get_stream_kwargs` only accepts dict-shaped contexts (it merges via + `dict.update`), but our graph declares `context_schema=RuntimeCtx` and expects the + frozen dataclass itself. + """ def __init__(self, *, runtime_context: Any, **kwargs: Any): super().__init__(**kwargs) self._runtime_context = runtime_context - def set_message_in_progress(self, run_id: str, data: dict[str, Any]) -> None: - """ - Merge in-progress message data while tolerating upstream None sentinels. - - ag-ui-langgraph stores `None` when a stream segment is finished. If a new - message/tool-call segment starts in the same run, the default merge logic - attempts to unpack that `None` as a mapping and crashes. - """ - current_message_in_progress = self.messages_in_process.get(run_id) or {} - self.messages_in_process[run_id] = {**current_message_in_progress, **data} - def get_stream_kwargs(self, *args: Any, **kwargs: Any) -> dict[str, Any]: stream_kwargs = super().get_stream_kwargs(*args, **kwargs) stream_kwargs.setdefault("context", self._runtime_context) diff --git a/pyproject.toml b/pyproject.toml index 1c37b41c9..2fbbc364f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,7 @@ classifiers = [ "Programming Language :: Python :: 3.14", ] dependencies = [ - "ag-ui-langgraph==0.0.31", + "ag-ui-langgraph==0.0.34", "copilotkit==0.1.86", "croniter==6.2.2", "ddgs==9.14.0", diff --git a/uv.lock b/uv.lock index 0fa297238..cace7d8a1 100644 --- a/uv.lock +++ b/uv.lock @@ -9,7 +9,7 @@ resolution-markers = [ [[package]] name = "ag-ui-langgraph" -version = "0.0.31" +version = "0.0.34" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "ag-ui-protocol" }, @@ -18,9 +18,9 @@ dependencies = [ { name = "langgraph" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c6/83/7a9026b6e9b82379cb1a356ca6f18b20b6106b94baf268c43a765da0acc1/ag_ui_langgraph-0.0.31.tar.gz", hash = "sha256:a7c58763fa267f96e12fae5e22a9f933b0f2caafcfa6e3d98bb6406247217058", size = 11342548, upload-time = "2026-04-08T18:31:41.351Z" } +sdist = { url = "https://files.pythonhosted.org/packages/11/5e/950cdb65de973660f2634a675b0a614657a5a00258a7eede21d3a9318992/ag_ui_langgraph-0.0.34.tar.gz", hash = "sha256:755323d5256407ce62d6b9af447a9f1250554e7056c5e2115027a4174a736c41", size = 258423, upload-time = "2026-04-20T21:09:25.789Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5b/64/6cd42a2c170ee174ce68bc761580b58c45454d7da0818495d08d0a8bf30d/ag_ui_langgraph-0.0.31-py3-none-any.whl", hash = "sha256:9ffae20103715f046182e2317efd10b1b8c59d454ec026a265e07a6e2ea4e4ec", size = 19825, upload-time = "2026-04-08T18:31:18.817Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e5/c4eeee50262de83eb10de29b55259244d0a95edceb0d08c63ba0ae175719/ag_ui_langgraph-0.0.34-py3-none-any.whl", hash = "sha256:124dccdae48af124f857b746aa3c0424b2d332b73b06ac426b6d5dcd811ea984", size = 27020, upload-time = "2026-04-20T21:09:26.816Z" }, ] [package.optional-dependencies] @@ -554,7 +554,7 @@ docs = [ [package.metadata] requires-dist = [ - { name = "ag-ui-langgraph", specifier = "==0.0.31" }, + { name = "ag-ui-langgraph", specifier = "==0.0.34" }, { name = "copilotkit", specifier = "==0.1.86" }, { name = "croniter", specifier = "==6.2.2" }, { name = "ddgs", specifier = "==9.14.0" }, From 8174ceb1ba65d171e7d16eca3c600966f72271b6 Mon Sep 17 00:00:00 2001 From: Sandro Date: Thu, 23 Apr 2026 19:34:58 +0100 Subject: [PATCH 04/50] test(chat): Rewrite chat API tests for AG-UI endpoint The old tests imported MODEL_ID and hit /models endpoints that were removed when the chat API was rewritten on top of CopilotKit/AG-UI. They failed at collection time. Replace them with coverage of the view's only custom behavior: the 404 raised when X-Repo-ID or X-Ref headers are missing. Everything past that point is third-party (AG-UI, LangGraph, ninja) and not worth exercising in unit tests. --- tests/unit_tests/chat/api/test_views.py | 32 ++++++++++++++----------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/tests/unit_tests/chat/api/test_views.py b/tests/unit_tests/chat/api/test_views.py index da66c5c6e..ee9e0fa33 100644 --- a/tests/unit_tests/chat/api/test_views.py +++ b/tests/unit_tests/chat/api/test_views.py @@ -1,30 +1,34 @@ import pytest from ninja.testing import TestAsyncClient -from chat.api.views import MODEL_ID from daiv.api import api @pytest.fixture -def client_unauthenticated(): +def client(): return TestAsyncClient(api) -@pytest.mark.django_db -async def test_create_chat_completion(client_unauthenticated: TestAsyncClient): - response = await client_unauthenticated.post( - "/chat/completions", json={"model": MODEL_ID, "messages": [{"role": "user", "content": "Hello, how are you?"}]} - ) - assert response.status_code == 401 +def _run_agent_input(**overrides) -> dict: + return { + "threadId": "t-1", + "runId": "r-1", + "state": {}, + "messages": [], + "tools": [], + "context": [], + "forwardedProps": {}, + **overrides, + } @pytest.mark.django_db -async def test_get_models_unauthenticated(client_unauthenticated: TestAsyncClient): - response = await client_unauthenticated.get("/models") - assert response.status_code == 401 +async def test_create_chat_completion_missing_repo_id_header(client: TestAsyncClient): + response = await client.post("/chat/completions", json=_run_agent_input(), headers={"X-Ref": "main"}) + assert response.status_code == 404 @pytest.mark.django_db -async def test_get_model_detail_unauthenticated(client_unauthenticated: TestAsyncClient): - response = await client_unauthenticated.get(f"/models/{MODEL_ID}") - assert response.status_code == 401 +async def test_create_chat_completion_missing_ref_header(client: TestAsyncClient): + response = await client.post("/chat/completions", json=_run_agent_input(), headers={"X-Repo-ID": "owner/repo"}) + assert response.status_code == 404 From 7fa1a82c0b870ab574d302120fb4a9fed99452ed Mon Sep 17 00:00:00 2001 From: Sandro Date: Fri, 24 Apr 2026 00:19:22 +0100 Subject: [PATCH 05/50] feat(activity): add Activity.thread_id for LangGraph checkpoint persistence --- .../migrations/0009_activity_thread_id.py | 23 +++++++++++++++++++ daiv/activity/models.py | 10 ++++++++ 2 files changed, 33 insertions(+) create mode 100644 daiv/activity/migrations/0009_activity_thread_id.py diff --git a/daiv/activity/migrations/0009_activity_thread_id.py b/daiv/activity/migrations/0009_activity_thread_id.py new file mode 100644 index 000000000..a0dbcca84 --- /dev/null +++ b/daiv/activity/migrations/0009_activity_thread_id.py @@ -0,0 +1,23 @@ +# Generated by Django 6.0.4 on 2026-04-23 23:18 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [("activity", "0008_activity_batch_id")] + + operations = [ + migrations.AddField( + model_name="activity", + name="thread_id", + field=models.CharField( + blank=True, + db_index=True, + help_text="LangGraph checkpoint key. Lets chat resume this run.", + max_length=64, + null=True, + unique=True, + verbose_name="thread ID", + ), + ) + ] diff --git a/daiv/activity/models.py b/daiv/activity/models.py index 25c657c10..50df79412 100644 --- a/daiv/activity/models.py +++ b/daiv/activity/models.py @@ -96,6 +96,16 @@ class Activity(models.Model): help_text=_("Shared identifier for activities from the same submission."), ) + thread_id = models.CharField( + _("thread ID"), + max_length=64, + null=True, + blank=True, + unique=True, + db_index=True, + help_text=_("LangGraph checkpoint key. Lets chat resume this run."), + ) + external_username = models.CharField( _("external username"), max_length=255, From a66d76c0e2ec89781ae77786e24dabe8e917017a Mon Sep 17 00:00:00 2001 From: Sandro Date: Fri, 24 Apr 2026 00:20:02 +0100 Subject: [PATCH 06/50] feat(chat): scaffold chat app and shared checkpointer factory --- daiv/chat/apps.py | 7 +++++++ daiv/core/checkpointer.py | 18 ++++++++++++++++++ daiv/daiv/settings/components/common.py | 1 + 3 files changed, 26 insertions(+) create mode 100644 daiv/chat/apps.py create mode 100644 daiv/core/checkpointer.py diff --git a/daiv/chat/apps.py b/daiv/chat/apps.py new file mode 100644 index 000000000..4ebd96ee3 --- /dev/null +++ b/daiv/chat/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class ChatConfig(AppConfig): + name = "chat" + default_auto_field = "django.db.models.BigAutoField" + verbose_name = "Chat" diff --git a/daiv/core/checkpointer.py b/daiv/core/checkpointer.py new file mode 100644 index 000000000..75cb421e8 --- /dev/null +++ b/daiv/core/checkpointer.py @@ -0,0 +1,18 @@ +from contextlib import asynccontextmanager + +from django.conf import settings + +from langgraph.checkpoint.redis.aio import AsyncRedisSaver + + +@asynccontextmanager +async def open_checkpointer(): + """Yield a configured AsyncRedisSaver using project settings. + + Single source of truth for the Redis connection + TTL used by the chat endpoint, + the job task, and the chat dashboard views. + """ + async with AsyncRedisSaver.from_conn_string( + settings.DJANGO_REDIS_CHECKPOINT_URL, ttl={"default_ttl": settings.DJANGO_REDIS_CHECKPOINT_TTL_MINUTES} + ) as cp: + yield cp diff --git a/daiv/daiv/settings/components/common.py b/daiv/daiv/settings/components/common.py index b64b23b56..1ea67e212 100644 --- a/daiv/daiv/settings/components/common.py +++ b/daiv/daiv/settings/components/common.py @@ -15,6 +15,7 @@ "accounts", "activity", "automation", + "chat", "codebase", "core", "mcp_server", From 39a317a55507f9f781527b934c29f7c7eb82919f Mon Sep 17 00:00:00 2001 From: Sandro Date: Fri, 24 Apr 2026 00:22:21 +0100 Subject: [PATCH 07/50] feat(jobs): persist agent-run state via AsyncRedisSaver; accept thread_id --- daiv/jobs/tasks.py | 34 ++++++++++++------------ tests/unit_tests/jobs/test_tasks.py | 41 +++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 17 deletions(-) create mode 100644 tests/unit_tests/jobs/test_tasks.py diff --git a/daiv/jobs/tasks.py b/daiv/jobs/tasks.py index 612523781..1c368bfc7 100644 --- a/daiv/jobs/tasks.py +++ b/daiv/jobs/tasks.py @@ -3,7 +3,6 @@ from django_tasks import task from langchain_core.messages import HumanMessage -from langgraph.checkpoint.memory import InMemorySaver from automation.agent.graph import create_daiv_agent from automation.agent.results import AgentResult, build_agent_result @@ -11,39 +10,40 @@ from automation.agent.utils import build_langsmith_config, extract_text_content, get_daiv_agent_kwargs from codebase.base import Scope from codebase.context import set_runtime_ctx +from core.checkpointer import open_checkpointer logger = logging.getLogger("daiv.jobs") @task() -async def run_job_task(repo_id: str, prompt: str, ref: str | None = None, use_max: bool = False) -> AgentResult: - """ - Run the DAIV agent for a submitted job and return a standardized result. - - Args: - repo_id: The repository id. - prompt: The user prompt to send to the agent. - ref: The git reference. Defaults to the repository's default branch. - use_max: Whether to use the max model configuration. +async def run_job_task( + repo_id: str, prompt: str, ref: str | None = None, use_max: bool = False, thread_id: str | None = None +) -> AgentResult: + """Run the DAIV agent for a submitted job and return a standardized result. - Returns: - An :class:`AgentResult` dict with the agent response and code_changes flag. + The ``thread_id`` is used as the LangGraph checkpoint key. Callers should mint one + up-front and persist it on the corresponding ``Activity`` so chat can resume the run. """ - logger.info("Starting job for repo_id=%s, ref=%s, use_max=%s", repo_id, ref, use_max) + if thread_id is None: + thread_id = str(uuid.uuid4()) + + logger.info("Starting job for repo_id=%s, ref=%s, use_max=%s, thread_id=%s", repo_id, ref, use_max, thread_id) input_data = {"messages": [HumanMessage(content=prompt)]} try: - async with set_runtime_ctx(repo_id=repo_id, scope=Scope.GLOBAL, ref=ref) as runtime_ctx: + async with ( + set_runtime_ctx(repo_id=repo_id, scope=Scope.GLOBAL, ref=ref) as runtime_ctx, + open_checkpointer() as checkpointer, + ): agent_kwargs = get_daiv_agent_kwargs(model_config=runtime_ctx.config.models.agent, use_max=use_max) - checkpointer = InMemorySaver() config = build_langsmith_config( runtime_ctx, trigger="job", model=agent_kwargs["model_names"][0], thinking_level=agent_kwargs["thinking_level"], extra_metadata={"ref": ref}, - configurable={"thread_id": str(uuid.uuid4())}, + configurable={"thread_id": thread_id}, ) daiv_agent = await create_daiv_agent(ctx=runtime_ctx, checkpointer=checkpointer, **agent_kwargs) with track_usage_metadata() as usage_handler: @@ -59,7 +59,7 @@ async def run_job_task(repo_id: str, prompt: str, ref: str | None = None, use_ma response_text = extract_text_content(messages[-1].content) - logger.info("Job completed for repo_id=%s", repo_id) + logger.info("Job completed for repo_id=%s, thread_id=%s", repo_id, thread_id) return await build_agent_result( daiv_agent, config, response=response_text, usage=build_usage_summary(usage_handler.usage_metadata).to_dict() ) diff --git a/tests/unit_tests/jobs/test_tasks.py b/tests/unit_tests/jobs/test_tasks.py new file mode 100644 index 000000000..3cedeb7cc --- /dev/null +++ b/tests/unit_tests/jobs/test_tasks.py @@ -0,0 +1,41 @@ +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from jobs.tasks import run_job_task + + +@pytest.mark.django_db +async def test_run_job_task_uses_async_redis_saver_with_thread_id(): + """run_job_task must use the shared open_checkpointer (AsyncRedisSaver) and thread its + thread_id through to the langgraph config.""" + last_message = MagicMock() + last_message.content = "ok" + fake_result = {"messages": [last_message]} + + runtime_ctx = MagicMock() + runtime_ctx.config.models.agent = MagicMock() + + agent = AsyncMock() + agent.ainvoke = AsyncMock(return_value=fake_result) + + with ( + patch("jobs.tasks.open_checkpointer") as cp_ctx, + patch("jobs.tasks.set_runtime_ctx") as rc_ctx, + patch("jobs.tasks.create_daiv_agent", new=AsyncMock(return_value=agent)), + patch( + "jobs.tasks.get_daiv_agent_kwargs", + return_value={"model_names": ["claude-4-7-opus"], "thinking_level": "medium"}, + ), + patch("jobs.tasks.build_langsmith_config", return_value={"configurable": {"thread_id": "t-123"}}), + patch("jobs.tasks.build_agent_result", new=AsyncMock(return_value={"response": "ok"})), + patch("jobs.tasks.build_usage_summary", return_value=MagicMock(to_dict=lambda: {})), + patch("jobs.tasks.track_usage_metadata"), + ): + cp_ctx.return_value.__aenter__.return_value = object() + rc_ctx.return_value.__aenter__.return_value = runtime_ctx + + await run_job_task.func(repo_id="owner/repo", prompt="hi", ref="main", use_max=False, thread_id="t-123") + + cp_ctx.assert_called_once() + call_kwargs = agent.ainvoke.call_args.kwargs + assert call_kwargs["config"]["configurable"]["thread_id"] == "t-123" From 81ea1a2879259df76c513f129195326ff7876541 Mon Sep 17 00:00:00 2001 From: Sandro Date: Fri, 24 Apr 2026 00:24:47 +0100 Subject: [PATCH 08/50] feat(activity): mint thread_id per run and persist on Activity --- daiv/activity/services.py | 10 +++++++++- tests/unit_tests/activity/test_batch_submit.py | 14 +++++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/daiv/activity/services.py b/daiv/activity/services.py index 80993e969..6d4fdcff9 100644 --- a/daiv/activity/services.py +++ b/daiv/activity/services.py @@ -96,6 +96,7 @@ def create_activity( external_username: str = "", notify_on: NotifyOn | None = None, batch_id: uuid.UUID | None = None, + thread_id: str | None = None, ) -> Activity: """Create an Activity record linked to a DBTaskResult. @@ -116,6 +117,7 @@ def create_activity( external_username=external_username, notify_on=notify_on, batch_id=batch_id, + thread_id=thread_id, ) @@ -135,6 +137,7 @@ async def acreate_activity( external_username: str = "", notify_on: NotifyOn | None = None, batch_id: uuid.UUID | None = None, + thread_id: str | None = None, ) -> Activity: """Async variant of create_activity.""" return await Activity.objects.acreate( @@ -152,6 +155,7 @@ async def acreate_activity( external_username=external_username, notify_on=notify_on, batch_id=batch_id, + thread_id=thread_id, ) @@ -177,8 +181,11 @@ async def asubmit_batch_runs( async def _submit_one(target: RepoTarget) -> Activity | BatchSubmitFailure: ref_for_task = target.ref or None + thread_id = str(uuid.uuid4()) try: - task = await run_job_task.aenqueue(repo_id=target.repo_id, prompt=prompt, ref=ref_for_task, use_max=use_max) + task = await run_job_task.aenqueue( + repo_id=target.repo_id, prompt=prompt, ref=ref_for_task, use_max=use_max, thread_id=thread_id + ) except Exception as err: # noqa: BLE001 logger.exception("submit_batch_runs: enqueue failed for repo_id=%s batch_id=%s", target.repo_id, batch_id) return BatchSubmitFailure(repo_id=target.repo_id, ref=target.ref, error=f"{type(err).__name__}: {err}") @@ -196,6 +203,7 @@ async def _submit_one(target: RepoTarget) -> Activity | BatchSubmitFailure: external_username=external_username, notify_on=notify_on, batch_id=batch_id, + thread_id=thread_id, ) except Exception: logger.exception( diff --git a/tests/unit_tests/activity/test_batch_submit.py b/tests/unit_tests/activity/test_batch_submit.py index 251fc9d59..16f9c9809 100644 --- a/tests/unit_tests/activity/test_batch_submit.py +++ b/tests/unit_tests/activity/test_batch_submit.py @@ -60,7 +60,13 @@ def test_single_repo_creates_one_activity_with_batch_id(self, member_user): assert result.activities[0].batch_id == result.batch_id assert result.activities[0].repo_id == "a/b" assert result.activities[0].trigger_type == TriggerType.UI_JOB - m_task.aenqueue.assert_awaited_once_with(repo_id="a/b", prompt="do it", ref=None, use_max=False) + m_task.aenqueue.assert_awaited_once() + enqueue_kwargs = m_task.aenqueue.await_args.kwargs + assert enqueue_kwargs["repo_id"] == "a/b" + assert enqueue_kwargs["prompt"] == "do it" + assert enqueue_kwargs["ref"] is None + assert enqueue_kwargs["use_max"] is False + assert enqueue_kwargs["thread_id"] == result.activities[0].thread_id def test_five_repos_creates_five_activities_sharing_batch_id(self, member_user): tasks_seen = [] @@ -86,6 +92,12 @@ async def _aenqueue(**kwargs): assert [t["repo_id"] for t in tasks_seen] == [f"o/r{i}" for i in range(5)] assert tasks_seen[0]["ref"] is None # empty ref threads as None assert tasks_seen[1]["ref"] == "dev" + # Each activity gets a distinct thread_id that matches the one passed to the task. + activity_thread_ids = [a.thread_id for a in result.activities] + assert all(activity_thread_ids) + assert len(set(activity_thread_ids)) == 5 + task_thread_ids = [t["thread_id"] for t in tasks_seen] + assert set(task_thread_ids) == set(activity_thread_ids) def test_empty_repos_raises_value_error(self, member_user): with pytest.raises(ValueError): From 8489aa459819825bde31662d4ce1a1d46203117a Mon Sep 17 00:00:00 2001 From: Sandro Date: Fri, 24 Apr 2026 00:28:20 +0100 Subject: [PATCH 09/50] feat(chat): ChatThread model with per-user ownership --- daiv/chat/managers.py | 6 ++++ daiv/chat/migrations/0001_initial.py | 38 ++++++++++++++++++++++ daiv/chat/migrations/__init__.py | 0 daiv/chat/models.py | 47 ++++++++++++++++++++++++++++ tests/unit_tests/chat/test_models.py | 33 +++++++++++++++++++ 5 files changed, 124 insertions(+) create mode 100644 daiv/chat/managers.py create mode 100644 daiv/chat/migrations/0001_initial.py create mode 100644 daiv/chat/migrations/__init__.py create mode 100644 daiv/chat/models.py create mode 100644 tests/unit_tests/chat/test_models.py diff --git a/daiv/chat/managers.py b/daiv/chat/managers.py new file mode 100644 index 000000000..7597f2adb --- /dev/null +++ b/daiv/chat/managers.py @@ -0,0 +1,6 @@ +from django.db import models + + +class ChatThreadManager(models.Manager): + def for_user(self, user): + return self.filter(user=user) diff --git a/daiv/chat/migrations/0001_initial.py b/daiv/chat/migrations/0001_initial.py new file mode 100644 index 000000000..ae0871b60 --- /dev/null +++ b/daiv/chat/migrations/0001_initial.py @@ -0,0 +1,38 @@ +# Generated by Django 6.0.4 on 2026-04-23 23:27 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [migrations.swappable_dependency(settings.AUTH_USER_MODEL)] + + operations = [ + migrations.CreateModel( + name="ChatThread", + fields=[ + ("thread_id", models.CharField(max_length=64, primary_key=True, serialize=False)), + ("repo_id", models.CharField(max_length=255, verbose_name="repository")), + ("ref", models.CharField(blank=True, default="", max_length=255, verbose_name="ref")), + ("title", models.CharField(blank=True, default="", max_length=120)), + ("active_run_id", models.CharField(blank=True, default="", max_length=64)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("last_active_at", models.DateTimeField(auto_now=True)), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="chat_threads", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "ordering": ["-last_active_at"], + "indexes": [models.Index(fields=["user", "-last_active_at"], name="chat_chatth_user_id_abfd75_idx")], + }, + ) + ] diff --git a/daiv/chat/migrations/__init__.py b/daiv/chat/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/daiv/chat/models.py b/daiv/chat/models.py new file mode 100644 index 000000000..84a489774 --- /dev/null +++ b/daiv/chat/models.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from django.conf import settings +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from chat.managers import ChatThreadManager + + +class ChatThread(models.Model): + """Metadata row for a chat conversation. The ``thread_id`` is the LangGraph + checkpoint key — shared with any ``activity.Activity`` that produced the run we're + continuing. + """ + + thread_id = models.CharField(max_length=64, primary_key=True) + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="chat_threads") + repo_id = models.CharField(_("repository"), max_length=255) + ref = models.CharField(_("ref"), max_length=255, blank=True, default="") + title = models.CharField(max_length=120, blank=True, default="") + active_run_id = models.CharField(max_length=64, blank=True, default="") + created_at = models.DateTimeField(auto_now_add=True) + last_active_at = models.DateTimeField(auto_now=True) + + objects = ChatThreadManager() + + class Meta: + ordering = ["-last_active_at"] + indexes = [models.Index(fields=["user", "-last_active_at"])] + + def __str__(self) -> str: + return self.title or self.thread_id + + @classmethod + async def aget_or_create_from_activity(cls, user, activity) -> tuple[ChatThread, bool]: + """Look up or create a thread that continues an activity run. Idempotent.""" + existing = await cls.objects.filter(thread_id=activity.thread_id).afirst() + if existing is not None: + return existing, False + thread = await cls.objects.acreate( + user=user, + thread_id=activity.thread_id, + repo_id=activity.repo_id, + ref=activity.ref or "", + title=(activity.prompt or "")[:120], + ) + return thread, True diff --git a/tests/unit_tests/chat/test_models.py b/tests/unit_tests/chat/test_models.py new file mode 100644 index 000000000..171e9a7b2 --- /dev/null +++ b/tests/unit_tests/chat/test_models.py @@ -0,0 +1,33 @@ +from django.db import IntegrityError + +import pytest +from activity.models import Activity, TriggerType + +from chat.models import ChatThread + + +@pytest.mark.django_db +def test_chat_thread_thread_id_is_unique_primary_key(member_user): + ChatThread.objects.create(thread_id="t-1", user=member_user, repo_id="a/b", ref="main") + with pytest.raises(IntegrityError): + ChatThread.objects.create(thread_id="t-1", user=member_user, repo_id="a/b", ref="main") + + +@pytest.mark.django_db(transaction=True) +async def test_aget_or_create_from_activity_is_idempotent(member_user): + activity = await Activity.objects.acreate( + trigger_type=TriggerType.UI_JOB, + repo_id="a/b", + ref="main", + prompt="first message", + thread_id="t-42", + user=member_user, + ) + thread_a, created_a = await ChatThread.aget_or_create_from_activity(member_user, activity) + thread_b, created_b = await ChatThread.aget_or_create_from_activity(member_user, activity) + assert created_a is True + assert created_b is False + assert thread_a.thread_id == thread_b.thread_id == "t-42" + assert thread_a.repo_id == "a/b" + assert thread_a.ref == "main" + assert thread_a.title.startswith("first message") From 82e0af56afa18be532043aee695445a22d1eb9bc Mon Sep 17 00:00:00 2001 From: Sandro Date: Fri, 24 Apr 2026 00:33:12 +0100 Subject: [PATCH 10/50] feat(chat): dual-auth endpoint with thread ownership and concurrency check --- daiv/chat/api/views.py | 92 ++++++++++++------- tests/unit_tests/chat/api/test_views.py | 117 +++++++++++++++++++++++- 2 files changed, 173 insertions(+), 36 deletions(-) diff --git a/daiv/chat/api/views.py b/daiv/chat/api/views.py index 9254d7cf9..33590787a 100644 --- a/daiv/chat/api/views.py +++ b/daiv/chat/api/views.py @@ -1,20 +1,22 @@ import logging from typing import TYPE_CHECKING, Any, cast -from django.conf import settings as django_settings from django.http import Http404, HttpRequest, StreamingHttpResponse from ag_ui.core import RunAgentInput # noqa: TC002 from ag_ui.encoder import EventEncoder from copilotkit import LangGraphAGUIAgent -from langgraph.checkpoint.redis.aio import AsyncRedisSaver from langgraph.store.memory import InMemoryStore from ninja import Router +from ninja.errors import HttpError +from ninja.security import django_auth from automation.agent.graph import create_daiv_agent from automation.agent.utils import build_langsmith_config +from chat.models import ChatThread from codebase.base import Scope from codebase.context import set_runtime_ctx +from core.checkpointer import open_checkpointer from core.site_settings import site_settings from .security import AuthBearer @@ -47,10 +49,18 @@ def get_stream_kwargs(self, *args: Any, **kwargs: Any) -> dict[str, Any]: return stream_kwargs -chat_router = Router(tags=["chat"]) +chat_router = Router(tags=["chat"], auth=[AuthBearer(), django_auth]) models_router = Router(auth=AuthBearer(), tags=["models"]) +def _extract_first_user_message(input_data: RunAgentInput) -> str: + for message in input_data.messages: + content = getattr(message, "content", "") + if isinstance(content, str) and content.strip(): + return content + return "" + + @chat_router.post( "/completions", response=dict, @@ -62,42 +72,62 @@ def get_stream_kwargs(self, *args: Any, **kwargs: Any) -> dict[str, Any]: }, ) async def create_chat_completion(request: HttpRequest, input_data: RunAgentInput): - """ - This endpoint is used to create a chat completion for a given set of messages within the indexed codebase. - - The main goal is to have an OpenAI compatible API to allow seamless integration with existing tools and services. + """Handle one AG-UI run. Implicit-creates the ChatThread on first sight for the + authenticated caller, enforces ownership thereafter, and rejects concurrent runs on + the same thread. """ repo_id = request.headers.get(HEADER_REPO_ID) ref = request.headers.get(HEADER_REF) - if not repo_id or not ref: raise Http404("Repository ID or reference not found") + user = request.auth + thread_id = input_data.thread_id + run_id = input_data.run_id + + thread = await ChatThread.objects.filter(thread_id=thread_id).afirst() + if thread is None: + thread = await ChatThread.objects.acreate( + user=user, + thread_id=thread_id, + repo_id=repo_id, + ref=ref, + title=_extract_first_user_message(input_data)[:120], + ) + elif thread.user_id != user.id: + raise HttpError(403, "Thread not found") + elif thread.active_run_id: + raise HttpError(409, "A run is already in progress for this thread") + + thread.active_run_id = run_id + await thread.asave(update_fields=["active_run_id", "last_active_at"]) + encoder = EventEncoder(accept=request.headers.get("accept")) async def event_generator(): - async with ( - AsyncRedisSaver.from_conn_string( - django_settings.DJANGO_REDIS_CHECKPOINT_URL, - ttl={"default_ttl": django_settings.DJANGO_REDIS_CHECKPOINT_TTL_MINUTES}, - ) as checkpointer, - set_runtime_ctx(repo_id=repo_id, scope=Scope.GLOBAL, ref=ref) as runtime_ctx, - ): - agent = await create_daiv_agent(ctx=runtime_ctx, checkpointer=checkpointer, store=InMemoryStore()) - langsmith_config = build_langsmith_config( - runtime_ctx, - trigger="chat", - model=site_settings.agent_model_name, - thinking_level=site_settings.agent_thinking_level, - ) - langgraph_agent = RuntimeContextLangGraphAGUIAgent( - name="DAIV", - description="DAIV agent", - graph=agent, - config={"recursion_limit": 500, **langsmith_config}, - runtime_context=runtime_ctx, - ) - async for event in langgraph_agent.run(input_data): - yield encoder.encode(cast("BaseEvent", event)) + try: + async with ( + open_checkpointer() as checkpointer, + set_runtime_ctx(repo_id=repo_id, scope=Scope.GLOBAL, ref=ref) as runtime_ctx, + ): + agent = await create_daiv_agent(ctx=runtime_ctx, checkpointer=checkpointer, store=InMemoryStore()) + langsmith_config = build_langsmith_config( + runtime_ctx, + trigger="chat", + model=site_settings.agent_model_name, + thinking_level=site_settings.agent_thinking_level, + ) + langgraph_agent = RuntimeContextLangGraphAGUIAgent( + name="DAIV", + description="DAIV agent", + graph=agent, + config={"recursion_limit": 500, **langsmith_config}, + runtime_context=runtime_ctx, + ) + async for event in langgraph_agent.run(input_data): + yield encoder.encode(cast("BaseEvent", event)) + finally: + thread.active_run_id = "" + await thread.asave(update_fields=["active_run_id", "last_active_at"]) return StreamingHttpResponse(event_generator(), content_type=encoder.get_content_type()) diff --git a/tests/unit_tests/chat/api/test_views.py b/tests/unit_tests/chat/api/test_views.py index ee9e0fa33..c65ee6a1c 100644 --- a/tests/unit_tests/chat/api/test_views.py +++ b/tests/unit_tests/chat/api/test_views.py @@ -1,6 +1,10 @@ +from unittest.mock import AsyncMock, MagicMock, patch + import pytest from ninja.testing import TestAsyncClient +from accounts.models import APIKey, User +from chat.models import ChatThread from daiv.api import api @@ -9,12 +13,28 @@ def client(): return TestAsyncClient(api) +@pytest.fixture +async def authed(): + """Return (APIKey, raw_key, user) for authenticated tests.""" + user = await User.objects.acreate_user( + username="chatuser", + email="chat@example.com", + password="testpass123", # noqa: S106 + ) + key_obj, raw = await APIKey.objects.create_key(user=user, name="Test") + return key_obj, raw, user + + +def _auth_headers(raw_key: str, **extra) -> dict: + return {"Authorization": f"Bearer {raw_key}", **extra} + + def _run_agent_input(**overrides) -> dict: return { "threadId": "t-1", "runId": "r-1", "state": {}, - "messages": [], + "messages": [{"id": "m-1", "role": "user", "content": "hello"}], "tools": [], "context": [], "forwardedProps": {}, @@ -22,13 +42,100 @@ def _run_agent_input(**overrides) -> dict: } +def _mock_stream(*_args, **_kwargs): + """Factory that returns an async context manager yielding a MagicMock. Used to patch + open_checkpointer() and set_runtime_ctx() during tests so we exercise the ownership + path without hitting Redis or cloning a repo. + """ + ctx = MagicMock() + ctx.__aenter__ = AsyncMock(return_value=MagicMock()) + ctx.__aexit__ = AsyncMock(return_value=None) + return ctx + + @pytest.mark.django_db -async def test_create_chat_completion_missing_repo_id_header(client: TestAsyncClient): - response = await client.post("/chat/completions", json=_run_agent_input(), headers={"X-Ref": "main"}) +async def test_missing_repo_id_header_returns_404(client: TestAsyncClient, authed): + _, raw, user = authed + response = await client.post( + "/chat/completions", json=_run_agent_input(), headers=_auth_headers(raw, **{"X-Ref": "main"}) + ) assert response.status_code == 404 + await user.adelete() @pytest.mark.django_db -async def test_create_chat_completion_missing_ref_header(client: TestAsyncClient): - response = await client.post("/chat/completions", json=_run_agent_input(), headers={"X-Repo-ID": "owner/repo"}) +async def test_missing_ref_header_returns_404(client: TestAsyncClient, authed): + _, raw, user = authed + response = await client.post( + "/chat/completions", json=_run_agent_input(), headers=_auth_headers(raw, **{"X-Repo-ID": "owner/repo"}) + ) assert response.status_code == 404 + await user.adelete() + + +@pytest.mark.django_db(transaction=True) +async def test_cross_user_thread_id_is_rejected(client: TestAsyncClient, authed): + _, raw, user = authed + other = await User.objects.acreate_user( + username="owner", + email="owner@example.com", + password="x", # noqa: S106 + ) + await ChatThread.objects.acreate(thread_id="t-owned", user=other, repo_id="a/b", ref="main") + + response = await client.post( + "/chat/completions", + json=_run_agent_input(threadId="t-owned"), + headers=_auth_headers(raw, **{"X-Repo-ID": "a/b", "X-Ref": "main"}), + ) + assert response.status_code == 403 + await user.adelete() + await other.adelete() + + +@pytest.mark.django_db(transaction=True) +async def test_unknown_thread_id_implicit_creates_thread(client: TestAsyncClient, authed): + _, raw, user = authed + with ( + patch("chat.api.views.open_checkpointer", _mock_stream), + patch("chat.api.views.set_runtime_ctx", _mock_stream), + patch("chat.api.views.create_daiv_agent", new=AsyncMock()), + patch("chat.api.views.RuntimeContextLangGraphAGUIAgent") as m_agent_cls, + ): + m_instance = MagicMock() + + async def _empty_stream(_input): + if False: # generator that yields nothing + yield + + m_instance.run = _empty_stream + m_agent_cls.return_value = m_instance + + response = await client.post( + "/chat/completions", + json=_run_agent_input(threadId="t-new"), + headers=_auth_headers(raw, **{"X-Repo-ID": "a/b", "X-Ref": "main"}), + ) + + assert response.status_code == 200 + created = await ChatThread.objects.filter(thread_id="t-new").afirst() + assert created is not None + assert created.user_id == user.id + assert created.repo_id == "a/b" + assert created.ref == "main" + await user.adelete() + + +@pytest.mark.django_db(transaction=True) +async def test_concurrent_run_returns_409(client: TestAsyncClient, authed): + _, raw, user = authed + await ChatThread.objects.acreate( + thread_id="t-busy", user=user, repo_id="a/b", ref="main", active_run_id="r-existing" + ) + response = await client.post( + "/chat/completions", + json=_run_agent_input(threadId="t-busy"), + headers=_auth_headers(raw, **{"X-Repo-ID": "a/b", "X-Ref": "main"}), + ) + assert response.status_code == 409 + await user.adelete() From 9d63391990020c5bc8563e4c7d2f9022f4234b71 Mon Sep 17 00:00:00 2001 From: Sandro Date: Fri, 24 Apr 2026 00:37:10 +0100 Subject: [PATCH 11/50] feat(chat): dashboard views (list, detail, from-activity bridge) --- daiv/chat/templates/chat/chat_detail.html | 7 ++ daiv/chat/templates/chat/chat_list.html | 11 ++ daiv/chat/urls.py | 10 ++ daiv/chat/views.py | 100 ++++++++++++++++ daiv/daiv/urls.py | 1 + tests/unit_tests/chat/test_views.py | 137 ++++++++++++++++++++++ 6 files changed, 266 insertions(+) create mode 100644 daiv/chat/templates/chat/chat_detail.html create mode 100644 daiv/chat/templates/chat/chat_list.html create mode 100644 daiv/chat/urls.py create mode 100644 daiv/chat/views.py create mode 100644 tests/unit_tests/chat/test_views.py diff --git a/daiv/chat/templates/chat/chat_detail.html b/daiv/chat/templates/chat/chat_detail.html new file mode 100644 index 000000000..21823fdb0 --- /dev/null +++ b/daiv/chat/templates/chat/chat_detail.html @@ -0,0 +1,7 @@ +{% extends "base_app.html" %} +{% load i18n %} + +{% block app_content %} +{% if expired %}

{% trans "Expired." %}

{% endif %} +{{ messages_history|json_script:"chat-initial-messages" }} +{% endblock %} diff --git a/daiv/chat/templates/chat/chat_list.html b/daiv/chat/templates/chat/chat_list.html new file mode 100644 index 000000000..bc1910a81 --- /dev/null +++ b/daiv/chat/templates/chat/chat_list.html @@ -0,0 +1,11 @@ +{% extends "base_app.html" %} +{% load i18n %} + +{% block app_content %} +

{% trans "Chat" %}

+ +{% endblock %} diff --git a/daiv/chat/urls.py b/daiv/chat/urls.py new file mode 100644 index 000000000..43a140273 --- /dev/null +++ b/daiv/chat/urls.py @@ -0,0 +1,10 @@ +from django.urls import path + +from chat.views import ChatThreadDetailView, ChatThreadFromActivityView, ChatThreadListView + +urlpatterns = [ + path("", ChatThreadListView.as_view(), name="chat_list"), + path("new/", ChatThreadDetailView.as_view(), name="chat_new"), + path("/", ChatThreadDetailView.as_view(), name="chat_detail"), + path("from-activity//", ChatThreadFromActivityView.as_view(), name="chat_from_activity"), +] diff --git a/daiv/chat/views.py b/daiv/chat/views.py new file mode 100644 index 000000000..59092b8a9 --- /dev/null +++ b/daiv/chat/views.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +from typing import Any + +from django.contrib.auth.mixins import LoginRequiredMixin +from django.http import Http404, HttpResponseGone +from django.shortcuts import get_object_or_404, redirect +from django.views.generic import DetailView, ListView, View + +from activity.models import Activity +from asgiref.sync import async_to_sync + +from accounts.mixins import BreadcrumbMixin +from chat.models import ChatThread +from core.checkpointer import open_checkpointer + + +async def _ahydrate(thread_id: str) -> tuple[list[Any], bool]: + """Return (messages, expired) for a thread.""" + async with open_checkpointer() as cp: + tup = await cp.aget_tuple({"configurable": {"thread_id": thread_id}}) + if tup is None: + return [], True + messages = (tup.channel_values or {}).get("messages", []) + return messages, False + + +def _serialize_message(m: Any) -> dict[str, Any]: + role = getattr(m, "type", None) or getattr(m, "role", "") + normalized = "assistant" if role in ("ai", "assistant") else ("user" if role in ("human", "user") else str(role)) + return {"id": getattr(m, "id", "") or "", "role": normalized, "content": getattr(m, "content", "")} + + +class ChatThreadListView(LoginRequiredMixin, BreadcrumbMixin, ListView): + model = ChatThread + template_name = "chat/chat_list.html" + context_object_name = "threads" + paginate_by = 25 + + def get_queryset(self): + return ChatThread.objects.for_user(self.request.user) + + def get_breadcrumbs(self): + return [{"label": "Chat", "url": None}] + + +class ChatThreadDetailView(LoginRequiredMixin, BreadcrumbMixin, DetailView): + """Renders the chat page for a specific thread, or the empty state when no + ``thread_id`` URL kwarg is present (the ``chat_new`` route). + """ + + model = ChatThread + template_name = "chat/chat_detail.html" + context_object_name = "thread" + pk_url_kwarg = "thread_id" + + def get_queryset(self): + return ChatThread.objects.for_user(self.request.user) + + def get_object(self, queryset=None): + if "thread_id" not in self.kwargs: + return None + return super().get_object(queryset) + + def get_context_data(self, **kwargs: Any) -> dict[str, Any]: + ctx = super().get_context_data(**kwargs) + thread = ctx.get("thread") + ctx["thread"] = thread # ensure key present even in empty-state + if thread is None: + ctx.update({"messages_history": [], "expired": False}) + return ctx + messages_history, expired = async_to_sync(_ahydrate)(thread.thread_id) + ctx["messages_history"] = [_serialize_message(m) for m in messages_history] + ctx["expired"] = expired + return ctx + + def get_breadcrumbs(self): + thread = getattr(self, "object", None) + if thread is None: + return [{"label": "Chat", "url": "/dashboard/chat/"}, {"label": "New", "url": None}] + return [ + {"label": "Chat", "url": "/dashboard/chat/"}, + {"label": thread.title or thread.thread_id[:8], "url": None}, + ] + + +class ChatThreadFromActivityView(LoginRequiredMixin, View): + """Bridge: create (or reuse) a ChatThread for an activity and redirect to it.""" + + def post(self, request, *, activity_id): + activity = get_object_or_404(Activity, pk=activity_id, user=request.user) + if not activity.thread_id: + raise Http404 + + messages, expired = async_to_sync(_ahydrate)(activity.thread_id) + if expired: + return HttpResponseGone("This run's state has expired. Start a fresh chat from its prompt.") + + thread, _ = async_to_sync(ChatThread.aget_or_create_from_activity)(request.user, activity) + return redirect("chat_detail", thread_id=thread.thread_id) diff --git a/daiv/daiv/urls.py b/daiv/daiv/urls.py index 9438f3d95..5760ccf99 100644 --- a/daiv/daiv/urls.py +++ b/daiv/daiv/urls.py @@ -28,6 +28,7 @@ def location(self, item): path("dashboard/", include("accounts.urls.dashboard")), path("dashboard/configuration/", include("core.urls.configuration")), path("dashboard/activity/", include("activity.urls")), + path("dashboard/chat/", include("chat.urls")), path("dashboard/runs/", include("activity.urls_runs", namespace="runs")), path("dashboard/notifications/", include("notifications.urls")), path("dashboard/schedules/", include("schedules.urls")), diff --git a/tests/unit_tests/chat/test_views.py b/tests/unit_tests/chat/test_views.py new file mode 100644 index 000000000..6d8d3c27d --- /dev/null +++ b/tests/unit_tests/chat/test_views.py @@ -0,0 +1,137 @@ +from unittest.mock import AsyncMock, MagicMock, patch + +from django.urls import reverse + +import pytest +from activity.models import Activity, TriggerType + +from accounts.models import Role, User +from chat.models import ChatThread + + +@pytest.fixture +def other_user(db): + return User.objects.create_user(username="other", email="other@test.com", password="x", role=Role.MEMBER) # noqa: S106 + + +@pytest.mark.django_db +def test_list_view_requires_login(client): + resp = client.get(reverse("chat_list")) + assert resp.status_code == 302 + assert "/accounts/login" in resp["Location"] or "login" in resp["Location"].lower() + + +@pytest.mark.django_db +def test_list_view_only_shows_users_threads(member_client, member_user, other_user): + mine = ChatThread.objects.create(thread_id="t-mine", user=member_user, repo_id="a/b", ref="main") + ChatThread.objects.create(thread_id="t-theirs", user=other_user, repo_id="a/b", ref="main") + resp = member_client.get(reverse("chat_list")) + assert resp.status_code == 200 + threads = list(resp.context["threads"]) + assert [t.thread_id for t in threads] == [mine.thread_id] + + +@pytest.mark.django_db +def test_detail_view_404s_for_other_user_thread(member_client, other_user): + thread = ChatThread.objects.create(thread_id="t-other", user=other_user, repo_id="a/b", ref="main") + resp = member_client.get(reverse("chat_detail", kwargs={"thread_id": thread.thread_id})) + assert resp.status_code == 404 + + +@pytest.mark.django_db +def test_detail_view_with_live_checkpoint_renders_transcript(member_client, member_user): + thread = ChatThread.objects.create(thread_id="t-live", user=member_user, repo_id="a/b", ref="main") + msg = MagicMock() + msg.type = "ai" + msg.content = "hello from agent" + msg.id = "m-1" + tup = MagicMock(channel_values={"messages": [msg]}) + with patch("chat.views.open_checkpointer") as cp_ctx: + saver = MagicMock() + saver.aget_tuple = AsyncMock(return_value=tup) + cp_ctx.return_value.__aenter__ = AsyncMock(return_value=saver) + cp_ctx.return_value.__aexit__ = AsyncMock(return_value=None) + resp = member_client.get(reverse("chat_detail", kwargs={"thread_id": thread.thread_id})) + + assert resp.status_code == 200 + assert resp.context["expired"] is False + history = resp.context["messages_history"] + assert len(history) == 1 + assert history[0]["role"] == "assistant" + assert history[0]["content"] == "hello from agent" + + +@pytest.mark.django_db +def test_detail_view_with_missing_checkpoint_flags_expired(member_client, member_user): + thread = ChatThread.objects.create(thread_id="t-gone", user=member_user, repo_id="a/b", ref="main") + with patch("chat.views.open_checkpointer") as cp_ctx: + saver = MagicMock() + saver.aget_tuple = AsyncMock(return_value=None) + cp_ctx.return_value.__aenter__ = AsyncMock(return_value=saver) + cp_ctx.return_value.__aexit__ = AsyncMock(return_value=None) + resp = member_client.get(reverse("chat_detail", kwargs={"thread_id": thread.thread_id})) + + assert resp.status_code == 200 + assert resp.context["expired"] is True + + +@pytest.mark.django_db +def test_detail_view_empty_state_renders_new_page(member_client): + resp = member_client.get(reverse("chat_new")) + assert resp.status_code == 200 + assert resp.context["thread"] is None + assert resp.context["expired"] is False + + +@pytest.mark.django_db +def test_from_activity_404_for_other_users_activity(member_client, other_user): + activity = Activity.objects.create( + trigger_type=TriggerType.UI_JOB, repo_id="a/b", ref="main", prompt="x", thread_id="t-x", user=other_user + ) + resp = member_client.post(reverse("chat_from_activity", kwargs={"activity_id": activity.id})) + assert resp.status_code == 404 + + +@pytest.mark.django_db +def test_from_activity_404_when_activity_has_no_thread_id(member_client, member_user): + activity = Activity.objects.create( + trigger_type=TriggerType.UI_JOB, repo_id="a/b", ref="main", prompt="x", user=member_user + ) + resp = member_client.post(reverse("chat_from_activity", kwargs={"activity_id": activity.id})) + assert resp.status_code == 404 + + +@pytest.mark.django_db +def test_from_activity_410_when_checkpoint_missing(member_client, member_user): + activity = Activity.objects.create( + trigger_type=TriggerType.UI_JOB, repo_id="a/b", ref="main", prompt="x", thread_id="t-gone", user=member_user + ) + with patch("chat.views.open_checkpointer") as cp_ctx: + saver = MagicMock() + saver.aget_tuple = AsyncMock(return_value=None) + cp_ctx.return_value.__aenter__ = AsyncMock(return_value=saver) + cp_ctx.return_value.__aexit__ = AsyncMock(return_value=None) + resp = member_client.post(reverse("chat_from_activity", kwargs={"activity_id": activity.id})) + assert resp.status_code == 410 + + +@pytest.mark.django_db +def test_from_activity_creates_thread_and_redirects(member_client, member_user): + activity = Activity.objects.create( + trigger_type=TriggerType.UI_JOB, + repo_id="a/b", + ref="main", + prompt="hello there", + thread_id="t-alive", + user=member_user, + ) + tup = MagicMock(channel_values={"messages": []}) + with patch("chat.views.open_checkpointer") as cp_ctx: + saver = MagicMock() + saver.aget_tuple = AsyncMock(return_value=tup) + cp_ctx.return_value.__aenter__ = AsyncMock(return_value=saver) + cp_ctx.return_value.__aexit__ = AsyncMock(return_value=None) + resp = member_client.post(reverse("chat_from_activity", kwargs={"activity_id": activity.id})) + assert resp.status_code == 302 + assert ChatThread.objects.filter(thread_id="t-alive", user=member_user).exists() + assert resp["Location"] == reverse("chat_detail", kwargs={"thread_id": "t-alive"}) From f4131eb53165425127e66d0a286389fd10f579b2 Mon Sep 17 00:00:00 2001 From: Sandro Date: Fri, 24 Apr 2026 00:39:06 +0100 Subject: [PATCH 12/50] feat(chat): list/detail/message/composer templates and Alpine streaming component --- daiv/chat/static/chat/js/chat-stream.js | 212 ++++++++++++++++++ daiv/chat/templates/chat/_composer.html | 52 +++++ daiv/chat/templates/chat/_message.html | 33 +++ daiv/chat/templates/chat/_tool_call_card.html | 16 ++ daiv/chat/templates/chat/chat_detail.html | 51 ++++- daiv/chat/templates/chat/chat_list.html | 35 ++- 6 files changed, 390 insertions(+), 9 deletions(-) create mode 100644 daiv/chat/static/chat/js/chat-stream.js create mode 100644 daiv/chat/templates/chat/_composer.html create mode 100644 daiv/chat/templates/chat/_message.html create mode 100644 daiv/chat/templates/chat/_tool_call_card.html diff --git a/daiv/chat/static/chat/js/chat-stream.js b/daiv/chat/static/chat/js/chat-stream.js new file mode 100644 index 000000000..04a988544 --- /dev/null +++ b/daiv/chat/static/chat/js/chat-stream.js @@ -0,0 +1,212 @@ +(() => { + const AGUI = { + RUN_STARTED: "RUN_STARTED", + RUN_FINISHED: "RUN_FINISHED", + RUN_ERROR: "RUN_ERROR", + TEXT_MESSAGE_START: "TEXT_MESSAGE_START", + TEXT_MESSAGE_CONTENT: "TEXT_MESSAGE_CONTENT", + TEXT_MESSAGE_END: "TEXT_MESSAGE_END", + TEXT_MESSAGE_CHUNK: "TEXT_MESSAGE_CHUNK", + TOOL_CALL_START: "TOOL_CALL_START", + TOOL_CALL_ARGS: "TOOL_CALL_ARGS", + TOOL_CALL_END: "TOOL_CALL_END", + TOOL_CALL_RESULT: "TOOL_CALL_RESULT", + }; + + const uuid = () => crypto.randomUUID(); + + const escapeHtml = (s) => + String(s ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">"); + + const renderMessage = (m) => { + const tpl = document.getElementById("message-template"); + if (!tpl) return `
${escapeHtml(m.content || "")}
`; + // Minimal renderer: our _message.html partial uses Django template tags that the browser + // can't evaluate, so we produce a compatible bubble structure here. Keep the classes in + // sync with _message.html when restyling. + const roleLabel = m.role === "user" ? "You" : m.role === "assistant" ? "DAIV" : m.role; + let html = `
+
${escapeHtml(roleLabel)}
+
${escapeHtml(m.content).replaceAll("\n", "
")}
`; + if (m.tool_calls && m.tool_calls.length) { + html += `
`; + for (const tc of m.tool_calls) { + html += `
+ ${escapeHtml(tc.name || "")} + ${tc.args ? `
${escapeHtml(tc.args)}
` : ""} + ${tc.result ? `
${escapeHtml(tc.result)}
` : ""} +
`; + } + html += `
`; + } + if (m.error) html += `

${escapeHtml(m.error)}

`; + html += `
`; + return html; + }; + + const loadInitialMessages = () => { + const el = document.getElementById("chat-initial-messages"); + if (!el) return []; + try { + return JSON.parse(el.textContent); + } catch { + return []; + } + }; + + const chat = (config) => ({ + endpoint: config.endpoint, + thread: config.thread, + messages: loadInitialMessages(), + draftMessage: "", + draftRepoId: "", + draftRef: "main", + streaming: false, + error: null, + abortCtl: null, + + canSend() { + if (!this.draftMessage.trim()) return false; + if (this.thread) return true; + return !!(this.draftRepoId && this.draftRef); + }, + + async submit() { + if (!this.canSend() || this.streaming) return; + this.error = null; + + if (!this.thread) { + const threadId = uuid(); + this.thread = { thread_id: threadId, repo_id: this.draftRepoId, ref: this.draftRef }; + history.replaceState(null, "", `/dashboard/chat/${threadId}/`); + } + + const userMsg = { id: uuid(), role: "user", content: this.draftMessage }; + this.messages.push(userMsg); + const assistantMsg = { id: uuid(), role: "assistant", content: "", tool_calls: [] }; + this.messages.push(assistantMsg); + + const body = { + threadId: this.thread.thread_id, + runId: uuid(), + state: {}, + messages: this.messages + .slice(0, -1) + .map((m) => ({ id: m.id, role: m.role, content: m.content })), + tools: [], + context: [], + forwardedProps: {}, + }; + + this.draftMessage = ""; + this.streaming = true; + this.abortCtl = new AbortController(); + + try { + const resp = await fetch(this.endpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Repo-ID": this.thread.repo_id, + "X-Ref": this.thread.ref, + "X-CSRFToken": document.cookie.match(/csrftoken=([^;]+)/)?.[1] || "", + }, + body: JSON.stringify(body), + credentials: "include", + signal: this.abortCtl.signal, + }); + + if (!resp.ok) { + const text = await resp.text(); + assistantMsg.error = `${resp.status}: ${text}`; + return; + } + + await this.consume(resp.body, assistantMsg); + } catch (err) { + if (err.name !== "AbortError") { + assistantMsg.error = err.message; + } + } finally { + this.streaming = false; + this.abortCtl = null; + this.scrollToBottom(); + } + }, + + stop() { + this.abortCtl?.abort(); + }, + + async consume(stream, assistantMsg) { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + while (true) { + const { value, done } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const frames = buffer.split("\n\n"); + buffer = frames.pop() || ""; + for (const frame of frames) { + const line = frame.split("\n").find((l) => l.startsWith("data:")); + if (!line) continue; + let evt; + try { + evt = JSON.parse(line.slice(5).trim()); + } catch { + console.warn("chat: bad SSE frame", line); + continue; + } + this.dispatch(evt, assistantMsg); + } + } + }, + + dispatch(evt, assistantMsg) { + switch (evt.type) { + case AGUI.TEXT_MESSAGE_CONTENT: + case AGUI.TEXT_MESSAGE_CHUNK: + assistantMsg.content += evt.delta || evt.content || ""; + break; + case AGUI.TOOL_CALL_START: + assistantMsg.tool_calls.push({ + id: evt.toolCallId, + name: evt.toolCallName, + args: "", + }); + break; + case AGUI.TOOL_CALL_ARGS: { + const tc = assistantMsg.tool_calls.find((t) => t.id === evt.toolCallId); + if (tc) tc.args += evt.delta || ""; + break; + } + case AGUI.TOOL_CALL_RESULT: { + const tc = assistantMsg.tool_calls.find((t) => t.id === evt.toolCallId); + if (tc) tc.result = evt.content; + break; + } + case AGUI.RUN_ERROR: + assistantMsg.error = evt.message || "Run failed"; + break; + default: + break; + } + this.scrollToBottom(); + }, + + scrollToBottom() { + const el = this.$refs.transcript; + if (el) el.scrollTop = el.scrollHeight; + }, + + renderMessage, + }); + + document.addEventListener("alpine:init", () => { + window.Alpine.data("chat", chat); + }); +})(); diff --git a/daiv/chat/templates/chat/_composer.html b/daiv/chat/templates/chat/_composer.html new file mode 100644 index 000000000..153def20e --- /dev/null +++ b/daiv/chat/templates/chat/_composer.html @@ -0,0 +1,52 @@ +{% load i18n %} +
+ +
+ + + +
+ + + + +
+ + +
+
diff --git a/daiv/chat/templates/chat/_message.html b/daiv/chat/templates/chat/_message.html new file mode 100644 index 000000000..4c7a25319 --- /dev/null +++ b/daiv/chat/templates/chat/_message.html @@ -0,0 +1,33 @@ +{% load i18n %} +
+
+ {% if message.role == 'user' %}{% trans "You" %} + {% elif message.role == 'assistant' %}DAIV + {% else %}{{ message.role|title }}{% endif %} +
+ + {% if message.reasoning %} +
+ {% trans "Reasoning" %} +
{{ message.reasoning }}
+
+ {% endif %} + +
+ {{ message.content|linebreaksbr }} +
+ + {% if message.tool_calls %} +
+ {% for tc in message.tool_calls %} + {% include "chat/_tool_call_card.html" with tool_call=tc %} + {% endfor %} +
+ {% endif %} + + {% if message.error %} +

{{ message.error }}

+ {% endif %} +
diff --git a/daiv/chat/templates/chat/_tool_call_card.html b/daiv/chat/templates/chat/_tool_call_card.html new file mode 100644 index 000000000..dc0d8c066 --- /dev/null +++ b/daiv/chat/templates/chat/_tool_call_card.html @@ -0,0 +1,16 @@ +{% load i18n %} +
+ + {{ tool_call.name }} + {% if tool_call.args_preview %} + {{ tool_call.args_preview }} + {% endif %} + + {% if tool_call.args %} +
{{ tool_call.args }}
+ {% endif %} + {% if tool_call.result %} +
{{ tool_call.result }}
+ {% endif %} +
diff --git a/daiv/chat/templates/chat/chat_detail.html b/daiv/chat/templates/chat/chat_detail.html index 21823fdb0..45ef9f145 100644 --- a/daiv/chat/templates/chat/chat_detail.html +++ b/daiv/chat/templates/chat/chat_detail.html @@ -1,7 +1,52 @@ {% extends "base_app.html" %} -{% load i18n %} +{% load static i18n %} + +{% block container_width %}max-w-3xl{% endblock %} + +{% block breadcrumb %}{% include "accounts/_breadcrumb.html" %}{% endblock %} {% block app_content %} -{% if expired %}

{% trans "Expired." %}

{% endif %} -{{ messages_history|json_script:"chat-initial-messages" }} + {{ messages_history|json_script:"chat-initial-messages" }} + +
+ + {% if expired %} +
+ {% trans "This conversation's state has expired. Start a new chat to continue." %} +
+ {% endif %} + +
+ + +
+ + {% if not expired %} + {% include "chat/_composer.html" %} + {% endif %} + + +
+ + {% endblock %} diff --git a/daiv/chat/templates/chat/chat_list.html b/daiv/chat/templates/chat/chat_list.html index bc1910a81..adb92fc67 100644 --- a/daiv/chat/templates/chat/chat_list.html +++ b/daiv/chat/templates/chat/chat_list.html @@ -1,11 +1,34 @@ {% extends "base_app.html" %} {% load i18n %} +{% block breadcrumb %}{% include "accounts/_breadcrumb.html" %}{% endblock %} + {% block app_content %} -

{% trans "Chat" %}

- +
+

{% trans "Chat" %}

+ {% trans "New chat" %} +
+ +{% if threads %} + + {% include "accounts/_pagination.html" %} +{% else %} +
+

{% trans "No conversations yet." %}

+ {% trans "Start a chat" %} +
+{% endif %} {% endblock %} From 576c94c9eec1527ec83cb12cbebb514da9c0aad7 Mon Sep 17 00:00:00 2001 From: Sandro Date: Fri, 24 Apr 2026 00:43:37 +0100 Subject: [PATCH 13/50] feat(chat): sidebar entry, chat icon, and 'Continue as chat' button on activity detail --- daiv/accounts/context_processors.py | 1 + daiv/accounts/templates/accounts/_sidebar.html | 7 +++++++ .../templates/activity/_hero_success.html | 10 ++++++++++ .../core/static/core/img/icons/chat-bubble.svg | 3 +++ tests/unit_tests/jobs/api/test_views.py | 18 ++++++++++++++---- tests/unit_tests/mcp_server/test_server.py | 10 +++++++--- 6 files changed, 42 insertions(+), 7 deletions(-) create mode 100644 daiv/core/static/core/img/icons/chat-bubble.svg diff --git a/daiv/accounts/context_processors.py b/daiv/accounts/context_processors.py index be190ec15..c1556a480 100644 --- a/daiv/accounts/context_processors.py +++ b/daiv/accounts/context_processors.py @@ -12,6 +12,7 @@ "dashboard": {"dashboard"}, "runs": {"agent_run_new"}, "activity": {"activity_list", "activity_detail", "activity_stream", "activity_download_md"}, + "chat": {"chat_list", "chat_new", "chat_detail"}, "schedules": { "schedule_list", "schedule_create", diff --git a/daiv/accounts/templates/accounts/_sidebar.html b/daiv/accounts/templates/accounts/_sidebar.html index 849e84b6e..02974a292 100644 --- a/daiv/accounts/templates/accounts/_sidebar.html +++ b/daiv/accounts/templates/accounts/_sidebar.html @@ -42,6 +42,13 @@ {% endif %} + + + {% icon "chat-bubble" "h-4 w-4" %} + {% translate "Chat" %} + + diff --git a/daiv/activity/templates/activity/_hero_success.html b/daiv/activity/templates/activity/_hero_success.html index eeeac8292..bb1bd0ee0 100644 --- a/daiv/activity/templates/activity/_hero_success.html +++ b/daiv/activity/templates/activity/_hero_success.html @@ -12,6 +12,16 @@

Result

Open !{{ activity.merge_request_iid|unlocalize }}
{% endif %} + {% if activity.thread_id %} +
+ {% csrf_token %} + +
+ {% endif %} {% if response_text %} {% include "activity/_copy_markdown_button.html" with source_id="activity-result-raw" label="Copy" %} + + diff --git a/tests/unit_tests/jobs/api/test_views.py b/tests/unit_tests/jobs/api/test_views.py index 73bc5f76f..48dbaa839 100644 --- a/tests/unit_tests/jobs/api/test_views.py +++ b/tests/unit_tests/jobs/api/test_views.py @@ -143,9 +143,13 @@ async def _aenq(**kwargs): assert len(data["jobs"]) == 1 assert data["jobs"][0]["job_id"] == str(task_id) assert data["failed"] == [] - mock_task.aenqueue.assert_called_once_with( - repo_id="group/project", prompt="List all files", ref=None, use_max=False - ) + mock_task.aenqueue.assert_called_once() + kwargs = mock_task.aenqueue.call_args.kwargs + assert kwargs["repo_id"] == "group/project" + assert kwargs["prompt"] == "List all files" + assert kwargs["ref"] is None + assert kwargs["use_max"] is False + assert kwargs["thread_id"] @pytest.mark.django_db(transaction=True) @@ -176,7 +180,13 @@ async def _aenq(**kwargs): response = await authenticated_client.post("/jobs", json=_single_repo_body(prompt="Fix the bug", use_max=True)) assert response.status_code == 202 - mock_task.aenqueue.assert_called_once_with(repo_id="group/project", prompt="Fix the bug", ref=None, use_max=True) + mock_task.aenqueue.assert_called_once() + kwargs = mock_task.aenqueue.call_args.kwargs + assert kwargs["repo_id"] == "group/project" + assert kwargs["prompt"] == "Fix the bug" + assert kwargs["ref"] is None + assert kwargs["use_max"] is True + assert kwargs["thread_id"] @pytest.mark.django_db(transaction=True) diff --git a/tests/unit_tests/mcp_server/test_server.py b/tests/unit_tests/mcp_server/test_server.py index 5eaccef66..6622dbbee 100644 --- a/tests/unit_tests/mcp_server/test_server.py +++ b/tests/unit_tests/mcp_server/test_server.py @@ -87,9 +87,13 @@ async def test_submit_job_passes_ref(): with patch("activity.services.run_job_task") as mock_task, _patch_acreate(): mock_task.aenqueue = AsyncMock(return_value=_mock_task()) await submit_job(repos=[{"repo_id": "group/project", "ref": "feature-branch"}], prompt="Fix the bug") - mock_task.aenqueue.assert_called_once_with( - repo_id="group/project", prompt="Fix the bug", ref="feature-branch", use_max=False - ) + mock_task.aenqueue.assert_called_once() + kwargs = mock_task.aenqueue.call_args.kwargs + assert kwargs["repo_id"] == "group/project" + assert kwargs["prompt"] == "Fix the bug" + assert kwargs["ref"] == "feature-branch" + assert kwargs["use_max"] is False + assert kwargs["thread_id"] @pytest.mark.django_db(transaction=True) From 7db71a590b7304d52d28e650ed4523d1be72c7b6 Mon Sep 17 00:00:00 2001 From: Sandro Date: Fri, 24 Apr 2026 00:47:04 +0100 Subject: [PATCH 14/50] style(chat): silence ty diagnostics introduced by new code --- daiv/chat/api/views.py | 2 +- daiv/chat/models.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/daiv/chat/api/views.py b/daiv/chat/api/views.py index 33590787a..d6de0e7f0 100644 --- a/daiv/chat/api/views.py +++ b/daiv/chat/api/views.py @@ -81,7 +81,7 @@ async def create_chat_completion(request: HttpRequest, input_data: RunAgentInput if not repo_id or not ref: raise Http404("Repository ID or reference not found") - user = request.auth + user = request.auth # ty: ignore[unresolved-attribute] # attached by django-ninja thread_id = input_data.thread_id run_id = input_data.run_id diff --git a/daiv/chat/models.py b/daiv/chat/models.py index 84a489774..d3a12e3e0 100644 --- a/daiv/chat/models.py +++ b/daiv/chat/models.py @@ -29,7 +29,7 @@ class Meta: indexes = [models.Index(fields=["user", "-last_active_at"])] def __str__(self) -> str: - return self.title or self.thread_id + return str(self.title or self.thread_id) @classmethod async def aget_or_create_from_activity(cls, user, activity) -> tuple[ChatThread, bool]: From 83919e0ed94ab5e49e00eced73c2db831d2abcb5 Mon Sep 17 00:00:00 2001 From: Sandro Date: Fri, 24 Apr 2026 08:23:35 +0100 Subject: [PATCH 15/50] refactor(chat): simplify views and reuse open_checkpointer in addressors - Use aget_or_create to resolve TOCTOU races on thread creation (ChatThread.aget_or_create_from_activity and the API endpoint's implicit-create path) - Replace role-normalization ternary with a lookup dict - Use reverse("chat_list") instead of hardcoded URLs in breadcrumbs - Rewrite _extract_first_user_message as a generator expression - Drop dead +
+ + +
+ {% if not expired %} {% include "chat/_composer.html" %} {% endif %} diff --git a/daiv/static_src/css/input.css b/daiv/static_src/css/input.css index 6a3ad5c60..335a2c353 100644 --- a/daiv/static_src/css/input.css +++ b/daiv/static_src/css/input.css @@ -543,24 +543,32 @@ a, button, [role="button"] { line-height: 1.65; } + .chat-turn--user { + grid-template-columns: 1fr 44px; + } + + .chat-turn--user .chat-turn__gutter { + order: 2; + } + .chat-turn--user .chat-turn__body { + order: 1; + display: flex; + flex-direction: column; + align-items: flex-end; white-space: pre-wrap; word-wrap: break-word; + text-align: right; } .chat-turn--user .chat-segment--text { - display: inline-block; - width: fit-content; - max-width: 100%; + max-width: min(100%, 70ch); padding: 8px 14px; background: rgba(255, 255, 255, 0.035); border: 1px solid rgba(255, 255, 255, 0.05); border-radius: 14px; - border-top-left-radius: 6px; - } - - .chat-turn--user .chat-segment--text > .chat-text { - display: inline; + border-top-right-radius: 6px; + text-align: left; } .chat-segment + .chat-segment { @@ -800,6 +808,36 @@ a, button, [role="button"] { animation: pulse-dot 1.4s ease-in-out infinite; } + /* Persistent run-status bar (shown while streaming, above composer) */ + + .chat-statusbar { + position: sticky; + bottom: calc(16px + 110px); /* sit above composer */ + margin: 0 auto 8px; + display: inline-flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + background: rgba(20, 22, 40, 0.9); + border: 1px solid rgba(167, 139, 250, 0.25); + border-radius: 999px; + color: #c4b5fd; + font-family: "JetBrains Mono", ui-monospace, monospace; + font-size: 12px; + backdrop-filter: blur(8px); + width: fit-content; + z-index: 11; + } + + .chat-statusbar__dot { + width: 8px; + height: 8px; + border-radius: 999px; + background: #fbbf24; + box-shadow: 0 0 10px rgba(251, 191, 36, 0.5); + animation: pulse-dot 1.4s ease-in-out infinite; + } + /* Composer */ .chat-composer { From afb96c762a22f2d795b20e8e9a8239037d87e857 Mon Sep 17 00:00:00 2001 From: Sandro Date: Fri, 24 Apr 2026 14:40:07 +0100 Subject: [PATCH 34/50] fix(chat): drop turn markers, keep streaming signal as left thread line --- daiv/chat/templates/chat/chat_detail.html | 11 +-- daiv/static_src/css/input.css | 98 +++++++---------------- 2 files changed, 32 insertions(+), 77 deletions(-) diff --git a/daiv/chat/templates/chat/chat_detail.html b/daiv/chat/templates/chat/chat_detail.html index 1e4f94da5..2d2529a46 100644 --- a/daiv/chat/templates/chat/chat_detail.html +++ b/daiv/chat/templates/chat/chat_detail.html @@ -70,15 +70,8 @@

{% trans "What are we building today?" %}

{# Turns #}