From fd952fb9ce6084d7ef150e41d587a4bc91ba11ef Mon Sep 17 00:00:00 2001 From: DemonGatanjieu <207458273+DemonGatanjieu@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:30:22 +0800 Subject: [PATCH] Go on in the task the user wrote to, not a new one When the user wrote to an older task while the AI had moved on, the AI's next call (once its own task ended or paused) started a new task, where report(reply_to=...) could not find the message. Now an app with no open task goes back to the newest task of its own holding a reply it has not read, and opens it again; an open task it is busy with stays. Naming the calling app moves to api/ai_callers.py to keep ai_tasks.py under its size. Co-Authored-By: Claude Opus 5.5 --- ARCHITECTURE.md | 5 +++- CHANGELOG.md | 4 +-- api/ai_callers.py | 41 ++++++++++++++++++++++++++ api/ai_tasks.py | 65 +++++++++++++++++------------------------- api/mcp_server.py | 8 +++--- tests/test_ai_tasks.py | 34 ++++++++++++++++++---- 6 files changed, 106 insertions(+), 51 deletions(-) create mode 100644 api/ai_callers.py diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index aecd25f..b41ce97 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -335,7 +335,10 @@ covered by the `styles.css` manifest. mode — `ai_task_canvas.set_mode` adds a `user_canvas` step — and stops); `GET` of a task adds `decisions` and `decisions_gap` (runs and canvas changes since the AI's last one), begin_task repeats them to an AI picking the task up. `ui_ai_decisions.js` draws the card and the side - column's Decisions view. `tests/test_ai_decisions.py`. `api/ai_task_links.py` keeps which task + column's Decisions view. `tests/test_ai_decisions.py`. `api/ai_callers.py` names who calls (the + app from its client info or User-Agent, a session, a task it names); an app whose task ended or + paused goes on in the task the user wrote to and it has not read (`ai_tasks._take_replied`, + which opens it again). `api/ai_task_links.py` keeps which task a task goes on from (`from`: set when an AI begins a new task while on another, or the user starts one while looking at a task; changed or taken off by the user, never a loop); `GET` of a task adds `links` {from, next}, begin_task gives an AI the task it continues and what was diff --git a/CHANGELOG.md b/CHANGELOG.md index 13730c4..139f685 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,8 @@ ## v1.62.0 — unreleased -- AI 已经把任务做完了,你再留言或在图上标注:任务重新打开,留言框上方出现那句一键复制的话(“我在 Anomalous 里回复了,看看再接着做”),AI 回来接着在这个任务里做、回复也挂在你那条下面,不再另开一个新任务。 -- When the AI had already finished a task and you write to it or mark a picture, the task opens again: the line to copy for the AI shows over the message box, and the AI goes on in this task, answering under what you wrote, instead of starting a new one. +- AI 已经把任务做完了,你再留言或在图上标注:任务重新打开,留言框上方出现那句一键复制的话(“我在 Anomalous 里回复了,看看再接着做”),AI 回来接着在这个任务里做、回复也挂在你那条下面,不再另开一个新任务。给更早的任务留言也一样:AI 手上的任务做完或停下后,会回到你留言的那个任务。 +- When the AI had already finished a task and you write to it or mark a picture, the task opens again: the line to copy for the AI shows over the message box, and the AI goes on in this task, answering under what you wrote, instead of starting a new one. The same holds when you write to an older task while the AI had moved on: once its own task is done or paused, it comes back to the one you wrote to. - 同一个东西只用一个名字:界面上的“配方”统一叫 **工作流**(工作流页的卡片、保存和导出、操作记录、备份说明),“配方包”保留,它是发给别人的 .zip;AI 工作台里统一用“素材”“留言”“节点结构”“连接”“副本”“撤销”。 - One name for each thing: what the screen called a recipe is a **workflow** everywhere (the Workflows page, saving and exporting, the activity log, backups); a recipe package stays the .zip you give to others. The AI workbench says materials, message, node map and copy throughout. diff --git a/api/ai_callers.py b/api/ai_callers.py new file mode 100644 index 0000000..b6ac6bb --- /dev/null +++ b/api/ai_callers.py @@ -0,0 +1,41 @@ +"""Who is calling Anomalous's MCP endpoint (mcp_server.py), for the AI workbench's record +(ai_tasks.py): a short name for the AI app, the session it runs when it keeps several, and a task +it names. Each comes from the request's headers or metadata and is cleaned to a short plain word.""" + +import re + + +_CLIENT_NAMES = ( + ("claude-code", "Claude Code"), ("claude", "Claude"), ("cursor", "Cursor"), ("cherry", "Cherry Studio"), + ("windsurf", "Windsurf"), ("codex", "Codex"), ("gemini", "Gemini"), ("vscode", "VS Code"), ("cline", "Cline"), +) + + +def client_of(headers, params): + """A short name for the AI app making a call: the client info it sends, else its User-Agent.""" + meta = params.get("_meta") if isinstance(params.get("_meta"), dict) else {} + info = meta.get("io.modelcontextprotocol/clientInfo") + raw = info.get("name") if isinstance(info, dict) and isinstance(info.get("name"), str) else "" + if not raw: + raw = (headers.get("User-Agent") or "").split(" ")[0].split("/")[0] + lowered = raw.lower() + for key, name in _CLIENT_NAMES: + if key in lowered: + return name + clean = re.sub(r"[^\w .-]", "", raw).strip()[:40] + return clean or "AI" + + +def session_of(headers, params): + """An optional session key when one app runs multiple MCP sessions at once.""" + meta = params.get("_meta") if isinstance(params.get("_meta"), dict) else {} + raw = headers.get("Mcp-Session-Id") or headers.get("X-Anomalous-Session") or meta.get("sessionId") or meta.get("conversationId") or "" + return re.sub(r"[^\w .:-]", "", str(raw)).strip()[:64] if isinstance(raw, str) else "" + + +def task_of(headers, params, arguments=None): + """An explicit task id from headers, request metadata or tool arguments.""" + meta = params.get("_meta") if isinstance(params.get("_meta"), dict) else {} + arg_task = arguments.get("task") if isinstance(arguments, dict) else None + raw = arg_task or headers.get("X-Anomalous-Task") or meta.get("anomalous/task") or meta.get("task") or "" + return re.sub(r"[^\w-]", "", str(raw)).strip()[:64] if isinstance(raw, str) else "" diff --git a/api/ai_tasks.py b/api/ai_tasks.py index 875686a..7eb5e53 100644 --- a/api/ai_tasks.py +++ b/api/ai_tasks.py @@ -14,7 +14,8 @@ made while an AI works join its task through ai_task_changes.py. A step a restar The user can start a task first (New task, POST /anomalous/ai_tasks): it waits for an AI, and the next app that calls takes it over, so the work it does next starts there instead of joining -the app's last task. +the app's last task. Likewise an app whose task ended or paused goes on in the task the user wrote +to (a message, marks, pictures, materials) that it has not read yet, rather than starting one. Tasks are files in the ComfyUI user folder (anomalous/ai_tasks/.json), the newest MAX_TASKS kept. Recording never fails a tool call: the record is a convenience. The page @@ -150,43 +151,7 @@ def _notify(task_id, extra=None): pass -# ---------------------------------------------------------------- who is calling - -_CLIENT_NAMES = ( - ("claude-code", "Claude Code"), ("claude", "Claude"), ("cursor", "Cursor"), ("cherry", "Cherry Studio"), - ("windsurf", "Windsurf"), ("codex", "Codex"), ("gemini", "Gemini"), ("vscode", "VS Code"), ("cline", "Cline"), -) - - -def client_of(headers, params): - """A short name for the AI app making a call: the client info it sends, else its User-Agent.""" - meta = params.get("_meta") if isinstance(params.get("_meta"), dict) else {} - info = meta.get("io.modelcontextprotocol/clientInfo") - raw = info.get("name") if isinstance(info, dict) and isinstance(info.get("name"), str) else "" - if not raw: - raw = (headers.get("User-Agent") or "").split(" ")[0].split("/")[0] - lowered = raw.lower() - for key, name in _CLIENT_NAMES: - if key in lowered: - return name - clean = re.sub(r"[^\w .-]", "", raw).strip()[:40] - return clean or "AI" - - -def session_of(headers, params): - """An optional session key when one app runs multiple MCP sessions at once.""" - meta = params.get("_meta") if isinstance(params.get("_meta"), dict) else {} - raw = headers.get("Mcp-Session-Id") or headers.get("X-Anomalous-Session") or meta.get("sessionId") or meta.get("conversationId") or "" - return re.sub(r"[^\w .:-]", "", str(raw)).strip()[:64] if isinstance(raw, str) else "" - - -def task_of(headers, params, arguments=None): - """An explicit task id from headers, request metadata or tool arguments.""" - meta = params.get("_meta") if isinstance(params.get("_meta"), dict) else {} - arg_task = arguments.get("task") if isinstance(arguments, dict) else None - raw = arg_task or headers.get("X-Anomalous-Task") or meta.get("anomalous/task") or meta.get("task") or "" - return re.sub(r"[^\w-]", "", str(raw)).strip()[:64] if isinstance(raw, str) else "" - +# ---------------------------------------------------------------- who is calling (named in ai_callers.py) def _owner_key(client, session=""): return f"{client}::{session}" if session else client @@ -319,7 +284,27 @@ def current_task(client, now=None, session="", task_id=None): if task and not task.get("ended"): task["ended"] = task.get("updated") _save(task) - return _new_task(client, now=now, session=session) + return _take_replied(tasks, client, session) or _new_task(client, now=now, session=session) + + +REPLIES = {"user_message", "user_marks", "user_pictures", "user_toolbox"} # what the user gives the AI (ai_call_lines.js) + + +def _take_replied(tasks, client, session="", previous=None): + """The newest task of this app the user wrote to since its AI last read (an ended one opens + again), now this app's instead of a new one (its own open task `previous` ends), or None.""" + replied = max((task for task in tasks.values() if task.get("client") == client and any( + step.get("kind") == "user" and step.get("tool") in REPLIES and not step.get("delivered") for step in task["steps"])), + key=lambda task: task.get("updated", 0), default=None) + if replied is None: + return None + if previous and previous is not replied and not previous.get("ended"): + previous["ended"] = previous.get("updated") + _save(previous) + replied["ended"] = None + _bind_owner(client, session, replied["id"]) + _save(replied) + return replied def begin_task(client, title, now=None, new_task=False, session="", task_id=None): @@ -333,6 +318,8 @@ def begin_task(client, title, now=None, new_task=False, session="", task_id=None task = _take_waiting(client, now, session=session) if task is None and not new_task and previous and not previous.get("ended") and now - previous.get("updated", 0) < IDLE_SECONDS: task = previous + if task is None and not new_task: + task = _take_replied(tasks, client, session, previous) if task is None: if previous and not previous.get("ended"): previous["ended"] = now diff --git a/api/mcp_server.py b/api/mcp_server.py index ad24132..351b786 100644 --- a/api/mcp_server.py +++ b/api/mcp_server.py @@ -24,7 +24,7 @@ import urllib.parse from aiohttp import web -from . import action_catalog, ai_gates, ai_outside, ai_panels, ai_run_tracking, ai_task_canvas, ai_task_runs, ai_tasks, mcp_apps, mcp_bridge, mcp_directory, mcp_prompts +from . import action_catalog, ai_callers, ai_gates, ai_outside, ai_panels, ai_run_tracking, ai_task_canvas, ai_task_runs, ai_tasks, mcp_apps, mcp_bridge, mcp_directory, mcp_prompts from .local_guard import is_local as _is_local, loopback_host as _loopback_host from .mcp_actions import ACTIONS from .mcp_canvas_edit import CANVAS_TOOLS @@ -187,9 +187,9 @@ async def _call(params, request): if not isinstance(name, str) or name not in TOOLS or not isinstance(raw_arguments, dict): return None arguments = dict(raw_arguments) - client = ai_tasks.client_of(request.headers, params) - session = ai_tasks.session_of(request.headers, params) - task_id = ai_tasks.task_of(request.headers, params, arguments) + client = ai_callers.client_of(request.headers, params) + session = ai_callers.session_of(request.headers, params) + task_id = ai_callers.task_of(request.headers, params, arguments) if "task" in arguments and "task" not in TOOLS[name]["spec"]["inputSchema"].get("properties", {}): arguments.pop("task", None) ctx = {"base_url": f"{request.scheme}://{request.host}", "client": client, "session": session, "task": task_id} # loopback, checked in api_mcp diff --git a/tests/test_ai_tasks.py b/tests/test_ai_tasks.py index 6be8dc2..1548f22 100644 --- a/tests/test_ai_tasks.py +++ b/tests/test_ai_tasks.py @@ -13,7 +13,7 @@ from unittest import mock PLUGIN = Path(__file__).resolve().parents[1] sys.path[:0] = [str(PLUGIN), str(PLUGIN.parents[1])] -from api import action_catalog, activity_log, ai_panels, ai_task_changes, ai_task_runs, ai_tasks, ai_tasks_routes, mcp_task_tools # noqa: E402 +from api import action_catalog, activity_log, ai_callers, ai_panels, ai_task_changes, ai_task_runs, ai_tasks, ai_tasks_routes, mcp_task_tools # noqa: E402 from api.mcp_server import TOOLS # noqa: E402 TMP = tempfile.mkdtemp(prefix="amb_ai_tasks_") @@ -314,11 +314,35 @@ class TaskTests(unittest.TestCase): ai_tasks.record("A", "add_lora", {}, ok({"_activity": entry["id"]}), ai_tasks.time.time()) self.assertEqual(activity_log.list_entries()[0]["task"], ai_tasks.list_tasks()[0]["id"]) + def test_the_ai_goes_on_in_the_task_the_user_wrote_to(self): + ai_tasks.begin_task("A", "睡衣少女") + ai_tasks.report("A", "做完了", done=True) + old_id = ai_tasks.list_tasks()[0]["id"] + ai_tasks.begin_task("A", "另一件事", new_task=True) + ai_tasks.report("A", "也做完了", done=True) + self.assertNotEqual(ai_tasks.list_tasks()[0]["id"], old_id) + asked = ai_panels.leave_message(old_id, "这根手指怪怪的") + ai_tasks.record("A", "describe_canvas", {}, ok({}), ai_tasks.time.time(), True) + self.assertEqual(ai_tasks.get_task(old_id)["steps"][-1]["tool"], "describe_canvas", "its next call lands in the task written to") + self.assertIsNone(ai_tasks.get_task(old_id)["ended"]) + ai_panels.attach_pending(ok({}), "A") + self.assertTrue(mcp_task_tools.report({"client": "A"}, "改好了", reply_to=asked["id"])["noted"]) + + def test_a_reply_elsewhere_does_not_pull_the_ai_off_its_open_task(self): + ai_tasks.begin_task("A", "旧的") + ai_tasks.report("A", "做完了", done=True) + old_id = ai_tasks.list_tasks()[0]["id"] + ai_tasks.begin_task("A", "正在做", new_task=True) + busy = ai_tasks.list_tasks()[0]["id"] + ai_panels.leave_message(old_id, "顺便看看这个") + ai_tasks.record("A", "describe_canvas", {}, ok({}), ai_tasks.time.time(), True) + self.assertEqual(ai_tasks.get_task(busy)["steps"][-1]["tool"], "describe_canvas") + def test_client_names(self): - self.assertEqual(ai_tasks.client_of({}, {"_meta": {"io.modelcontextprotocol/clientInfo": {"name": "claude-code"}}}), "Claude Code") - self.assertEqual(ai_tasks.client_of({"User-Agent": "Cursor/1.2 (win)"}, {}), "Cursor") - self.assertEqual(ai_tasks.client_of({"User-Agent": "my/1"}, {}), "mytool") - self.assertEqual(ai_tasks.client_of({}, {}), "AI") + self.assertEqual(ai_callers.client_of({}, {"_meta": {"io.modelcontextprotocol/clientInfo": {"name": "claude-code"}}}), "Claude Code") + self.assertEqual(ai_callers.client_of({"User-Agent": "Cursor/1.2 (win)"}, {}), "Cursor") + self.assertEqual(ai_callers.client_of({"User-Agent": "my/1"}, {}), "mytool") + self.assertEqual(ai_callers.client_of({}, {}), "AI") def test_routes(self): ai_tasks.record("A", "describe_canvas", {}, ok({}), 1.0, True)