Przeglądaj źródła

补齐双模式能力、配置与隔离测试

SamLee 1 dzień temu
rodzic
commit
91fa079951

+ 1 - 0
frontend/react-template/package.json

@@ -7,6 +7,7 @@
     "dev": "vite --host 0.0.0.0 --port 3000",
     "build": "tsc && vite build",
     "preview": "vite preview --host 0.0.0.0",
+    "test": "vitest",
     "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
   },
   "dependencies": {

+ 71 - 0
frontend/react-template/src/components/FlowChart/hooks/useFlowChartData.test.tsx

@@ -0,0 +1,71 @@
+// @vitest-environment jsdom
+
+import { act, renderHook, waitFor } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import { useFlowChartData } from "./useFlowChartData";
+
+const mocks = vi.hoisted(() => ({ request: vi.fn() }));
+
+vi.mock("../../../api/client", () => ({ request: mocks.request }));
+vi.mock("../../../hooks/useWebSocket", () => ({
+  useWebSocket: () => ({ connected: false }),
+}));
+
+const deferred = <T,>() => {
+  let resolve!: (value: T) => void;
+  const promise = new Promise<T>((done) => {
+    resolve = done;
+  });
+  return { promise, resolve };
+};
+
+describe("useFlowChartData trace isolation", () => {
+  it("ignores a stale Recursive response after switching to Legacy", async () => {
+    const recursiveTrace = deferred<unknown>();
+    const recursiveMessages = deferred<unknown>();
+    const legacyTrace = deferred<unknown>();
+    const legacyMessages = deferred<unknown>();
+
+    mocks.request.mockImplementation((url: string) => {
+      if (url.includes("recursive") && url.includes("/messages")) {
+        return recursiveMessages.promise;
+      }
+      if (url.includes("recursive")) return recursiveTrace.promise;
+      if (url.includes("legacy") && url.includes("/messages")) {
+        return legacyMessages.promise;
+      }
+      return legacyTrace.promise;
+    });
+
+    const { result, rerender } = renderHook(
+      ({ traceId }) => useFlowChartData(traceId),
+      { initialProps: { traceId: "recursive" } },
+    );
+    await waitFor(() => expect(mocks.request).toHaveBeenCalledTimes(2));
+
+    rerender({ traceId: "legacy" });
+    await waitFor(() => expect(mocks.request).toHaveBeenCalledTimes(4));
+
+    await act(async () => {
+      legacyTrace.resolve({
+        trace: { context: { agent_mode: "legacy" } },
+        goal_tree: { goals: [] },
+      });
+      legacyMessages.resolve({ messages: [] });
+      await Promise.resolve();
+    });
+    await waitFor(() => expect(result.current.reloading).toBe(false));
+    expect(result.current.agentMode).toBe("legacy");
+
+    await act(async () => {
+      recursiveTrace.resolve({
+        trace: { context: { agent_mode: "recursive" } },
+        goal_tree: { goals: [] },
+      });
+      recursiveMessages.resolve({ messages: [] });
+      await Promise.resolve();
+    });
+    expect(result.current.agentMode).toBe("legacy");
+  });
+});

+ 37 - 0
frontend/react-template/src/components/FlowChart/subTraceEntries.test.ts

@@ -0,0 +1,37 @@
+import { describe, expect, it } from "vitest";
+
+import type { Goal } from "../../types/goal";
+import { visibleSubTraceEntries } from "./subTraceEntries";
+
+const goal: Goal = {
+  id: "1",
+  description: "delegate",
+  status: "running",
+  created_at: "",
+  sub_trace_ids: [
+    "child-1",
+    { trace_id: "child-2", mission: "second" },
+    "child-3",
+    "child-4",
+    "child-5",
+    "child-6",
+    "child-7",
+  ],
+};
+
+describe("Sub-Trace shortcuts", () => {
+  it("hides A shortcuts in legacy mode", () => {
+    expect(visibleSubTraceEntries(goal, "legacy")).toEqual([]);
+  });
+
+  it("normalizes and caps recursive shortcuts at six", () => {
+    expect(visibleSubTraceEntries(goal, "recursive")).toEqual([
+      { id: "child-1" },
+      { id: "child-2", mission: "second" },
+      { id: "child-3" },
+      { id: "child-4" },
+      { id: "child-5" },
+      { id: "child-6" },
+    ]);
+  });
+});

+ 128 - 0
tests/test_agent_mode.py

@@ -0,0 +1,128 @@
+import os
+import tempfile
+import unittest
+from unittest.mock import AsyncMock, patch
+
+from cyber_agent.core.agent_mode import (
+    AgentMode,
+    assert_removed_config_absent,
+    policy_from_context,
+    policy_from_environment,
+)
+from cyber_agent.core.runner import AgentRunner, RunConfig
+from cyber_agent.trace.goal_models import GoalTree
+from cyber_agent.trace.models import Trace
+from cyber_agent.trace.store import FileSystemTraceStore
+
+
+class AgentModePolicyTest(unittest.IsolatedAsyncioTestCase):
+    async def asyncSetUp(self):
+        self.temp_dir = tempfile.TemporaryDirectory()
+        self.store = FileSystemTraceStore(self.temp_dir.name)
+        self.runner = AgentRunner(trace_store=self.store)
+        self.previous = {
+            name: os.environ.get(name)
+            for name in ("AGENT_MODE", "AGENT_RECURSION_ENABLED")
+        }
+        os.environ.pop("AGENT_MODE", None)
+        os.environ.pop("AGENT_RECURSION_ENABLED", None)
+
+    async def asyncTearDown(self):
+        for name, value in self.previous.items():
+            if value is None:
+                os.environ.pop(name, None)
+            else:
+                os.environ[name] = value
+        self.temp_dir.cleanup()
+
+    async def _prepare_new(self, mode=None):
+        if mode is None:
+            os.environ.pop("AGENT_MODE", None)
+        else:
+            os.environ["AGENT_MODE"] = mode
+        return await self.runner._prepare_trace(
+            [{"role": "user", "content": "test"}],
+            RunConfig(name="mode test"),
+        )
+
+    async def test_unset_defaults_to_legacy(self):
+        trace, _, _ = await self._prepare_new()
+        self.assertEqual("legacy", trace.context["agent_mode"])
+        self.assertEqual(1, trace.context["agent_mode_revision"])
+        self.assertEqual(0, trace.context["agent_depth"])
+        self.assertEqual(trace.trace_id, trace.context["root_trace_id"])
+
+    async def test_legacy_and_recursive_are_parsed_explicitly(self):
+        self.assertIs(AgentMode.LEGACY, policy_from_environment().mode)
+        os.environ["AGENT_MODE"] = "recursive"
+        self.assertIs(AgentMode.RECURSIVE, policy_from_environment().mode)
+
+    async def test_invalid_mode_does_not_create_trace(self):
+        with self.assertRaisesRegex(ValueError, "Invalid AGENT_MODE"):
+            await self._prepare_new("unsupported")
+        traces = await self.store.list_traces(limit=10)
+        self.assertEqual([], traces)
+
+    async def test_invalid_mode_is_rejected_before_task_name_generation(self):
+        os.environ["AGENT_MODE"] = "unsupported"
+        with patch.object(
+            self.runner,
+            "_generate_task_name",
+            new=AsyncMock(return_value="should not run"),
+        ) as generate_name:
+            with self.assertRaisesRegex(ValueError, "Invalid AGENT_MODE"):
+                await self.runner._prepare_trace(
+                    [{"role": "user", "content": "test"}],
+                    RunConfig(),
+                )
+        generate_name.assert_not_awaited()
+
+    async def test_removed_recursion_variable_fails_explicitly(self):
+        os.environ["AGENT_RECURSION_ENABLED"] = "true"
+        with self.assertRaisesRegex(ValueError, "has been removed"):
+            assert_removed_config_absent()
+        with self.assertRaisesRegex(ValueError, "AGENT_MODE"):
+            await self._prepare_new("recursive")
+
+    async def test_existing_trace_keeps_persisted_mode_after_env_switch(self):
+        trace, _, _ = await self._prepare_new("recursive")
+        os.environ["AGENT_MODE"] = "legacy"
+
+        continued, _, _ = await self.runner._prepare_trace(
+            [],
+            RunConfig(trace_id=trace.trace_id, name="continue"),
+        )
+        self.assertEqual("recursive", continued.context["agent_mode"])
+        persisted = await self.store.get_trace(trace.trace_id)
+        self.assertEqual("recursive", persisted.context["agent_mode"])
+
+    async def test_historical_trace_is_persisted_as_legacy_on_continue(self):
+        trace = Trace(trace_id="historical", mode="agent", task="old", context={})
+        await self.store.create_trace(trace)
+        os.environ["AGENT_MODE"] = "recursive"
+
+        continued, _, _ = await self.runner._prepare_trace(
+            [],
+            RunConfig(trace_id=trace.trace_id, name="continue"),
+        )
+        self.assertEqual("legacy", continued.context["agent_mode"])
+        self.assertIs(AgentMode.LEGACY, policy_from_context(continued.context).mode)
+
+    async def test_legacy_goal_tree_keeps_automatic_sibling_and_parent_progress(self):
+        tree = GoalTree(mission="legacy goals")
+        parent = tree.add_goals(["parent"])[0]
+        first, second = tree.add_goals(["first", "second"], parent_id=parent.id)
+        tree.focus(parent.id)
+        tree.focus(first.id)
+
+        tree.complete(first.id, "done")
+        self.assertEqual(second.id, tree.current_id)
+        self.assertEqual("in_progress", parent.status)
+
+        tree.complete(second.id, "done")
+        self.assertEqual("completed", parent.status)
+        self.assertEqual("所有子目标已完成", parent.summary)
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 110 - 36
tests/test_subagent_recursion.py

@@ -1,5 +1,4 @@
 import asyncio
-import os
 import tempfile
 import unittest
 from unittest.mock import AsyncMock, patch
@@ -49,30 +48,40 @@ class FakeRunner:
         }
 
 
-class SubAgentRecursionTest(unittest.IsolatedAsyncioTestCase):
+class SubAgentModeTest(unittest.IsolatedAsyncioTestCase):
     async def asyncSetUp(self):
         self.temp_dir = tempfile.TemporaryDirectory()
         self.store = FileSystemTraceStore(self.temp_dir.name)
         self.runner = FakeRunner(self.store)
-        self.previous_recursion_setting = os.environ.get("AGENT_RECURSION_ENABLED")
-        os.environ["AGENT_RECURSION_ENABLED"] = "true"
         self.root_id = await self._create_root("root")
 
     async def asyncTearDown(self):
-        if self.previous_recursion_setting is None:
-            os.environ.pop("AGENT_RECURSION_ENABLED", None)
-        else:
-            os.environ["AGENT_RECURSION_ENABLED"] = self.previous_recursion_setting
         self.temp_dir.cleanup()
 
-    async def _create_root(self, trace_id, uid="user-1"):
+    async def _create_root(
+        self,
+        trace_id,
+        uid="user-1",
+        *,
+        agent_mode="recursive",
+        include_mode=True,
+    ):
+        context = {
+            "agent_depth": 0,
+            "root_trace_id": trace_id,
+        }
+        if include_mode:
+            context.update({
+                "agent_mode": agent_mode,
+                "agent_mode_revision": 1,
+            })
         trace = Trace(
             trace_id=trace_id,
             mode="agent",
             task=f"task-{trace_id}",
             uid=uid,
             model="fake-model",
-            context={},
+            context=context,
         )
         await self.store.create_trace(trace)
         await self.store.update_goal_tree(trace_id, GoalTree(mission=trace.task))
@@ -97,9 +106,9 @@ class SubAgentRecursionTest(unittest.IsolatedAsyncioTestCase):
             limit=20,
         )
 
-    async def test_recursion_disabled_preserves_single_level_behavior(self):
-        os.environ["AGENT_RECURSION_ENABLED"] = "false"
-        first = await self._spawn_one(self.root_id)
+    async def test_legacy_preserves_single_level_behavior(self):
+        legacy_root = await self._create_root("legacy-root", agent_mode="legacy")
+        first = await self._spawn_one(legacy_root)
         self.assertEqual("completed", first["status"])
 
         child_id = first["sub_trace_id"]
@@ -108,7 +117,17 @@ class SubAgentRecursionTest(unittest.IsolatedAsyncioTestCase):
 
         blocked = await self._spawn_one(child_id, "grandchild")
         self.assertEqual("failed", blocked["status"])
-        self.assertIn("recursion is disabled", blocked["error"])
+        self.assertIn("depth limit reached", blocked["error"])
+        self.assertIn("mode=legacy", blocked["error"])
+
+    async def test_legacy_has_no_direct_child_quota(self):
+        legacy_root = await self._create_root("legacy-unlimited", agent_mode="legacy")
+        result = await agent(
+            task=[f"legacy-child-{index}" for index in range(8)],
+            context=self._context(legacy_root),
+        )
+        self.assertEqual("completed", result["status"])
+        self.assertEqual(8, len(await self._local_children(legacy_root)))
 
     async def test_five_subagent_levels_and_sixth_level_rejected(self):
         parent_id = self.root_id
@@ -181,6 +200,24 @@ class SubAgentRecursionTest(unittest.IsolatedAsyncioTestCase):
         self.assertEqual(2, len(sub_trace_ids))
         self.assertEqual(2, len({entry["trace_id"] for entry in sub_trace_ids}))
 
+    async def test_legacy_goal_overwrites_sub_traces_with_latest_call(self):
+        legacy_root = await self._create_root("legacy-goal", agent_mode="legacy")
+        tree = await self.store.get_goal_tree(legacy_root)
+        tree.goals = [Goal(id="1", description="delegate twice")]
+        await self.store.update_goal_tree(legacy_root, tree)
+        context = self._context(legacy_root, goal_id="1")
+
+        first = await agent(task="first", context=context)
+        second = await agent(task="second", context=context)
+
+        updated_tree = await self.store.get_goal_tree(legacy_root)
+        sub_trace_ids = updated_tree.find("1").sub_trace_ids
+        self.assertEqual(
+            [{"trace_id": second["sub_trace_id"], "mission": "second"}],
+            sub_trace_ids,
+        )
+        self.assertNotEqual(first["sub_trace_id"], second["sub_trace_id"])
+
     async def test_concurrent_batches_never_exceed_limit(self):
         results = await asyncio.gather(
             agent(
@@ -231,6 +268,56 @@ class SubAgentRecursionTest(unittest.IsolatedAsyncioTestCase):
         self.assertEqual("failed", wrong_owner["status"])
         self.assertIn("owner", wrong_owner["error"])
 
+    async def test_continue_from_requires_matching_persisted_mode(self):
+        created = await self._spawn_one(self.root_id)
+        child_id = created["sub_trace_id"]
+        child = await self.store.get_trace(child_id)
+        child.context["agent_mode"] = "legacy"
+        await self.store.update_trace(child_id, context=child.context)
+
+        result = await agent(
+            task="continue",
+            continue_from=child_id,
+            context=self._context(self.root_id),
+        )
+        self.assertEqual("failed", result["status"])
+        self.assertIn("mode does not match", result["error"])
+
+    async def test_continue_from_rejects_cross_tool_traces(self):
+        evaluated = await evaluate(messages=[], context=self._context(self.root_id))
+        rejected_agent = await agent(
+            task="wrong tool",
+            continue_from=evaluated["sub_trace_id"],
+            context=self._context(self.root_id),
+        )
+        self.assertEqual("failed", rejected_agent["status"])
+        self.assertIn("created by the agent tool", rejected_agent["error"])
+
+        created = await self._spawn_one(self.root_id)
+        rejected_evaluator = await evaluate(
+            messages=[],
+            continue_from=created["sub_trace_id"],
+            context=self._context(self.root_id),
+        )
+        self.assertEqual("failed", rejected_evaluator["status"])
+        self.assertIn("created by evaluate", rejected_evaluator["error"])
+
+    async def test_continue_from_rejects_forged_depth_or_root_lineage(self):
+        created = await self._spawn_one(self.root_id)
+        child_id = created["sub_trace_id"]
+        child = await self.store.get_trace(child_id)
+        child.context["agent_depth"] = 0
+        child.context["root_trace_id"] = "wrong-root"
+        await self.store.update_trace(child_id, context=child.context)
+
+        result = await agent(
+            task="continue forged child",
+            continue_from=child_id,
+            context=self._context(self.root_id),
+        )
+        self.assertEqual("failed", result["status"])
+        self.assertIn("lineage does not match", result["error"])
+
     async def test_evaluator_never_receives_recursive_or_shell_tools(self):
         await evaluate(messages=[], context=self._context(self.root_id))
         config = self.runner.configs[-1]
@@ -252,29 +339,16 @@ class SubAgentRecursionTest(unittest.IsolatedAsyncioTestCase):
         self.assertEqual("completed", result["status"])
         self.assertEqual(6, len(await self._local_children(self.root_id)))
 
-    async def test_legacy_trace_depth_is_recovered_from_parent_links(self):
-        legacy_child_id = "legacy-child"
-        await self.store.create_trace(
-            Trace(
-                trace_id=legacy_child_id,
-                mode="agent",
-                task="legacy child",
-                parent_trace_id=self.root_id,
-                uid="user-1",
-                model="fake-model",
-                context={"created_by_tool": "agent"},
-            )
-        )
-        await self.store.update_goal_tree(
-            legacy_child_id,
-            GoalTree(mission="legacy child"),
+    async def test_trace_without_mode_is_treated_as_legacy(self):
+        historical_root = await self._create_root(
+            "historical-root",
+            include_mode=False,
         )
-
-        result = await self._spawn_one(legacy_child_id, "grandchild")
-        self.assertEqual("completed", result["status"])
-        grandchild = await self.store.get_trace(result["sub_trace_id"])
-        self.assertEqual(2, grandchild.context["agent_depth"])
-        self.assertEqual(self.root_id, grandchild.context["root_trace_id"])
+        child = await self._spawn_one(historical_root, "legacy child")
+        self.assertEqual("completed", child["status"])
+        child_trace = await self.store.get_trace(child["sub_trace_id"])
+        self.assertEqual("legacy", child_trace.context["agent_mode"])
+        self.assertNotIn("agent", self.runner.configs[-1].tools)
 
     async def test_remote_agent_is_not_counted_as_local_child(self):
         child = await self._spawn_one(self.root_id)