Files
cowork-local/ui/cowork_tab.py
T
anhtnm1andClaude Opus 5 a53163ebaf feat(R04): immutable turn snapshot, typed agent events, conversation service
EPIC R04 (Team Duy) - the turn lifecycle leaves the widget.

R04-T01 domain/agents/conversation_execution_request.py
  Frozen snapshot of one turn, captured on the UI thread at submit time. The
  job closure used to read widget/workspace state from inside the worker
  thread, so a turn could run on a mix of submit-time and later state
  depending on thread timing.
R04-T02 domain/agents/agent_event.py
  13 frozen event types replacing untyped emit() dicts, with a two-way bridge
  so existing widgets keep consuming the legacy shape until EPIC R08. Adds
  TurnCompletedEvent - the end-of-turn signal the engine never had, which is
  why a cancelled turn and a failed turn look identical to the UI today.
R04-T03 application/conversations/conversation_application_service.py
  Runs a turn from a request and reports typed events. Never raises across the
  worker boundary; TurnResult.raise_if_failed() preserves the existing
  exception-based failure path. begin_turn()/execute_turn() expose the live
  message list for callers that autosave history mid-run.
R04-T04 ui/cowork_tab.py::build_job -> snapshot + service.
R04-T05 core/task_executors.py::_run_agent -> same service (was a second,
  slightly different assembly of the same call).

Caught while wiring the bridge: the first event vocabulary had no "notice"
event, so Agent Security warnings and auto-compaction notices would have been
silently swallowed. Added NoticeEvent plus a test that scans the engine sources
for emit() tags and fails when one has no typed counterpart.

New: tests/integration/ - real offscreen CoworkTab running a scripted turn end
to end (7 tests), including a characterisation of the extra provider call Agent
Security spends reviewing each request.

Suite: 225 passed, 2.74s. check_imports: PASS. All new files < 400 LOC.
2 pre-existing failures remain in test_config_security.py (EPIC R02/Team Nam).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 10:32:14 +09:00

454 lines
22 KiB
Python

"""Cowork tab — chat with the local Internal Agent."""
from __future__ import annotations
from pathlib import Path
from PySide6.QtWidgets import (
QFileDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget,
)
from ..config import PROVIDER_LABELS
from ..core import agent_roles
from ..core.worker import AgentWorker
from ..i18n import on_language_changed, tr
from ..state import AppContext
from .chat_panel import ChatPanel
from .icons import icon
_FOLDER_LBL_MAX_CHARS = 42 # keep the composer's bottom row from crowding out Agent/Send
class CoworkTab(ChatPanel):
def __init__(self, ctx: AppContext):
super().__init__(ctx, "cowork", "Cowork", placeholder_key="composer.placeholder_cowork")
self._title_lbl = QLabel()
self._title_lbl.setStyleSheet("font-weight:700; font-size:15px;")
self.model_lbl = QLabel("")
self.model_lbl.setObjectName("hint")
self.skills_btn = QPushButton()
self.skills_btn.setIcon(icon("book"))
self.skills_btn.clicked.connect(self._open_skills_manager)
self._new_btn = QPushButton()
self._new_btn.setIcon(icon("new"))
self._new_btn.clicked.connect(self.new_session)
self.toolbar_layout.addWidget(self._title_lbl)
self.toolbar_layout.addWidget(self.model_lbl)
self.toolbar_layout.addStretch(1)
self.toolbar_layout.addWidget(self.skills_btn)
self.toolbar_layout.addWidget(self._new_btn)
# Where Cowork saves its deliverables — the project's sandbox workspace
# (or, for a folder picked via the button, that folder). Only the
# folder NAME shows beside the button (not the full path as a
# link/hint text) — the full path is still available as a tooltip.
self.folder_btn = QPushButton()
self.folder_btn.setIcon(icon("folder"))
self.folder_btn.clicked.connect(self._pick_output_folder)
self.folder_lbl = QLabel("")
self.folder_lbl.setObjectName("hint")
folder_box = QWidget()
_fbl = QHBoxLayout(folder_box)
_fbl.setContentsMargins(0, 0, 0, 0)
_fbl.setSpacing(6)
_fbl.addWidget(self.folder_btn)
_fbl.addWidget(self.folder_lbl) # folder name BESIDE the button
# Which project (workspace) the CURRENT thread belongs to. Cowork now
# always lives INSIDE the Workspace screen for a single selected project,
# so the project name beside the composer is redundant chrome — the label
# is kept as a hidden member (its text still tracks the active project for
# tooltips/tests) but no longer added to the layout.
self.project_lbl = QLabel("")
self.project_lbl.setObjectName("hint")
self.project_lbl.setVisible(False)
self.composer.add_bottom_left(folder_box)
# Per-workspace "Auto-run" toggle (auto-approve commands in THIS
# workspace) — sits next to the routing toggle the base class added.
from .routing_toggle import AutoRunToggle
self.autorun_toggle = AutoRunToggle(self.ctx)
self.composer.add_bottom_right(self.autorun_toggle)
self.refresh_header()
# Start watching the output folder for new files
wd = self.workspace_dir()
if wd:
self._start_watching(wd)
# Cowork shows ONLY the final deliverables, refreshed from disk once a turn
# succeeds (see _cleanup_turn) — never intermediate files or a folder name.
on_language_changed(self._retranslate)
def refresh_title(self) -> None:
"""Head the screen with the thread you are in, as the drawing does.
It said "Cowork" on every conversation — the screen's own name, which
the rail already shows. The thread's title is the thing that changes and
the thing that tells you where you are; a thread with no title yet (a
new chat, before its first turn) falls back to the screen name.
"""
lbl = getattr(self, "_title_lbl", None)
if lbl is None:
return # ChatPanel.__init__ sets self.title before we exist
lbl.setText(getattr(self, "title", "") or tr("cowork.title"))
def _retranslate(self) -> None:
self.refresh_title()
self.skills_btn.setText(tr("cowork.skills_btn"))
self.skills_btn.setToolTip(tr("cowork.skills_tooltip"))
self._new_btn.setText(tr("cowork.new_chat"))
self.folder_btn.setText(tr("cowork.pick_folder_btn"))
self.folder_btn.setToolTip(tr("cowork.pick_folder_tooltip"))
self._apply_output_folder_label()
# ---- output folder --------------------------------------------------
def _pick_output_folder(self) -> None:
start = str(self._session_output_dir())
chosen = QFileDialog.getExistingDirectory(self, tr("cowork.pick_folder_title"), start)
if not chosen:
return
if self.project_id not in ("", "default"):
# Inside a project, the picked folder becomes THAT project's
# workspace (its sandbox + shared-knowledge root) — not the
# global default-output setting.
from ..core.projects import save_project
project = self._project()
if project is not None:
project.output_dir = chosen
save_project(project)
else:
self.ctx.config.cowork["output_dir"] = chosen
self.ctx.save()
self._apply_output_folder_label()
self._refresh_outputs_from_disk()
def _apply_output_folder_label(self) -> None:
full = str(self._session_output_dir())
# Only the folder NAME is shown beside the button — the full path
# (still available on hover) reads as noisy clutter for a value the
# user just picked and already knows the location of.
name = Path(full).name or full
if len(name) > _FOLDER_LBL_MAX_CHARS:
name = name[:_FOLDER_LBL_MAX_CHARS - 1] + "…"
self.folder_lbl.setText(name)
self.folder_lbl.setToolTip(full)
# Text still tracks the active project (tooltip/tests read it), but the
# label stays hidden — see its creation note above.
project = self._project()
if project is not None:
self.project_lbl.setText(tr("cowork.project_label", name=project.name))
self.project_lbl.setToolTip(tr("cowork.project_tooltip", name=project.name))
else:
self.project_lbl.setText("")
# Start watching the output folder for new files
wd = self.workspace_dir()
if wd:
self._start_watching(wd)
# ---- project (workspace) --------------------------------------------
def _project(self):
from ..core.projects import load_project
return load_project(self.project_id)
def set_project(self, project_id: str) -> None:
"""Assign the CURRENT thread to a project (called by the Workspace
screen's 'New chat in project'). Output folder + shared context follow.
Also makes this the ACTIVE workspace for per-workspace mode resolution
and refreshes the routing/auto-run toggles to show THIS workspace's
modes (so switching projects switches the visible modes)."""
self.project_id = project_id or "default"
self.ctx.active_project_id = self.project_id
for t in (getattr(self, "routing_toggle", None), getattr(self, "autorun_toggle", None)):
if t is not None:
t.refresh()
self._apply_output_folder_label()
self._refresh_outputs_from_disk()
def project_knowledge_dir(self):
"""A non-default project's shared-knowledge folder — its workspace
ROOT. This is now the SAME folder every thread of the project already
saves its deliverables into (no more per-session sub-folder — see
_session_output_dir), so ChatPanel._augment's equality check skips the
separate '[Project files]' scan and the '[Workspace files]' scan alone
already covers everything in one shared place, Claude-Projects style."""
if self.project_id in ("", "default"):
return None
project = self._project()
return project.workspace_dir() if project else None
# Generator/helper scripts are never a Cowork deliverable — keep them out of
# the Output list entirely (only the final file is shown).
_INTERMEDIATE_EXTS = {".py", ".pyw", ".js", ".mjs", ".cjs", ".ts",
".sh", ".bat", ".ps1", ".rb", ".pl"}
def assistant_title(self) -> str:
return tr("cowork.assistant_title")
def _session_output_dir(self):
"""Where this session's deliverables are saved.
Default: a sub-folder of the configured Output root named by the
session id (a timestamp — never the chat content), so same-named
outputs from different sessions don't overwrite each other.
Once a folder has been EXPLICITLY specified — either "Chọn thư mục
khác" (see _pick_output_folder) for the default project, or a
project's own workspace folder — files are saved DIRECTLY into it
instead, no auto-created per-session sub-folder: the Output box/link
always points at exactly that folder, and every thread that shares it
sees the same files (deliverables AND project knowledge together,
Claude-Projects style). Running turns still get isolated '.turns/<id>'
sandboxes (unique across every session in this tab — see
_turn_output_dir/_turn_seq — moved up on success by
_promote_turn_outputs, which de-dupes by name via _unique_path), so
concurrent turns/threads sharing one folder never clash.
Threads of a NON-default project are sandboxed inside that project's
own workspace: the agent's tools are confined there (ToolContext
rejects any path escape), and it's a single shared folder for the
whole project — not one sub-folder per thread."""
if self.project_id not in ("", "default"):
project = self._project()
if project is not None:
return project.workspace_dir()
custom = (self.ctx.config.cowork.get("output_dir") or "").strip()
if custom:
return Path(custom).expanduser()
return self.ctx.config.cowork_output_dir() / self.session_id
def workspace_dir(self):
return self._session_output_dir()
def _turn_output_dir(self, turn_id: str):
"""Each running turn writes into its own '.turns/<id>' sandbox so parallel
turns never clobber each other's files (run_cowork's cleanup diffs the
folder before/after, which would misfire on a shared dir). On success the
finished turn's deliverables are moved up to the session root — see
_cleanup_turn. The dot-prefixed folder is ignored by the Output list
(which shows only top-level files)."""
return self._session_output_dir() / ".turns" / turn_id
def _is_intermediate_output(self, path: str) -> bool:
from pathlib import Path
return Path(path).suffix.lower() in self._INTERMEDIATE_EXTS
def _existing_output_names(self):
"""Deliverables already sitting in this session's output folder (from
earlier, already-finished turns) — the current turn writes into its own
'.turns/<id>' sandbox, so this never includes its own in-flight files."""
try:
return sorted(p.name for p in self._session_output_dir().iterdir()
if p.is_file() and not p.name.startswith(".")
and not self._is_intermediate_output(str(p)))
except OSError:
return []
def _session_notes(self) -> str:
# Lets the agent (and the user, without re-uploading) reference/revise a
# file it made earlier in this same conversation — e.g. "sửa lại tiêu đề
# trong file báo cáo vừa tạo" — since it can read_file/edit_file/write_file
# it directly by the exact name listed here.
names = self._existing_output_names()
if not names:
return ""
listing = ", ".join(names)
return (
"[Session context] Files already created earlier in this conversation's output "
"folder — read/revise them directly by their exact name with read_file/edit_file/"
"write_file; the user does NOT need to re-upload them if they refer to \"the file "
f"I made\" / \"file vừa tạo\" or similar: {listing}"
)
def _promote_turn_outputs(self, turn_dir, record, session_root) -> None:
"""Move a finished turn's files from its '.turns/<id>' sandbox up to its
conversation's Output root (``session_root`` — the turn's HOME session, so a
background turn lands in the right chat even after the user switched away),
then remove the sandbox. run_cowork already flattened and stripped
'.scratch'/generator scripts, so the sandbox root holds the final file(s).
Every top-level file is moved (no extension filtering) so a file the user
genuinely asked for is never dropped before the sandbox is removed — the
Output list itself decides what to *show* (see _refresh_outputs_from_disk)."""
import shutil
from pathlib import Path
from ..core.chat_agent import _unique_path
turn_dir = Path(turn_dir)
session_root = Path(session_root)
try:
if not turn_dir.is_dir() or turn_dir.resolve() == session_root.resolve():
return
except OSError:
return
session_root.mkdir(parents=True, exist_ok=True)
remap = {}
try:
files = [p for p in turn_dir.iterdir() if p.is_file()]
except OSError:
files = []
for p in files:
try:
dest = _unique_path(session_root, p.name)
p.replace(dest)
remap[str(p)] = str(dest)
except OSError:
pass
shutil.rmtree(turn_dir, ignore_errors=True)
# Keep this turn's recorded outputs pointing at the moved files so
# deleting the message later still finds and removes them.
if remap and record is not None:
record["outputs"] = [remap.get(p, p) for p in record.get("outputs", [])]
# ---- Output box: only the final, successful deliverables ----------
def register_output(self, path: str) -> None:
# Suppress live/intermediate updates during a run — the Output box is
# rebuilt from the surviving files once the turn succeeds (see below).
return
def on_file_written(self, path: str) -> None:
return # no live file updates in Cowork
def _refresh_outputs_from_disk(self) -> None:
"""Show only the final deliverable files now sitting in the session folder
(no scripts/intermediate files, no hidden/`.scratch`, no folders)."""
self.output_section.clear()
try:
files = sorted((p for p in self._session_output_dir().iterdir() if p.is_file()),
key=lambda p: p.name)
except OSError:
return
for p in files:
if not p.name.startswith(".") and not self._is_intermediate_output(str(p)):
self.output_section.add(str(p))
self.output_changed.emit(str(self._session_output_dir()))
def new_session(self) -> None:
# The new thread stays in the CURRENT project (Claude-style).
super().new_session()
# Refresh the project/folder labels + watch the new session's folder.
self._apply_output_folder_label()
def load_conversation(self, conv) -> None:
super().load_conversation(conv) # restores this conversation's project_id
self._refresh_outputs_from_disk() # show this session's deliverables from disk
# Refresh the project/folder labels + watch this conversation's folder.
self._apply_output_folder_label()
def refresh_header(self) -> None:
cfg = self.ctx.config
label = PROVIDER_LABELS.get(cfg.active_provider, cfg.active_provider)
self.model_lbl.setText(f"{label} · {cfg.model_label()}")
self._apply_output_folder_label() # picks up edits made via Settings too
def build_job(self, text: str, messages, out_dir):
"""Build the worker job for one Cowork turn (R04-T04).
Every input the turn needs is captured HERE, on the UI thread, into an
immutable ``ConversationExecutionRequest``. Previously the job closure
read widget state (selected model, active workspace, project
instructions) from inside the worker thread, so a turn could run on a
mixture of the state at submit time and the state the user changed while
it was running - and which mixture you got depended on thread timing.
Each turn still writes into its OWN isolated folder (out_dir) and works
on its OWN message list, so several turns can run in parallel without
clobbering each other's files or history. Deliverables are moved up to
the session Output root when the turn finishes (see _cleanup_turn).
"""
from ..core.projects import load_project, project_context_text
from ..domain.agents import ConversationExecutionRequest
output_dir = out_dir or self._session_output_dir()
# Shared project instructions (Claude-Projects style), read now so edits
# made in the Workspace screen mid-turn cannot change this turn's prompt.
project_context = project_context_text(load_project(self.project_id))
# The Admin-defined agent preset's instructions, if one is selected.
agent_prompt = self.admin_agent_prompt()
if agent_prompt:
project_context = (f"{project_context}\n\n{agent_prompt}"
if project_context else agent_prompt)
request = ConversationExecutionRequest.create(
text, messages,
output_dir=str(output_dir),
session_id=self.session_id,
surface=self.kind,
title=self.title,
project_id=self.project_id,
project_context=project_context,
agent_role=agent_roles.COWORK,
# Permission Management (Sandbox Security Layer): off by default -
# matches the pre-existing auto-run behavior. Resolved PER WORKSPACE:
# this project's Auto-run override wins, else the global setting.
confirm_commands=bool(self.ctx.project_confirm_commands()),
)
# Built on the UI thread with everything else: it already reflects this
# turn's routing decision and the selected agent/model.
provider = self.build_provider()
def job(worker: AgentWorker):
from ..application.conversations import ConversationApplicationService
service = ConversationApplicationService(
# The provider is part of the snapshot, so the factory ignores
# the request's provider/model rather than re-resolving them
# from live config inside the worker thread.
lambda _provider_id, _model: provider,
# 🔌 MCP Layer: every tool source flows through MCP now - the
# external servers configured in Settings AND Microsoft 365 (a
# built-in MCP server auto-registered while signed in, see
# AppContext._ms365_builtin_connection / mcp_servers/ms365_server.py).
tool_source=self.ctx.build_mcp_tools,
gate_factory=lambda req: worker.new_gate("confirm",
agent_role=agent_roles.COWORK),
security_config=self.ctx.config,
)
result = service.run_turn(
request,
# The typed events are rendered back into the legacy dict shape
# the chat widgets already consume; they migrate to AgentEvent
# directly in EPIC R08.
on_event=lambda event: worker.emit_event(event.to_dict()),
cancel=worker.is_cancelled,
)
# The service reports a failure instead of raising, but this worker's
# contract is exception-based (core/worker.py turns one into the
# `failed` signal that _on_failed already handles), so re-raise the
# ORIGINAL exception to keep that path byte-for-byte unchanged.
result.raise_if_failed()
return {"messages": result.messages, "turn_dir": str(output_dir)}
return job
def _cleanup_turn(self, ctx, ok) -> None:
"""A turn ended (successfully or not): promote whatever files it
produced up to ITS conversation's Output root, THEN discard the
(by then empty, or intermediate-only) sandbox.
This runs the SAME promotion on failure as on success — a turn can
genuinely create a deliverable (e.g. save_file succeeds) and THEN hit
an unrelated error later in the same turn (a follow-up tool call, a
network drop, a rate limit that didn't recover) which raises and
marks the whole turn as failed. Discarding the sandbox unconditionally
in that case would silently delete a file the user actually got —
exactly the "file đã tạo bị xóa" bug. Promoting first is always safe:
an empty/intermediate-only sandbox just promotes zero files.
Refresh the visible Output list only when that conversation is the
one on screen."""
turn_dir = ctx.get("out_dir")
home_root = ctx.get("home_out_root")
live = ctx.get("home_id") == self.session_id and not ctx.get("detached")
if turn_dir and home_root:
self._promote_turn_outputs(turn_dir, ctx.get("record"), home_root)
elif turn_dir:
import shutil
shutil.rmtree(turn_dir, ignore_errors=True)
if live:
self._refresh_outputs_from_disk()