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 <noreply@anthropic.com>
This commit is contained in:
DemonGatanjieu
2026-10-26 00:30:22 +08:00
co-authored by Claude Opus 5.5
parent ea9d446151
commit fd952fb9ce
6 changed files with 106 additions and 51 deletions
+4 -1
View File
@@ -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
+2 -2
View File
@@ -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.
+41
View File
@@ -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 ""
+26 -39
View File
@@ -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/<id>.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
+4 -4
View File
@@ -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
+29 -5
View File
@@ -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<tool>/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<tool>/1"}, {}), "mytool")
self.assertEqual(ai_callers.client_of({}, {}), "AI")
def test_routes(self):
ai_tasks.record("A", "describe_canvas", {}, ok({}), 1.0, True)