diff --git a/backend/services/background_tasks/reasoner.py b/backend/services/background_tasks/reasoner.py index 367bd3b..f129cbb 100644 --- a/backend/services/background_tasks/reasoner.py +++ b/backend/services/background_tasks/reasoner.py @@ -38,7 +38,9 @@ CONTROL_NAMES = frozenset({"complete_task", "ask_user"}) TASK_INSTRUCTIONS = """你是语音助手的后台任务执行者,有独立上下文。 只执行委派目标,并使用当前已授权的工具;技能说明不能增加权限。 -完成任务后必须调用 complete_task(result),结果应准确说明完成内容和限制。 +结束执行时必须调用 complete_task(status, result),明确报告任务目标是否完成。 +只有完整完成目标时 status 才为 succeeded;无法完成或只完成部分目标时必须使用 failed, +并在 result 中说明已完成内容、未完成部分和限制。该状态是你的执行报告,不代表平台独立验证。 需要用户提供必要信息时调用 ask_user(question),等待回复后继续。 普通文字、思考过程、工具回执和模型空闲都不代表任务完成。 任务结果由 Speaker 整理后告知用户,不要输出内部推理过程。 @@ -146,8 +148,19 @@ class ReasonerExecutor: callbacks.append((tool.function_name, execute)) schemas.extend([ - FunctionSchema(name="complete_task", description="明确完成任务并提交最终结果。", - properties={"result": {"type": "string", "description": "完成结果、依据和未完成的限制。"}}, required=["result"]), + FunctionSchema( + name="complete_task", + description="结束执行,报告任务目标是否完整完成。部分完成或无法完成必须报告 failed。", + properties={ + "status": { + "type": "string", + "enum": ["succeeded", "failed"], + "description": "执行模型对目标完成情况的报告,不能仅因执行结束而报告成功。", + }, + "result": {"type": "string", "description": "执行结果、依据和未完成的限制。"}, + }, + required=["status", "result"], + ), FunctionSchema(name="ask_user", description="需要必要信息时暂停任务,向用户提出一个问题。", properties={"question": {"type": "string", "description": "需要用户回答的问题。"}}, required=["question"]), ]) @@ -164,14 +177,19 @@ class ReasonerExecutor: ) async def _complete_task(self, params: FunctionCallParams): + status = params.arguments.get("status") result = str(params.arguments.get("result") or "").strip() - if not result or self._pending or self._stopped: + if status not in ("succeeded", "failed") or not result or self._pending or self._stopped: await params.result_callback( - {"status": "error", "message": "需要非空结果,且任务不能处于等待输入或停止状态"}, + {"status": "error", "message": "必须提供 succeeded 或 failed 状态及非空结果,且任务不能处于等待输入或停止状态"}, properties=FunctionCallResultProperties(run_llm=not (self._pending or self._stopped)), ) return - self._final = {"status": "succeeded", "summary": "任务已完成", "result": result} + self._final = { + "status": status, + "summary": "Reasoner 报告任务已完成" if status == "succeeded" else "Reasoner 报告任务未完成", + "result": result, + } await params.result_callback({"status": "accepted"}, properties=FunctionCallResultProperties(run_llm=False)) async def _ask_user(self, params: FunctionCallParams): diff --git a/backend/services/brains/prompt_tasks.py b/backend/services/brains/prompt_tasks.py index b22d6d4..b2f09b2 100644 --- a/backend/services/brains/prompt_tasks.py +++ b/backend/services/brains/prompt_tasks.py @@ -16,9 +16,29 @@ TASK_HINT = """ """ +async def load_current_backend(assistant_id, configured_backend, variable_definitions): + """Recheck task permissions at delegation, even during a long voice call.""" + from db.models import Assistant + from db.session import SessionLocal + from services.task_backend_resources import resolve_task_backend + + async with SessionLocal() as session: + assistant = await session.get(Assistant, assistant_id) + if assistant is None: + raise ValueError("助手已删除,不能创建新任务") + if (assistant.dynamic_variable_definitions or {}) != variable_definitions: + raise ValueError("助手变量配置已更新,请重新连接后再委派任务") + current = await resolve_task_backend(session, assistant) + if (current is None or current.id != configured_backend.id + or current.instruction_source != configured_backend.instruction_source): + raise ValueError("助手任务绑定已更新,请重新连接后再委派任务") + return current + + class PromptTaskBridge: - def __init__(self, cfg, runtime, executor, service=task_service): + def __init__(self, cfg, runtime, executor, service=task_service, backend_loader=load_current_backend): self.cfg, self.runtime, self.executor, self.service = cfg, runtime, executor, service + self._backend_loader = backend_loader self._announced = set() self._detach = service.subscribe(runtime.session_id, self.on_task) if runtime.transport is not None else lambda: None @@ -39,6 +59,8 @@ class PromptTaskBridge: await params.result_callback({"status": "error", "message": "自动测试不启动真实后台任务,请使用语音调试验证"}) return try: + assistant_id = self.cfg.dynamic_variables.get("system__agent_id", "") + current_backend = await self._backend_loader(assistant_id, backend, self.cfg.dynamic_variable_definitions) kind = source.get("source") if kind == "model": instruction = (params.arguments or {}).get("instruction") @@ -53,9 +75,9 @@ class PromptTaskBridge: if not isinstance(instruction, str): raise ValueError("任务指令必须是字符串") result = await self.service.submit( - assistant_id=self.cfg.dynamic_variables.get("system__agent_id", ""), + assistant_id=assistant_id, conversation_id=self.cfg.conversation_id, - backend=backend, instruction=instruction, invocation_id=params.tool_call_id, + backend=current_backend, instruction=instruction, invocation_id=params.tool_call_id, variable_state={"values": self.executor.store.values, "definitions": self.cfg.dynamic_variable_definitions, "secrets": self.executor.store.secrets}, diff --git a/backend/tests/test_prompt_background_tasks.py b/backend/tests/test_prompt_background_tasks.py index 90b23c1..c2838cd 100644 --- a/backend/tests/test_prompt_background_tasks.py +++ b/backend/tests/test_prompt_background_tasks.py @@ -16,7 +16,7 @@ class PromptTaskBridgeTest(unittest.IsolatedAsyncioTestCase): runtime = SimpleNamespace(session_id="conv_1", transport=object(), tool_executor_factory=None, call_end=SimpleNamespace(ending=False), queue_frame=AsyncMock(), llm=SimpleNamespace(queue_frame=AsyncMock())) detach = Mock() service = SimpleNamespace(subscribe=Mock(return_value=detach), submit=AsyncMock(return_value={"id": "task_1", "status": "queued"})) - bridge = PromptTaskBridge(cfg, runtime, ToolExecutor(DynamicVariableStore({})), service) + bridge = PromptTaskBridge(cfg, runtime, ToolExecutor(DynamicVariableStore({})), service, AsyncMock(return_value=cfg.task_backend)) functions = bridge.functions() self.assertEqual(functions[0][0].name, "delegate_task") callback = AsyncMock() @@ -62,10 +62,46 @@ class PromptTaskBridgeTest(unittest.IsolatedAsyncioTestCase): runtime = SimpleNamespace(session_id="conv_1", transport=None, tool_executor_factory=None) service = SimpleNamespace(submit=AsyncMock(return_value={"id": "task_1"})) store = DynamicVariableStore({"project": "demo"}) - bridge = PromptTaskBridge(cfg, runtime, ToolExecutor(store), service) + bridge = PromptTaskBridge(cfg, runtime, ToolExecutor(store), service, AsyncMock(return_value=cfg.task_backend)) schema, handler = bridge.functions()[0] self.assertEqual(schema.to_default_dict()["parameters"]["properties"], {}) callback = AsyncMock() await handler(SimpleNamespace(arguments={"instruction": "unauthorized override"}, tool_call_id="call_1", result_callback=callback)) self.assertEqual(service.submit.await_args.kwargs["instruction"], expected) self.assertEqual(service.submit.await_args.kwargs["variable_state"]["values"], store.values) + + async def test_each_delegation_uses_refreshed_backend_and_rejects_disabled_resources(self): + cfg = self.config(conversation_id="conv_1", dynamic_variables={"system__agent_id": "asst_1"}) + runtime = SimpleNamespace(session_id="conv_1", transport=None, tool_executor_factory=None) + service = SimpleNamespace(submit=AsyncMock(return_value={"id": "task_1"})) + updated = cfg.task_backend.model_copy(deep=True) + updated.secrets = {"password": "rotated"} + loader = AsyncMock(side_effect=[updated, ValueError("任务后端不存在或未启用")]) + bridge = PromptTaskBridge(cfg, runtime, ToolExecutor(DynamicVariableStore({})), service, loader) + handler = bridge.functions()[0][1] + callback = AsyncMock() + params = SimpleNamespace(arguments={"instruction": "检查项目"}, tool_call_id="call_1", result_callback=callback) + await handler(params) + self.assertIs(service.submit.await_args.kwargs["backend"], updated) + await handler(params) + self.assertEqual(loader.await_count, 2) + service.submit.assert_awaited_once() + self.assertEqual(callback.await_args.args[0]["status"], "error") + + async def test_live_loader_rejects_binding_changes_and_revalidates_resources(self): + from unittest.mock import MagicMock, patch + from services.brains.prompt_tasks import load_current_backend + cfg = self.config() + assistant = SimpleNamespace(dynamic_variable_definitions={}) + session = SimpleNamespace(get=AsyncMock(return_value=assistant)) + database = MagicMock() + database.__aenter__ = AsyncMock(return_value=session) + database.__aexit__ = AsyncMock(return_value=False) + updated = cfg.task_backend.model_copy(update={"id": "different_backend"}) + with patch("db.session.SessionLocal", return_value=database), patch("services.task_backend_resources.resolve_task_backend", AsyncMock(return_value=updated)) as resolve: + with self.assertRaisesRegex(ValueError, "绑定已更新"): + await load_current_backend("asst_1", cfg.task_backend, {}) + resolve.assert_awaited_once_with(session, assistant) + resolve.side_effect = ValueError("模型已停用") + with self.assertRaisesRegex(ValueError, "模型已停用"): + await load_current_backend("asst_1", cfg.task_backend, {}) diff --git a/backend/tests/test_reasoner_backend.py b/backend/tests/test_reasoner_backend.py index c6d55d4..2a97b15 100644 --- a/backend/tests/test_reasoner_backend.py +++ b/backend/tests/test_reasoner_backend.py @@ -134,7 +134,7 @@ class ReasonerNativeTest(unittest.IsolatedAsyncioTestCase): await asyncio.wait_for(wait(), 5) async def test_explicit_completion_uses_actual_native_backend_and_clears_context(self): - await self.start_reasoner([[('complete_task', {"result": "显式模拟结果"})]]) + await self.start_reasoner([[('complete_task', {"status": "succeeded", "result": "显式模拟结果"})]]) await asyncio.wait_for(self.finished.wait(), 5) self.assertEqual(self.results[0]["status"], "succeeded") self.assertEqual(self.results[0]["result"], "显式模拟结果") @@ -150,10 +150,41 @@ class ReasonerNativeTest(unittest.IsolatedAsyncioTestCase): self.assertIn("complete_task", self.results[0]["result"]) self.assertNotIn("private reasoning", str(self.events)) + async def test_explicit_partial_failure_preserves_failed_business_result(self): + await self.start_reasoner([[('complete_task', { + "status": "failed", "result": "只完成查询;写入服务不可用,目标未完整完成。", + })]]) + await asyncio.wait_for(self.finished.wait(), 5) + self.assertEqual(self.results[0]["status"], "failed") + self.assertIn("报告任务未完成", self.results[0]["summary"]) + self.assertIn("只完成查询", self.results[0]["result"]) + self.assertIsNone(self.executor._worker) + + async def test_missing_completion_status_does_not_accept_success(self): + await self.start_reasoner([ + [('complete_task', {"result": "执行结束,但未报告目标是否完成"})], + "simulated model stopped without correcting completion status", + ]) + await asyncio.wait_for(self.finished.wait(), 5) + self.assertEqual(self.results[0]["status"], "failed") + self.assertIsNone(self.executor._final) + self.assertEqual(len(self.model.contexts), 2) + self.assertIn("必须提供 succeeded 或 failed", str(self.model.contexts[1])) + + async def test_invalid_completion_status_requires_model_correction(self): + await self.start_reasoner([ + [('complete_task', {"status": "partial", "result": "只完成查询"})], + [('complete_task', {"status": "failed", "result": "只完成查询,目标未完成"})], + ]) + await asyncio.wait_for(self.finished.wait(), 5) + self.assertEqual(self.results[0]["status"], "failed") + self.assertEqual(self.results[0]["result"], "只完成查询,目标未完成") + self.assertEqual(len(self.model.contexts), 2) + async def test_question_reply_continues_same_native_context(self): await self.start_reasoner([ [('ask_user', {"question": "需要哪一天的数据?"})], - [('complete_task', {"result": "已按用户回答完成"})], + [('complete_task', {"status": "succeeded", "result": "已按用户回答完成"})], ]) await self.wait_status("waiting_input") self.assertFalse(self.finished.is_set()) @@ -186,7 +217,7 @@ class ReasonerNativeTest(unittest.IsolatedAsyncioTestCase): # Isolate the HTTP side effect while retaining real argument/result handling. with patch("services.tool_executor.ToolExecutor._execute_http", AsyncMock(return_value={"status": "ok", "data": {"answer": "after"}})) as http: - await self.start_reasoner([[('lookup', {})], [('complete_task', {"result": "查询完成"})]], config) + await self.start_reasoner([[('lookup', {})], [('complete_task', {"status": "succeeded", "result": "查询完成"})]], config) await asyncio.wait_for(self.finished.wait(), 5) self.assertEqual(self.results[0]["status"], "succeeded") self.assertEqual(http.await_args.args[1], {"customer": "local-customer"}) diff --git a/backend/tests/test_task_backend_spine.py b/backend/tests/test_task_backend_spine.py index 73b2b0e..c7e0a73 100644 --- a/backend/tests/test_task_backend_spine.py +++ b/backend/tests/test_task_backend_spine.py @@ -60,7 +60,7 @@ class ReasonerServiceTest(unittest.IsolatedAsyncioTestCase): ) async def test_snapshot_is_frozen_and_business_projection_has_no_secrets(self): - model = SimulatedLLM([[('complete_task', {"result": "模拟任务完成"})]]) + model = SimulatedLLM([[('complete_task', {"status": "succeeded", "result": "模拟任务完成"})]]) self.backend.instructions = "为 {{customer}} 核查结果" self.backend.config["instructions"] = "字面模板 {{customer}}" state = {"values": {"customer": "before"}, "definitions": {"customer": {"type": "string"}}, "secrets": {"secret__token": "private-token"}} @@ -83,7 +83,7 @@ class ReasonerServiceTest(unittest.IsolatedAsyncioTestCase): model = SimulatedLLM([ [('ask_user', {"question": "选择日期?"})], [('ask_user', {"question": "选择范围?"})], - [('complete_task', {"result": "两个问题均已回答"})], + [('complete_task', {"status": "succeeded", "result": "两个问题均已回答"})], ]) with patch("services.background_tasks.reasoner.create_reasoner_llm", return_value=model): task = await self.submit() diff --git a/docs-developer/prompt-task-backends.mdx b/docs-developer/prompt-task-backends.mdx index 23e6f2e..19d67db 100644 --- a/docs-developer/prompt-task-backends.mdx +++ b/docs-developer/prompt-task-backends.mdx @@ -50,6 +50,10 @@ Speaker 使用现有 ASR → 对话 LLM → TTS 链路,负责交流、澄清 默认任务时限为 600 秒,可设置 30–7200 秒。资源配置保存时和助手绑定时都会校验模型、工具及变量引用。修改资源不会改变已创建任务的冻结配置。 +Speaker 每次委派前会重新读取后端、模型、工具及 MCP Server 的有效状态和凭据;助手绑定、任务要求来源或变量声明发生变化时,需要重新连接。业务变量使用提交时的快照,指令中的系统时间沿用共享渲染器,在执行时刷新。 + +停用资源允许保留暂时失效的引用。若后端仍被助手绑定,新会话会因配置无效而拒绝启动;先解绑或重新启用即可。已有通话继续,但新委派会重新校验并拒绝使用停用资源。 + ### 内置 Reasoner Reasoner 引用 `openai-llm` 或 `dashscope-llm` 的 LLM 模型资源。即使与 Speaker 选择同一资源,每个任务也会创建独立的模型服务、上下文和运行设置。 @@ -84,7 +88,7 @@ Pipecat 负责 Job 派发、FIFO 串行、超时和取消。`TaskService` 负责 - **并发与部署**:仅单应用进程、一个 Uvicorn worker;所有任务全局并发固定为 1。已有未结束的 OpenCode 句柄会阻止新任务提交到后端,需先核查或停止旧任务。该约束无法控制平台之外的 OpenCode 客户端。 - **截止时间**:新任务在创建时保存截止时间,覆盖排队和执行;OpenCode 重连观察或应用重启后使用剩余时间,不重新延长截止时间。迁移前没有截止时间的历史任务保留原有观察时限语义。 -- **成功判定**:OpenCode 的 204 只表示接收。需要取得关联到本次 message ID 的完成消息;Reasoner 需调用明确完成控制函数。 +- **成功判定**:OpenCode 的 204 只表示接收,需要取得关联到本次 message ID 的完成消息。Reasoner 通过 `complete_task(status, result)` 报告成功或失败;仅完成部分目标时标为失败并说明限制。这是执行模型的结果报告,平台没有独立核验业务目标。 - **取消**:先取消原生执行 Job,再核查对应后端。OpenCode 需 abort 后确认状态,未确认时保留「停止待确认」。Reasoner 可停止本地执行;已发出的 HTTP/MCP 请求可能已有副作用,应核查业务系统结果。 - **恢复**:OpenCode 使用已存 session/message 句柄重新观察,不重发未知提交。Reasoner 在应用重启后标为中断失败,保留记录,不自动重放可能有副作用的工具。 - **输入与目标变更**:共同支持查询、取消、回答当前待处理问题。首版不支持任意运行中 steer;修改目标时先停止,再创建新任务。 diff --git a/frontend/src/components/pages/ComponentsTaskBackendsPage.tsx b/frontend/src/components/pages/ComponentsTaskBackendsPage.tsx index 3581c1c..e1356a1 100644 --- a/frontend/src/components/pages/ComponentsTaskBackendsPage.tsx +++ b/frontend/src/components/pages/ComponentsTaskBackendsPage.tsx @@ -147,6 +147,7 @@ export function ComponentsTaskBackendsPage() { setDraft(previous => ({ ...previous, enabled }))} /> + {!draft.enabled &&

停用后,已绑定助手需解绑或重新启用此后端才能开启新会话。已创建的任务继续使用提交时的配置。

} {draft.type === "reasoner" ? : } diff --git a/implement-stage/task-backends/ASSUMPTIONS.md b/implement-stage/task-backends/ASSUMPTIONS.md index 5836a01..e4e27d1 100644 --- a/implement-stage/task-backends/ASSUMPTIONS.md +++ b/implement-stage/task-backends/ASSUMPTIONS.md @@ -12,7 +12,7 @@ | B-007 | 推理强度 | provider_default 或 low/medium/high;非默认值要求模型资源显式声明 supportsReasoningEffort,按 reasoning_effort 参数发送,不支持时拒绝 | semantic | default | | B-008 | 技能 | Reasoner 使用后端资源中的命名技能正文;OpenCode 使用远端原生技能/agent,平台仅保存说明,不伪装远程安装或权限隔离 | semantic | default | | B-009 | 外部选择 | OpenCode保留地址/目录/服务鉴权,新增可选 agent 与 modelProviderId/modelId;推理强度及沙箱由远端管理 | interface | default | -| B-010 | 执行结果 | Reasoner通过明确完成/提问控制函数返回业务结果;idle、普通文本、接收回执不能直接证明成功 | semantic | default | +| B-010 | 执行结果 | Reasoner通过complete_task(status,result)明确报告目标成功或失败;部分完成记为failed并说明限制。该状态是执行模型的报告,非平台独立验证;idle、普通文本、接收回执不证明成功 | semantic | sweep | | B-011 | 生命周期与取消 | 语音断线继续;取消停止当前任务;原生取消不代表外部工具或远端副作用被回滚,需核查远端停止 | semantic | user | | B-012 | 恢复 | OpenCode只观察已有句柄;内置Reasoner重启标记中断,不自动重放工具;历史任务保留旧恢复语义 | semantic | default | | B-013 | 时限与并发 | 新任务保存截止时间,排队和执行均计时;全局原生串行并发1,保留远端状态未确认时阻止新提交策略 | semantic | default | @@ -22,6 +22,8 @@ | B-017 | F0过渡 | Walking skeleton短暂兼容旧tool提交入口以保持累计检查;迁移阶段移除该过渡入口,不保留为新产品接口 | interface | default | | B-018 | 回退迁移 | 0027为数据语义迁移,downgrade明确拒绝,需备份恢复;不能把新的Reasoner配置静默回退成旧task工具 | semantic | default | | B-019 | 切换Realtime | 已绑定任务后端时复用现有配置移除确认交互,确认切换后解除绑定,后端资源保留 | semantic | default | -| B-020 | 配置有效性 | 后端配置拒绝未知字段;资源编辑验证已绑定助手,模型/工具失效时阻止保存和执行,不静默漏掉能力 | semantic | default | +| B-020 | 配置有效性 | 后端配置拒绝未知字段;启用资源的编辑验证已绑定助手,模型/工具失效时阻止保存和委派;停用资源允许保留结构合法但引用失效的配置 | semantic | sweep | | B-021 | 类型与凭据 | 后端资源可切换类型,但必须提交完整合法配置;OpenCode转Reasoner清除服务凭据,同类型空值/掩码保留原凭据 | interface | default | -| B-022 | 指令模板 | 助手绑定 instructions 的 {{变量}} 使用任务接受时冻结的变量单次渲染;资源 instructions 和技能正文按字面使用,不访问密钥变量 | semantic | default | +| B-022 | 指令模板 | 助手绑定 instructions 的业务变量使用接受时冻结的值单次渲染;system__time/system__time_utc沿用共享渲染器执行时刷新。资源 instructions 和技能正文按字面使用,不访问密钥变量 | semantic | sweep | +| B-023 | 通话内更新 | 每次Speaker委派前重新读取后端、模型、工具和MCP启用状态及凭据;绑定或任务参数来源改变时要求重新连接。接受后的任务继续用快照,不因配置编辑撤销已有副作用 | semantic | sweep | +| B-024 | 停用影响 | 允许停用仍被引用的后端并保留绑定;新助手会话因配置校验失败不能启动,需先解绑或启用。已有Speaker通话保留,但新委派会重新校验并拒绝停用资源 | semantic | sweep | diff --git a/implement-stage/task-backends/SILENT_ASSUMPTION_SWEEP.json b/implement-stage/task-backends/SILENT_ASSUMPTION_SWEEP.json new file mode 100644 index 0000000..8b35173 --- /dev/null +++ b/implement-stage/task-backends/SILENT_ASSUMPTION_SWEEP.json @@ -0,0 +1,42 @@ +{ + "undeclared": [ + { + "site": "backend/services/brains/prompt_tasks.py:29; backend/services/brains/prompt_tasks.py:55; backend/services/background_tasks/service.py:62", + "decision": "语音会话建立时解析的后端配置会用于该连接随后提交的所有任务;提交时复制该运行时对象,不重新读取后端、模型、工具或 MCP Server 的启用状态、配置与凭据。", + "why_it_matters": "管理员在通话期间修改配置、停用能力或轮换凭据后,该通话仍可使用旧配置创建新任务。B-004 声明任务提交后的快照冻结,但没有声明提交前配置已经固定于语音连接;B-020 也未说明有效性检查只在解析时进行。", + "class": "semantic" + }, + { + "site": "backend/services/task_backend_resources.py:135; backend/services/config_resolver.py:328; backend/routes/task_backends.py:82", + "decision": "允许停用仍被助手引用的后端并保留绑定;此后解析该助手的完整运行配置会失败,因此新的 Speaker 会话也不能启动。", + "why_it_matters": "停用任务后端的影响范围是整个已绑定助手的新会话,而不仅是任务委派。规格和假设记录没有声明这一影响范围。", + "class": "semantic" + }, + { + "site": "backend/services/background_tasks/reasoner.py:148; backend/services/background_tasks/reasoner.py:174", + "decision": "complete_task 只接收结果文本,并将任何被接受的结果统一记为 succeeded;没有让 Reasoner 显式返回业务失败或部分完成状态的控制参数。", + "why_it_matters": "模型即使通过结果文本说明目标无法完成,调用 complete_task 后任务卡片仍显示成功。B-010 声明了显式完成控制,但未定义 succeeded 表示结束执行还是业务目标达成。", + "class": "semantic" + } + ], + "stale_rows": [ + { + "id": "B-020", + "site": "backend/routes/task_backends.py:37; backend/routes/task_backends.py:82", + "stated": "资源编辑验证已绑定助手,模型/工具失效时阻止保存。", + "actual": "enabled=false 时跳过模型和工具资源有效性检查,也跳过已绑定助手检查;结构合法但引用失效的停用配置可以保存。启用状态下的检查与记录一致。", + "why_it_matters": "记录没有声明停用资源的保存例外,因此描述的配置有效性保证强于实际代码。", + "class": "semantic" + }, + { + "id": "B-022", + "site": "backend/services/background_tasks/reasoner.py:107; backend/services/background_tasks/opencode.py:91; backend/services/runtime_variables.py:155", + "stated": "助手绑定 instructions 的变量使用任务接受时冻结的变量单次渲染。", + "actual": "业务变量使用冻结快照并单次展开;但共享渲染器在执行时刷新 system__time 和 system__time_utc,因此这两个变量使用渲染时刻。最新 OpenCode 可选变量修复正确保留了既有可选变量语义,不改变此时钟行为。", + "why_it_matters": "任务排队后,包含系统时间的指令会得到执行时刻,而不是接受任务时刻;记录需要明确这个例外。", + "class": "semantic" + } + ], + "semantic_undeclared": 3, + "verdict": "gaps" +} diff --git a/implement-stage/task-backends/frontend-smoke.json b/implement-stage/task-backends/frontend-smoke.json new file mode 100644 index 0000000..77bdf80 --- /dev/null +++ b/implement-stage/task-backends/frontend-smoke.json @@ -0,0 +1,176 @@ +{ + "status": "passed_with_limitations", + "method": "Headless Google Chrome via temporary Playwright Core; built Next frontend at localhost:3217", + "mock_boundary": "Every /api request intercepted with explicit in-memory fixtures; non-local requests aborted. No backend, real model, OpenCode, database or real credentials contacted.", + "prior_attempts": [ + { + "outcome": "Reasoner and OpenCode saves passed; narrow resource row edit was obstructed with existing expanded sidebar at390px. Repeated using existing sidebar collapse control; no application code changed." + } + ], + "fixtures": { + "models": [ + "UI Strong Model: supportsReasoningEffort=true", + "UI Basic Model: supportsReasoningEffort=false" + ], + "tools": [ + "UI HTTP Tool", + "UI Client Tool (must be excluded from Reasoner picker)" + ] + }, + "steps": [ + { + "name": "Render dedicated resources page and open Reasoner form", + "status": "passed", + "details": { + "desktop_layout": { + "viewport": 1440, + "width": 751.712158203125, + "left": 344.1438903808594, + "right": 1095.8560485839844, + "scrollWidth": 768, + "clientWidth": 768, + "horizontalOverflow": false, + "outsideViewport": false + } + } + }, + { + "name": "Configure Reasoner model, high effort, inline skill and server-only tool", + "status": "passed", + "details": { + "captured_payload": { + "name": "UI Reasoner Smoke", + "description": "", + "enabled": true, + "type": "reasoner", + "secrets": {}, + "config": { + "instructions": "", + "timeoutSeconds": 600, + "maxConcurrency": 1, + "modelResourceId": "ui_model_strong", + "reasoningEffort": "high", + "toolBindings": [ + { + "toolId": "ui_http", + "argumentBindings": { + "query": { + "source": "model" + } + }, + "templateBindings": {}, + "resultAssignments": {} + } + ], + "skills": [ + { + "name": "UI Analysis", + "content": "Analyze constraints before proposing a result." + } + ] + } + } + } + }, + { + "name": "Edit preserves Reasoner values; unsupported model disables nondefault effort", + "status": "passed" + }, + { + "name": "Configure OpenCode connection, agent, paired model and remote skill note", + "status": "passed", + "details": { + "captured_payload": { + "name": "UI OpenCode Smoke", + "description": "", + "enabled": true, + "type": "opencode", + "secrets": { + "password": "" + }, + "config": { + "instructions": "", + "timeoutSeconds": 600, + "maxConcurrency": 1, + "url": "http://127.0.0.1:9", + "directory": "/test-fixture/repo", + "username": "opencode", + "agent": "build", + "modelProviderId": "ui-provider", + "modelId": "ui-model", + "skillsNote": "Installed remotely; fixture note only." + } + } + } + }, + { + "name": "Responsive resource page and OpenCode modal fit a 390px viewport", + "status": "passed", + "details": { + "page_layout": { + "viewport": 390, + "width": 390, + "left": 0, + "right": 390, + "scrollWidth": 390, + "clientWidth": 390, + "horizontalOverflow": false, + "outsideViewport": false + }, + "dialog_layout": { + "viewport": 390, + "width": 340.0999755859375, + "left": 24.950002670288086, + "right": 365.0499782562256, + "scrollWidth": 358, + "clientWidth": 358, + "horizontalOverflow": false, + "outsideViewport": false + } + } + }, + { + "name": "Prompt binds backend independently and sends taskBackend without ToolBinding", + "status": "passed", + "details": { + "captured_task_backend": { + "backendId": "ui_backend_1", + "instructions": "Return clear evidence and limitations.", + "instructionSource": { + "source": "model" + } + }, + "ordinary_tool_bindings": [] + } + }, + { + "name": "Migrated variable instruction source survives fetch/edit/save unchanged", + "status": "incomplete", + "error": "Optional legacy-variable fetch/edit/save follow-up timed out waiting for the task-instruction editor after page reload. Not diagnosed; this browser run does not claim to verify that scenario." + } + ], + "console_errors": [], + "page_errors": [], + "unexpected_requests": [], + "captures": [ + "/private/tmp/ai-video-browser-smoke/reasoner-desktop.png", + "/private/tmp/ai-video-browser-smoke/opencode-desktop.png", + "/private/tmp/ai-video-browser-smoke/expanded-sidebar-mobile-limitation.png", + "/private/tmp/ai-video-browser-smoke/opencode-mobile.png", + "/private/tmp/ai-video-browser-smoke/prompt-binding-desktop.png" + ], + "limitations": [ + "Browser smoke verifies rendered client behavior and request payloads against mocked APIs, not backend validation or genuine model/coding execution.", + "No microphone/WebRTC connection was started.", + "Responsive checks cover new resource page/dialog with the existing sidebar manually collapsed. Existing AppShell does not auto-collapse its252px sidebar on resize; the expanded sidebar obstructed row edit at390px.", + "Optional migrated variable instructionSource reload was not verified in this browser run; no backend or migration behavior is inferred from it.", + "Screenshots are diagnostic captures; mobile capture occurred during modal transition. Geometry checks, not pixel comparison, established the responsive result." + ], + "completed_at": "2026-10-24T01:25:20.247Z", + "acceptance": "Core frontend browser smoke passed against explicitly mocked APIs; optional legacy variable reload remains unverified.", + "cleanup": { + "temporary_browser_closed": true, + "local_next_server_stopped": true, + "project_dependencies_changed": false + } +} diff --git a/implement-stage/task-backends/sweep-metadata.json b/implement-stage/task-backends/sweep-metadata.json new file mode 100644 index 0000000..e1631d5 --- /dev/null +++ b/implement-stage/task-backends/sweep-metadata.json @@ -0,0 +1,7 @@ +{ + "review_independence": "same-family", + "acceptance_status": "provisional", + "round_limit": 2, + "rounds_completed": 1, + "first_round_semantic_undeclared": 3 +}