CI / test (push) Canceled after 0s
## Summary epic r04 - begin refactor ## Change Type - [x] Cowork feature - [ ] Bug fix - [ ] Core AI contribution - [ ] Test / hardening - [ ] Performance - [ ] Documentation ## Related Work Cowork Task: Core Repo: http://34.143.229.138/gitea-admin/fsg-ai-core-assets Core AI Issue: Core Task: Related PR: ## Scope What is intentionally included? What is intentionally NOT included? ## Validation - [ ] Unit tests - [ ] Integration tests - [ ] Manual verification - [ ] Regression check Commands / evidence: ## Security Impact Permission / credential / network / customer data impact: ## Compatibility - [ ] No breaking change - [ ] Breaking change documented ## Reviewer Notes Anything Cowork reviewers should pay attention to. --------- Co-authored-by: Anh Tran Nguyen Minh <anhtnm1@fpt.com> Co-authored-by: Huong Le Thi Thien <huongltt35@fpt.com> Co-authored-by: Nam Pham Dinh Thanh <nampdt@fpt.com> Co-authored-by: Vu Dam Tuan <vudt15@fpt.com> Co-authored-by: Hiep Ha Van <hiephv3@fpt.com> Co-authored-by: Lam Hoang Van <lamhv7@fpt.com> Reviewed-on: #7 Co-authored-by: Duy Le Huu <duylh19@fpt.com>
243 lines
13 KiB
Python
243 lines
13 KiB
Python
"""Nhận sự kiện phát về từ luồng chạy nền — R08-T06.
|
|
|
|
Agent chạy ở luồng khác và bắn sự kiện dần: chữ, lời gọi tool, kế hoạch, xin
|
|
quyền. ``_on_event`` phân nhánh theo loại rồi cập nhật đúng bong bóng.
|
|
|
|
``_on_permission`` là chỗ giao diện hỏi người dùng — cổng chính sách chỉ trả
|
|
lời ALLOW/DENY/ASK, còn hỏi thế nào là việc của tầng này (xem
|
|
``docs/architecture/security-policy.md`` mục 5).
|
|
|
|
Cùng kiểu mixin, xem ghi chú ở đầu ``presentation/shell/nav_rail.py``.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Optional
|
|
from PySide6.QtCore import Qt, Signal
|
|
from ...core.worker import AgentWorker
|
|
from ...i18n import tr
|
|
from ...state import AppContext
|
|
# Nhập thẳng từ chỗ ở thật, không đi qua vỏ chuyển tiếp ``ui/composer.py``:
|
|
# vỏ ấy lại nhập ngược vào gói này, nên đi vòng qua nó tạo một chu trình
|
|
# import — ``import cowork_local.ui.composer`` khi chưa nạp gói này sẽ hỏng.
|
|
from .composer_widget import Composer
|
|
|
|
|
|
class ChatEventStreamMixin:
|
|
"""Xử lý sự kiện của một lượt. Trộn vào ChatPanel."""
|
|
|
|
def _on_event(self, ctx: Dict[str, Any], ev: Dict[str, Any]) -> None:
|
|
"""Nhận một sự kiện từ lượt chạy và vẽ vào dòng thời gian.
|
|
|
|
Trạng thái của lượt vẫn được cập nhật kể cả khi hội thoại này không còn hiện
|
|
trên màn — người dùng có thể quay lại và phải thấy đúng những gì đã xảy ra.
|
|
"""
|
|
etype = ev.get("type")
|
|
# Track the in-progress state even while this turn is a detached background
|
|
# job, so reopening its conversation can re-render the CURRENT task (partial
|
|
# answer + live plan) — see _reattach_running_turn.
|
|
if etype == "text":
|
|
ctx["partial"] = ctx.get("partial", "") + ev.get("delta", "")
|
|
elif etype == "assistant_done":
|
|
ctx["partial"] = ""
|
|
elif etype == "plan_set":
|
|
ctx["plan_steps"] = ev.get("steps") or []
|
|
# A turn only RENDERS into the transcript/sidebar of the conversation it was
|
|
# started in. If the user navigated away, skip live rendering (the data is
|
|
# tracked above and shown when the conversation is reopened).
|
|
if ctx.get("detached") or ctx.get("home_id") != self.session_id:
|
|
return
|
|
record = ctx["record"]
|
|
if etype == "text":
|
|
self.thinking.stop() # real output is streaming now
|
|
if ctx["assistant"] is None:
|
|
ctx["assistant"] = self.chat_view.add_assistant(self.assistant_title())
|
|
ctx["last_assistant"] = ctx["assistant"] # for the per-turn usage footer
|
|
record["bubbles"].append(ctx["assistant"])
|
|
folder = self.workspace_dir()
|
|
if folder:
|
|
ctx["assistant"].add_folder_link(str(folder))
|
|
ctx["assistant"].append_delta(ev.get("delta", ""))
|
|
elif etype == "assistant_done":
|
|
self.graph_event.emit(self.session_name, ev)
|
|
ctx["assistant"] = None
|
|
ctx["reasoning"] = None # next step starts a fresh Thinking box
|
|
self._autosave() # persist latest result (crash-safe, mid-turn)
|
|
elif etype == "tool_proposed":
|
|
# Show WHAT it's doing (e.g. "Creating…" while a document is generated).
|
|
from ...ui.chat_panel import _TOOL_STATUS
|
|
self.thinking.start(_TOOL_STATUS.get(ev.get("name"), "chat.running"))
|
|
if ev.get("name") == "update_plan":
|
|
return # the plan tool drives the Plan view, not a chat bubble
|
|
# Show the step in the transcript (the code being written / diff /
|
|
# command being run) so the whole process is visible, CLI-style.
|
|
preview = ev.get("preview") or {}
|
|
body = preview.get("text", "")
|
|
if body:
|
|
icons = {"diff": "✎", "command": "▶"}
|
|
title = preview.get("title") or ev.get("name", "tool")
|
|
label = f"{icons.get(preview.get('kind'), '⚙')} {title}"
|
|
# A diff/create/edit preview renders as a colored before/after
|
|
# (additions/deletions), not a flat text block.
|
|
if preview.get("kind") == "diff":
|
|
step = self.chat_view.add_diff(label, body, True)
|
|
else:
|
|
step = self.chat_view.add_tool(label, body, True)
|
|
record["bubbles"].append(step)
|
|
# Remember this step's bubble so live stdout/stderr ("tool_output")
|
|
# can be appended to it in real time while the command runs.
|
|
ctx.setdefault("step_bubbles", {})[ev.get("id")] = step
|
|
self.graph_event.emit(self.session_name, ev)
|
|
elif etype == "tool_output":
|
|
# Live output from a running command/install (see run_cancellable) —
|
|
# append to its step bubble so progress is visible before it finishes.
|
|
step = ctx.get("step_bubbles", {}).get(ev.get("id"))
|
|
if step is not None:
|
|
step.append_plain(ev.get("delta", ""))
|
|
elif etype == "notice":
|
|
# A UI-visible aside outside the model's own turn: either a live
|
|
# "reading page X/Y" progress line, or a warning that something
|
|
# (e.g. an attachment) could not be processed.
|
|
if ev.get("level") == "progress":
|
|
self.thinking.set_progress_text(ev.get("text", ""))
|
|
else:
|
|
bubble = self.chat_view.add_tool(
|
|
tr("chat.attachment_warning_title"), ev.get("text", ""), False)
|
|
record["bubbles"].append(bubble)
|
|
elif etype == "tool_result":
|
|
ctx.get("step_bubbles", {}).pop(ev.get("id"), None)
|
|
self.thinking.start("chat.running") # back to the model for the next step
|
|
if ev.get("name") == "update_plan":
|
|
return # plan tool: no chat bubble (Plan view already updated)
|
|
mark = "✓" if ev.get("ok") else "✗"
|
|
tool_bubble = self.chat_view.add_tool(
|
|
f"{ev.get('name')} {mark}", ev.get("output", ""), ev.get("ok", True))
|
|
record["bubbles"].append(tool_bubble)
|
|
folder = ev.get("path") or self.workspace_dir()
|
|
if folder:
|
|
tool_bubble.add_folder_link(str(folder), tr("chat.open_folder"))
|
|
if ev.get("path"):
|
|
record["outputs"].append(ev["path"])
|
|
self.on_file_written(ev["path"])
|
|
# Files produced by a command (e.g. a script that builds a .pptx) —
|
|
# surface the real deliverable, not the generator script.
|
|
for pr in ev.get("produced", []) or []:
|
|
record["outputs"].append(pr)
|
|
self.register_output(pr)
|
|
self.graph_event.emit(self.session_name, ev)
|
|
self._autosave() # persist after each tool result (crash-safe)
|
|
elif etype == "outputs_removed":
|
|
# Intermediate/generator files were cleaned up — drop them from Output.
|
|
for p in ev.get("paths", []) or []:
|
|
self.output_section.remove(p)
|
|
if p in record.get("outputs", []):
|
|
record["outputs"].remove(p)
|
|
elif etype == "outputs_added":
|
|
# Deliverables flattened out of a sub-folder into the Output root.
|
|
for p in ev.get("paths", []) or []:
|
|
if p not in record.get("outputs", []):
|
|
record["outputs"].append(p)
|
|
self.register_output(p)
|
|
elif etype == "reasoning":
|
|
# A reasoning model is "thinking" (Qwen3/DeepSeek-R1 etc.). Relabel the
|
|
# indicator AND stream the reasoning into a collapsed "🧠 Thinking" box
|
|
# so the process is visible without flooding the chat.
|
|
self.thinking.set_label("chat.thinking")
|
|
piece = ev.get("delta", "")
|
|
if piece:
|
|
if ctx.get("reasoning") is None:
|
|
ctx["reasoning"] = self.chat_view.add_reasoning()
|
|
record["bubbles"].append(ctx["reasoning"])
|
|
ctx["reasoning"].append_delta(piece)
|
|
elif etype == "plan_set":
|
|
steps = ev.get("steps") or []
|
|
self.on_plan(steps) # Plan panel (right sidebar)
|
|
# Also show the checklist inline in the chat, updated in place.
|
|
from ...ui.chat_panel import _format_plan_steps
|
|
body = _format_plan_steps(steps)
|
|
if ctx.get("plan_bubble") is None:
|
|
ctx["plan_bubble"] = self.chat_view.add_plan(body)
|
|
record["bubbles"].append(ctx["plan_bubble"])
|
|
else:
|
|
ctx["plan_bubble"].set_plain(body)
|
|
|
|
def on_plan(self, steps) -> None:
|
|
"""Render the current message's step checklist in the Plan panel above the
|
|
Output list. The agent sends the full list on each ``update_plan`` call."""
|
|
self.plan_section.set_steps(steps)
|
|
|
|
def _on_permission(self, ctx: Dict[str, Any], action: Dict[str, Any]) -> None:
|
|
# Auto-approves UNLESS this workspace requires confirming commands —
|
|
# a per-workspace Auto-run override (see AppContext.project_confirm_commands),
|
|
# falling back to the global "confirm before running commands" setting.
|
|
# Resolve on THIS turn's worker, never the latest — several turns may
|
|
# be awaiting approval at once.
|
|
"""Một tool cần phê duyệt trước khi chạy.
|
|
|
|
Tự đồng ý TRỪ KHI workspace này bật "hỏi trước khi chạy lệnh" (đặt riêng theo
|
|
workspace, không có thì lấy cấu hình chung). Trả lời phải gửi đúng worker của
|
|
LƯỢT NÀY, không phải worker mới nhất — có thể nhiều lượt cùng đang chờ duyệt.
|
|
"""
|
|
if self.ctx.project_confirm_commands():
|
|
from ...ui.permission_dialog import PermissionDialog
|
|
|
|
approved, _remember = PermissionDialog.ask(action, parent=self)
|
|
ctx["worker"].resolve_permission(approved)
|
|
return
|
|
ctx["worker"].resolve_permission(True)
|
|
|
|
def _finalize_plan(self, ctx: Dict[str, Any]) -> None:
|
|
"""On a successful finish, keep the plan visible with every step ticked
|
|
'done' (so a completed plan can be reviewed) — it is cleared only when the
|
|
NEXT message starts a fresh plan (see _start_turn)."""
|
|
steps = ctx.get("plan_steps")
|
|
if not steps:
|
|
return
|
|
changed = False
|
|
for s in steps:
|
|
if s.get("status") != "done":
|
|
s["status"] = "done"
|
|
changed = True
|
|
if changed:
|
|
self.on_plan(steps) # re-render (Plan panel for Cowork / preview for Code)
|
|
pb = ctx.get("plan_bubble")
|
|
if pb is not None:
|
|
pb.set_plain(_format_plan_steps(steps))
|
|
|
|
def _finalize_turn(self, ctx: Dict[str, Any]) -> None:
|
|
"""Merge one turn's new messages into its OWN conversation's history.
|
|
|
|
"New" = everything the job appended after this turn's snapshot. Drop any
|
|
system prompt the agent inserted when the history already carries one, so
|
|
two turns started from an empty history don't leave a duplicate system
|
|
message. Merges into ``home_messages`` (the list of the conversation the
|
|
turn started in) so a background turn saves to the right chat even after the
|
|
user switched away. Same object refs are reused, so _delete_turn's id-based
|
|
removal still finds them."""
|
|
home = ctx["home_messages"]
|
|
local = ctx["messages"]
|
|
new = local[ctx["snapshot_len"]:]
|
|
if any(m.get("role") == "system" for m in home):
|
|
new = [m for m in new if m.get("role") != "system"]
|
|
home.extend(new)
|
|
ctx["record"]["messages"] = new
|
|
|
|
def _end_turn(self, ctx: Dict[str, Any]) -> None:
|
|
"""Shared teardown for a finished/failed turn: merge history, drop the
|
|
worker, release the conversation once nothing else is running for it, and
|
|
refresh the (global) running/capacity indicators."""
|
|
self._finalize_turn(ctx)
|
|
self._active.pop(ctx["worker"], None)
|
|
home_id = ctx.get("home_id")
|
|
if home_id and not any(c.get("home_id") == home_id for c in self._active.values()):
|
|
self._sessions_live.pop(home_id, None)
|
|
# Update the chat-box indicator for the CURRENT view: stop it once the viewed
|
|
# conversation is idle (a live turn's own streaming manages it otherwise, so
|
|
# we don't restart it here and disturb streaming).
|
|
if not self._view_busy():
|
|
self.thinking.stop()
|
|
self.composer.set_running(bool(self._active)) # Stop shows while anything runs
|
|
# Re-evaluate the per-conversation gate: sends dispatch again only when THIS
|
|
# conversation is idle and the global cap allows.
|
|
self.composer.set_busy(self._view_busy() or len(self._active) >= self._max_parallel())
|