@meiyukichan/dsh-sandore 0.1.0-rc.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/cordis.patch.yml +39 -0
- package/dsh.plugin.json +16 -0
- package/lib/client/CommandEditor.d.ts +19 -0
- package/lib/client/DagEditor.d.ts +22 -0
- package/lib/client/DefinitionDetail.d.ts +13 -0
- package/lib/client/DefinitionList.d.ts +19 -0
- package/lib/client/DiscussionInput.d.ts +63 -0
- package/lib/client/DragHandle.d.ts +16 -0
- package/lib/client/EditorPane.d.ts +13 -0
- package/lib/client/FlowEditor.d.ts +31 -0
- package/lib/client/InstanceList.d.ts +24 -0
- package/lib/client/NewInstanceDialog.d.ts +19 -0
- package/lib/client/PreviewPanel.d.ts +13 -0
- package/lib/client/RunView.d.ts +79 -0
- package/lib/client/StepChatPanel.d.ts +34 -0
- package/lib/client/TreeNav.d.ts +27 -0
- package/lib/client/UnifiedTimeline.d.ts +11 -0
- package/lib/client/WorkflowButton.d.ts +11 -0
- package/lib/client/WorkflowList.d.ts +18 -0
- package/lib/client/WorkflowOverlay.d.ts +6 -0
- package/lib/client/chat/AssistantMarkdown.d.ts +22 -0
- package/lib/client/chat/CompactionItem.d.ts +24 -0
- package/lib/client/chat/ContextBody.d.ts +136 -0
- package/lib/client/chat/ContextInjectionRow.d.ts +25 -0
- package/lib/client/chat/GenericCommandCard.d.ts +10 -0
- package/lib/client/chat/MessageIconActions.d.ts +41 -0
- package/lib/client/chat/MessageItem.d.ts +65 -0
- package/lib/client/chat/ReasoningRow.d.ts +13 -0
- package/lib/client/chat/TurnTailNodeView.d.ts +29 -0
- package/lib/client/chat/UserMessageRow.d.ts +18 -0
- package/lib/client/chat/attachment/ImageLightbox.d.ts +26 -0
- package/lib/client/chat/attachment/MessageImage.d.ts +56 -0
- package/lib/client/chat/attachment/image-attachment.d.ts +41 -0
- package/lib/client/chat/attachment/image-labels.d.ts +21 -0
- package/lib/client/chat/message-chrome.d.ts +51 -0
- package/lib/client/chat/reference/ReferenceIcon.d.ts +20 -0
- package/lib/client/chat/turn-assistant.d.ts +12 -0
- package/lib/client/chat/turn-metrics.d.ts +42 -0
- package/lib/client/chat/use-calendar-day.d.ts +5 -0
- package/lib/client/chat/use-throttled-visual-update.d.ts +7 -0
- package/lib/client/chat-flow.d.ts +110 -0
- package/lib/client/composer-selects.d.ts +17 -0
- package/lib/client/dag-editor-model.d.ts +141 -0
- package/lib/client/describe-error.d.ts +9 -0
- package/lib/client/index.d.ts +6 -0
- package/lib/client/locales.d.ts +777 -0
- package/lib/client/model-faces.d.ts +156 -0
- package/lib/client/open-discussion.d.ts +24 -0
- package/lib/client/panel-model.d.ts +34 -0
- package/lib/client/remote.d.ts +34 -0
- package/lib/client/run-view-model.d.ts +304 -0
- package/lib/client/sandore-save.d.ts +41 -0
- package/lib/client/sandore-sse.d.ts +15 -0
- package/lib/client/step-chat-retry.d.ts +44 -0
- package/lib/client/tool/GenericToolCard.d.ts +28 -0
- package/lib/client/tool/ToolRow.d.ts +95 -0
- package/lib/client/tool/diff-card-model.d.ts +63 -0
- package/lib/client/tool/read-card-model.d.ts +67 -0
- package/lib/client/tool/search-card-model.d.ts +94 -0
- package/lib/client/tool/terminal-card-model.d.ts +86 -0
- package/lib/client/tool/tool-call-model.d.ts +74 -0
- package/lib/client/tool/web-card-model.d.ts +44 -0
- package/lib/client/use-focus-refresh.d.ts +1 -0
- package/lib/client/workflow-face.d.ts +68 -0
- package/lib/client/workflow-store.d.ts +80 -0
- package/lib/client/workspace-path.d.ts +15 -0
- package/lib/client.js +38389 -0
- package/lib/index.js +8854 -0
- package/lib/microflow/branch-evaluator.d.ts +290 -0
- package/lib/microflow/concurrency-limiter.d.ts +39 -0
- package/lib/microflow/errors.d.ts +39 -0
- package/lib/microflow/file-utils.d.ts +210 -0
- package/lib/microflow/finalize-manager.d.ts +414 -0
- package/lib/microflow/fork-session.d.ts +151 -0
- package/lib/microflow/index.d.ts +17 -0
- package/lib/microflow/internal.d.ts +56 -0
- package/lib/microflow/loop-manager.d.ts +127 -0
- package/lib/microflow/rules-resolver.d.ts +28 -0
- package/lib/microflow/stage-executor.d.ts +362 -0
- package/lib/microflow/step-navigator.d.ts +108 -0
- package/lib/microflow/step-runner.d.ts +190 -0
- package/lib/microflow/subagents.d.ts +380 -0
- package/lib/tool/index.d.ts +65 -0
- package/lib/typert.host.d.ts +16 -0
- package/lib/typert.host.js +25 -0
- package/lib/typert.remote-client.d.ts +114 -0
- package/lib/typert.remote-client.js +399 -0
- package/lib/workflow/ask-tool.d.ts +78 -0
- package/lib/workflow/dag-navigator.d.ts +136 -0
- package/lib/workflow/domain.d.ts +304 -0
- package/lib/workflow/engine-surface.d.ts +70 -0
- package/lib/workflow/errors.d.ts +13 -0
- package/lib/workflow/host-agent.d.ts +53 -0
- package/lib/workflow/index.d.ts +23 -0
- package/lib/workflow/interaction-manager.d.ts +170 -0
- package/lib/workflow/lifecycle-manager.d.ts +111 -0
- package/lib/workflow/persistence.d.ts +35 -0
- package/lib/workflow/plugin.d.ts +27 -0
- package/lib/workflow/reactive-state.d.ts +27 -0
- package/lib/workflow/recovery-manager.d.ts +158 -0
- package/lib/workflow/remote.d.ts +139 -0
- package/lib/workflow/schema.d.ts +1917 -0
- package/lib/workflow/seam.d.ts +254 -0
- package/lib/workflow/shell.d.ts +127 -0
- package/lib/workflow/sse.d.ts +16 -0
- package/lib/workflow/typert/typert.host.d.ts +16 -0
- package/lib/workflow/typert/typert.remote-client.d.ts +114 -0
- package/lib/workflow/types.d.ts +187 -0
- package/lib/workflow/wire.d.ts +256 -0
- package/lib/workflow/workflow-engine.d.ts +373 -0
- package/lib/workflow/yaml-error.d.ts +22 -0
- package/lib/workflow/yaml-parser.d.ts +23 -0
- package/package.json +140 -0
- package/packages/client/CommandEditor.tsx +73 -0
- package/packages/client/DagEditor.module.css +711 -0
- package/packages/client/DagEditor.tsx +1203 -0
- package/packages/client/DefinitionDetail.module.css +110 -0
- package/packages/client/DefinitionDetail.tsx +126 -0
- package/packages/client/DefinitionList.tsx +173 -0
- package/packages/client/DiscussionInput.tsx +763 -0
- package/packages/client/DragHandle.tsx +92 -0
- package/packages/client/EditorPane.tsx +70 -0
- package/packages/client/FlowEditor.tsx +227 -0
- package/packages/client/InstanceList.tsx +250 -0
- package/packages/client/NewInstanceDialog.module.css +244 -0
- package/packages/client/NewInstanceDialog.tsx +478 -0
- package/packages/client/PreviewPanel.tsx +55 -0
- package/packages/client/RunView.module.css +246 -0
- package/packages/client/RunView.tsx +314 -0
- package/packages/client/StepChatPanel.tsx +486 -0
- package/packages/client/TreeNav.tsx +294 -0
- package/packages/client/UnifiedTimeline.tsx +484 -0
- package/packages/client/WorkflowButton.module.css +72 -0
- package/packages/client/WorkflowButton.tsx +50 -0
- package/packages/client/WorkflowList.tsx +158 -0
- package/packages/client/WorkflowOverlay.module.css +390 -0
- package/packages/client/WorkflowOverlay.tsx +136 -0
- package/packages/client/WorkflowPanel.module.css +247 -0
- package/packages/client/chat/AssistantMarkdown.module.css +38 -0
- package/packages/client/chat/AssistantMarkdown.tsx +120 -0
- package/packages/client/chat/ChatView.module.css +198 -0
- package/packages/client/chat/CompactionItem.tsx +85 -0
- package/packages/client/chat/ContextBody.module.css +162 -0
- package/packages/client/chat/ContextBody.tsx +598 -0
- package/packages/client/chat/ContextInjectionRow.module.css +63 -0
- package/packages/client/chat/ContextInjectionRow.tsx +78 -0
- package/packages/client/chat/GenericCommandCard.module.css +87 -0
- package/packages/client/chat/GenericCommandCard.tsx +76 -0
- package/packages/client/chat/MessageIconActions.module.css +84 -0
- package/packages/client/chat/MessageIconActions.tsx +147 -0
- package/packages/client/chat/MessageItem.module.css +293 -0
- package/packages/client/chat/MessageItem.tsx +384 -0
- package/packages/client/chat/ReasoningRow.module.css +82 -0
- package/packages/client/chat/ReasoningRow.tsx +70 -0
- package/packages/client/chat/TurnTailNodeView.module.css +10 -0
- package/packages/client/chat/TurnTailNodeView.tsx +63 -0
- package/packages/client/chat/UserMessageRow.module.css +75 -0
- package/packages/client/chat/UserMessageRow.tsx +92 -0
- package/packages/client/chat/accessibility.module.css +9 -0
- package/packages/client/chat/attachment/ImageLightbox.module.css +45 -0
- package/packages/client/chat/attachment/ImageLightbox.tsx +70 -0
- package/packages/client/chat/attachment/MessageImage.module.css +70 -0
- package/packages/client/chat/attachment/MessageImage.tsx +136 -0
- package/packages/client/chat/attachment/image-attachment.ts +98 -0
- package/packages/client/chat/attachment/image-labels.ts +34 -0
- package/packages/client/chat/message-chrome.ts +96 -0
- package/packages/client/chat/reference/ReferenceIcon.tsx +40 -0
- package/packages/client/chat/turn-assistant.ts +15 -0
- package/packages/client/chat/turn-metrics.ts +102 -0
- package/packages/client/chat/use-calendar-day.ts +27 -0
- package/packages/client/chat/use-throttled-visual-update.ts +44 -0
- package/packages/client/chat-flow.spec.ts +357 -0
- package/packages/client/chat-flow.ts +278 -0
- package/packages/client/composer-selects.module.css +376 -0
- package/packages/client/composer-selects.tsx +531 -0
- package/packages/client/dag-editor-model.spec.ts +173 -0
- package/packages/client/dag-editor-model.ts +359 -0
- package/packages/client/describe-error.spec.ts +18 -0
- package/packages/client/describe-error.ts +12 -0
- package/packages/client/index.ts +144 -0
- package/packages/client/input.module.css +854 -0
- package/packages/client/is-conditional-check.spec.ts +34 -0
- package/packages/client/locales.ts +789 -0
- package/packages/client/model-faces.ts +225 -0
- package/packages/client/open-discussion.spec.ts +39 -0
- package/packages/client/open-discussion.ts +32 -0
- package/packages/client/panel-model.spec.ts +152 -0
- package/packages/client/panel-model.ts +72 -0
- package/packages/client/preview.module.css +95 -0
- package/packages/client/react-dom.d.ts +11 -0
- package/packages/client/reactflow-theme.css +91 -0
- package/packages/client/remote.spec.ts +41 -0
- package/packages/client/remote.ts +46 -0
- package/packages/client/run-view-model.spec.ts +641 -0
- package/packages/client/run-view-model.ts +636 -0
- package/packages/client/sandore-save.spec.ts +146 -0
- package/packages/client/sandore-save.ts +97 -0
- package/packages/client/sandore-sse.ts +59 -0
- package/packages/client/step-chat-retry.spec.ts +70 -0
- package/packages/client/step-chat-retry.ts +65 -0
- package/packages/client/timeline.module.css +503 -0
- package/packages/client/tool/GenericToolCard.tsx +99 -0
- package/packages/client/tool/ToolRow.module.css +308 -0
- package/packages/client/tool/ToolRow.tsx +328 -0
- package/packages/client/tool/diff-card-model.ts +103 -0
- package/packages/client/tool/read-card-model.ts +86 -0
- package/packages/client/tool/search-card-model.ts +166 -0
- package/packages/client/tool/terminal-card-model.ts +225 -0
- package/packages/client/tool/tool-call-model.ts +267 -0
- package/packages/client/tool/web-card-model.ts +80 -0
- package/packages/client/tree-nav.module.css +282 -0
- package/packages/client/use-focus-refresh.ts +23 -0
- package/packages/client/workflow-face.ts +72 -0
- package/packages/client/workflow-store.spec.ts +49 -0
- package/packages/client/workflow-store.ts +161 -0
- package/packages/client/workspace-path.ts +27 -0
- package/packages/css-modules.d.ts +6 -0
- package/packages/microflow/branch-evaluator.spec.ts +745 -0
- package/packages/microflow/branch-evaluator.ts +1037 -0
- package/packages/microflow/concurrency-limiter.ts +70 -0
- package/packages/microflow/errors.ts +64 -0
- package/packages/microflow/file-utils.spec.ts +604 -0
- package/packages/microflow/file-utils.ts +521 -0
- package/packages/microflow/finalize-manager.spec.ts +1977 -0
- package/packages/microflow/finalize-manager.ts +1464 -0
- package/packages/microflow/fork-session.spec.ts +184 -0
- package/packages/microflow/fork-session.ts +316 -0
- package/packages/microflow/index.ts +17 -0
- package/packages/microflow/internal.ts +166 -0
- package/packages/microflow/loop-manager.spec.ts +294 -0
- package/packages/microflow/loop-manager.ts +255 -0
- package/packages/microflow/rules-resolver.spec.ts +147 -0
- package/packages/microflow/rules-resolver.ts +58 -0
- package/packages/microflow/stage-executor.spec.ts +1577 -0
- package/packages/microflow/stage-executor.ts +1478 -0
- package/packages/microflow/step-navigator.spec.ts +301 -0
- package/packages/microflow/step-navigator.ts +300 -0
- package/packages/microflow/step-runner.spec.ts +1132 -0
- package/packages/microflow/step-runner.ts +752 -0
- package/packages/microflow/subagents.spec.ts +215 -0
- package/packages/microflow/subagents.ts +422 -0
- package/packages/tests/fixtures/test-workflow.yaml +128 -0
- package/packages/tests/mocks/agents.ts +206 -0
- package/packages/tests/mocks/llm.ts +46 -0
- package/packages/tests/mocks/session-title.ts +31 -0
- package/packages/tests/mocks/sessions.ts +131 -0
- package/packages/tests/mocks/shell.ts +60 -0
- package/packages/tests/mocks/storage-domain.ts +101 -0
- package/packages/tests/mocks/subagents.ts +197 -0
- package/packages/tests/mocks/subprocess.ts +59 -0
- package/packages/tests/mocks/user-questions.ts +33 -0
- package/packages/tests/mocks/workspace.ts +52 -0
- package/packages/tool/index.spec.ts +287 -0
- package/packages/tool/index.ts +348 -0
- package/packages/workflow/ask-tool.ts +173 -0
- package/packages/workflow/dag-navigator.spec.ts +236 -0
- package/packages/workflow/dag-navigator.ts +425 -0
- package/packages/workflow/domain.ts +26 -0
- package/packages/workflow/engine-surface.ts +71 -0
- package/packages/workflow/errors.ts +13 -0
- package/packages/workflow/host-agent.spec.ts +92 -0
- package/packages/workflow/host-agent.ts +62 -0
- package/packages/workflow/index.ts +25 -0
- package/packages/workflow/interaction-manager.spec.ts +390 -0
- package/packages/workflow/interaction-manager.ts +397 -0
- package/packages/workflow/lifecycle-manager.spec.ts +683 -0
- package/packages/workflow/lifecycle-manager.ts +288 -0
- package/packages/workflow/persistence.spec.ts +109 -0
- package/packages/workflow/persistence.ts +64 -0
- package/packages/workflow/plugin.ts +39 -0
- package/packages/workflow/reactive-state.spec.ts +69 -0
- package/packages/workflow/reactive-state.ts +52 -0
- package/packages/workflow/recovery-manager.spec.ts +606 -0
- package/packages/workflow/recovery-manager.ts +471 -0
- package/packages/workflow/remote.spec.ts +1045 -0
- package/packages/workflow/remote.ts +479 -0
- package/packages/workflow/schema.spec.ts +303 -0
- package/packages/workflow/schema.ts +562 -0
- package/packages/workflow/seam.ts +293 -0
- package/packages/workflow/shell.ts +142 -0
- package/packages/workflow/sse.ts +110 -0
- package/packages/workflow/typert/typert.host.d.ts +16 -0
- package/packages/workflow/typert/typert.host.js +25 -0
- package/packages/workflow/typert/typert.remote-client.d.ts +114 -0
- package/packages/workflow/typert/typert.remote-client.js +399 -0
- package/packages/workflow/types.ts +258 -0
- package/packages/workflow/wire.ts +267 -0
- package/packages/workflow/workflow-engine-e2e.spec.ts +262 -0
- package/packages/workflow/workflow-engine-real-e2e.spec.ts +922 -0
- package/packages/workflow/workflow-engine.spec.ts +4389 -0
- package/packages/workflow/workflow-engine.ts +2133 -0
- package/packages/workflow/yaml-error.ts +29 -0
- package/packages/workflow/yaml-parser.spec.ts +757 -0
- package/packages/workflow/yaml-parser.ts +287 -0
|
@@ -0,0 +1,4389 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* workflow-engine.ts(SandoreEngineRuntime)的集成测试:线性执行到 completed、
|
|
3
|
+
* 事件序、双发可观测、childId/sessionMeta、失败/中止终态与持久化快照。
|
|
4
|
+
* @module dsh-sandore/workflow/src/workflow-engine.spec
|
|
5
|
+
*/
|
|
6
|
+
import { Context } from '@deepseek-ai/cordis';
|
|
7
|
+
import type { LlmRuntime, TextBlock } from '@deepseek-ai/dsh-llm';
|
|
8
|
+
import type { SessionStore } from '@deepseek-ai/dsh-session';
|
|
9
|
+
import type { DomainFacility } from '@deepseek-ai/dsh-storage-domain';
|
|
10
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
11
|
+
|
|
12
|
+
import { assistantMessageEvent, FakeAgent, FakeAgentRegistry } from '../tests/mocks/agents.ts';
|
|
13
|
+
import { FakeLlm, textChunks } from '../tests/mocks/llm.ts';
|
|
14
|
+
import { FakeSessionStore } from '../tests/mocks/sessions.ts';
|
|
15
|
+
import { FakeSessionTitle } from '../tests/mocks/session-title.ts';
|
|
16
|
+
import { FakeShell, successResult } from '../tests/mocks/shell.ts';
|
|
17
|
+
import { MemoryStorageDomain } from '../tests/mocks/storage-domain.ts';
|
|
18
|
+
import { FakeSubagents } from '../tests/mocks/subagents.ts';
|
|
19
|
+
import { FakeSubprocess } from '../tests/mocks/subprocess.ts';
|
|
20
|
+
import { FakeUserQuestions } from '../tests/mocks/user-questions.ts';
|
|
21
|
+
import { FakeWorkspace, FakeWorkspaceRegistry } from '../tests/mocks/workspace.ts';
|
|
22
|
+
import { SandoreError } from './errors.ts';
|
|
23
|
+
import { SandoreEngineRuntime, SandoreEngineStub } from './index.ts';
|
|
24
|
+
import type { SandoreConfig, SandoreEngine, WorkflowTaskState } from './index.ts';
|
|
25
|
+
import { SandorePersistence } from './persistence.ts';
|
|
26
|
+
import type { ShellExecutor } from './shell.ts';
|
|
27
|
+
import { parseWorkflowYaml } from './yaml-parser.ts';
|
|
28
|
+
|
|
29
|
+
/** UUID 正则(合成 sessionId 统一纯 UUID 格式)。 */
|
|
30
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
31
|
+
|
|
32
|
+
/** 两 Stage(shell → prompt)线性定义。 */
|
|
33
|
+
const linearYaml = `id: wfl-linear
|
|
34
|
+
name: linear
|
|
35
|
+
version: "1.0.0"
|
|
36
|
+
stages:
|
|
37
|
+
- id: stg-1
|
|
38
|
+
name: 分析
|
|
39
|
+
steps:
|
|
40
|
+
- id: step-abc
|
|
41
|
+
- id: stg-2
|
|
42
|
+
name: 推理
|
|
43
|
+
steps:
|
|
44
|
+
- id: step-prompt
|
|
45
|
+
steps:
|
|
46
|
+
- id: step-abc
|
|
47
|
+
command:
|
|
48
|
+
type: shell
|
|
49
|
+
value:
|
|
50
|
+
command: "echo hello"
|
|
51
|
+
- id: step-prompt
|
|
52
|
+
command:
|
|
53
|
+
type: prompt
|
|
54
|
+
value:
|
|
55
|
+
promptText: "summarize"
|
|
56
|
+
`;
|
|
57
|
+
|
|
58
|
+
/** Stage 级条件分支:stg-entry 完成后按 branch.condition 选下一 Stage
|
|
59
|
+
* (验证 tryAdvanceStage 评估 Stage branch.condition,不再盲取首个 Stage 目标)。 */
|
|
60
|
+
const stageBranchYaml = `id: wfl-stage-branch
|
|
61
|
+
name: stage-branch
|
|
62
|
+
version: "1.0.0"
|
|
63
|
+
stages:
|
|
64
|
+
- id: stg-entry
|
|
65
|
+
name: 入口
|
|
66
|
+
steps:
|
|
67
|
+
- id: step-entry
|
|
68
|
+
branches:
|
|
69
|
+
- condition:
|
|
70
|
+
type: prompt
|
|
71
|
+
value:
|
|
72
|
+
promptText: "判断是否进入分支B"
|
|
73
|
+
next: stg-b
|
|
74
|
+
- next: stg-default
|
|
75
|
+
- id: stg-b
|
|
76
|
+
name: 分支B
|
|
77
|
+
steps:
|
|
78
|
+
- id: step-b
|
|
79
|
+
- id: stg-c
|
|
80
|
+
name: 分支C
|
|
81
|
+
steps:
|
|
82
|
+
- id: step-c
|
|
83
|
+
- id: stg-default
|
|
84
|
+
name: 默认
|
|
85
|
+
steps:
|
|
86
|
+
- id: step-default
|
|
87
|
+
steps:
|
|
88
|
+
- id: step-entry
|
|
89
|
+
command:
|
|
90
|
+
type: shell
|
|
91
|
+
value:
|
|
92
|
+
command: "echo entry"
|
|
93
|
+
- id: step-b
|
|
94
|
+
command:
|
|
95
|
+
type: shell
|
|
96
|
+
value:
|
|
97
|
+
command: "echo b"
|
|
98
|
+
- id: step-c
|
|
99
|
+
command:
|
|
100
|
+
type: shell
|
|
101
|
+
value:
|
|
102
|
+
command: "echo c"
|
|
103
|
+
- id: step-default
|
|
104
|
+
command:
|
|
105
|
+
type: shell
|
|
106
|
+
value:
|
|
107
|
+
command: "echo default"
|
|
108
|
+
`;
|
|
109
|
+
|
|
110
|
+
/** Stage 级多条件分支(无默认分支):entry→[condA→stg-a, condB→stg-b],
|
|
111
|
+
* stageOrder=[entry, mid, a, b],数组下一 of entry=stg-mid(≠ 条件目标,便于区分条件命中与兜底)。 */
|
|
112
|
+
const stageBranchMultiYaml = `id: wfl-stage-multi
|
|
113
|
+
name: stage-multi
|
|
114
|
+
version: "1.0.0"
|
|
115
|
+
stages:
|
|
116
|
+
- id: stg-entry
|
|
117
|
+
name: 入口
|
|
118
|
+
steps:
|
|
119
|
+
- id: step-entry
|
|
120
|
+
branches:
|
|
121
|
+
- condition:
|
|
122
|
+
type: prompt
|
|
123
|
+
value:
|
|
124
|
+
promptText: "判断是否进入A"
|
|
125
|
+
next: stg-a
|
|
126
|
+
- condition:
|
|
127
|
+
type: prompt
|
|
128
|
+
value:
|
|
129
|
+
promptText: "判断是否进入B"
|
|
130
|
+
next: stg-b
|
|
131
|
+
- id: stg-mid
|
|
132
|
+
name: 中转
|
|
133
|
+
steps:
|
|
134
|
+
- id: step-mid
|
|
135
|
+
- id: stg-a
|
|
136
|
+
name: A
|
|
137
|
+
steps:
|
|
138
|
+
- id: step-a
|
|
139
|
+
- id: stg-b
|
|
140
|
+
name: B
|
|
141
|
+
steps:
|
|
142
|
+
- id: step-b
|
|
143
|
+
steps:
|
|
144
|
+
- id: step-entry
|
|
145
|
+
command:
|
|
146
|
+
type: shell
|
|
147
|
+
value:
|
|
148
|
+
command: "echo entry"
|
|
149
|
+
- id: step-mid
|
|
150
|
+
command:
|
|
151
|
+
type: shell
|
|
152
|
+
value:
|
|
153
|
+
command: "echo mid"
|
|
154
|
+
- id: step-a
|
|
155
|
+
command:
|
|
156
|
+
type: shell
|
|
157
|
+
value:
|
|
158
|
+
command: "echo a"
|
|
159
|
+
- id: step-b
|
|
160
|
+
command:
|
|
161
|
+
type: shell
|
|
162
|
+
value:
|
|
163
|
+
command: "echo b"
|
|
164
|
+
`;
|
|
165
|
+
|
|
166
|
+
/** 单 Stage 多 Step(A 输出被 B 引用)定义。 */
|
|
167
|
+
const multiStepYaml = `id: wfl-multi
|
|
168
|
+
name: multi
|
|
169
|
+
version: "1.0.0"
|
|
170
|
+
stages:
|
|
171
|
+
- id: stg-1
|
|
172
|
+
name: 处理
|
|
173
|
+
steps:
|
|
174
|
+
- id: step-a
|
|
175
|
+
- id: step-b
|
|
176
|
+
steps:
|
|
177
|
+
- id: step-a
|
|
178
|
+
command:
|
|
179
|
+
type: shell
|
|
180
|
+
value:
|
|
181
|
+
command: "echo hello"
|
|
182
|
+
outputTo:
|
|
183
|
+
- id: opt-a
|
|
184
|
+
path: "out.txt"
|
|
185
|
+
- id: step-b
|
|
186
|
+
command:
|
|
187
|
+
type: prompt
|
|
188
|
+
value:
|
|
189
|
+
promptText: "summarize"
|
|
190
|
+
inputFrom:
|
|
191
|
+
- id: in-a
|
|
192
|
+
path: "step-a"
|
|
193
|
+
`;
|
|
194
|
+
|
|
195
|
+
/** 单 Stage 两个 shell Step(用于第二步失败断言)。 */
|
|
196
|
+
const twoShellYaml = `id: wfl-two-shell
|
|
197
|
+
name: two-shell
|
|
198
|
+
version: "1.0.0"
|
|
199
|
+
stages:
|
|
200
|
+
- id: stg-1
|
|
201
|
+
name: 处理
|
|
202
|
+
steps:
|
|
203
|
+
- id: step-a
|
|
204
|
+
- id: step-b
|
|
205
|
+
steps:
|
|
206
|
+
- id: step-a
|
|
207
|
+
command:
|
|
208
|
+
type: shell
|
|
209
|
+
value:
|
|
210
|
+
command: "echo ok"
|
|
211
|
+
- id: step-b
|
|
212
|
+
command:
|
|
213
|
+
type: shell
|
|
214
|
+
value:
|
|
215
|
+
command: "false"
|
|
216
|
+
`;
|
|
217
|
+
|
|
218
|
+
/** 单 Stage 单 shell Step(用于崩溃恢复重跑:无需 llm 目录即可跑通)。 */
|
|
219
|
+
const shellOnlyYaml = `id: wfl-shell-only
|
|
220
|
+
name: shell-only
|
|
221
|
+
version: "1.0.0"
|
|
222
|
+
stages:
|
|
223
|
+
- id: stg-1
|
|
224
|
+
name: 单shell
|
|
225
|
+
steps:
|
|
226
|
+
- id: step-shell
|
|
227
|
+
steps:
|
|
228
|
+
- id: step-shell
|
|
229
|
+
command:
|
|
230
|
+
type: shell
|
|
231
|
+
value:
|
|
232
|
+
command: "echo hi"
|
|
233
|
+
`;
|
|
234
|
+
|
|
235
|
+
/** 单 Stage 两步线性 shell(step-a → step-b 均成功,无 branches 数组顺序兜底),供 between-steps 崩溃恢复测试。 */
|
|
236
|
+
const linearShellYaml = `id: wfl-linear-shell
|
|
237
|
+
name: linear-shell
|
|
238
|
+
version: "1.0.0"
|
|
239
|
+
stages:
|
|
240
|
+
- id: stg-1
|
|
241
|
+
name: 两步
|
|
242
|
+
steps:
|
|
243
|
+
- id: step-a
|
|
244
|
+
- id: step-b
|
|
245
|
+
steps:
|
|
246
|
+
- id: step-a
|
|
247
|
+
command:
|
|
248
|
+
type: shell
|
|
249
|
+
value:
|
|
250
|
+
command: "echo a"
|
|
251
|
+
- id: step-b
|
|
252
|
+
command:
|
|
253
|
+
type: shell
|
|
254
|
+
value:
|
|
255
|
+
command: "echo b"
|
|
256
|
+
`;
|
|
257
|
+
|
|
258
|
+
/** 完整 DAG(分支 + 并行 + 汇合 + 循环 + 门禁 + 透传变量 + agent 子代理)。 */
|
|
259
|
+
const dagYaml = `id: wfl-dag
|
|
260
|
+
name: dag
|
|
261
|
+
version: "1.0.0"
|
|
262
|
+
stages:
|
|
263
|
+
- id: stg-1
|
|
264
|
+
name: 分支
|
|
265
|
+
steps:
|
|
266
|
+
- id: step-branch
|
|
267
|
+
branches:
|
|
268
|
+
- condition:
|
|
269
|
+
type: prompt
|
|
270
|
+
value:
|
|
271
|
+
promptText: "route"
|
|
272
|
+
next: step-agent
|
|
273
|
+
- next: step-parallel
|
|
274
|
+
- id: step-agent
|
|
275
|
+
branches:
|
|
276
|
+
- next: step-merge
|
|
277
|
+
- id: step-parallel
|
|
278
|
+
branches:
|
|
279
|
+
- next: step-merge
|
|
280
|
+
- id: step-fallback
|
|
281
|
+
branches:
|
|
282
|
+
- next: step-merge
|
|
283
|
+
- id: step-merge
|
|
284
|
+
- id: stg-2
|
|
285
|
+
name: 循环
|
|
286
|
+
steps:
|
|
287
|
+
- id: step-loop
|
|
288
|
+
branches:
|
|
289
|
+
- next: step-iter
|
|
290
|
+
loop:
|
|
291
|
+
maxIterations: 3
|
|
292
|
+
next: step-iter
|
|
293
|
+
exitTo: step-finalize
|
|
294
|
+
- id: step-iter
|
|
295
|
+
branches:
|
|
296
|
+
- next: step-loop
|
|
297
|
+
- id: step-finalize
|
|
298
|
+
steps:
|
|
299
|
+
- id: step-branch
|
|
300
|
+
- id: step-agent
|
|
301
|
+
command:
|
|
302
|
+
type: agent
|
|
303
|
+
value:
|
|
304
|
+
promptText: "do-work"
|
|
305
|
+
- id: step-parallel
|
|
306
|
+
command:
|
|
307
|
+
type: shell
|
|
308
|
+
value:
|
|
309
|
+
command: "echo parallel"
|
|
310
|
+
- id: step-fallback
|
|
311
|
+
command:
|
|
312
|
+
type: shell
|
|
313
|
+
value:
|
|
314
|
+
command: "echo fallback"
|
|
315
|
+
- id: step-merge
|
|
316
|
+
command:
|
|
317
|
+
type: shell
|
|
318
|
+
value:
|
|
319
|
+
command: "echo merge"
|
|
320
|
+
- id: step-loop
|
|
321
|
+
- id: step-iter
|
|
322
|
+
command:
|
|
323
|
+
type: shell
|
|
324
|
+
value:
|
|
325
|
+
command: "echo iter"
|
|
326
|
+
- id: step-finalize
|
|
327
|
+
command:
|
|
328
|
+
type: prompt
|
|
329
|
+
value:
|
|
330
|
+
promptText: "report \${projectName}"
|
|
331
|
+
passThroughVars:
|
|
332
|
+
- name: projectName
|
|
333
|
+
value: "\${projectName}"
|
|
334
|
+
description: "项目名"
|
|
335
|
+
gates:
|
|
336
|
+
primary:
|
|
337
|
+
enabled: true
|
|
338
|
+
`;
|
|
339
|
+
|
|
340
|
+
/** 单 Stage 单 shell Step 引用 \${projectName}(无声明),供缺陷 15「崩溃恢复保留提取值」测试。 */
|
|
341
|
+
const recoverPtvYaml = `id: wfl-recover-ptv
|
|
342
|
+
name: recover-ptv
|
|
343
|
+
version: "1.0.0"
|
|
344
|
+
stages:
|
|
345
|
+
- id: stg-1
|
|
346
|
+
name: 恢复
|
|
347
|
+
steps:
|
|
348
|
+
- id: step-use
|
|
349
|
+
steps:
|
|
350
|
+
- id: step-use
|
|
351
|
+
command:
|
|
352
|
+
type: shell
|
|
353
|
+
value:
|
|
354
|
+
command: "echo \${projectName}"
|
|
355
|
+
`;
|
|
356
|
+
|
|
357
|
+
/** 两 Stage:stg-extract(prompt 提取 projectName=sandore)→ stg-use(shell 引用
|
|
358
|
+
* \${projectName} 且声明同名默认值 "unknown"),供缺陷 15「跨 Stage 同名变量
|
|
359
|
+
* A 提取 B 默认 → B 不覆盖 A」测试(残余差异二:initState 合并顺序)。 */
|
|
360
|
+
const crossStagePtvYaml = `id: wfl-cross-stage-ptv
|
|
361
|
+
name: cross-stage-ptv
|
|
362
|
+
version: "1.0.0"
|
|
363
|
+
stages:
|
|
364
|
+
- id: stg-extract
|
|
365
|
+
name: 提取
|
|
366
|
+
steps:
|
|
367
|
+
- id: step-extract
|
|
368
|
+
- id: stg-use
|
|
369
|
+
name: 使用
|
|
370
|
+
steps:
|
|
371
|
+
- id: step-use
|
|
372
|
+
steps:
|
|
373
|
+
- id: step-extract
|
|
374
|
+
command:
|
|
375
|
+
type: prompt
|
|
376
|
+
value:
|
|
377
|
+
promptText: "extract projectName"
|
|
378
|
+
passThroughVars:
|
|
379
|
+
- name: projectName
|
|
380
|
+
value: "\${projectName}"
|
|
381
|
+
description: "项目名"
|
|
382
|
+
- id: step-use
|
|
383
|
+
command:
|
|
384
|
+
type: shell
|
|
385
|
+
value:
|
|
386
|
+
command: "echo \${projectName}"
|
|
387
|
+
passThroughVars:
|
|
388
|
+
- name: projectName
|
|
389
|
+
value: "unknown"
|
|
390
|
+
description: "项目名默认"
|
|
391
|
+
`;
|
|
392
|
+
|
|
393
|
+
/** 两 Stage:stg-use(shell 引用 \${config})→ stg-define(shell + 声明 config 默认值
|
|
394
|
+
* "default-cfg"),供缺陷 15「先引用后定义」全局默认收集测试(残余差异一)。 */
|
|
395
|
+
const forwardRefYaml = `id: wfl-forward-ref
|
|
396
|
+
name: forward-ref
|
|
397
|
+
version: "1.0.0"
|
|
398
|
+
stages:
|
|
399
|
+
- id: stg-use
|
|
400
|
+
name: 使用
|
|
401
|
+
steps:
|
|
402
|
+
- id: step-use
|
|
403
|
+
- id: stg-define
|
|
404
|
+
name: 定义
|
|
405
|
+
steps:
|
|
406
|
+
- id: step-define
|
|
407
|
+
steps:
|
|
408
|
+
- id: step-use
|
|
409
|
+
command:
|
|
410
|
+
type: shell
|
|
411
|
+
value:
|
|
412
|
+
command: "echo \${config}"
|
|
413
|
+
- id: step-define
|
|
414
|
+
command:
|
|
415
|
+
type: shell
|
|
416
|
+
value:
|
|
417
|
+
command: "echo defined"
|
|
418
|
+
passThroughVars:
|
|
419
|
+
- name: config
|
|
420
|
+
value: "default-cfg"
|
|
421
|
+
description: "配置"
|
|
422
|
+
`;
|
|
423
|
+
|
|
424
|
+
/** 分支无匹配(条件 false)→ 数组顺序兜底(用于验证兜底 + branchMeta 现场保留)。 */
|
|
425
|
+
const branchFallbackYaml = `id: wfl-branch-fallback
|
|
426
|
+
name: branch-fallback
|
|
427
|
+
version: "1.0.0"
|
|
428
|
+
stages:
|
|
429
|
+
- id: stg-1
|
|
430
|
+
name: 分支
|
|
431
|
+
steps:
|
|
432
|
+
- id: step-branch
|
|
433
|
+
branches:
|
|
434
|
+
- condition:
|
|
435
|
+
type: prompt
|
|
436
|
+
value:
|
|
437
|
+
promptText: "route"
|
|
438
|
+
next: step-matched
|
|
439
|
+
- id: step-fallback
|
|
440
|
+
- id: step-matched
|
|
441
|
+
branches: []
|
|
442
|
+
steps:
|
|
443
|
+
- id: step-branch
|
|
444
|
+
- id: step-fallback
|
|
445
|
+
command:
|
|
446
|
+
type: shell
|
|
447
|
+
value:
|
|
448
|
+
command: "echo fallback"
|
|
449
|
+
- id: step-matched
|
|
450
|
+
command:
|
|
451
|
+
type: shell
|
|
452
|
+
value:
|
|
453
|
+
command: "echo matched"
|
|
454
|
+
`;
|
|
455
|
+
|
|
456
|
+
/** 嵌套循环定义:外层 3 次 × 内层 3 次(基准模型:路由门 body 跑 maxIter-1 次,
|
|
457
|
+
* 故 (3-1)×(3-1)=4 次 body 执行;外层重入内层时内层 visitCount 经 resetLoopVisitCounts
|
|
458
|
+
* 从 0 起重新计数——验证跳出时归零语义)。 */
|
|
459
|
+
const nestedLoopYaml = `id: wfl-nested-loop
|
|
460
|
+
name: nested-loop
|
|
461
|
+
version: "1.0.0"
|
|
462
|
+
stages:
|
|
463
|
+
- id: stg-1
|
|
464
|
+
name: 嵌套循环
|
|
465
|
+
steps:
|
|
466
|
+
- id: step-outer-loop
|
|
467
|
+
branches:
|
|
468
|
+
- next: step-inner-loop
|
|
469
|
+
loop:
|
|
470
|
+
maxIterations: 3
|
|
471
|
+
next: step-inner-loop
|
|
472
|
+
exitTo: step-done
|
|
473
|
+
- id: step-inner-loop
|
|
474
|
+
branches:
|
|
475
|
+
- next: step-iter
|
|
476
|
+
loop:
|
|
477
|
+
maxIterations: 3
|
|
478
|
+
next: step-iter
|
|
479
|
+
exitTo: step-outer-loop
|
|
480
|
+
- id: step-iter
|
|
481
|
+
branches:
|
|
482
|
+
- next: step-inner-loop
|
|
483
|
+
- id: step-done
|
|
484
|
+
steps:
|
|
485
|
+
- id: step-outer-loop
|
|
486
|
+
- id: step-inner-loop
|
|
487
|
+
- id: step-iter
|
|
488
|
+
command:
|
|
489
|
+
type: shell
|
|
490
|
+
value:
|
|
491
|
+
command: "echo iter"
|
|
492
|
+
- id: step-done
|
|
493
|
+
command:
|
|
494
|
+
type: shell
|
|
495
|
+
value:
|
|
496
|
+
command: "echo done"
|
|
497
|
+
`;
|
|
498
|
+
|
|
499
|
+
/** 带 breakCondition 的循环定义(prompt 判定跳出,验证 loopMeta 回写)。 */
|
|
500
|
+
const loopBreakYaml = `id: wfl-loop-break
|
|
501
|
+
name: loop-break
|
|
502
|
+
version: "1.0.0"
|
|
503
|
+
stages:
|
|
504
|
+
- id: stg-1
|
|
505
|
+
name: 循环跳出
|
|
506
|
+
steps:
|
|
507
|
+
- id: step-loop
|
|
508
|
+
branches:
|
|
509
|
+
- next: step-iter
|
|
510
|
+
loop:
|
|
511
|
+
maxIterations: 5
|
|
512
|
+
breakCondition:
|
|
513
|
+
type: prompt
|
|
514
|
+
value:
|
|
515
|
+
promptText: "should we break?"
|
|
516
|
+
next: step-iter
|
|
517
|
+
exitTo: step-done
|
|
518
|
+
- id: step-iter
|
|
519
|
+
branches:
|
|
520
|
+
- next: step-loop
|
|
521
|
+
- id: step-done
|
|
522
|
+
steps:
|
|
523
|
+
- id: step-loop
|
|
524
|
+
- id: step-iter
|
|
525
|
+
command:
|
|
526
|
+
type: shell
|
|
527
|
+
value:
|
|
528
|
+
command: "echo iter"
|
|
529
|
+
- id: step-done
|
|
530
|
+
command:
|
|
531
|
+
type: shell
|
|
532
|
+
value:
|
|
533
|
+
command: "echo done"
|
|
534
|
+
`;
|
|
535
|
+
|
|
536
|
+
/** 命令门循环:循环节点自身带 command 且自回边,每趟产生 executed 条目(验证命令门 visitCount 不双重计数)。 */
|
|
537
|
+
const loopCommandGateYaml = `id: wfl-loop-command-gate
|
|
538
|
+
name: loop-command-gate
|
|
539
|
+
version: "1.0.0"
|
|
540
|
+
stages:
|
|
541
|
+
- id: stg-1
|
|
542
|
+
name: 命令门循环
|
|
543
|
+
steps:
|
|
544
|
+
- id: step-loop
|
|
545
|
+
branches:
|
|
546
|
+
- next: step-loop
|
|
547
|
+
loop:
|
|
548
|
+
maxIterations: 3
|
|
549
|
+
next: step-loop
|
|
550
|
+
exitTo: step-done
|
|
551
|
+
- id: step-done
|
|
552
|
+
steps:
|
|
553
|
+
- id: step-loop
|
|
554
|
+
command:
|
|
555
|
+
type: shell
|
|
556
|
+
value:
|
|
557
|
+
command: "echo loop"
|
|
558
|
+
- id: step-done
|
|
559
|
+
command:
|
|
560
|
+
type: shell
|
|
561
|
+
value:
|
|
562
|
+
command: "echo done"
|
|
563
|
+
`;
|
|
564
|
+
|
|
565
|
+
/** 生成指定 maxIter 的命令门循环 YAML(loop head 自带 command + 自回边)。 */
|
|
566
|
+
function commandGateYaml(maxIter: number): string {
|
|
567
|
+
return `id: wfl-cg-${maxIter}
|
|
568
|
+
name: loop-command-gate
|
|
569
|
+
version: "1.0.0"
|
|
570
|
+
stages:
|
|
571
|
+
- id: stg-1
|
|
572
|
+
name: 命令门循环
|
|
573
|
+
steps:
|
|
574
|
+
- id: step-loop
|
|
575
|
+
branches:
|
|
576
|
+
- next: step-loop
|
|
577
|
+
loop:
|
|
578
|
+
maxIterations: ${maxIter}
|
|
579
|
+
next: step-loop
|
|
580
|
+
exitTo: step-done
|
|
581
|
+
- id: step-done
|
|
582
|
+
steps:
|
|
583
|
+
- id: step-loop
|
|
584
|
+
command:
|
|
585
|
+
type: shell
|
|
586
|
+
value:
|
|
587
|
+
command: "echo loop"
|
|
588
|
+
- id: step-done
|
|
589
|
+
command:
|
|
590
|
+
type: shell
|
|
591
|
+
value:
|
|
592
|
+
command: "echo done"
|
|
593
|
+
`;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/** 带 breakCondition 但恒不跳出的循环(每趟评估都产出 loopMeta,不 break),验证 breakCondition 门 visitCount 不双重计数。 */
|
|
597
|
+
const loopBreakNoBreakYaml = `id: wfl-loop-break-no-break
|
|
598
|
+
name: loop-break-no-break
|
|
599
|
+
version: "1.0.0"
|
|
600
|
+
stages:
|
|
601
|
+
- id: stg-1
|
|
602
|
+
name: 循环不跳出
|
|
603
|
+
steps:
|
|
604
|
+
- id: step-loop
|
|
605
|
+
branches:
|
|
606
|
+
- next: step-iter
|
|
607
|
+
loop:
|
|
608
|
+
maxIterations: 3
|
|
609
|
+
breakCondition:
|
|
610
|
+
type: prompt
|
|
611
|
+
value:
|
|
612
|
+
promptText: "should we break?"
|
|
613
|
+
next: step-iter
|
|
614
|
+
exitTo: step-done
|
|
615
|
+
- id: step-iter
|
|
616
|
+
branches:
|
|
617
|
+
- next: step-loop
|
|
618
|
+
- id: step-done
|
|
619
|
+
steps:
|
|
620
|
+
- id: step-loop
|
|
621
|
+
- id: step-iter
|
|
622
|
+
command:
|
|
623
|
+
type: shell
|
|
624
|
+
value:
|
|
625
|
+
command: "echo iter"
|
|
626
|
+
- id: step-done
|
|
627
|
+
command:
|
|
628
|
+
type: shell
|
|
629
|
+
value:
|
|
630
|
+
command: "echo done"
|
|
631
|
+
`;
|
|
632
|
+
|
|
633
|
+
/** 混合分支门循环:循环节点同时带 condition 分支与 loop 分支(isLoopControl=false),每趟回边都建路由条目并递增 visitCount(验证不与回边计数叠加)。 */
|
|
634
|
+
const loopMixedGateYaml = `id: wfl-loop-mixed-gate
|
|
635
|
+
name: loop-mixed-gate
|
|
636
|
+
version: "1.0.0"
|
|
637
|
+
stages:
|
|
638
|
+
- id: stg-1
|
|
639
|
+
name: 混合门循环
|
|
640
|
+
steps:
|
|
641
|
+
- id: step-loop
|
|
642
|
+
branches:
|
|
643
|
+
- condition:
|
|
644
|
+
type: prompt
|
|
645
|
+
value:
|
|
646
|
+
promptText: "exit early?"
|
|
647
|
+
next: step-exit
|
|
648
|
+
- next: step-iter
|
|
649
|
+
loop:
|
|
650
|
+
maxIterations: 3
|
|
651
|
+
next: step-iter
|
|
652
|
+
exitTo: step-done
|
|
653
|
+
- id: step-iter
|
|
654
|
+
branches:
|
|
655
|
+
- next: step-loop
|
|
656
|
+
- id: step-exit
|
|
657
|
+
- id: step-done
|
|
658
|
+
steps:
|
|
659
|
+
- id: step-loop
|
|
660
|
+
- id: step-iter
|
|
661
|
+
command:
|
|
662
|
+
type: shell
|
|
663
|
+
value:
|
|
664
|
+
command: "echo iter"
|
|
665
|
+
- id: step-exit
|
|
666
|
+
command:
|
|
667
|
+
type: shell
|
|
668
|
+
value:
|
|
669
|
+
command: "echo exit"
|
|
670
|
+
- id: step-done
|
|
671
|
+
command:
|
|
672
|
+
type: shell
|
|
673
|
+
value:
|
|
674
|
+
command: "echo done"
|
|
675
|
+
`;
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* 复杂工作流 e2e 定义(代码审查与修复流程):覆盖 prompt 命令 + shell 命令 + 条件分支
|
|
679
|
+
* (shouldExecute)+ 并行分支(无 condition 多 next)+ 循环(loop + breakCondition
|
|
680
|
+
* shouldBreak + maxIterations)+ 收尾门禁(gates primary/secondary)+ 跨 Stage。
|
|
681
|
+
*
|
|
682
|
+
* Stage 1「代码审查与修复」:step-scan(shell) → step-route(路由门) 经条件分支(shouldExecute)
|
|
683
|
+
* 走 step-fix + 并行分支走 step-report,二者汇合 step-merge → step-loop(循环门) 循环体
|
|
684
|
+
* step-iter,breakCondition(shouldBreak) 跳出到 step-exit。
|
|
685
|
+
* Stage 2「门禁收尾」:step-finalize(prompt) 带透传变量(projectName) + 双门禁(primary +
|
|
686
|
+
* secondary),assess 提取 projectName、primary 纯代码产物校验 + secondary AI 门禁审查
|
|
687
|
+
* 判定产物合格。
|
|
688
|
+
*/
|
|
689
|
+
const complexReviewFixYaml = `id: wfl-complex
|
|
690
|
+
name: complex-review-fix
|
|
691
|
+
version: "1.0.0"
|
|
692
|
+
stages:
|
|
693
|
+
- id: stg-review
|
|
694
|
+
name: 代码审查与修复
|
|
695
|
+
steps:
|
|
696
|
+
- id: step-scan
|
|
697
|
+
- id: step-route
|
|
698
|
+
branches:
|
|
699
|
+
- condition:
|
|
700
|
+
type: prompt
|
|
701
|
+
value:
|
|
702
|
+
promptText: "判断是否需要修复"
|
|
703
|
+
next: step-fix
|
|
704
|
+
- next: step-report
|
|
705
|
+
- id: step-fix
|
|
706
|
+
branches:
|
|
707
|
+
- next: step-merge
|
|
708
|
+
- id: step-report
|
|
709
|
+
branches:
|
|
710
|
+
- next: step-merge
|
|
711
|
+
- id: step-merge
|
|
712
|
+
branches:
|
|
713
|
+
- next: step-loop
|
|
714
|
+
- id: step-loop
|
|
715
|
+
branches:
|
|
716
|
+
- next: step-iter
|
|
717
|
+
loop:
|
|
718
|
+
maxIterations: 4
|
|
719
|
+
breakCondition:
|
|
720
|
+
type: prompt
|
|
721
|
+
value:
|
|
722
|
+
promptText: "判断是否跳出循环"
|
|
723
|
+
next: step-iter
|
|
724
|
+
exitTo: step-exit
|
|
725
|
+
- id: step-iter
|
|
726
|
+
branches:
|
|
727
|
+
- next: step-loop
|
|
728
|
+
- id: step-exit
|
|
729
|
+
- id: stg-gate
|
|
730
|
+
name: 门禁收尾
|
|
731
|
+
steps:
|
|
732
|
+
- id: step-finalize
|
|
733
|
+
steps:
|
|
734
|
+
- id: step-scan
|
|
735
|
+
command:
|
|
736
|
+
type: shell
|
|
737
|
+
value:
|
|
738
|
+
command: "echo scan"
|
|
739
|
+
outputTo:
|
|
740
|
+
- id: opt-scan
|
|
741
|
+
path: "scan-report.txt"
|
|
742
|
+
- id: step-route
|
|
743
|
+
- id: step-fix
|
|
744
|
+
command:
|
|
745
|
+
type: shell
|
|
746
|
+
value:
|
|
747
|
+
command: "echo fix"
|
|
748
|
+
- id: step-report
|
|
749
|
+
command:
|
|
750
|
+
type: shell
|
|
751
|
+
value:
|
|
752
|
+
command: "echo report"
|
|
753
|
+
- id: step-merge
|
|
754
|
+
command:
|
|
755
|
+
type: shell
|
|
756
|
+
value:
|
|
757
|
+
command: "echo merge"
|
|
758
|
+
- id: step-loop
|
|
759
|
+
- id: step-iter
|
|
760
|
+
command:
|
|
761
|
+
type: shell
|
|
762
|
+
value:
|
|
763
|
+
command: "echo iter"
|
|
764
|
+
- id: step-exit
|
|
765
|
+
command:
|
|
766
|
+
type: shell
|
|
767
|
+
value:
|
|
768
|
+
command: "echo stage1-done"
|
|
769
|
+
- id: step-finalize
|
|
770
|
+
command:
|
|
771
|
+
type: prompt
|
|
772
|
+
value:
|
|
773
|
+
promptText: "分析代码并生成最终报告"
|
|
774
|
+
passThroughVars:
|
|
775
|
+
- name: projectName
|
|
776
|
+
value: "\${projectName}"
|
|
777
|
+
description: "项目名"
|
|
778
|
+
gates:
|
|
779
|
+
primary:
|
|
780
|
+
enabled: true
|
|
781
|
+
secondary:
|
|
782
|
+
enabled: true
|
|
783
|
+
prompt: "审查产物是否合格"
|
|
784
|
+
`;
|
|
785
|
+
|
|
786
|
+
/** 写入一条工作流定义记录。 */
|
|
787
|
+
async function putDefinition(persistence: SandorePersistence, id: string, yaml: string): Promise<void> {
|
|
788
|
+
const definition = parseWorkflowYaml(yaml);
|
|
789
|
+
const createdAt = new Date().toISOString();
|
|
790
|
+
await persistence.definitions.put(id, {
|
|
791
|
+
yaml,
|
|
792
|
+
name: definition.name,
|
|
793
|
+
version: definition.version,
|
|
794
|
+
createdAt,
|
|
795
|
+
updatedAt: createdAt,
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/** 组装一条已完成的实例状态快照(供 getState 兜底读回测试)。 */
|
|
800
|
+
function completedSnapshot(instanceId: string): WorkflowTaskState {
|
|
801
|
+
const createdAt = new Date().toISOString();
|
|
802
|
+
return {
|
|
803
|
+
taskId: instanceId,
|
|
804
|
+
workflowId: 'wfl-linear',
|
|
805
|
+
cwd: '/tmp',
|
|
806
|
+
status: 'completed',
|
|
807
|
+
originalInput: 'hello world',
|
|
808
|
+
currentStageId: 'stg-1',
|
|
809
|
+
executedSteps: [{
|
|
810
|
+
id: 'exec-1',
|
|
811
|
+
stepId: 'step-abc',
|
|
812
|
+
stageId: 'stg-1',
|
|
813
|
+
name: 'step-abc',
|
|
814
|
+
status: 'completed',
|
|
815
|
+
visitCount: 1,
|
|
816
|
+
output: [{ type: 'text', text: 'hello' }],
|
|
817
|
+
}],
|
|
818
|
+
stageInfos: [{ id: 'stg-1', name: '分析', status: 'completed' }],
|
|
819
|
+
pendingInteractions: [],
|
|
820
|
+
pendingQueue: [],
|
|
821
|
+
visitCounts: {},
|
|
822
|
+
passThroughVars: {},
|
|
823
|
+
activeLoopIterationStack: [],
|
|
824
|
+
activeLoopNodeId: null,
|
|
825
|
+
activeLoopStack: [],
|
|
826
|
+
pausedChildIds: [],
|
|
827
|
+
stageContracts: {},
|
|
828
|
+
createdAt,
|
|
829
|
+
updatedAt: createdAt,
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/** 组装一条非终态(running)实例状态快照(供恢复扫描测试)。 */
|
|
834
|
+
function runningSnapshot(instanceId: string, workflowId = 'wfl-linear'): WorkflowTaskState {
|
|
835
|
+
const createdAt = new Date().toISOString();
|
|
836
|
+
return {
|
|
837
|
+
taskId: instanceId,
|
|
838
|
+
workflowId,
|
|
839
|
+
cwd: '/tmp',
|
|
840
|
+
status: 'running',
|
|
841
|
+
originalInput: 'hello world',
|
|
842
|
+
currentStageId: 'stg-1',
|
|
843
|
+
executedSteps: [],
|
|
844
|
+
stageInfos: [{ id: 'stg-1', name: '分析', status: 'running' }],
|
|
845
|
+
pendingInteractions: [],
|
|
846
|
+
pendingQueue: [],
|
|
847
|
+
visitCounts: {},
|
|
848
|
+
passThroughVars: {},
|
|
849
|
+
activeLoopIterationStack: [],
|
|
850
|
+
activeLoopNodeId: null,
|
|
851
|
+
activeLoopStack: [],
|
|
852
|
+
pausedChildIds: [],
|
|
853
|
+
stageContracts: {},
|
|
854
|
+
createdAt,
|
|
855
|
+
updatedAt: createdAt,
|
|
856
|
+
};
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/** 单个已执行 Step 的快照类型。 */
|
|
860
|
+
type SnapshotStep = WorkflowTaskState['executedSteps'][number];
|
|
861
|
+
|
|
862
|
+
/** 构造一条 agent 子代理 Step 快照(running/paused 两态,供恢复分类测试)。 */
|
|
863
|
+
function agentStepSnapshot(status: 'running' | 'paused', childId = 'child-1'): SnapshotStep {
|
|
864
|
+
const createdAt = new Date().toISOString();
|
|
865
|
+
return {
|
|
866
|
+
id: 'exec-1',
|
|
867
|
+
stepId: 'step-agent',
|
|
868
|
+
stageId: 'stg-1',
|
|
869
|
+
name: 'step-agent',
|
|
870
|
+
status,
|
|
871
|
+
command: { type: 'agent', value: { promptText: 'do work' } },
|
|
872
|
+
sessionMeta: { childId, synthetic: false, command: 'agent' },
|
|
873
|
+
visitCount: 1,
|
|
874
|
+
...(status === 'paused' ? { pausedAt: createdAt } : {}),
|
|
875
|
+
};
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
/** 构造一条 shell 合成 Step 快照(synthetic,供「合成 session 标 failed」测试)。 */
|
|
879
|
+
function shellStepSnapshot(status: 'running' = 'running'): SnapshotStep {
|
|
880
|
+
return {
|
|
881
|
+
id: 'exec-1',
|
|
882
|
+
stepId: 'step-shell',
|
|
883
|
+
stageId: 'stg-1',
|
|
884
|
+
name: 'step-shell',
|
|
885
|
+
status,
|
|
886
|
+
command: { type: 'shell', value: { command: 'echo hi' } },
|
|
887
|
+
sessionMeta: { childId: 'step-shell-synthetic', synthetic: true, command: 'shell' },
|
|
888
|
+
visitCount: 1,
|
|
889
|
+
};
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
/** 组装一条含单个指定 Step 的实例状态快照(status 决定 pausedChildIds 是否重建)。 */
|
|
893
|
+
function stepSnapshot(instanceId: string, workflowId: string, step: SnapshotStep, status: WorkflowTaskState['status']): WorkflowTaskState {
|
|
894
|
+
const createdAt = new Date().toISOString();
|
|
895
|
+
return {
|
|
896
|
+
taskId: instanceId,
|
|
897
|
+
workflowId,
|
|
898
|
+
cwd: '/tmp',
|
|
899
|
+
status,
|
|
900
|
+
originalInput: 'do work',
|
|
901
|
+
currentStageId: step.stageId,
|
|
902
|
+
executedSteps: [step],
|
|
903
|
+
stageInfos: [{ id: step.stageId, name: 'stage', status: status === 'completed' ? 'completed' : 'running' }],
|
|
904
|
+
pendingInteractions: [],
|
|
905
|
+
pendingQueue: [],
|
|
906
|
+
visitCounts: {},
|
|
907
|
+
passThroughVars: {},
|
|
908
|
+
activeLoopIterationStack: [],
|
|
909
|
+
activeLoopNodeId: null,
|
|
910
|
+
activeLoopStack: [],
|
|
911
|
+
pausedChildIds: status === 'paused' ? [step.sessionMeta?.childId ?? step.id] : [],
|
|
912
|
+
stageContracts: {},
|
|
913
|
+
createdAt,
|
|
914
|
+
updatedAt: createdAt,
|
|
915
|
+
};
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
/** 组装一条非终态(paused)实例状态快照(含 paused Step + pausedChildIds,供登记表重建测试)。 */
|
|
919
|
+
function pausedSnapshot(instanceId: string, workflowId = 'wfl-agent-fail'): WorkflowTaskState {
|
|
920
|
+
return stepSnapshot(instanceId, workflowId, agentStepSnapshot('paused'), 'paused');
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
/** 组装一条 between-steps 崩溃快照:step-a 已完成落盘、step-b 未执行(无活跃段),供缺陷 9 G 测试。 */
|
|
924
|
+
function betweenStepsSnapshot(instanceId: string, workflowId = 'wfl-linear-shell'): WorkflowTaskState {
|
|
925
|
+
const createdAt = new Date().toISOString();
|
|
926
|
+
return {
|
|
927
|
+
taskId: instanceId,
|
|
928
|
+
workflowId,
|
|
929
|
+
cwd: '/tmp',
|
|
930
|
+
status: 'running',
|
|
931
|
+
originalInput: 'do it',
|
|
932
|
+
currentStageId: 'stg-1',
|
|
933
|
+
executedSteps: [{
|
|
934
|
+
id: 'exec-a',
|
|
935
|
+
stepId: 'step-a',
|
|
936
|
+
stageId: 'stg-1',
|
|
937
|
+
name: 'step-a',
|
|
938
|
+
status: 'completed',
|
|
939
|
+
command: { type: 'shell', value: { command: 'echo a' } },
|
|
940
|
+
sessionMeta: { childId: 'step-a-synthetic', synthetic: true, command: 'shell' },
|
|
941
|
+
visitCount: 1,
|
|
942
|
+
createdAt,
|
|
943
|
+
updatedAt: createdAt,
|
|
944
|
+
attemptIndex: 0,
|
|
945
|
+
output: [{ type: 'text', text: 'a' }],
|
|
946
|
+
}],
|
|
947
|
+
stageInfos: [{ id: 'stg-1', name: '两步', status: 'running' }],
|
|
948
|
+
pendingInteractions: [],
|
|
949
|
+
pendingQueue: [],
|
|
950
|
+
visitCounts: { 'step-a': 1 },
|
|
951
|
+
passThroughVars: {},
|
|
952
|
+
activeLoopIterationStack: [],
|
|
953
|
+
activeLoopNodeId: null,
|
|
954
|
+
activeLoopStack: [],
|
|
955
|
+
pausedChildIds: [],
|
|
956
|
+
stageContracts: {},
|
|
957
|
+
createdAt,
|
|
958
|
+
updatedAt: createdAt,
|
|
959
|
+
};
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
/** 组装一条含已提取透传变量(projectName=sandore)的 running 快照(无活跃段、无已完成
|
|
963
|
+
* Step),供缺陷 15「崩溃恢复保留提取值」测试:recoverInstance 重跑 step-use 时
|
|
964
|
+
* 断言 shell 命令经 replacePassThroughVars 拿到提取值而非保留字面量。 */
|
|
965
|
+
function extractedPtvSnapshot(instanceId: string, workflowId = 'wfl-recover-ptv'): WorkflowTaskState {
|
|
966
|
+
const createdAt = new Date().toISOString();
|
|
967
|
+
return {
|
|
968
|
+
taskId: instanceId,
|
|
969
|
+
workflowId,
|
|
970
|
+
cwd: '/tmp',
|
|
971
|
+
status: 'running',
|
|
972
|
+
originalInput: 'do it',
|
|
973
|
+
currentStageId: 'stg-1',
|
|
974
|
+
executedSteps: [],
|
|
975
|
+
stageInfos: [{ id: 'stg-1', name: '恢复', status: 'running' }],
|
|
976
|
+
pendingInteractions: [],
|
|
977
|
+
pendingQueue: [],
|
|
978
|
+
visitCounts: {},
|
|
979
|
+
passThroughVars: {
|
|
980
|
+
projectName: { value: 'sandore', description: '项目名' },
|
|
981
|
+
},
|
|
982
|
+
activeLoopIterationStack: [],
|
|
983
|
+
activeLoopNodeId: null,
|
|
984
|
+
activeLoopStack: [],
|
|
985
|
+
pausedChildIds: [],
|
|
986
|
+
stageContracts: {},
|
|
987
|
+
createdAt,
|
|
988
|
+
updatedAt: createdAt,
|
|
989
|
+
};
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
/** 组装一条含 stale pendingInteractions 的快照(崩溃前 ask 挂起,恢复时需清理),供缺陷 9 B / 27 测试。 */
|
|
993
|
+
function stalePendingSnapshot(instanceId: string): WorkflowTaskState {
|
|
994
|
+
const snapshot = pausedSnapshot(instanceId);
|
|
995
|
+
snapshot.pendingInteractions = [{
|
|
996
|
+
id: 'req-stale',
|
|
997
|
+
stageId: 'stg-1',
|
|
998
|
+
stepId: 'step-agent',
|
|
999
|
+
sessionId: instanceId,
|
|
1000
|
+
requestId: 'req-stale',
|
|
1001
|
+
kind: 'question',
|
|
1002
|
+
status: 'pending',
|
|
1003
|
+
payload: { questions: [{ id: 'q1', question: 'stale?' }] },
|
|
1004
|
+
createdAt: '2026-08-16T00:00:00.000Z',
|
|
1005
|
+
}];
|
|
1006
|
+
return snapshot;
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
/** 组装集成测试 harness:注入内存 DSH 服务并加载引擎运行时。 */
|
|
1010
|
+
async function harness(config: SandoreConfig = {}): Promise<{
|
|
1011
|
+
ctx: Context;
|
|
1012
|
+
engine: SandoreEngine;
|
|
1013
|
+
persistence: SandorePersistence;
|
|
1014
|
+
llm: FakeLlm;
|
|
1015
|
+
shell: FakeShell;
|
|
1016
|
+
sessions: FakeSessionStore;
|
|
1017
|
+
subagents: FakeSubagents;
|
|
1018
|
+
agents: FakeAgentRegistry;
|
|
1019
|
+
userQuestions: FakeUserQuestions;
|
|
1020
|
+
sessionTitle: FakeSessionTitle;
|
|
1021
|
+
}> {
|
|
1022
|
+
const ctx = new Context();
|
|
1023
|
+
const storageDomain = new MemoryStorageDomain();
|
|
1024
|
+
ctx.provide('storageDomain', storageDomain as unknown as DomainFacility);
|
|
1025
|
+
await ctx.plugin(SandorePersistence);
|
|
1026
|
+
const persistence = ctx.sandorePersistence;
|
|
1027
|
+
|
|
1028
|
+
const llm = new FakeLlm();
|
|
1029
|
+
const shell = new FakeShell();
|
|
1030
|
+
const sessions = new FakeSessionStore();
|
|
1031
|
+
const agents = new FakeAgentRegistry(sessions);
|
|
1032
|
+
const subagents = new FakeSubagents(ctx, (id) => agents.get(id));
|
|
1033
|
+
const subprocess = new FakeSubprocess();
|
|
1034
|
+
const userQuestions = new FakeUserQuestions();
|
|
1035
|
+
const sessionTitle = new FakeSessionTitle();
|
|
1036
|
+
ctx.provide('llm', llm as unknown as LlmRuntime);
|
|
1037
|
+
ctx.provide('shell', shell as unknown as ShellExecutor);
|
|
1038
|
+
ctx.provide('sessions', sessions as unknown as SessionStore);
|
|
1039
|
+
ctx.provide('sessionPersistence', {});
|
|
1040
|
+
ctx.provide('subagents', subagents);
|
|
1041
|
+
ctx.provide('agents', agents);
|
|
1042
|
+
ctx.provide('subprocess', subprocess);
|
|
1043
|
+
ctx.provide('userQuestions', userQuestions);
|
|
1044
|
+
ctx.provide('sessionTitle', sessionTitle);
|
|
1045
|
+
ctx.provide('fs', {});
|
|
1046
|
+
|
|
1047
|
+
await ctx.plugin(SandoreEngineRuntime, config);
|
|
1048
|
+
const engine = ctx.sandoreEngine;
|
|
1049
|
+
return { ctx, engine, persistence, llm, shell, sessions, subagents, agents, userQuestions, sessionTitle };
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
/** 注册全部 sandore/* 监听器并记录事件(名称 + 参数)。 */
|
|
1053
|
+
function recordSandoreEvents(ctx: Context): Array<{ name: string; args: unknown[] }> {
|
|
1054
|
+
const records: Array<{ name: string; args: unknown[] }> = [];
|
|
1055
|
+
ctx.on('sandore/start', info => records.push({ name: 'sandore/start', args: [info] }));
|
|
1056
|
+
ctx.on('sandore/stage-start', (info, stageId) => records.push({ name: 'sandore/stage-start', args: [info, stageId] }));
|
|
1057
|
+
ctx.on('sandore/stage-completed', (info, stageId, contract) => records.push({ name: 'sandore/stage-completed', args: [info, stageId, contract] }));
|
|
1058
|
+
ctx.on('sandore/step-start', (info, stepId, childId, name, loopIteration) => records.push({ name: 'sandore/step-start', args: [info, stepId, childId, name, loopIteration] }));
|
|
1059
|
+
ctx.on('sandore/step-completed', (info, stepId, childId, output, name, loopIteration) => records.push({ name: 'sandore/step-completed', args: [info, stepId, childId, output, name, loopIteration] }));
|
|
1060
|
+
ctx.on('sandore/step-failed', (info, stepId, childId, error, name, loopIteration) => records.push({ name: 'sandore/step-failed', args: [info, stepId, childId, error, name, loopIteration] }));
|
|
1061
|
+
ctx.on('sandore/step-phase', (info, stepId, childId, phase, name, loopIteration) => records.push({ name: 'sandore/step-phase', args: [info, stepId, childId, phase, name, loopIteration] }));
|
|
1062
|
+
ctx.on('sandore/end', (info, result) => records.push({ name: 'sandore/end', args: [info, result] }));
|
|
1063
|
+
ctx.on('sandore/aborted', info => records.push({ name: 'sandore/aborted', args: [info] }));
|
|
1064
|
+
return records;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
/** 等待实例到达 completed/failed 终态(以 sandore/end 为信号)。 */
|
|
1068
|
+
function waitForEnd(ctx: Context): Promise<void> {
|
|
1069
|
+
return new Promise<void>(resolve => {
|
|
1070
|
+
ctx.once('sandore/end', () => resolve());
|
|
1071
|
+
});
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
/** 段模型定位:同 stepId 取 attemptIndex 最大(最后)的执行段(重试/续跑后新段)。 */
|
|
1075
|
+
function lastSegmentOf(
|
|
1076
|
+
steps: readonly SnapshotStep[],
|
|
1077
|
+
stepId: string,
|
|
1078
|
+
): SnapshotStep | undefined {
|
|
1079
|
+
const matches = steps.filter(step => step.stepId === stepId);
|
|
1080
|
+
if (matches.length === 0) {
|
|
1081
|
+
return undefined;
|
|
1082
|
+
}
|
|
1083
|
+
return matches.sort((a, b) => (b.attemptIndex ?? 0) - (a.attemptIndex ?? 0))[0];
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
/** 等待实例到达 aborted 终态(以 sandore/aborted 为信号)。 */
|
|
1087
|
+
function waitForAborted(ctx: Context): Promise<void> {
|
|
1088
|
+
return new Promise<void>(resolve => {
|
|
1089
|
+
ctx.once('sandore/aborted', () => resolve());
|
|
1090
|
+
});
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
/** 等待一次 step-start(用于确认 Step 已开始执行)。 */
|
|
1094
|
+
function waitForStepStart(ctx: Context): Promise<void> {
|
|
1095
|
+
return new Promise<void>(resolve => {
|
|
1096
|
+
ctx.once('sandore/step-start', () => resolve());
|
|
1097
|
+
});
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
/** `agent/pre-step` waterfall 载荷/决策最小面(与引擎冻结监听器结构对齐,不 import DSH 类型)。 */
|
|
1101
|
+
type PreStepPayload = {
|
|
1102
|
+
agent: { id: string };
|
|
1103
|
+
messages: unknown[];
|
|
1104
|
+
turn: number;
|
|
1105
|
+
step: number;
|
|
1106
|
+
signal: AbortSignal;
|
|
1107
|
+
};
|
|
1108
|
+
type PreStepDecision = { kind: 'reject' } | { kind: 'enter'; messages: unknown[] };
|
|
1109
|
+
type PreStepNext = () => Promise<PreStepDecision>;
|
|
1110
|
+
|
|
1111
|
+
/**
|
|
1112
|
+
* 模拟 DSH 对某 agent 分发一次 `agent/pre-step` waterfall:thisArg 的 filter 全放行,
|
|
1113
|
+
* 命中引擎 ctx 注册的冻结监听器(与真实 DSH 在 agent scope 分发、事件沿 scope 链
|
|
1114
|
+
* 冒泡到引擎 ctx 等价)。返回最外层监听器(引擎冻结监听器)的决策;`next` 为内建
|
|
1115
|
+
* 行为替身(记录是否被调用——被调用意味着步骤未被 veto)。
|
|
1116
|
+
*/
|
|
1117
|
+
async function dispatchPreStep(ctx: Context, agentId: string, next: PreStepNext): Promise<PreStepDecision> {
|
|
1118
|
+
const thisArg = { [Context.filter]: () => true };
|
|
1119
|
+
const payload: PreStepPayload = {
|
|
1120
|
+
agent: { id: agentId },
|
|
1121
|
+
messages: [],
|
|
1122
|
+
turn: 1,
|
|
1123
|
+
step: 1,
|
|
1124
|
+
signal: new AbortController().signal,
|
|
1125
|
+
};
|
|
1126
|
+
return await (ctx.waterfall as unknown as (
|
|
1127
|
+
thisArg: object,
|
|
1128
|
+
name: string,
|
|
1129
|
+
payload: PreStepPayload,
|
|
1130
|
+
next: PreStepNext,
|
|
1131
|
+
) => Promise<PreStepDecision>)(thisArg, 'agent/pre-step', payload, next);
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
/** 让 shell step 挂起(直到 abort signal 触发 finish),保持实例 running 不进入终态。 */
|
|
1135
|
+
function hangingShell(shell: FakeShell): void {
|
|
1136
|
+
shell.runHandler = spec => new Promise(resolve => {
|
|
1137
|
+
const finish = (): void => { resolve(successResult(spec, '')); };
|
|
1138
|
+
if (spec.signal?.aborted) { finish(); return; }
|
|
1139
|
+
spec.signal?.addEventListener('abort', finish, { once: true });
|
|
1140
|
+
});
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
describe('SandoreEngineRuntime interface', () => {
|
|
1144
|
+
it('is the default export and keeps SandoreEngineStub as a named export', async () => {
|
|
1145
|
+
const { ctx } = await harness();
|
|
1146
|
+
expect(ctx.sandoreEngine).toBeInstanceOf(SandoreEngineRuntime);
|
|
1147
|
+
expect(SandoreEngineStub).toBeTypeOf('function');
|
|
1148
|
+
});
|
|
1149
|
+
|
|
1150
|
+
it('declares agents, userQuestions, and sessionTitle in the seam inject list', () => {
|
|
1151
|
+
expect(SandoreEngineStub.inject).toContain('agents');
|
|
1152
|
+
expect(SandoreEngineStub.inject).toContain('userQuestions');
|
|
1153
|
+
expect(SandoreEngineStub.inject).toContain('sessionTitle');
|
|
1154
|
+
expect(SandoreEngineRuntime.inject).toContain('agents');
|
|
1155
|
+
expect(SandoreEngineRuntime.inject).toContain('userQuestions');
|
|
1156
|
+
expect(SandoreEngineRuntime.inject).toContain('sessionTitle');
|
|
1157
|
+
});
|
|
1158
|
+
|
|
1159
|
+
it('routes recoverInstance and retryStep on unknown instances to NODE_NOT_FOUND (no longer not-implemented)', async () => {
|
|
1160
|
+
const { engine } = await harness();
|
|
1161
|
+
await expect(engine.recoverInstance('x')).rejects.toMatchObject({ code: 'NODE_NOT_FOUND' });
|
|
1162
|
+
await expect(engine.retryStep('x', 's', 'continue', 'm')).rejects.toMatchObject({ code: 'NODE_NOT_FOUND' });
|
|
1163
|
+
});
|
|
1164
|
+
|
|
1165
|
+
it('throws NODE_NOT_FOUND for pause/resume on unknown instances', async () => {
|
|
1166
|
+
const { engine } = await harness();
|
|
1167
|
+
await expect(engine.pauseStep('x', 's')).rejects.toMatchObject({ code: 'NODE_NOT_FOUND' });
|
|
1168
|
+
await expect(engine.resumeStep('x', 's')).rejects.toMatchObject({ code: 'NODE_NOT_FOUND' });
|
|
1169
|
+
await expect(engine.pauseAllSteps('x')).rejects.toMatchObject({ code: 'NODE_NOT_FOUND' });
|
|
1170
|
+
await expect(engine.resumeAllSteps('x')).rejects.toMatchObject({ code: 'NODE_NOT_FOUND' });
|
|
1171
|
+
});
|
|
1172
|
+
|
|
1173
|
+
it('replyQuestion for an unknown instance throws NODE_NOT_FOUND', async () => {
|
|
1174
|
+
const { engine } = await harness();
|
|
1175
|
+
await expect(engine.replyQuestion('x', 'q', { answers: [] })).rejects.toMatchObject({ code: 'NODE_NOT_FOUND' });
|
|
1176
|
+
await expect(engine.replyPermission('x', 'q', 'once')).rejects.toMatchObject({ code: 'NODE_NOT_FOUND' });
|
|
1177
|
+
});
|
|
1178
|
+
});
|
|
1179
|
+
|
|
1180
|
+
describe('SandoreEngineRuntime startInstance', () => {
|
|
1181
|
+
it('throws when the workflowId does not exist and leaves the state table empty', async () => {
|
|
1182
|
+
const { engine, persistence } = await harness();
|
|
1183
|
+
await expect(
|
|
1184
|
+
engine.startInstance({ workflowId: 'wfl-none', originalInput: 'x', cwd: '/tmp' }),
|
|
1185
|
+
).rejects.toBeInstanceOf(SandoreError);
|
|
1186
|
+
expect(persistence.state.size).toBe(0);
|
|
1187
|
+
});
|
|
1188
|
+
|
|
1189
|
+
it('returns a run whose state is immediately readable via getState', async () => {
|
|
1190
|
+
const { engine, persistence } = await harness();
|
|
1191
|
+
await putDefinition(persistence, 'wfl-linear', linearYaml);
|
|
1192
|
+
|
|
1193
|
+
const run = await engine.startInstance({ workflowId: 'wfl-linear', originalInput: 'hello world', cwd: '/tmp' });
|
|
1194
|
+
expect(run.status).toBe('running');
|
|
1195
|
+
|
|
1196
|
+
const state = engine.getState(run.instanceId);
|
|
1197
|
+
expect(state.originalInput).toBe('hello world');
|
|
1198
|
+
expect(['pending', 'running']).toContain(state.status);
|
|
1199
|
+
});
|
|
1200
|
+
|
|
1201
|
+
it('creates a host agent via agents.create with meta.cwd and holds it idle (no auto prompt)', async () => {
|
|
1202
|
+
const { engine, persistence, agents } = await harness();
|
|
1203
|
+
await putDefinition(persistence, 'wfl-linear', linearYaml);
|
|
1204
|
+
|
|
1205
|
+
const run = await engine.startInstance({ workflowId: 'wfl-linear', originalInput: 'do it', cwd: '/srv/app' });
|
|
1206
|
+
|
|
1207
|
+
expect(agents.createCwds).toEqual(['/srv/app']);
|
|
1208
|
+
expect(agents.setupCount).toBe(1);
|
|
1209
|
+
const hostAgent = agents.get(run.instanceId);
|
|
1210
|
+
expect(hostAgent).toBeDefined();
|
|
1211
|
+
expect(hostAgent?.status).toBe('idle');
|
|
1212
|
+
expect(hostAgent?.inbox.hasPending).toBe(false);
|
|
1213
|
+
});
|
|
1214
|
+
|
|
1215
|
+
it('rejects startInstance when agents.create setup throws and leaves no state', async () => {
|
|
1216
|
+
const { engine, persistence, agents } = await harness();
|
|
1217
|
+
await putDefinition(persistence, 'wfl-linear', linearYaml);
|
|
1218
|
+
agents.setupError = new Error('setup boom');
|
|
1219
|
+
|
|
1220
|
+
await expect(
|
|
1221
|
+
engine.startInstance({ workflowId: 'wfl-linear', originalInput: 'do it', cwd: '/tmp' }),
|
|
1222
|
+
).rejects.toThrow('setup boom');
|
|
1223
|
+
expect(persistence.state.size).toBe(0);
|
|
1224
|
+
});
|
|
1225
|
+
|
|
1226
|
+
it('renames the host session title to the user originalInput after creating the host session', async () => {
|
|
1227
|
+
const { engine, persistence, sessions, sessionTitle } = await harness();
|
|
1228
|
+
await putDefinition(persistence, 'wfl-linear', linearYaml);
|
|
1229
|
+
|
|
1230
|
+
const run = await engine.startInstance({ workflowId: 'wfl-linear', originalInput: 'do it', cwd: '/tmp' });
|
|
1231
|
+
|
|
1232
|
+
expect(sessionTitle.calls).toHaveLength(1);
|
|
1233
|
+
// 新建宿主会话标题 = 用户输入的需求(originalInput),非实例 id。
|
|
1234
|
+
expect(sessionTitle.calls[0]?.title).toBe('do it');
|
|
1235
|
+
// rename 传入的是经 ctx.sessions.get 读回的 live 宿主 Session(同一引用)。
|
|
1236
|
+
expect(sessionTitle.calls[0]?.session).toBe(sessions.get(run.instanceId));
|
|
1237
|
+
});
|
|
1238
|
+
|
|
1239
|
+
it('reuses the caller-provided host agent session when hostSessionId is given (no create, freeze applies)', async () => {
|
|
1240
|
+
const { ctx, engine, persistence, shell, agents, sessions } = await harness();
|
|
1241
|
+
await putDefinition(persistence, 'wfl-shell-only', shellOnlyYaml);
|
|
1242
|
+
// 预置已存在的宿主主代理会话(模拟调用方传入复用)。
|
|
1243
|
+
const existingHost = new FakeAgent('host-1');
|
|
1244
|
+
agents.register(existingHost);
|
|
1245
|
+
sessions.create('host-1');
|
|
1246
|
+
hangingShell(shell); // 保持实例 running
|
|
1247
|
+
|
|
1248
|
+
const run = await engine.startInstance({ workflowId: 'wfl-shell-only', originalInput: 'do it', cwd: '/tmp', hostSessionId: 'host-1' });
|
|
1249
|
+
expect(run.status).toBe('running');
|
|
1250
|
+
|
|
1251
|
+
// 未新建 hostAgent(复用 host-1):无 agents.create、无 setup 重跑。
|
|
1252
|
+
expect(agents.createCwds).toHaveLength(0);
|
|
1253
|
+
expect(agents.setupCount).toBe(0);
|
|
1254
|
+
expect(agents.get('host-1')).toBe(existingHost);
|
|
1255
|
+
|
|
1256
|
+
// 复用的 hostAgent 同样冻结:pre-step 被 reject、下游 next 不被调用(冻结登记按
|
|
1257
|
+
// hostAgent.id = hostSessionId 登记,复用与新建等价)。
|
|
1258
|
+
const next = vi.fn(async (): Promise<PreStepDecision> => ({ kind: 'enter', messages: [] }));
|
|
1259
|
+
const decision = await dispatchPreStep(ctx, 'host-1', next);
|
|
1260
|
+
expect(decision).toEqual({ kind: 'reject' });
|
|
1261
|
+
expect(next).not.toHaveBeenCalled();
|
|
1262
|
+
|
|
1263
|
+
// abort 清表:复用的 hostAgent 随实例生命周期结束放行(清表分支移除冻结登记)。
|
|
1264
|
+
const aborted = waitForAborted(ctx);
|
|
1265
|
+
await engine.abortInstance(run.instanceId);
|
|
1266
|
+
await aborted;
|
|
1267
|
+
const afterAbortNext = vi.fn(async (): Promise<PreStepDecision> => ({ kind: 'enter', messages: [] }));
|
|
1268
|
+
const afterAbort = await dispatchPreStep(ctx, 'host-1', afterAbortNext);
|
|
1269
|
+
expect(afterAbort).toEqual({ kind: 'enter', messages: [] });
|
|
1270
|
+
expect(afterAbortNext).toHaveBeenCalledTimes(1);
|
|
1271
|
+
});
|
|
1272
|
+
|
|
1273
|
+
it('thaws the reused host agent after the workflow completes', async () => {
|
|
1274
|
+
const { ctx, engine, persistence, shell, agents, sessions } = await harness();
|
|
1275
|
+
await putDefinition(persistence, 'wfl-shell-only', shellOnlyYaml);
|
|
1276
|
+
const existingHost = new FakeAgent('host-1');
|
|
1277
|
+
agents.register(existingHost);
|
|
1278
|
+
sessions.create('host-1');
|
|
1279
|
+
shell.runHandler = async spec => successResult(spec, 'hi');
|
|
1280
|
+
|
|
1281
|
+
const ended = waitForEnd(ctx);
|
|
1282
|
+
const run = await engine.startInstance({ workflowId: 'wfl-shell-only', originalInput: 'do it', cwd: '/tmp', hostSessionId: 'host-1' });
|
|
1283
|
+
await ended;
|
|
1284
|
+
expect(persistence.state.get(run.instanceId)?.status).toBe('completed');
|
|
1285
|
+
|
|
1286
|
+
// completed 后复用的 hostAgent 解冻:pre-step 放行(唯一解冻点同样作用于复用场景)。
|
|
1287
|
+
const next = vi.fn(async (): Promise<PreStepDecision> => ({ kind: 'enter', messages: [] }));
|
|
1288
|
+
const decision = await dispatchPreStep(ctx, 'host-1', next);
|
|
1289
|
+
expect(decision).toEqual({ kind: 'enter', messages: [] });
|
|
1290
|
+
expect(next).toHaveBeenCalledTimes(1);
|
|
1291
|
+
});
|
|
1292
|
+
|
|
1293
|
+
it('rejects startInstance when hostSessionId does not resolve to a live agent', async () => {
|
|
1294
|
+
const { engine, persistence } = await harness();
|
|
1295
|
+
await putDefinition(persistence, 'wfl-shell-only', shellOnlyYaml);
|
|
1296
|
+
|
|
1297
|
+
await expect(
|
|
1298
|
+
engine.startInstance({ workflowId: 'wfl-shell-only', originalInput: 'do it', cwd: '/tmp', hostSessionId: 'ghost-host' }),
|
|
1299
|
+
).rejects.toMatchObject({ code: 'NODE_NOT_FOUND' });
|
|
1300
|
+
expect(persistence.state.size).toBe(0);
|
|
1301
|
+
});
|
|
1302
|
+
|
|
1303
|
+
it('does not fail startInstance when sessionTitle.rename throws and still executes to completion', async () => {
|
|
1304
|
+
const { ctx, engine, persistence, sessionTitle } = await harness();
|
|
1305
|
+
await putDefinition(persistence, 'wfl-shell-only', shellOnlyYaml);
|
|
1306
|
+
sessionTitle.renameError = new Error('title boom');
|
|
1307
|
+
|
|
1308
|
+
const ended = waitForEnd(ctx);
|
|
1309
|
+
const run = await engine.startInstance({ workflowId: 'wfl-shell-only', originalInput: 'do it', cwd: '/tmp' });
|
|
1310
|
+
|
|
1311
|
+
expect(run.status).toBe('running');
|
|
1312
|
+
await ended;
|
|
1313
|
+
expect(persistence.state.get(run.instanceId)?.status).toBe('completed');
|
|
1314
|
+
expect(sessionTitle.calls).toHaveLength(1);
|
|
1315
|
+
});
|
|
1316
|
+
|
|
1317
|
+
it('falls back to the state table when the instance is not in memory', async () => {
|
|
1318
|
+
const { engine, persistence } = await harness();
|
|
1319
|
+
await persistence.state.put('inst-fallback', completedSnapshot('inst-fallback'));
|
|
1320
|
+
|
|
1321
|
+
const state = engine.getState('inst-fallback');
|
|
1322
|
+
expect(state.taskId).toBe('inst-fallback');
|
|
1323
|
+
expect(state.status).toBe('completed');
|
|
1324
|
+
expect(state.originalInput).toBe('hello world');
|
|
1325
|
+
expect(state.currentStageId).toBe('stg-1');
|
|
1326
|
+
expect(state.executedSteps).toHaveLength(1);
|
|
1327
|
+
expect(state.executedSteps[0]?.stepId).toBe('step-abc');
|
|
1328
|
+
});
|
|
1329
|
+
|
|
1330
|
+
it('throws NODE_NOT_FOUND when neither memory nor the state table has the instance', async () => {
|
|
1331
|
+
const { engine } = await harness();
|
|
1332
|
+
let caught: unknown;
|
|
1333
|
+
try {
|
|
1334
|
+
engine.getState('inst-none');
|
|
1335
|
+
} catch (error) {
|
|
1336
|
+
caught = error;
|
|
1337
|
+
}
|
|
1338
|
+
expect(caught).toBeInstanceOf(SandoreError);
|
|
1339
|
+
expect(caught).toMatchObject({ code: 'NODE_NOT_FOUND' });
|
|
1340
|
+
});
|
|
1341
|
+
|
|
1342
|
+
it('getInstanceState returns active.state for a running instance (含阶段中间态)', async () => {
|
|
1343
|
+
const { engine, persistence } = await harness();
|
|
1344
|
+
await putDefinition(persistence, 'wfl-linear', linearYaml);
|
|
1345
|
+
|
|
1346
|
+
const run = await engine.startInstance({ workflowId: 'wfl-linear', originalInput: 'hello', cwd: '/tmp' });
|
|
1347
|
+
// 实例刚启动(fire-and-forget),内存有 active instance
|
|
1348
|
+
const live = engine.getInstanceState(run.instanceId);
|
|
1349
|
+
expect(live).toBeDefined();
|
|
1350
|
+
expect(live?.taskId).toBe(run.instanceId);
|
|
1351
|
+
expect(live?.originalInput).toBe('hello');
|
|
1352
|
+
// 状态应为 running/pending(fire-and-forget 后内存 state 已更新)
|
|
1353
|
+
expect(['pending', 'running']).toContain(live?.status);
|
|
1354
|
+
});
|
|
1355
|
+
|
|
1356
|
+
it('getInstanceState returns undefined when instance is not in memory (终态清表/未启动)', async () => {
|
|
1357
|
+
const { engine, persistence } = await harness();
|
|
1358
|
+
// 直接写入持久化(不经过引擎内存)
|
|
1359
|
+
await persistence.state.put('inst-persisted', completedSnapshot('inst-persisted'));
|
|
1360
|
+
|
|
1361
|
+
// 内存无此实例 → getInstanceState 返回 undefined
|
|
1362
|
+
const live = engine.getInstanceState('inst-persisted');
|
|
1363
|
+
expect(live).toBeUndefined();
|
|
1364
|
+
});
|
|
1365
|
+
|
|
1366
|
+
it('seeds global default pass-through vars at start so a forward reference resolves (defect 15)', async () => {
|
|
1367
|
+
const { ctx, engine, persistence, shell } = await harness();
|
|
1368
|
+
await putDefinition(persistence, 'wfl-forward-ref', forwardRefYaml);
|
|
1369
|
+
// shell 回显命令本身,便于断言经 replacePassThroughVars 替换后的实际命令。
|
|
1370
|
+
shell.runHandler = async spec => successResult(spec, spec.command);
|
|
1371
|
+
|
|
1372
|
+
const ended = waitForEnd(ctx);
|
|
1373
|
+
await engine.startInstance({ workflowId: 'wfl-forward-ref', originalInput: 'do it', cwd: '/tmp' });
|
|
1374
|
+
await ended;
|
|
1375
|
+
|
|
1376
|
+
// 全局默认收集(first-wins,残余差异一):step-use(Stage 1)引用 ${config},而
|
|
1377
|
+
// config 的字面量默认值 "default-cfg" 只在 Stage 2 的 step-define 声明。startInstance
|
|
1378
|
+
// 全局播种后,Stage 1 执行时 replacePassThroughVars 拿到 'default-cfg' 而非保留
|
|
1379
|
+
// 字面量 ${config}。修复前(passThroughVars 初始为 {})此处为 'echo ${config}'。
|
|
1380
|
+
expect(shell.specs.map(spec => spec.command)).toEqual(['echo default-cfg', 'echo defined']);
|
|
1381
|
+
});
|
|
1382
|
+
});
|
|
1383
|
+
|
|
1384
|
+
describe('SandoreEngineRuntime host agent workspace attach', () => {
|
|
1385
|
+
it('attaches the new host agent to the web workspace resolved by cwd', async () => {
|
|
1386
|
+
const { ctx, engine, persistence } = await harness();
|
|
1387
|
+
await putDefinition(persistence, 'wfl-shell-only', shellOnlyYaml);
|
|
1388
|
+
// 显式提供 workspaceRegistry(默认 harness 不 provide,仅本 describe 内注入)。
|
|
1389
|
+
const workspace = new FakeWorkspace('/ws/project');
|
|
1390
|
+
const registry = new FakeWorkspaceRegistry([workspace]);
|
|
1391
|
+
ctx.provide('workspaceRegistry', registry);
|
|
1392
|
+
|
|
1393
|
+
const ended = waitForEnd(ctx);
|
|
1394
|
+
const run = await engine.startInstance({ workflowId: 'wfl-shell-only', originalInput: 'do it', cwd: '/ws/project' });
|
|
1395
|
+
await ended;
|
|
1396
|
+
|
|
1397
|
+
// 新建分支 attach:hostAgent.id = instanceId 挂到 cwd 命中的 workspace 成员账。
|
|
1398
|
+
expect(workspace.attached).toEqual([run.instanceId]);
|
|
1399
|
+
expect(persistence.state.get(run.instanceId)?.status).toBe('completed');
|
|
1400
|
+
});
|
|
1401
|
+
|
|
1402
|
+
it('auto-creates a workspace when cwd does not resolve to a registered one (对齐 DSH ensureWorkspace)', async () => {
|
|
1403
|
+
const { ctx, engine, persistence } = await harness();
|
|
1404
|
+
await putDefinition(persistence, 'wfl-shell-only', shellOnlyYaml);
|
|
1405
|
+
const registry = new FakeWorkspaceRegistry([new FakeWorkspace('/ws/project')]);
|
|
1406
|
+
ctx.provide('workspaceRegistry', registry);
|
|
1407
|
+
|
|
1408
|
+
// cwd 未命中任何已登记 workspace:attach 前自动 create(不再抛 INVALID_OPERATION),
|
|
1409
|
+
// 实例正常启动,hostAgent 会话挂到按 cwd 新建的 workspace 成员账。
|
|
1410
|
+
const ended = waitForEnd(ctx);
|
|
1411
|
+
const run = await engine.startInstance({ workflowId: 'wfl-shell-only', originalInput: 'do it', cwd: '/other/dir' });
|
|
1412
|
+
await ended;
|
|
1413
|
+
|
|
1414
|
+
const created = await registry.resolveByPath('/other/dir');
|
|
1415
|
+
expect(created).toBeDefined();
|
|
1416
|
+
expect(created?.attached).toEqual([run.instanceId]);
|
|
1417
|
+
expect(persistence.state.get(run.instanceId)?.status).toBe('completed');
|
|
1418
|
+
});
|
|
1419
|
+
|
|
1420
|
+
it('silently skips attach when no workspaceRegistry is provided (test/headless degradation)', async () => {
|
|
1421
|
+
const { ctx, engine, persistence } = await harness();
|
|
1422
|
+
await putDefinition(persistence, 'wfl-shell-only', shellOnlyYaml);
|
|
1423
|
+
|
|
1424
|
+
// 默认 harness 不 provide workspaceRegistry:attach 静默跳过(测试/headless 退化),
|
|
1425
|
+
// 实例正常启动;cwd 仍按必填透传进状态快照。
|
|
1426
|
+
const run = await engine.startInstance({ workflowId: 'wfl-shell-only', originalInput: 'do it', cwd: '/tmp' });
|
|
1427
|
+
expect(run.status).toBe('running');
|
|
1428
|
+
expect(persistence.state.get(run.instanceId)?.cwd).toBe('/tmp');
|
|
1429
|
+
expect(ctx.get('workspaceRegistry')).toBeUndefined();
|
|
1430
|
+
});
|
|
1431
|
+
|
|
1432
|
+
it('re-attaches the recovered host agent to the workspace from the snapshot cwd', async () => {
|
|
1433
|
+
const { ctx, engine, persistence, sessions } = await harness();
|
|
1434
|
+
await putDefinition(persistence, 'wfl-shell-only', shellOnlyYaml);
|
|
1435
|
+
const workspace = new FakeWorkspace('/ws/project');
|
|
1436
|
+
const registry = new FakeWorkspaceRegistry([workspace]);
|
|
1437
|
+
ctx.provide('workspaceRegistry', registry);
|
|
1438
|
+
|
|
1439
|
+
// 崩溃恢复:running 快照的 cwd 指向 workspace 目录(宿主会话 header 同 cwd)。
|
|
1440
|
+
await persistence.state.put('inst-recover', { ...runningSnapshot('inst-recover', 'wfl-shell-only'), cwd: '/ws/project' });
|
|
1441
|
+
sessions.create('inst-recover');
|
|
1442
|
+
|
|
1443
|
+
const ended = waitForEnd(ctx);
|
|
1444
|
+
const result = await engine.recoverInstance('inst-recover');
|
|
1445
|
+
expect(result.status).toBe('running');
|
|
1446
|
+
await ended;
|
|
1447
|
+
|
|
1448
|
+
// 恢复重建 hostAgent 后重新 attach(hostAgent.id = instanceId,对齐 startInstance 新建分支)。
|
|
1449
|
+
expect(workspace.attached).toEqual(['inst-recover']);
|
|
1450
|
+
expect(persistence.state.get('inst-recover')?.status).toBe('completed');
|
|
1451
|
+
});
|
|
1452
|
+
});
|
|
1453
|
+
|
|
1454
|
+
describe('SandoreEngineRuntime linear execution', () => {
|
|
1455
|
+
it('runs a shell Step then a prompt Step to completed with the exact event order', async () => {
|
|
1456
|
+
const { ctx, engine, persistence, llm, shell, agents, sessions } = await harness();
|
|
1457
|
+
await putDefinition(persistence, 'wfl-linear', linearYaml);
|
|
1458
|
+
shell.runHandler = async spec => successResult(spec, 'hello');
|
|
1459
|
+
llm.providers.push({ id: 'p', name: 'Provider' });
|
|
1460
|
+
llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
|
|
1461
|
+
llm.streamHandler = () => textChunks('summary');
|
|
1462
|
+
// prompt Step 走可续接子代理(决策 D3):注册真实 childId 并回填最终输出。
|
|
1463
|
+
const promptChild = new FakeAgent('child-1');
|
|
1464
|
+
promptChild.session.append(assistantMessageEvent([{ type: 'text', text: 'summary' }]));
|
|
1465
|
+
agents.register(promptChild);
|
|
1466
|
+
|
|
1467
|
+
const records = recordSandoreEvents(ctx);
|
|
1468
|
+
const ended = waitForEnd(ctx);
|
|
1469
|
+
|
|
1470
|
+
const run = await engine.startInstance({ workflowId: 'wfl-linear', originalInput: 'do it', cwd: '/tmp' });
|
|
1471
|
+
await ended;
|
|
1472
|
+
|
|
1473
|
+
expect(records.map(record => record.name)).toEqual([
|
|
1474
|
+
'sandore/start',
|
|
1475
|
+
'sandore/stage-start',
|
|
1476
|
+
'sandore/step-start',
|
|
1477
|
+
'sandore/step-completed',
|
|
1478
|
+
'sandore/stage-completed',
|
|
1479
|
+
'sandore/stage-start',
|
|
1480
|
+
'sandore/step-start',
|
|
1481
|
+
'sandore/step-completed',
|
|
1482
|
+
'sandore/stage-completed',
|
|
1483
|
+
'sandore/end',
|
|
1484
|
+
]);
|
|
1485
|
+
|
|
1486
|
+
// Round 25(去污染):宿主会话不再承载 sandore/* 事件(Cordis 事件断言见上
|
|
1487
|
+
// L1473-1484 records;宿主会话仅保留 sandbox/mode 等宿主自身事件)。
|
|
1488
|
+
const session = sessions.get(run.instanceId);
|
|
1489
|
+
expect(session?.events.some(event => event.type.startsWith('sandore/'))).toBe(false);
|
|
1490
|
+
|
|
1491
|
+
// prompt 成功:step-completed 事件 payload 的 output 即聚合 ContentBlock[],
|
|
1492
|
+
// 且 name 为 Step 定义 id(YAML 未写 name 时兜底回退 id);非循环 Step 无 loopIteration。
|
|
1493
|
+
const promptCompleted = records.find(record => record.name === 'sandore/step-completed' && record.args[1] === 'step-prompt');
|
|
1494
|
+
expect(promptCompleted?.args[3]).toEqual([{ type: 'text', text: 'summary' }]);
|
|
1495
|
+
expect(promptCompleted?.args[4]).toBe('step-prompt');
|
|
1496
|
+
expect(promptCompleted?.args[5]).toBeUndefined();
|
|
1497
|
+
|
|
1498
|
+
// 完成后 state 表读回快照。
|
|
1499
|
+
const state = persistence.state.get(run.instanceId);
|
|
1500
|
+
expect(state?.status).toBe('completed');
|
|
1501
|
+
expect(state?.executedSteps).toHaveLength(2);
|
|
1502
|
+
expect(state?.executedSteps[0]?.status).toBe('completed');
|
|
1503
|
+
expect(state?.executedSteps[1]?.status).toBe('completed');
|
|
1504
|
+
expect(state?.executedSteps[0]?.output).toEqual([{ type: 'text', text: 'hello' }]);
|
|
1505
|
+
expect(state?.executedSteps[1]?.output).toEqual([{ type: 'text', text: 'summary' }]);
|
|
1506
|
+
expect(state?.stageInfos[0]?.status).toBe('completed');
|
|
1507
|
+
expect(state?.stageInfos[1]?.status).toBe('completed');
|
|
1508
|
+
expect(Object.keys(state?.stageContracts ?? {})).toHaveLength(2);
|
|
1509
|
+
});
|
|
1510
|
+
|
|
1511
|
+
it('executes multiple steps within a stage in order and assembles the stage contract', async () => {
|
|
1512
|
+
const { ctx, engine, persistence, llm, shell, agents } = await harness();
|
|
1513
|
+
await putDefinition(persistence, 'wfl-multi', multiStepYaml);
|
|
1514
|
+
shell.runHandler = async spec => successResult(spec, 'hello');
|
|
1515
|
+
llm.providers.push({ id: 'p', name: 'Provider' });
|
|
1516
|
+
llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
|
|
1517
|
+
llm.streamHandler = () => textChunks('summary');
|
|
1518
|
+
// step-b 为 prompt Step,走可续接子代理:注册真实 childId 并回填最终输出。
|
|
1519
|
+
const promptChild = new FakeAgent('child-1');
|
|
1520
|
+
promptChild.session.append(assistantMessageEvent([{ type: 'text', text: 'summary' }]));
|
|
1521
|
+
agents.register(promptChild);
|
|
1522
|
+
|
|
1523
|
+
const ended = waitForEnd(ctx);
|
|
1524
|
+
const run = await engine.startInstance({ workflowId: 'wfl-multi', originalInput: 'do it', cwd: '/tmp' });
|
|
1525
|
+
await ended;
|
|
1526
|
+
|
|
1527
|
+
const state = persistence.state.get(run.instanceId);
|
|
1528
|
+
expect(state?.status).toBe('completed');
|
|
1529
|
+
expect(state?.executedSteps.map(step => step.stepId)).toEqual(['step-a', 'step-b']);
|
|
1530
|
+
const contract = state?.stageContracts['stg-1'];
|
|
1531
|
+
expect(contract?.stageId).toBe('stg-1');
|
|
1532
|
+
expect(contract?.stageName).toBe('处理');
|
|
1533
|
+
expect(contract?.status).toBe('completed');
|
|
1534
|
+
expect(contract?.completedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
|
1535
|
+
expect(contract?.artifacts[0]?.stepId).toBe('step-a');
|
|
1536
|
+
expect(contract?.artifacts[0]?.outputToId).toBe('opt-a');
|
|
1537
|
+
});
|
|
1538
|
+
|
|
1539
|
+
it('continues to completed even when a ctx.emit listener throws', async () => {
|
|
1540
|
+
const { ctx, engine, persistence, llm, shell, agents } = await harness();
|
|
1541
|
+
await putDefinition(persistence, 'wfl-linear', linearYaml);
|
|
1542
|
+
shell.runHandler = async spec => successResult(spec, 'hello');
|
|
1543
|
+
llm.providers.push({ id: 'p', name: 'Provider' });
|
|
1544
|
+
llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
|
|
1545
|
+
llm.streamHandler = () => textChunks('summary');
|
|
1546
|
+
// prompt Step 走可续接子代理:注册真实 childId 并回填最终输出。
|
|
1547
|
+
const promptChild = new FakeAgent('child-1');
|
|
1548
|
+
promptChild.session.append(assistantMessageEvent([{ type: 'text', text: 'summary' }]));
|
|
1549
|
+
agents.register(promptChild);
|
|
1550
|
+
|
|
1551
|
+
ctx.on('sandore/step-start', () => {
|
|
1552
|
+
throw new Error('listener boom');
|
|
1553
|
+
});
|
|
1554
|
+
|
|
1555
|
+
const ended = waitForEnd(ctx);
|
|
1556
|
+
const run = await engine.startInstance({ workflowId: 'wfl-linear', originalInput: 'do it', cwd: '/tmp' });
|
|
1557
|
+
await ended;
|
|
1558
|
+
|
|
1559
|
+
expect(persistence.state.get(run.instanceId)?.status).toBe('completed');
|
|
1560
|
+
});
|
|
1561
|
+
|
|
1562
|
+
it('synthesizes childId and sessionMeta for each executed step', async () => {
|
|
1563
|
+
const { ctx, engine, persistence, llm, shell, agents } = await harness();
|
|
1564
|
+
await putDefinition(persistence, 'wfl-linear', linearYaml);
|
|
1565
|
+
shell.runHandler = async spec => successResult(spec, 'hello');
|
|
1566
|
+
llm.providers.push({ id: 'p', name: 'Provider' });
|
|
1567
|
+
llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
|
|
1568
|
+
llm.streamHandler = () => textChunks('summary');
|
|
1569
|
+
// prompt Step 走可续接子代理:注册真实 childId 并回填最终输出。
|
|
1570
|
+
const promptChild = new FakeAgent('child-1');
|
|
1571
|
+
promptChild.session.append(assistantMessageEvent([{ type: 'text', text: 'summary' }]));
|
|
1572
|
+
agents.register(promptChild);
|
|
1573
|
+
|
|
1574
|
+
const ended = waitForEnd(ctx);
|
|
1575
|
+
const run = await engine.startInstance({ workflowId: 'wfl-linear', originalInput: 'do it', cwd: '/tmp' });
|
|
1576
|
+
await ended;
|
|
1577
|
+
|
|
1578
|
+
const state = persistence.state.get(run.instanceId);
|
|
1579
|
+
// shell 合成会话:childId 为合成占位、synthetic:true。
|
|
1580
|
+
expect(state?.executedSteps[0]?.sessionMeta?.childId).toMatch(UUID_RE);
|
|
1581
|
+
expect(state?.executedSteps[0]?.sessionMeta?.synthetic).toBe(true);
|
|
1582
|
+
expect(state?.executedSteps[0]?.sessionMeta?.command).toBe('shell');
|
|
1583
|
+
// prompt 走真实子代理(决策 D3):childId 为真实会话 id、synthetic:false、command 保留 'prompt'。
|
|
1584
|
+
expect(state?.executedSteps[1]?.sessionMeta?.childId).toBe('child-1');
|
|
1585
|
+
expect(state?.executedSteps[1]?.sessionMeta?.synthetic).toBe(false);
|
|
1586
|
+
expect(state?.executedSteps[1]?.sessionMeta?.command).toBe('prompt');
|
|
1587
|
+
});
|
|
1588
|
+
});
|
|
1589
|
+
|
|
1590
|
+
describe('SandoreEngineRuntime failure', () => {
|
|
1591
|
+
it('fails the instance on shell nonzero exit and emits step-failed', async () => {
|
|
1592
|
+
const { ctx, engine, persistence, shell } = await harness();
|
|
1593
|
+
await putDefinition(persistence, 'wfl-linear', linearYaml);
|
|
1594
|
+
shell.runHandler = async spec => ({
|
|
1595
|
+
exitCode: 1,
|
|
1596
|
+
signal: null,
|
|
1597
|
+
timedOut: false,
|
|
1598
|
+
aborted: false,
|
|
1599
|
+
timeoutMs: spec.timeoutMs,
|
|
1600
|
+
stdout: { text: '', truncated: false },
|
|
1601
|
+
stderr: { text: 'boom', truncated: false },
|
|
1602
|
+
});
|
|
1603
|
+
|
|
1604
|
+
const records = recordSandoreEvents(ctx);
|
|
1605
|
+
const ended = waitForEnd(ctx);
|
|
1606
|
+
const run = await engine.startInstance({ workflowId: 'wfl-linear', originalInput: 'do it', cwd: '/tmp' });
|
|
1607
|
+
await ended;
|
|
1608
|
+
|
|
1609
|
+
const state = persistence.state.get(run.instanceId);
|
|
1610
|
+
expect(state?.status).toBe('failed');
|
|
1611
|
+
expect(state?.executedSteps[0]?.error).toContain('boom');
|
|
1612
|
+
const failed = records.find(record => record.name === 'sandore/step-failed');
|
|
1613
|
+
expect(failed).toBeDefined();
|
|
1614
|
+
expect(failed?.args[1]).toBe('step-abc');
|
|
1615
|
+
expect(failed?.args[4]).toBe('step-abc');
|
|
1616
|
+
});
|
|
1617
|
+
|
|
1618
|
+
it('keeps the first step completed when the second step fails', async () => {
|
|
1619
|
+
const { ctx, engine, persistence, shell } = await harness();
|
|
1620
|
+
await putDefinition(persistence, 'wfl-two-shell', twoShellYaml);
|
|
1621
|
+
shell.runHandler = async spec => (spec.command.includes('echo')
|
|
1622
|
+
? successResult(spec, 'ok')
|
|
1623
|
+
: {
|
|
1624
|
+
exitCode: 2,
|
|
1625
|
+
signal: null,
|
|
1626
|
+
timedOut: false,
|
|
1627
|
+
aborted: false,
|
|
1628
|
+
timeoutMs: spec.timeoutMs,
|
|
1629
|
+
stdout: { text: '', truncated: false },
|
|
1630
|
+
stderr: { text: 'second boom', truncated: false },
|
|
1631
|
+
});
|
|
1632
|
+
|
|
1633
|
+
const ended = waitForEnd(ctx);
|
|
1634
|
+
const run = await engine.startInstance({ workflowId: 'wfl-two-shell', originalInput: 'do it', cwd: '/tmp' });
|
|
1635
|
+
await ended;
|
|
1636
|
+
|
|
1637
|
+
const state = persistence.state.get(run.instanceId);
|
|
1638
|
+
expect(state?.status).toBe('failed');
|
|
1639
|
+
expect(state?.executedSteps).toHaveLength(2);
|
|
1640
|
+
expect(state?.executedSteps[0]?.status).toBe('completed');
|
|
1641
|
+
expect(state?.executedSteps[1]?.status).toBe('failed');
|
|
1642
|
+
expect(state?.executedSteps[1]?.error).toBeTruthy();
|
|
1643
|
+
});
|
|
1644
|
+
|
|
1645
|
+
it('fails the instance with failure.message on prompt finish error', async () => {
|
|
1646
|
+
const { ctx, engine, persistence, llm, shell, agents, subagents } = await harness();
|
|
1647
|
+
await putDefinition(persistence, 'wfl-linear', linearYaml);
|
|
1648
|
+
shell.runHandler = async spec => successResult(spec, 'hello');
|
|
1649
|
+
llm.providers.push({ id: 'p', name: 'Provider' });
|
|
1650
|
+
llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
|
|
1651
|
+
// prompt Step 走可续接子代理:child status=running 不自动 settle,由测试触发
|
|
1652
|
+
// settle(error) 模拟模型失败(对齐 DSH `subagent/end` stopReason=error)。
|
|
1653
|
+
const promptChild = new FakeAgent('child-1');
|
|
1654
|
+
promptChild.status = 'running';
|
|
1655
|
+
agents.register(promptChild);
|
|
1656
|
+
|
|
1657
|
+
const stepStarted = waitForStepStart(ctx);
|
|
1658
|
+
const ended = waitForEnd(ctx);
|
|
1659
|
+
const run = await engine.startInstance({ workflowId: 'wfl-linear', originalInput: 'do it', cwd: '/tmp' });
|
|
1660
|
+
await stepStarted;
|
|
1661
|
+
subagents.settleChild('child-1', 'error');
|
|
1662
|
+
await ended;
|
|
1663
|
+
|
|
1664
|
+
const state = persistence.state.get(run.instanceId);
|
|
1665
|
+
expect(state?.status).toBe('failed');
|
|
1666
|
+
expect(state?.executedSteps[1]?.error).toContain('settle error');
|
|
1667
|
+
});
|
|
1668
|
+
});
|
|
1669
|
+
|
|
1670
|
+
describe('SandoreEngineRuntime abort', () => {
|
|
1671
|
+
it('aborts a hanging shell step to the aborted terminal state', async () => {
|
|
1672
|
+
const { ctx, engine, persistence, shell } = await harness();
|
|
1673
|
+
await putDefinition(persistence, 'wfl-linear', linearYaml);
|
|
1674
|
+
shell.runHandler = spec => new Promise(resolve => {
|
|
1675
|
+
const finish = (): void => {
|
|
1676
|
+
resolve({
|
|
1677
|
+
exitCode: null,
|
|
1678
|
+
signal: 'SIGTERM',
|
|
1679
|
+
timedOut: false,
|
|
1680
|
+
aborted: true,
|
|
1681
|
+
timeoutMs: spec.timeoutMs,
|
|
1682
|
+
stdout: { text: '', truncated: false },
|
|
1683
|
+
stderr: { text: '', truncated: false },
|
|
1684
|
+
});
|
|
1685
|
+
};
|
|
1686
|
+
if (spec.signal?.aborted) {
|
|
1687
|
+
finish();
|
|
1688
|
+
return;
|
|
1689
|
+
}
|
|
1690
|
+
spec.signal?.addEventListener('abort', finish, { once: true });
|
|
1691
|
+
});
|
|
1692
|
+
|
|
1693
|
+
const records = recordSandoreEvents(ctx);
|
|
1694
|
+
const aborted = waitForAborted(ctx);
|
|
1695
|
+
const stepStarted = waitForStepStart(ctx);
|
|
1696
|
+
|
|
1697
|
+
const run = await engine.startInstance({ workflowId: 'wfl-linear', originalInput: 'do it', cwd: '/tmp' });
|
|
1698
|
+
await stepStarted;
|
|
1699
|
+
await engine.abortInstance(run.instanceId);
|
|
1700
|
+
await aborted;
|
|
1701
|
+
|
|
1702
|
+
// 收敛到单一出口:abortInstance 显式发射一次,runInstance failed 分支不再重复发射。
|
|
1703
|
+
expect(records.filter(record => record.name === 'sandore/aborted')).toHaveLength(1);
|
|
1704
|
+
expect(persistence.state.get(run.instanceId)?.status).toBe('aborted');
|
|
1705
|
+
});
|
|
1706
|
+
|
|
1707
|
+
it('rejects abortInstance for a completed instance', async () => {
|
|
1708
|
+
const { ctx, engine, persistence, llm, shell } = await harness();
|
|
1709
|
+
await putDefinition(persistence, 'wfl-linear', linearYaml);
|
|
1710
|
+
shell.runHandler = async spec => successResult(spec, 'hello');
|
|
1711
|
+
llm.providers.push({ id: 'p', name: 'Provider' });
|
|
1712
|
+
llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
|
|
1713
|
+
llm.streamHandler = () => textChunks('summary');
|
|
1714
|
+
|
|
1715
|
+
const ended = waitForEnd(ctx);
|
|
1716
|
+
const run = await engine.startInstance({ workflowId: 'wfl-linear', originalInput: 'do it', cwd: '/tmp' });
|
|
1717
|
+
await ended;
|
|
1718
|
+
|
|
1719
|
+
await expect(engine.abortInstance(run.instanceId)).rejects.toMatchObject({ code: 'INVALID_OPERATION' });
|
|
1720
|
+
});
|
|
1721
|
+
});
|
|
1722
|
+
|
|
1723
|
+
describe('SandoreEngineRuntime hostAgent freeze', () => {
|
|
1724
|
+
it('rejects hostAgent pre-step while the workflow is running and passes child pre-steps through', async () => {
|
|
1725
|
+
const { ctx, engine, persistence, shell } = await harness();
|
|
1726
|
+
await putDefinition(persistence, 'wfl-shell-only', shellOnlyYaml);
|
|
1727
|
+
hangingShell(shell);
|
|
1728
|
+
const run = await engine.startInstance({ workflowId: 'wfl-shell-only', originalInput: 'do it', cwd: '/tmp' });
|
|
1729
|
+
|
|
1730
|
+
// 执行中(running):宿主主代理 pre-step 被 reject——DSH 将 turn 以 blocked 关闭、
|
|
1731
|
+
// 不发 user/message、不产生任何模型调用(reject 即 veto,下游 next 不被调用,
|
|
1732
|
+
// 消息不会进入 step)。
|
|
1733
|
+
const hostNext = vi.fn(async (): Promise<PreStepDecision> => ({ kind: 'enter', messages: [] }));
|
|
1734
|
+
const hostDecision = await dispatchPreStep(ctx, run.instanceId, hostNext);
|
|
1735
|
+
expect(hostDecision).toEqual({ kind: 'reject' });
|
|
1736
|
+
expect(hostNext).not.toHaveBeenCalled();
|
|
1737
|
+
|
|
1738
|
+
// 子代理(childId 不在 instances 表):放行——next 被调用、决策为下游 enter
|
|
1739
|
+
// (冻结只作用于宿主主代理,不冻结 Step 子代理)。
|
|
1740
|
+
const childNext = vi.fn(async (): Promise<PreStepDecision> => ({ kind: 'enter', messages: [] }));
|
|
1741
|
+
const childDecision = await dispatchPreStep(ctx, 'child-1', childNext);
|
|
1742
|
+
expect(childDecision).toEqual({ kind: 'enter', messages: [] });
|
|
1743
|
+
expect(childNext).toHaveBeenCalledTimes(1);
|
|
1744
|
+
|
|
1745
|
+
await engine.abortInstance(run.instanceId);
|
|
1746
|
+
});
|
|
1747
|
+
|
|
1748
|
+
it('passes hostAgent pre-step through after the workflow completes (thawed)', async () => {
|
|
1749
|
+
const { ctx, engine, persistence, shell } = await harness();
|
|
1750
|
+
await putDefinition(persistence, 'wfl-shell-only', shellOnlyYaml);
|
|
1751
|
+
shell.runHandler = async spec => successResult(spec, 'hi');
|
|
1752
|
+
const ended = waitForEnd(ctx);
|
|
1753
|
+
const run = await engine.startInstance({ workflowId: 'wfl-shell-only', originalInput: 'do it', cwd: '/tmp' });
|
|
1754
|
+
await ended;
|
|
1755
|
+
expect(persistence.state.get(run.instanceId)?.status).toBe('completed');
|
|
1756
|
+
|
|
1757
|
+
// **唯一解冻点**:completed 后 hostAgent pre-step 放行(引擎不 dispose hostAgent、
|
|
1758
|
+
// ActiveInstance 仍留 instances 表,冻结登记已移除 → 监听器委托 next)。
|
|
1759
|
+
const next = vi.fn(async (): Promise<PreStepDecision> => ({ kind: 'enter', messages: [] }));
|
|
1760
|
+
const decision = await dispatchPreStep(ctx, run.instanceId, next);
|
|
1761
|
+
expect(decision).toEqual({ kind: 'enter', messages: [] });
|
|
1762
|
+
expect(next).toHaveBeenCalledTimes(1);
|
|
1763
|
+
});
|
|
1764
|
+
|
|
1765
|
+
it('still rejects hostAgent pre-step after the workflow fails (stays frozen)', async () => {
|
|
1766
|
+
const { ctx, engine, persistence, shell } = await harness();
|
|
1767
|
+
await putDefinition(persistence, 'wfl-shell-only', shellOnlyYaml);
|
|
1768
|
+
shell.runHandler = async () => ({
|
|
1769
|
+
exitCode: 1,
|
|
1770
|
+
signal: null,
|
|
1771
|
+
timedOut: false,
|
|
1772
|
+
aborted: false,
|
|
1773
|
+
timeoutMs: 0,
|
|
1774
|
+
stdout: { text: '', truncated: false },
|
|
1775
|
+
stderr: { text: 'boom', truncated: false },
|
|
1776
|
+
});
|
|
1777
|
+
const ended = waitForEnd(ctx);
|
|
1778
|
+
const run = await engine.startInstance({ workflowId: 'wfl-shell-only', originalInput: 'do it', cwd: '/tmp' });
|
|
1779
|
+
await ended;
|
|
1780
|
+
expect(persistence.state.get(run.instanceId)?.status).toBe('failed');
|
|
1781
|
+
|
|
1782
|
+
// **仅 completed 解冻**:failed 终态保持冻结——pre-step 仍被 reject、下游 next 不被调用
|
|
1783
|
+
// (工作流未成功完成,hostAgent 不恢复活跃)。
|
|
1784
|
+
const next = vi.fn(async (): Promise<PreStepDecision> => ({ kind: 'enter', messages: [] }));
|
|
1785
|
+
const decision = await dispatchPreStep(ctx, run.instanceId, next);
|
|
1786
|
+
expect(decision).toEqual({ kind: 'reject' });
|
|
1787
|
+
expect(next).not.toHaveBeenCalled();
|
|
1788
|
+
});
|
|
1789
|
+
|
|
1790
|
+
it('passes hostAgent pre-step through after abort (instance cleared from memory → auto thaw)', async () => {
|
|
1791
|
+
const { ctx, engine, persistence, shell } = await harness();
|
|
1792
|
+
await putDefinition(persistence, 'wfl-shell-only', shellOnlyYaml);
|
|
1793
|
+
hangingShell(shell);
|
|
1794
|
+
const stepStarted = waitForStepStart(ctx);
|
|
1795
|
+
const aborted = waitForAborted(ctx);
|
|
1796
|
+
const run = await engine.startInstance({ workflowId: 'wfl-shell-only', originalInput: 'do it', cwd: '/tmp' });
|
|
1797
|
+
await stepStarted;
|
|
1798
|
+
await engine.abortInstance(run.instanceId);
|
|
1799
|
+
await aborted;
|
|
1800
|
+
expect(persistence.state.get(run.instanceId)?.status).toBe('aborted');
|
|
1801
|
+
|
|
1802
|
+
// 清表分支:aborted 后实例清出 instances 表,冻结登记同步移除 → pre-step 放行
|
|
1803
|
+
// (hostAgent 恢复普通响应;与 failed 留表保持冻结区分)。
|
|
1804
|
+
const next = vi.fn(async (): Promise<PreStepDecision> => ({ kind: 'enter', messages: [] }));
|
|
1805
|
+
const decision = await dispatchPreStep(ctx, run.instanceId, next);
|
|
1806
|
+
expect(decision).toEqual({ kind: 'enter', messages: [] });
|
|
1807
|
+
expect(next).toHaveBeenCalledTimes(1);
|
|
1808
|
+
});
|
|
1809
|
+
|
|
1810
|
+
it('freezes the recovered hostAgent again after recoverInstance resumes the instance', async () => {
|
|
1811
|
+
const { ctx, engine, persistence, sessions, shell } = await harness();
|
|
1812
|
+
await putDefinition(persistence, 'wfl-shell-only', shellOnlyYaml);
|
|
1813
|
+
// 空 executedSteps 的 running 快照:recoverInstance 冷恢复宿主主代理(agents.resume)
|
|
1814
|
+
// 后重跑 runInstance;挂起 shell 保持续跑实例 running,便于断言恢复后重新冻结。
|
|
1815
|
+
await persistence.state.put('inst-rec', runningSnapshot('inst-rec', 'wfl-shell-only'));
|
|
1816
|
+
sessions.create('inst-rec');
|
|
1817
|
+
hangingShell(shell);
|
|
1818
|
+
|
|
1819
|
+
const result = await engine.recoverInstance('inst-rec');
|
|
1820
|
+
expect(result.status).toBe('running');
|
|
1821
|
+
|
|
1822
|
+
// 恢复重建的 hostAgent 重新冻结(recoverInstance 把实例 id 重新加入冻结登记):
|
|
1823
|
+
// pre-step 被 reject、下游 next 不被调用。
|
|
1824
|
+
const next = vi.fn(async (): Promise<PreStepDecision> => ({ kind: 'enter', messages: [] }));
|
|
1825
|
+
const decision = await dispatchPreStep(ctx, 'inst-rec', next);
|
|
1826
|
+
expect(decision).toEqual({ kind: 'reject' });
|
|
1827
|
+
expect(next).not.toHaveBeenCalled();
|
|
1828
|
+
|
|
1829
|
+
await engine.abortInstance('inst-rec');
|
|
1830
|
+
});
|
|
1831
|
+
});
|
|
1832
|
+
|
|
1833
|
+
describe('SandoreEngineRuntime setPermissionMode', () => {
|
|
1834
|
+
/**
|
|
1835
|
+
* 折叠读取 session.events 中最新 `sandbox/mode` 事件的 mode(对齐 DSH
|
|
1836
|
+
* `setSandboxMode` 折叠语义:每次受限调用取最新 sandbox/mode 事件值,
|
|
1837
|
+
* 而非创建时快照)。缺陷 29 验证运行中切换后宿主会话折叠值即时变化。
|
|
1838
|
+
*/
|
|
1839
|
+
function effectiveSandboxMode(events: ReadonlyArray<{ type: string; data: unknown }>): string | undefined {
|
|
1840
|
+
let mode: string | undefined;
|
|
1841
|
+
for (const event of events) {
|
|
1842
|
+
if (event.type === 'sandbox/mode' && event.data !== null && typeof event.data === 'object'
|
|
1843
|
+
&& 'mode' in (event.data as Record<string, unknown>)) {
|
|
1844
|
+
mode = (event.data as { mode: string }).mode;
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
return mode;
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
/** 让 shell step 挂起(直到 abort signal 触发 finish),保持实例 running 不进入终态。 */
|
|
1851
|
+
function hangingShell(shell: FakeShell): void {
|
|
1852
|
+
shell.runHandler = spec => new Promise(resolve => {
|
|
1853
|
+
const finish = (): void => { resolve(successResult(spec, '')); };
|
|
1854
|
+
if (spec.signal?.aborted) { finish(); return; }
|
|
1855
|
+
spec.signal?.addEventListener('abort', finish, { once: true });
|
|
1856
|
+
});
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
it('re-applies sandbox mode on the host session and persists state.permission (缺陷 29)', async () => {
|
|
1860
|
+
const { ctx, engine, persistence, shell, sessions } = await harness();
|
|
1861
|
+
await putDefinition(persistence, 'wfl-linear', linearYaml);
|
|
1862
|
+
hangingShell(shell);
|
|
1863
|
+
const stepStarted = waitForStepStart(ctx);
|
|
1864
|
+
|
|
1865
|
+
const run = await engine.startInstance({ workflowId: 'wfl-linear', originalInput: 'do it', cwd: '/tmp', permission: 'read-only' });
|
|
1866
|
+
await stepStarted;
|
|
1867
|
+
const session = sessions.get(run.instanceId)!;
|
|
1868
|
+
|
|
1869
|
+
// 创建时应用 read-only:宿主 session 含 sandbox/mode=read-only 事件,快照 permission=read-only。
|
|
1870
|
+
expect(effectiveSandboxMode(session.events)).toBe('read-only');
|
|
1871
|
+
expect(persistence.state.get(run.instanceId)?.permission).toBe('read-only');
|
|
1872
|
+
|
|
1873
|
+
// 运行中切换到 workspace-write(复用 applyPermission 可重入:追加 sandbox/mode
|
|
1874
|
+
// 事件 + setPolicy,DSH 折叠读取取最新,幂等重放不累积副作用)。
|
|
1875
|
+
await engine.setPermissionMode(run.instanceId, 'workspace-write');
|
|
1876
|
+
|
|
1877
|
+
// 宿主 session 新增 sandbox/mode=workspace-write 事件(折叠后 effectiveSandboxMode 变化)。
|
|
1878
|
+
expect(effectiveSandboxMode(session.events)).toBe('workspace-write');
|
|
1879
|
+
// 快照 permission 同步更新并持久化(UI 列表/详情可读当前权限;recoverInstance 靠
|
|
1880
|
+
// session 事件重放恢复权限,快照字段保持一致避免漂移)。
|
|
1881
|
+
expect(persistence.state.get(run.instanceId)?.permission).toBe('workspace-write');
|
|
1882
|
+
|
|
1883
|
+
await engine.abortInstance(run.instanceId);
|
|
1884
|
+
});
|
|
1885
|
+
|
|
1886
|
+
it('throws NODE_NOT_FOUND for an unknown instance (缺陷 29)', async () => {
|
|
1887
|
+
const { engine } = await harness();
|
|
1888
|
+
await expect(engine.setPermissionMode('inst-none', 'read-only')).rejects.toMatchObject({ code: 'NODE_NOT_FOUND' });
|
|
1889
|
+
});
|
|
1890
|
+
|
|
1891
|
+
it('rejects a terminal instance with INVALID_OPERATION (缺陷 29)', async () => {
|
|
1892
|
+
const { ctx, engine, persistence, llm, shell } = await harness();
|
|
1893
|
+
await putDefinition(persistence, 'wfl-linear', linearYaml);
|
|
1894
|
+
shell.runHandler = async spec => successResult(spec, 'hello');
|
|
1895
|
+
llm.providers.push({ id: 'p', name: 'Provider' });
|
|
1896
|
+
llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
|
|
1897
|
+
llm.streamHandler = () => textChunks('summary');
|
|
1898
|
+
|
|
1899
|
+
const ended = waitForEnd(ctx);
|
|
1900
|
+
const run = await engine.startInstance({ workflowId: 'wfl-linear', originalInput: 'do it', cwd: '/tmp', permission: 'read-only' });
|
|
1901
|
+
await ended;
|
|
1902
|
+
|
|
1903
|
+
await expect(engine.setPermissionMode(run.instanceId, 'workspace-write')).rejects.toMatchObject({ code: 'INVALID_OPERATION' });
|
|
1904
|
+
});
|
|
1905
|
+
|
|
1906
|
+
// ── 子代理快照契约(SA8 核实要求固化)──────────────────────────────────
|
|
1907
|
+
// sandore Step 以子代理运行(step-runner.ts startContinuable,parent=宿主),委派时
|
|
1908
|
+
// 快照宿主 sandbox+approval。故运行中切换权限对【宿主会话】与【切换后新委派的
|
|
1909
|
+
// Step】即时生效,但【已运行 Step 子代理】保持其委派时的权限快照——与基准
|
|
1910
|
+
// 「StepRunner 闭包 `() => this._permissionMode` 每次权限请求读最新值」语义不同。
|
|
1911
|
+
// 此为既定架构差异,非缺陷。mock 不模拟真实 sandbox 折叠,故以「setPermissionMode
|
|
1912
|
+
// 不触及 subagents 任何调用路径」+「宿主 effectiveSandboxMode 即时变化」为可观测契约
|
|
1913
|
+
// 代理指标,固化「已委派子代理快照不被运行中切换回溯」的语义边界。
|
|
1914
|
+
it('subagent delegation snapshot is not retroactively changed by runtime switch (缺陷 29 架构差异, 非缺陷)', async () => {
|
|
1915
|
+
const { ctx, engine, persistence, shell, sessions, subagents } = await harness();
|
|
1916
|
+
await putDefinition(persistence, 'wfl-linear', linearYaml);
|
|
1917
|
+
hangingShell(shell);
|
|
1918
|
+
const stepStarted = waitForStepStart(ctx);
|
|
1919
|
+
|
|
1920
|
+
const run = await engine.startInstance({ workflowId: 'wfl-linear', originalInput: 'do it', cwd: '/tmp', permission: 'read-only' });
|
|
1921
|
+
await stepStarted;
|
|
1922
|
+
const session = sessions.get(run.instanceId)!;
|
|
1923
|
+
|
|
1924
|
+
// 委派时刻基线:宿主会话 effectiveSandboxMode=read-only(子代理在 startContinuable
|
|
1925
|
+
// 时快照此值——已运行 Step 子代理的权限请求按此旧值判定)。
|
|
1926
|
+
expect(effectiveSandboxMode(session.events)).toBe('read-only');
|
|
1927
|
+
const startsBefore = subagents.continuableStarts.length;
|
|
1928
|
+
|
|
1929
|
+
// 运行中切换到 workspace-write。
|
|
1930
|
+
await engine.setPermissionMode(run.instanceId, 'workspace-write');
|
|
1931
|
+
|
|
1932
|
+
// ①宿主会话 effectiveSandboxMode 即时变为 workspace-write(折叠读取最新事件)。
|
|
1933
|
+
// 切换后新委派的 Step 在 startContinuable 时会快照此新值 → 即时生效。
|
|
1934
|
+
expect(effectiveSandboxMode(session.events)).toBe('workspace-write');
|
|
1935
|
+
|
|
1936
|
+
// ②setPermissionMode 不触及 subagents 任何调用路径:不触发新子代理委派、不
|
|
1937
|
+
// followup、不 interrupt 已运行子代理。sandore 子代理在 startContinuable 时
|
|
1938
|
+
// 快照宿主 sandbox+approval,运行中切换只对宿主会话追加 sandbox/mode 事件,
|
|
1939
|
+
// 不回溯已委派子代理——故已运行 Step 子代理的委派快照保持其创建时的值
|
|
1940
|
+
// (权限请求仍按旧模式判定)。【架构差异 vs 基准】基准 StepRunner 以闭包
|
|
1941
|
+
// `() => this._permissionMode` 每次权限请求读最新值,运行中切换对已运行 Step
|
|
1942
|
+
// 也即时生效;sandore 因子代理委派快照机制,已运行 Step 保持旧快照。此差异
|
|
1943
|
+
// 为既定架构,非缺陷。
|
|
1944
|
+
expect(subagents.continuableStarts.length).toBe(startsBefore);
|
|
1945
|
+
expect(subagents.followups).toHaveLength(0);
|
|
1946
|
+
expect(subagents.interrupts).toHaveLength(0);
|
|
1947
|
+
|
|
1948
|
+
await engine.abortInstance(run.instanceId);
|
|
1949
|
+
});
|
|
1950
|
+
});
|
|
1951
|
+
|
|
1952
|
+
describe('SandoreEngineRuntime DAG execution', () => {
|
|
1953
|
+
it('routes branches, runs the loop, extracts pass-through vars and passes the gate to completed', async () => {
|
|
1954
|
+
const { ctx, engine, persistence, llm, shell, subagents, agents } = await harness();
|
|
1955
|
+
await putDefinition(persistence, 'wfl-dag', dagYaml);
|
|
1956
|
+
shell.runHandler = async spec => successResult(spec, 'ok');
|
|
1957
|
+
llm.providers.push({ id: 'p', name: 'Provider' });
|
|
1958
|
+
llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
|
|
1959
|
+
|
|
1960
|
+
// 条件分支评估(批量 shouldExecute 分支 id 数组)→ agent 执行 → prompt 执行(子代理)→
|
|
1961
|
+
// 收尾 Phase A assess(hasAsk 格式)→ Phase B 真 fork/降级(compressedOutput 合并
|
|
1962
|
+
// JSON,偏离 2:不复用 Phase A 结果)。primary 门禁为纯代码产物校验(零 AI)。
|
|
1963
|
+
// 按首条消息文本分发:shouldExecute → 批量分支 JSON;compressedOutput → Phase B 收尾 JSON;
|
|
1964
|
+
// 其余(hasAsk)→ Phase A 评估 JSON。
|
|
1965
|
+
llm.streamHandler = (options) => {
|
|
1966
|
+
const text = (options.messages[0]?.content[0] as TextBlock | undefined)?.text ?? '';
|
|
1967
|
+
if (text.includes('shouldExecute')) {
|
|
1968
|
+
// Round 20:批量模板——返回满足条件的分支 id 数组(branchId = branch.next)。
|
|
1969
|
+
return textChunks('{"shouldExecute": ["step-agent"]}');
|
|
1970
|
+
}
|
|
1971
|
+
if (text.includes('compressedOutput')) {
|
|
1972
|
+
return textChunks('{"passThroughVars":{"projectName":"sandore"},"compressedOutput":"finalize 摘要"}');
|
|
1973
|
+
}
|
|
1974
|
+
return textChunks('{"hasAsk":false,"passThroughVars":{"projectName":"sandore"}}');
|
|
1975
|
+
};
|
|
1976
|
+
|
|
1977
|
+
// agent Step 走可续接子代理:注册真实 childId 并回填最终 assistant 输出。
|
|
1978
|
+
const child = new FakeAgent('child-1');
|
|
1979
|
+
child.session.append(assistantMessageEvent([{ type: 'text', text: 'agent output' }]));
|
|
1980
|
+
agents.register(child);
|
|
1981
|
+
// prompt Step(step-finalize)同为真实子代理(决策 D3):childId=child-2。
|
|
1982
|
+
const promptChild = new FakeAgent('child-2');
|
|
1983
|
+
promptChild.session.append(assistantMessageEvent([{ type: 'text', text: 'report done' }]));
|
|
1984
|
+
agents.register(promptChild);
|
|
1985
|
+
|
|
1986
|
+
const records = recordSandoreEvents(ctx);
|
|
1987
|
+
const ended = waitForEnd(ctx);
|
|
1988
|
+
const run = await engine.startInstance({ workflowId: 'wfl-dag', originalInput: 'do it', cwd: '/tmp' });
|
|
1989
|
+
await ended;
|
|
1990
|
+
|
|
1991
|
+
const state = persistence.state.get(run.instanceId);
|
|
1992
|
+
expect(state?.status).toBe('completed');
|
|
1993
|
+
|
|
1994
|
+
// 分支路由:匹配 agent 分支 + 并行分支;回退分支未执行;汇合节点仅执行一次。
|
|
1995
|
+
// 注:基准模型下路由门+body 的 body 跑 maxIter-1 次(loop head 每 visit 一次,
|
|
1996
|
+
// 末次 break 不路由到 body),故 step-loop(maxIter=3) 的 body(step-iter) 跑 2 次。
|
|
1997
|
+
const stepIds = state?.executedSteps.map(step => step.stepId);
|
|
1998
|
+
expect(stepIds).toEqual([
|
|
1999
|
+
'step-branch',
|
|
2000
|
+
'step-parallel',
|
|
2001
|
+
'step-agent',
|
|
2002
|
+
'step-merge',
|
|
2003
|
+
'step-iter',
|
|
2004
|
+
'step-iter',
|
|
2005
|
+
'step-finalize',
|
|
2006
|
+
]);
|
|
2007
|
+
expect(stepIds?.filter(id => id === 'step-fallback')).toHaveLength(0);
|
|
2008
|
+
expect(stepIds?.filter(id => id === 'step-merge')).toHaveLength(1);
|
|
2009
|
+
|
|
2010
|
+
// 分支决策点保留 branchMeta 评估现场(批量 shouldExecute 分支 id 数组判定输出)。
|
|
2011
|
+
const branchEntry = state?.executedSteps.find(step => step.stepId === 'step-branch');
|
|
2012
|
+
expect(branchEntry?.branchMeta?.status).toBe('completed');
|
|
2013
|
+
expect(branchEntry?.branchMeta?.result).toBe('{"shouldExecute": ["step-agent"]}');
|
|
2014
|
+
|
|
2015
|
+
// agent Step:真实 childId(synthetic:false)+ 子代理最终输出。
|
|
2016
|
+
const agentStep = state?.executedSteps.find(step => step.stepId === 'step-agent');
|
|
2017
|
+
expect(agentStep?.sessionMeta).toEqual({ childId: 'child-1', synthetic: false, command: 'agent' });
|
|
2018
|
+
expect(agentStep?.output).toEqual([{ type: 'text', text: 'agent output' }]);
|
|
2019
|
+
|
|
2020
|
+
// hostAgent 注入:agent Step 的子代理 parent 即宿主主代理(id = instanceId)。
|
|
2021
|
+
expect(subagents.continuableStarts[0]?.request.parent.id).toBe(run.instanceId);
|
|
2022
|
+
|
|
2023
|
+
// 循环:2 次扁平迭代(路由门 body 跑 maxIter-1 次),loopIteration 嵌套路径;
|
|
2024
|
+
// 段 id 为纯 UUID(§3.3),sessionMeta.childId 也为纯 UUID(合成占位)。
|
|
2025
|
+
const iterations = state?.executedSteps.filter(step => step.stepId === 'step-iter') ?? [];
|
|
2026
|
+
expect(iterations.map(step => step.loopIteration)).toEqual([[1], [2]]);
|
|
2027
|
+
expect(iterations[0]?.id).toMatch(UUID_RE);
|
|
2028
|
+
expect(iterations[0]?.sessionMeta?.childId).toMatch(/^step-iter-[0-9a-f]{4}$/);
|
|
2029
|
+
expect(iterations[1]?.visitCount).toBe(2);
|
|
2030
|
+
|
|
2031
|
+
// 透传变量回写:finalize 提取 projectName 并保留 description。
|
|
2032
|
+
expect(state?.passThroughVars['projectName']).toEqual({ value: 'sandore', description: '项目名' });
|
|
2033
|
+
|
|
2034
|
+
// 门禁通过(primary 纯代码产物校验,无 outputTo → 通过;无 secondary → 零门禁 AI):
|
|
2035
|
+
// finalizeMeta 记录 gateResult + currentPhase='completed'。
|
|
2036
|
+
const finalizeStep = state?.executedSteps.find(step => step.stepId === 'step-finalize');
|
|
2037
|
+
expect(finalizeStep?.finalizeMeta?.gateResult?.gatesPassed).toBe(true);
|
|
2038
|
+
expect(finalizeStep?.finalizeMeta?.gateResult?.primaryPassed).toBe(true);
|
|
2039
|
+
expect(finalizeStep?.finalizeMeta?.currentPhase).toBe('completed');
|
|
2040
|
+
|
|
2041
|
+
// llm 调用覆盖:条件分支 1 + Phase A assess 1 + Phase B 重新 assess 1 = 3 次
|
|
2042
|
+
// (primary 纯代码校验零 AI;prompt Step 输出来自子代理会话,不消费 llm.stream)。
|
|
2043
|
+
expect(llm.calls).toHaveLength(3);
|
|
2044
|
+
|
|
2045
|
+
// agent Step 的 step-completed 事件载荷:真实 childId + 最终 output + name;
|
|
2046
|
+
// 非循环 Step 无 loopIteration。
|
|
2047
|
+
const agentCompleted = records.find(record => record.name === 'sandore/step-completed' && record.args[1] === 'step-agent');
|
|
2048
|
+
expect(agentCompleted?.args[2]).toBe('child-1');
|
|
2049
|
+
expect(agentCompleted?.args[3]).toEqual([{ type: 'text', text: 'agent output' }]);
|
|
2050
|
+
expect(agentCompleted?.args[4]).toBe('step-agent');
|
|
2051
|
+
expect(agentCompleted?.args[5]).toBeUndefined();
|
|
2052
|
+
|
|
2053
|
+
// 循环内 Step 的 step-completed 事件载荷:name + 扁平迭代路径(末尾位置)。
|
|
2054
|
+
const iterCompleted = records.filter(record => record.name === 'sandore/step-completed' && record.args[1] === 'step-iter');
|
|
2055
|
+
expect(iterCompleted.map(record => record.args[4])).toEqual(['step-iter', 'step-iter']);
|
|
2056
|
+
expect(iterCompleted.map(record => record.args[5])).toEqual([[1], [2]]);
|
|
2057
|
+
|
|
2058
|
+
// 两 Stage 均 completed,契约落盘。
|
|
2059
|
+
expect(state?.stageInfos.map(info => info.status)).toEqual(['completed', 'completed']);
|
|
2060
|
+
expect(Object.keys(state?.stageContracts ?? {})).toHaveLength(2);
|
|
2061
|
+
});
|
|
2062
|
+
|
|
2063
|
+
it('falls back to array order and preserves branchMeta when no branch matches', async () => {
|
|
2064
|
+
const { ctx, engine, persistence, llm, shell } = await harness();
|
|
2065
|
+
await putDefinition(persistence, 'wfl-branch-fallback', branchFallbackYaml);
|
|
2066
|
+
shell.runHandler = async spec => successResult(spec, 'ok');
|
|
2067
|
+
llm.providers.push({ id: 'p', name: 'Provider' });
|
|
2068
|
+
llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
|
|
2069
|
+
// Round 20:批量模板——空数组 = 无分支命中。
|
|
2070
|
+
llm.streamHandler = () => textChunks('{"shouldExecute": []}');
|
|
2071
|
+
|
|
2072
|
+
const ended = waitForEnd(ctx);
|
|
2073
|
+
const run = await engine.startInstance({ workflowId: 'wfl-branch-fallback', originalInput: 'do it', cwd: '/tmp' });
|
|
2074
|
+
await ended;
|
|
2075
|
+
|
|
2076
|
+
const state = persistence.state.get(run.instanceId);
|
|
2077
|
+
expect(state?.status).toBe('completed');
|
|
2078
|
+
const stepIds = state?.executedSteps.map(step => step.stepId);
|
|
2079
|
+
expect(stepIds).toEqual(['step-branch', 'step-fallback', 'step-matched']);
|
|
2080
|
+
|
|
2081
|
+
// branchMeta 保留原始评估结果(空数组证明条件分支未命中)。
|
|
2082
|
+
const branchEntry = state?.executedSteps.find(step => step.stepId === 'step-branch');
|
|
2083
|
+
expect(branchEntry?.branchMeta?.status).toBe('completed');
|
|
2084
|
+
expect(branchEntry?.branchMeta?.result).toBe('{"shouldExecute": []}');
|
|
2085
|
+
});
|
|
2086
|
+
|
|
2087
|
+
it('retries an AI condition branch when the first output is unparseable then passes on the retry', async () => {
|
|
2088
|
+
const { ctx, engine, persistence, llm, shell } = await harness();
|
|
2089
|
+
await putDefinition(persistence, 'wfl-branch-fallback', branchFallbackYaml);
|
|
2090
|
+
shell.runHandler = async spec => successResult(spec, 'ok');
|
|
2091
|
+
llm.providers.push({ id: 'p', name: 'Provider' });
|
|
2092
|
+
llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
|
|
2093
|
+
// 首轮返回乱码(不可解析 shouldExecute 数组)→ evaluateConditions 触发 follow-up
|
|
2094
|
+
// 重试 → 第二轮返回合法分支 id 数组(branchId = branch.next = step-matched)。
|
|
2095
|
+
const responses = ['I cannot decide', '{"shouldExecute": ["step-matched"]}'];
|
|
2096
|
+
let callIndex = 0;
|
|
2097
|
+
llm.streamHandler = () => textChunks(responses[callIndex++] ?? '');
|
|
2098
|
+
|
|
2099
|
+
const ended = waitForEnd(ctx);
|
|
2100
|
+
const run = await engine.startInstance({ workflowId: 'wfl-branch-fallback', originalInput: 'do it', cwd: '/tmp' });
|
|
2101
|
+
await ended;
|
|
2102
|
+
|
|
2103
|
+
const state = persistence.state.get(run.instanceId);
|
|
2104
|
+
expect(state?.status).toBe('completed');
|
|
2105
|
+
|
|
2106
|
+
// 重试后条件通过 → step-matched 执行;step-fallback 未命中(条件分支优先于数组兜底)。
|
|
2107
|
+
const stepIds = state?.executedSteps.map(step => step.stepId);
|
|
2108
|
+
expect(stepIds).toEqual(['step-branch', 'step-matched']);
|
|
2109
|
+
|
|
2110
|
+
// 条件评估共 2 次 llm.stream 调用(首轮乱码 + 1 次纠正重试)。
|
|
2111
|
+
expect(llm.calls).toHaveLength(2);
|
|
2112
|
+
const branchEntry = state?.executedSteps.find(step => step.stepId === 'step-branch');
|
|
2113
|
+
expect(branchEntry?.branchMeta?.status).toBe('completed');
|
|
2114
|
+
expect(branchEntry?.branchMeta?.result).toBe('{"shouldExecute": ["step-matched"]}');
|
|
2115
|
+
});
|
|
2116
|
+
|
|
2117
|
+
it('falls back to array order when an AI condition branch exhausts shouldExecute retries', async () => {
|
|
2118
|
+
const { ctx, engine, persistence, llm, shell } = await harness();
|
|
2119
|
+
await putDefinition(persistence, 'wfl-branch-fallback', branchFallbackYaml);
|
|
2120
|
+
shell.runHandler = async spec => successResult(spec, 'ok');
|
|
2121
|
+
llm.providers.push({ id: 'p', name: 'Provider' });
|
|
2122
|
+
llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
|
|
2123
|
+
// 始终返回乱码:首轮 + MAX_BRANCH_EVAL_RETRIES(2) 次重试均不可解析 → 兜底 false。
|
|
2124
|
+
llm.streamHandler = () => textChunks('totally not json');
|
|
2125
|
+
|
|
2126
|
+
const ended = waitForEnd(ctx);
|
|
2127
|
+
const run = await engine.startInstance({ workflowId: 'wfl-branch-fallback', originalInput: 'do it', cwd: '/tmp' });
|
|
2128
|
+
await ended;
|
|
2129
|
+
|
|
2130
|
+
const state = persistence.state.get(run.instanceId);
|
|
2131
|
+
expect(state?.status).toBe('completed');
|
|
2132
|
+
|
|
2133
|
+
// 重试耗尽 → 条件未通过 → 数组顺序兜底 step-fallback;step-matched 未执行。
|
|
2134
|
+
const stepIds = state?.executedSteps.map(step => step.stepId);
|
|
2135
|
+
expect(stepIds).toEqual(['step-branch', 'step-fallback', 'step-matched']);
|
|
2136
|
+
|
|
2137
|
+
// 条件评估共 3 次 llm.stream 调用(首轮 + 2 次重试)。
|
|
2138
|
+
expect(llm.calls).toHaveLength(3);
|
|
2139
|
+
const branchEntry = state?.executedSteps.find(step => step.stepId === 'step-branch');
|
|
2140
|
+
expect(branchEntry?.branchMeta?.status).toBe('completed');
|
|
2141
|
+
expect(branchEntry?.branchMeta?.result).toBe('totally not json');
|
|
2142
|
+
});
|
|
2143
|
+
|
|
2144
|
+
it('executes nested loops fully on every outer iteration (inner loop visitCount resets)', async () => {
|
|
2145
|
+
const { ctx, engine, persistence, shell } = await harness();
|
|
2146
|
+
await putDefinition(persistence, 'wfl-nested-loop', nestedLoopYaml);
|
|
2147
|
+
shell.runHandler = async spec => successResult(spec, 'iter');
|
|
2148
|
+
|
|
2149
|
+
const records = recordSandoreEvents(ctx);
|
|
2150
|
+
const ended = waitForEnd(ctx);
|
|
2151
|
+
const run = await engine.startInstance({ workflowId: 'wfl-nested-loop', originalInput: 'do it', cwd: '/tmp' });
|
|
2152
|
+
await ended;
|
|
2153
|
+
|
|
2154
|
+
const state = persistence.state.get(run.instanceId);
|
|
2155
|
+
expect(state?.status).toBe('completed');
|
|
2156
|
+
|
|
2157
|
+
// 基准模型:路由门 body 跑 maxIter-1 次。外层 maxIter=3 → 外层 body(内层循环) 跑 2
|
|
2158
|
+
// 次(外层 visit 1、2 继续,visit 3 跳出);内层 maxIter=3 → 内层 body 跑 2 次。
|
|
2159
|
+
// 故 (3-1)×(3-1)=4 次 body 执行,loopIteration 路径 [[1,1],[1,2],[2,1],[2,2]]。
|
|
2160
|
+
// 外层重入内层时内层 visitCount 经 resetLoopVisitCounts 从 0 起重新计数
|
|
2161
|
+
// (跳出时归零作用域语义),验证嵌套重入不残留旧计数。
|
|
2162
|
+
const iterations = state?.executedSteps.filter(step => step.stepId === 'step-iter') ?? [];
|
|
2163
|
+
expect(iterations.map(step => step.loopIteration)).toEqual([[1, 1], [1, 2], [2, 1], [2, 2]]);
|
|
2164
|
+
|
|
2165
|
+
// 事件 payload 的 loopIteration 为嵌套完整路径数组(与 state 快照一致)。
|
|
2166
|
+
const iterCompleted = records.filter(record => record.name === 'sandore/step-completed' && record.args[1] === 'step-iter');
|
|
2167
|
+
expect(iterCompleted.map(record => record.args[5])).toEqual([[1, 1], [1, 2], [2, 1], [2, 2]]);
|
|
2168
|
+
});
|
|
2169
|
+
|
|
2170
|
+
it('runs the loop to maxIterations when the output-driven breakCondition never signals done', async () => {
|
|
2171
|
+
const { ctx, engine, persistence, llm, shell } = await harness();
|
|
2172
|
+
await putDefinition(persistence, 'wfl-loop-break', loopBreakYaml);
|
|
2173
|
+
shell.runHandler = async spec => successResult(spec, 'iter');
|
|
2174
|
+
llm.providers.push({ id: 'p', name: 'Provider' });
|
|
2175
|
+
llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
|
|
2176
|
+
// fork-based 改造(缺陷 4):breakCondition 走 fork 会话注入全量历史;step-iter 为 shell
|
|
2177
|
+
// 合成 session(无真实 session 可 fork)→ 判断降级为临时会话(judgePrompt 不含手动 I/O 块)。
|
|
2178
|
+
// mock 恒返回 shouldBreak=false → 每趟不跳 → 跑满 maxIter=5 后硬上限 break。
|
|
2179
|
+
llm.streamHandler = () => textChunks('{"shouldBreak": false}');
|
|
2180
|
+
|
|
2181
|
+
const ended = waitForEnd(ctx);
|
|
2182
|
+
const run = await engine.startInstance({ workflowId: 'wfl-loop-break', originalInput: 'do it', cwd: '/tmp' });
|
|
2183
|
+
await ended;
|
|
2184
|
+
|
|
2185
|
+
const state = persistence.state.get(run.instanceId);
|
|
2186
|
+
expect(state?.status).toBe('completed');
|
|
2187
|
+
// 循环体 step-iter 跑 maxIter-1 次(visit 1..4 继续,visit 5 硬上限 break)。
|
|
2188
|
+
const iterations = state?.executedSteps.filter(step => step.stepId === 'step-iter') ?? [];
|
|
2189
|
+
expect(iterations).toHaveLength(4);
|
|
2190
|
+
// 跑满 maxIter 后跳出,进入 step-done 收尾。
|
|
2191
|
+
expect(state?.executedSteps.some(step => step.stepId === 'step-done')).toBe(true);
|
|
2192
|
+
// fork-based 改造:breakCondition 走专用 shouldBreak 模板(不再手动注入 I/O 块——
|
|
2193
|
+
// shell step 降级为临时会话,AI 依据 promptText 判断)。
|
|
2194
|
+
const breakPrompt = llm.calls
|
|
2195
|
+
.map(call => (call.messages[0]?.content[0] as TextBlock | undefined)?.text ?? '')
|
|
2196
|
+
.find(text => text.includes('shouldBreak'));
|
|
2197
|
+
expect(breakPrompt).toBeDefined();
|
|
2198
|
+
});
|
|
2199
|
+
|
|
2200
|
+
it('runs a command-gate loop exactly maxIterations times (loop head carries a command)', async () => {
|
|
2201
|
+
const { ctx, engine, persistence, shell } = await harness();
|
|
2202
|
+
await putDefinition(persistence, 'wfl-loop-command-gate', loopCommandGateYaml);
|
|
2203
|
+
shell.runHandler = async spec => successResult(spec, 'loop');
|
|
2204
|
+
|
|
2205
|
+
const ended = waitForEnd(ctx);
|
|
2206
|
+
const run = await engine.startInstance({ workflowId: 'wfl-loop-command-gate', originalInput: 'do it', cwd: '/tmp' });
|
|
2207
|
+
await ended;
|
|
2208
|
+
|
|
2209
|
+
const state = persistence.state.get(run.instanceId);
|
|
2210
|
+
expect(state?.status).toBe('completed');
|
|
2211
|
+
|
|
2212
|
+
// 命令门(loop head 自带 command + 自回边):基准模型 maxIter = loop head 被 visit
|
|
2213
|
+
// 的次数,命令门每 visit 一次就跑一次 command → 命令跑 maxIter 次。visitCount 递增
|
|
2214
|
+
// 已统一移至 runLoop(单一递增点),checkLoopBreak 只读,根治双重计数。
|
|
2215
|
+
const iterations = state?.executedSteps.filter(step => step.stepId === 'step-loop') ?? [];
|
|
2216
|
+
expect(iterations).toHaveLength(3);
|
|
2217
|
+
// 跳出后进入 step-done 收尾。
|
|
2218
|
+
expect(state?.executedSteps.some(step => step.stepId === 'step-done')).toBe(true);
|
|
2219
|
+
});
|
|
2220
|
+
|
|
2221
|
+
it('runs a command-gate loop exactly N times for maxIter=N (1..6)', async () => {
|
|
2222
|
+
const { ctx, engine, persistence, shell } = await harness();
|
|
2223
|
+
shell.runHandler = async spec => successResult(spec, 'loop');
|
|
2224
|
+
|
|
2225
|
+
for (let maxIter = 1; maxIter <= 6; maxIter += 1) {
|
|
2226
|
+
const wfId = `wfl-cg-${maxIter}`;
|
|
2227
|
+
await putDefinition(persistence, wfId, commandGateYaml(maxIter));
|
|
2228
|
+
|
|
2229
|
+
const ended = waitForEnd(ctx);
|
|
2230
|
+
const run = await engine.startInstance({ workflowId: wfId, originalInput: 'do it', cwd: '/tmp' });
|
|
2231
|
+
await ended;
|
|
2232
|
+
|
|
2233
|
+
const state = persistence.state.get(run.instanceId);
|
|
2234
|
+
expect(state?.status).toBe('completed');
|
|
2235
|
+
// 命令门:maxIter=N → command 跑 N 次(loop head 每 visit 一次即跑一次 command,
|
|
2236
|
+
// 末次 visit 触发 break)。根治修前 maxIter=4 只跑 3 次的双重计数 bug。
|
|
2237
|
+
const iterations = state?.executedSteps.filter(step => step.stepId === 'step-loop') ?? [];
|
|
2238
|
+
expect(iterations).toHaveLength(maxIter);
|
|
2239
|
+
// 跳出后进入 step-done 收尾。
|
|
2240
|
+
expect(state?.executedSteps.some(step => step.stepId === 'step-done')).toBe(true);
|
|
2241
|
+
// 清理实例避免跨轮干扰。
|
|
2242
|
+
persistence.state.delete(run.instanceId);
|
|
2243
|
+
}
|
|
2244
|
+
});
|
|
2245
|
+
|
|
2246
|
+
it('runs a breakCondition loop exactly maxIterations times when shouldBreak stays false', async () => {
|
|
2247
|
+
const { ctx, engine, persistence, llm, shell } = await harness();
|
|
2248
|
+
await putDefinition(persistence, 'wfl-loop-break-no-break', loopBreakNoBreakYaml);
|
|
2249
|
+
shell.runHandler = async spec => successResult(spec, 'iter');
|
|
2250
|
+
llm.providers.push({ id: 'p', name: 'Provider' });
|
|
2251
|
+
llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
|
|
2252
|
+
llm.streamHandler = () => textChunks('{"shouldBreak": false}');
|
|
2253
|
+
|
|
2254
|
+
const ended = waitForEnd(ctx);
|
|
2255
|
+
const run = await engine.startInstance({ workflowId: 'wfl-loop-break-no-break', originalInput: 'do it', cwd: '/tmp' });
|
|
2256
|
+
await ended;
|
|
2257
|
+
|
|
2258
|
+
const state = persistence.state.get(run.instanceId);
|
|
2259
|
+
expect(state?.status).toBe('completed');
|
|
2260
|
+
|
|
2261
|
+
// breakCondition 门:loop-manager 每趟评估产出 loopMeta,step-navigator continue
|
|
2262
|
+
// 与 break 分支均透传 loopMeta(缺陷 23)→ 每次评估都回写对应 step-loop 路由条目。
|
|
2263
|
+
// 不双重计数:visitCount 递增仍由 runLoop 单一完成(缺陷 3 已重构),路由条目仅
|
|
2264
|
+
// 快照当前值——continue 透传不触发提前跳出。基准模型下路由门 body 跑 maxIter-1 次,
|
|
2265
|
+
// 故循环体执行 2 次。
|
|
2266
|
+
const iterations = state?.executedSteps.filter(step => step.stepId === 'step-iter') ?? [];
|
|
2267
|
+
expect(iterations).toHaveLength(2);
|
|
2268
|
+
expect(iterations.map(step => step.loopIteration)).toEqual([[1], [2]]);
|
|
2269
|
+
});
|
|
2270
|
+
|
|
2271
|
+
it('writes loopMeta to step-loop routing entries on both continue and break (defect 23)', async () => {
|
|
2272
|
+
const { ctx, engine, persistence, llm, shell } = await harness();
|
|
2273
|
+
await putDefinition(persistence, 'wfl-loop-break', loopBreakYaml);
|
|
2274
|
+
shell.runHandler = async spec => successResult(spec, 'iter');
|
|
2275
|
+
llm.providers.push({ id: 'p', name: 'Provider' });
|
|
2276
|
+
llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
|
|
2277
|
+
// fork-based 改造(缺陷 4):breakCondition 走 fork 会话注入全量历史;step-iter 为 shell
|
|
2278
|
+
// 合成 session(无真实 session 可 fork)→ 判断降级为临时会话。mock:首次评估 false(继续),
|
|
2279
|
+
// 第二次评估 true(跳出)——验证 continue 与 break 两条 step-loop 路由条目均回写 loopMeta
|
|
2280
|
+
// (缺陷 23:修复前 continue 分支丢弃 outcome.loopMeta,仅 break 条目回写)。
|
|
2281
|
+
let breakCall = 0;
|
|
2282
|
+
llm.streamHandler = (options) => {
|
|
2283
|
+
const text = (options.messages[0]?.content[0] as TextBlock | undefined)?.text ?? '';
|
|
2284
|
+
if (text.includes('shouldBreak')) {
|
|
2285
|
+
breakCall += 1;
|
|
2286
|
+
return textChunks(breakCall === 1 ? '{"shouldBreak": false}' : '{"shouldBreak": true}');
|
|
2287
|
+
}
|
|
2288
|
+
return textChunks('summary');
|
|
2289
|
+
};
|
|
2290
|
+
|
|
2291
|
+
const ended = waitForEnd(ctx);
|
|
2292
|
+
const run = await engine.startInstance({ workflowId: 'wfl-loop-break', originalInput: 'do it', cwd: '/tmp' });
|
|
2293
|
+
await ended;
|
|
2294
|
+
|
|
2295
|
+
const state = persistence.state.get(run.instanceId);
|
|
2296
|
+
expect(state?.status).toBe('completed');
|
|
2297
|
+
|
|
2298
|
+
// 缺陷 23:continue 分支(首评 false)与 break 分支(次评 true)均透传 loopMeta →
|
|
2299
|
+
// 两条 step-loop 路由条目均含 loopMeta(completed)。修复前 continue 分支不透传,
|
|
2300
|
+
// 仅 break 条目回写(loop-manager.spec L174-175 实证 checkLoopBreak 在 shouldBreak=false
|
|
2301
|
+
// 时同样产出 loopMeta(completed))。
|
|
2302
|
+
const loopEntries = state?.executedSteps.filter(step => step.stepId === 'step-loop') ?? [];
|
|
2303
|
+
expect(loopEntries).toHaveLength(2);
|
|
2304
|
+
expect(loopEntries.every(entry => entry.loopMeta?.status === 'completed')).toBe(true);
|
|
2305
|
+
// 两条均携带 shouldBreak 评估现场(continue=false 现场、break=true 现场)。
|
|
2306
|
+
expect(loopEntries[0]?.loopMeta?.result).toContain('shouldBreak');
|
|
2307
|
+
expect(loopEntries[1]?.loopMeta?.result).toContain('shouldBreak');
|
|
2308
|
+
|
|
2309
|
+
// continue 透传不双重计数回归(缺陷 3 point ② 已由 runLoop 单一递增解决):
|
|
2310
|
+
// 循环体 step-iter 仅在首趟 continue 后跑一次(第二趟 break 直接跳出,未再跑 body)。
|
|
2311
|
+
const iterEntries = state?.executedSteps.filter(step => step.stepId === 'step-iter') ?? [];
|
|
2312
|
+
expect(iterEntries).toHaveLength(1);
|
|
2313
|
+
// 跳出后进入 step-done 收尾。
|
|
2314
|
+
expect(state?.executedSteps.some(step => step.stepId === 'step-done')).toBe(true);
|
|
2315
|
+
});
|
|
2316
|
+
|
|
2317
|
+
it('runs a mixed-gate loop exactly maxIterations times when the condition branch never matches', async () => {
|
|
2318
|
+
const { ctx, engine, persistence, llm, shell } = await harness();
|
|
2319
|
+
await putDefinition(persistence, 'wfl-loop-mixed-gate', loopMixedGateYaml);
|
|
2320
|
+
shell.runHandler = async spec => successResult(spec, 'iter');
|
|
2321
|
+
llm.providers.push({ id: 'p', name: 'Provider' });
|
|
2322
|
+
llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
|
|
2323
|
+
// 条件分支恒不命中(批量空数组)→ 循环跑到 maxIterations 硬上限。
|
|
2324
|
+
llm.streamHandler = () => textChunks('{"shouldExecute": []}');
|
|
2325
|
+
|
|
2326
|
+
const ended = waitForEnd(ctx);
|
|
2327
|
+
const run = await engine.startInstance({ workflowId: 'wfl-loop-mixed-gate', originalInput: 'do it', cwd: '/tmp' });
|
|
2328
|
+
await ended;
|
|
2329
|
+
|
|
2330
|
+
const state = persistence.state.get(run.instanceId);
|
|
2331
|
+
expect(state?.status).toBe('completed');
|
|
2332
|
+
|
|
2333
|
+
// 混合门(condition + loop,isLoopControl=false):基准模型下路由门 body 跑
|
|
2334
|
+
// maxIter-1 次。visitCount 递增已统一移至 runLoop(单一递增点),回边/checkLoopBreak
|
|
2335
|
+
// 只读不递增,根治双重计数。故循环体执行 2 次。
|
|
2336
|
+
const iterations = state?.executedSteps.filter(step => step.stepId === 'step-iter') ?? [];
|
|
2337
|
+
expect(iterations).toHaveLength(2);
|
|
2338
|
+
expect(iterations.map(step => step.loopIteration)).toEqual([[1], [2]]);
|
|
2339
|
+
// 条件分支恒不命中 → step-exit 未执行;跳出后进入 step-done 收尾。
|
|
2340
|
+
expect(state?.executedSteps.some(step => step.stepId === 'step-exit')).toBe(false);
|
|
2341
|
+
expect(state?.executedSteps.some(step => step.stepId === 'step-done')).toBe(true);
|
|
2342
|
+
});
|
|
2343
|
+
|
|
2344
|
+
it('does not let a later stage default override an earlier stage extracted pass-through var (defect 15)', async () => {
|
|
2345
|
+
const { ctx, engine, persistence, llm, shell, agents } = await harness();
|
|
2346
|
+
await putDefinition(persistence, 'wfl-cross-stage-ptv', crossStagePtvYaml);
|
|
2347
|
+
// shell 回显命令本身,便于断言 step-use 拿到的实际值。
|
|
2348
|
+
shell.runHandler = async spec => successResult(spec, spec.command);
|
|
2349
|
+
llm.providers.push({ id: 'p', name: 'Provider' });
|
|
2350
|
+
llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
|
|
2351
|
+
// step-extract 为 prompt Step,走可续接子代理:注册真实 childId 并回填最终输出。
|
|
2352
|
+
const promptChild = new FakeAgent('child-1');
|
|
2353
|
+
promptChild.session.append(assistantMessageEvent([{ type: 'text', text: 'extracted text' }]));
|
|
2354
|
+
agents.register(promptChild);
|
|
2355
|
+
|
|
2356
|
+
// step-extract(prompt)执行 → finalize Phase A assess(hasAsk 格式)+ Phase B 收尾
|
|
2357
|
+
// (compressedOutput 合并 JSON)提取 projectName=sandore;Stage 2 step-use 声明同名
|
|
2358
|
+
// 默认值 "unknown"(非 \${} 引用 → 不触发 assess)。按首条消息文本分发:
|
|
2359
|
+
// compressedOutput → Phase B 收尾 JSON;其余(hasAsk)→ Phase A 评估 JSON。
|
|
2360
|
+
llm.streamHandler = (options) => {
|
|
2361
|
+
const text = (options.messages[0]?.content[0] as TextBlock | undefined)?.text ?? '';
|
|
2362
|
+
if (text.includes('compressedOutput')) {
|
|
2363
|
+
return textChunks('{"passThroughVars":{"projectName":"sandore"},"compressedOutput":"摘要"}');
|
|
2364
|
+
}
|
|
2365
|
+
return textChunks('{"hasAsk":false,"passThroughVars":{"projectName":"sandore"}}');
|
|
2366
|
+
};
|
|
2367
|
+
|
|
2368
|
+
const ended = waitForEnd(ctx);
|
|
2369
|
+
await engine.startInstance({ workflowId: 'wfl-cross-stage-ptv', originalInput: 'do it', cwd: '/tmp' });
|
|
2370
|
+
await ended;
|
|
2371
|
+
|
|
2372
|
+
// 残余差异二修复(initState 合并顺序):Stage 2 step-use 声明同名 projectName
|
|
2373
|
+
// 默认值 "unknown",但合并顺序已改为「input 提取值覆盖定义默认值」→ step-use
|
|
2374
|
+
// 拿到 'sandore' 而非被 'unknown' 覆盖。修复前(旧合并顺序:定义值覆盖提取值)
|
|
2375
|
+
// 此处为 'echo unknown',破坏跨 Stage 变量传递链。
|
|
2376
|
+
expect(shell.specs.map(spec => spec.command)).toEqual(['echo sandore']);
|
|
2377
|
+
});
|
|
2378
|
+
});
|
|
2379
|
+
|
|
2380
|
+
/** 单 Stage prompt Step 带透传变量(触发 finalize assess → ask)。 */
|
|
2381
|
+
const askYaml = `id: wfl-ask
|
|
2382
|
+
name: ask
|
|
2383
|
+
version: "1.0.0"
|
|
2384
|
+
stages:
|
|
2385
|
+
- id: stg-1
|
|
2386
|
+
name: 提问
|
|
2387
|
+
steps:
|
|
2388
|
+
- id: step-ask
|
|
2389
|
+
steps:
|
|
2390
|
+
- id: step-ask
|
|
2391
|
+
command:
|
|
2392
|
+
type: prompt
|
|
2393
|
+
value:
|
|
2394
|
+
promptText: "do it"
|
|
2395
|
+
passThroughVars:
|
|
2396
|
+
- name: projectName
|
|
2397
|
+
value: "\${projectName}"
|
|
2398
|
+
description: "项目名"
|
|
2399
|
+
`;
|
|
2400
|
+
|
|
2401
|
+
/** 单 Stage 多 Step:step-ask(prompt + 门禁 + 透传变量)触发中途 ask,step-after 为剩余 Step。 */
|
|
2402
|
+
const askMidYaml = `id: wfl-ask-mid
|
|
2403
|
+
name: ask-mid
|
|
2404
|
+
version: "1.0.0"
|
|
2405
|
+
stages:
|
|
2406
|
+
- id: stg-1
|
|
2407
|
+
name: 中途提问
|
|
2408
|
+
steps:
|
|
2409
|
+
- id: step-ask
|
|
2410
|
+
- id: step-after
|
|
2411
|
+
steps:
|
|
2412
|
+
- id: step-ask
|
|
2413
|
+
command:
|
|
2414
|
+
type: prompt
|
|
2415
|
+
value:
|
|
2416
|
+
promptText: "do it"
|
|
2417
|
+
passThroughVars:
|
|
2418
|
+
- name: projectName
|
|
2419
|
+
value: "\${projectName}"
|
|
2420
|
+
description: "项目名"
|
|
2421
|
+
gates:
|
|
2422
|
+
primary:
|
|
2423
|
+
enabled: true
|
|
2424
|
+
- id: step-after
|
|
2425
|
+
command:
|
|
2426
|
+
type: shell
|
|
2427
|
+
value:
|
|
2428
|
+
command: "echo done"
|
|
2429
|
+
`;
|
|
2430
|
+
|
|
2431
|
+
/**
|
|
2432
|
+
* 收尾阶段流转(sandore/step-phase 事件全四相):
|
|
2433
|
+
* step-ask(prompt + 透传变量)触发 ask 保底中断(ask-detect)→ 回答后续跑
|
|
2434
|
+
* (finalizing → completed);step-gate(shell + 门禁 + outputTo 缺失文件)
|
|
2435
|
+
* primary 纯代码产物校验不通过(finalizing → gating → Step failed)。
|
|
2436
|
+
*/
|
|
2437
|
+
const phaseFlowYaml = `id: wfl-phase-flow
|
|
2438
|
+
name: phase-flow
|
|
2439
|
+
version: "1.0.0"
|
|
2440
|
+
stages:
|
|
2441
|
+
- id: stg-1
|
|
2442
|
+
name: 收尾阶段
|
|
2443
|
+
steps:
|
|
2444
|
+
- id: step-ask
|
|
2445
|
+
- id: step-gate
|
|
2446
|
+
steps:
|
|
2447
|
+
- id: step-ask
|
|
2448
|
+
command:
|
|
2449
|
+
type: prompt
|
|
2450
|
+
value:
|
|
2451
|
+
promptText: "do it"
|
|
2452
|
+
passThroughVars:
|
|
2453
|
+
- name: projectName
|
|
2454
|
+
value: "\${projectName}"
|
|
2455
|
+
description: "项目名"
|
|
2456
|
+
- id: step-gate
|
|
2457
|
+
command:
|
|
2458
|
+
type: shell
|
|
2459
|
+
value:
|
|
2460
|
+
command: "echo gate"
|
|
2461
|
+
outputTo:
|
|
2462
|
+
- id: opt-a
|
|
2463
|
+
path: "missing-report.txt"
|
|
2464
|
+
gates:
|
|
2465
|
+
primary:
|
|
2466
|
+
enabled: true
|
|
2467
|
+
`;
|
|
2468
|
+
|
|
2469
|
+
/** 单 Stage agent Step(子代理 final result 含 [[SANDORE_ASK]] 标记块——双通道 ask 重构后按普通输出透传)。 */
|
|
2470
|
+
const delegatedAskYaml = `id: wfl-delegated
|
|
2471
|
+
name: delegated
|
|
2472
|
+
version: "1.0.0"
|
|
2473
|
+
stages:
|
|
2474
|
+
- id: stg-1
|
|
2475
|
+
name: 委托
|
|
2476
|
+
steps:
|
|
2477
|
+
- id: step-agent
|
|
2478
|
+
steps:
|
|
2479
|
+
- id: step-agent
|
|
2480
|
+
command:
|
|
2481
|
+
type: agent
|
|
2482
|
+
value:
|
|
2483
|
+
promptText: "do-work"
|
|
2484
|
+
`;
|
|
2485
|
+
|
|
2486
|
+
/** 等待一次 sandore/question 事件并返回载荷(用于读取 requestId)。 */
|
|
2487
|
+
function waitForQuestion(ctx: Context): Promise<{ info: unknown; question: unknown }> {
|
|
2488
|
+
return new Promise(resolve => {
|
|
2489
|
+
ctx.once('sandore/question', (info, question) => resolve({ info, question }));
|
|
2490
|
+
});
|
|
2491
|
+
}
|
|
2492
|
+
|
|
2493
|
+
describe('SandoreEngineRuntime Stage-level conditional branching', () => {
|
|
2494
|
+
it('evaluates the Stage branch condition and routes to the matched Stage when shouldExecute is true', async () => {
|
|
2495
|
+
const { ctx, engine, persistence, llm, shell } = await harness();
|
|
2496
|
+
await putDefinition(persistence, 'wfl-stage-branch', stageBranchYaml);
|
|
2497
|
+
shell.runHandler = async spec => successResult(spec, 'ok');
|
|
2498
|
+
llm.providers.push({ id: 'p', name: 'Provider' });
|
|
2499
|
+
llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
|
|
2500
|
+
// Stage 条件评估(StageContract-based,非 fork):批量返回命中的 branchId → 取 stg-b。
|
|
2501
|
+
llm.streamHandler = () => textChunks('{"shouldExecute": ["stg-b"]}');
|
|
2502
|
+
|
|
2503
|
+
const records = recordSandoreEvents(ctx);
|
|
2504
|
+
const ended = waitForEnd(ctx);
|
|
2505
|
+
await engine.startInstance({ workflowId: 'wfl-stage-branch', originalInput: 'do it', cwd: '/tmp' });
|
|
2506
|
+
await ended;
|
|
2507
|
+
|
|
2508
|
+
const completedStages = records
|
|
2509
|
+
.filter(record => record.name === 'sandore/stage-completed')
|
|
2510
|
+
.map(record => record.args[1]);
|
|
2511
|
+
// 条件命中 stg-b → 数组顺序继续 stg-c → stg-default → 结束。
|
|
2512
|
+
expect(completedStages).toEqual(['stg-entry', 'stg-b', 'stg-c', 'stg-default']);
|
|
2513
|
+
});
|
|
2514
|
+
|
|
2515
|
+
it('skips a false Stage branch condition and falls through to the default (no-condition) branch', async () => {
|
|
2516
|
+
const { ctx, engine, persistence, llm, shell } = await harness();
|
|
2517
|
+
await putDefinition(persistence, 'wfl-stage-branch', stageBranchYaml);
|
|
2518
|
+
shell.runHandler = async spec => successResult(spec, 'ok');
|
|
2519
|
+
llm.providers.push({ id: 'p', name: 'Provider' });
|
|
2520
|
+
llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
|
|
2521
|
+
// Stage 条件评估:批量返回空数组 → 跳过 stg-b,走无 condition 的默认分支 stg-default。
|
|
2522
|
+
llm.streamHandler = () => textChunks('{"shouldExecute": []}');
|
|
2523
|
+
|
|
2524
|
+
const records = recordSandoreEvents(ctx);
|
|
2525
|
+
const ended = waitForEnd(ctx);
|
|
2526
|
+
await engine.startInstance({ workflowId: 'wfl-stage-branch', originalInput: 'do it', cwd: '/tmp' });
|
|
2527
|
+
await ended;
|
|
2528
|
+
|
|
2529
|
+
const completedStages = records
|
|
2530
|
+
.filter(record => record.name === 'sandore/stage-completed')
|
|
2531
|
+
.map(record => record.args[1]);
|
|
2532
|
+
// 条件不命中 → 默认分支 stg-default(stg-default 为数组末位,无后续 → 结束),
|
|
2533
|
+
// 不走盲取首个 Stage 目标(旧 tryAdvanceStage 会取 stg-b)。
|
|
2534
|
+
expect(completedStages).toEqual(['stg-entry', 'stg-default']);
|
|
2535
|
+
});
|
|
2536
|
+
|
|
2537
|
+
it('routes to Stage A when the first multi-condition branch matches (skips array-order stg-mid)', async () => {
|
|
2538
|
+
const { ctx, engine, persistence, llm, shell } = await harness();
|
|
2539
|
+
await putDefinition(persistence, 'wfl-stage-multi', stageBranchMultiYaml);
|
|
2540
|
+
shell.runHandler = async spec => successResult(spec, 'ok');
|
|
2541
|
+
llm.providers.push({ id: 'p', name: 'Provider' });
|
|
2542
|
+
llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
|
|
2543
|
+
// 批量一次评估:condA 命中 → 取 stg-a(定义序首个命中;condB 同批评估但不影响结果)。
|
|
2544
|
+
llm.streamHandler = () => textChunks('{"shouldExecute": ["stg-a"]}');
|
|
2545
|
+
|
|
2546
|
+
const records = recordSandoreEvents(ctx);
|
|
2547
|
+
const ended = waitForEnd(ctx);
|
|
2548
|
+
await engine.startInstance({ workflowId: 'wfl-stage-multi', originalInput: 'do it', cwd: '/tmp' });
|
|
2549
|
+
await ended;
|
|
2550
|
+
|
|
2551
|
+
const completedStages = records
|
|
2552
|
+
.filter(record => record.name === 'sandore/stage-completed')
|
|
2553
|
+
.map(record => record.args[1]);
|
|
2554
|
+
// 条件命中 stg-a(跳过数组下一 stg-mid)→ stg-a 数组顺序继续 stg-b → 结束。
|
|
2555
|
+
expect(completedStages).toEqual(['stg-entry', 'stg-a', 'stg-b']);
|
|
2556
|
+
});
|
|
2557
|
+
|
|
2558
|
+
it('routes to Stage B when the first condition is false and the second matches', async () => {
|
|
2559
|
+
const { ctx, engine, persistence, llm, shell } = await harness();
|
|
2560
|
+
await putDefinition(persistence, 'wfl-stage-multi', stageBranchMultiYaml);
|
|
2561
|
+
shell.runHandler = async spec => successResult(spec, 'ok');
|
|
2562
|
+
llm.providers.push({ id: 'p', name: 'Provider' });
|
|
2563
|
+
llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
|
|
2564
|
+
// 批量一次评估:condA false → 跳过;condB 命中 → 取 stg-b。
|
|
2565
|
+
llm.streamHandler = () => textChunks('{"shouldExecute": ["stg-b"]}');
|
|
2566
|
+
|
|
2567
|
+
const records = recordSandoreEvents(ctx);
|
|
2568
|
+
const ended = waitForEnd(ctx);
|
|
2569
|
+
await engine.startInstance({ workflowId: 'wfl-stage-multi', originalInput: 'do it', cwd: '/tmp' });
|
|
2570
|
+
await ended;
|
|
2571
|
+
|
|
2572
|
+
const completedStages = records
|
|
2573
|
+
.filter(record => record.name === 'sandore/stage-completed')
|
|
2574
|
+
.map(record => record.args[1]);
|
|
2575
|
+
// 第二条件命中 stg-b(跳过数组下一 stg-mid 与 stg-a)→ stg-b 数组末位无后续 → 结束。
|
|
2576
|
+
expect(completedStages).toEqual(['stg-entry', 'stg-b']);
|
|
2577
|
+
});
|
|
2578
|
+
});
|
|
2579
|
+
|
|
2580
|
+
describe('SandoreEngineRuntime interaction', () => {
|
|
2581
|
+
it('registers a pending question, asks via userQuestions (hostAgent), and resumes after the card reply', async () => {
|
|
2582
|
+
const { ctx, engine, persistence, llm, agents, subagents, sessions, userQuestions } = await harness();
|
|
2583
|
+
await putDefinition(persistence, 'wfl-ask', askYaml);
|
|
2584
|
+
llm.providers.push({ id: 'p', name: 'Provider' });
|
|
2585
|
+
llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
|
|
2586
|
+
// Round 26(场景 B):复用宿主会话(hostSessionId)→ ask 上报宿主(userQuestions.ask)。
|
|
2587
|
+
const existingHost = new FakeAgent('host-1');
|
|
2588
|
+
agents.register(existingHost);
|
|
2589
|
+
sessions.create('host-1');
|
|
2590
|
+
// prompt Step(step-ask)走可续接子代理:注册真实 childId 并回填最终输出。
|
|
2591
|
+
const child = new FakeAgent('child-1');
|
|
2592
|
+
child.session.append(assistantMessageEvent([{ type: 'text', text: 'work done' }]));
|
|
2593
|
+
agents.register(child);
|
|
2594
|
+
|
|
2595
|
+
// prompt Step 输出(子代理)→ finalize assess ①(ask JSON)→ 回答注回子代理 →
|
|
2596
|
+
// 重新收集输出 + 重新评估 ②(completed JSON)。
|
|
2597
|
+
const responses = [
|
|
2598
|
+
'{"completionType":"ask","passThroughVars":{"projectName":"sandore"},"askQuestion":{"questions":[{"id":"q1","question":"Which?","options":[{"label":"A"},{"label":"B"}]}]}}',
|
|
2599
|
+
'{"completionType":"completed","passThroughVars":{}}',
|
|
2600
|
+
];
|
|
2601
|
+
let callIndex = 0;
|
|
2602
|
+
llm.streamHandler = () => textChunks(responses[callIndex++ % responses.length] ?? '');
|
|
2603
|
+
|
|
2604
|
+
const firedQuestions: unknown[] = [];
|
|
2605
|
+
ctx.on('sandore/question', (info, question) => firedQuestions.push({ info, question }));
|
|
2606
|
+
|
|
2607
|
+
// 卡片回复:捕获 ask 请求并回填答案。
|
|
2608
|
+
userQuestions.resolveNext = async (request) => {
|
|
2609
|
+
expect(request.questions).toEqual([{ id: 'q1', question: 'Which?', options: [{ label: 'A' }, { label: 'B' }] }]);
|
|
2610
|
+
return { answers: [{ id: 'q1', selected: ['A'] }] };
|
|
2611
|
+
};
|
|
2612
|
+
|
|
2613
|
+
const ended = waitForEnd(ctx);
|
|
2614
|
+
const run = await engine.startInstance({ workflowId: 'wfl-ask', originalInput: 'do it', cwd: '/tmp', hostSessionId: 'host-1' });
|
|
2615
|
+
await ended;
|
|
2616
|
+
|
|
2617
|
+
// ask 以 hostAgent(id = hostSessionId)身份发起(场景 B:复用宿主会话 → 上报宿主)。
|
|
2618
|
+
expect(userQuestions.calls).toHaveLength(1);
|
|
2619
|
+
expect(userQuestions.calls[0]?.agent?.id).toBe('host-1');
|
|
2620
|
+
|
|
2621
|
+
// sandore/question 事件:pending 条目(kind/status/payload)。
|
|
2622
|
+
expect(firedQuestions).toHaveLength(1);
|
|
2623
|
+
const fired = firedQuestions[0] as { question: { kind: string; status: string; payload: { questions: Array<{ id: string }> } } };
|
|
2624
|
+
expect(fired.question.kind).toBe('question');
|
|
2625
|
+
expect(fired.question.status).toBe('pending');
|
|
2626
|
+
expect(fired.question.payload.questions[0]?.id).toBe('q1');
|
|
2627
|
+
|
|
2628
|
+
// 回复后恢复:Step completed + 实例 completed + 条目不再持久化(pending 已清,
|
|
2629
|
+
// 交互管理器只持久化 pending,§3.6A)。Round 26:回答**不写入** userMessages
|
|
2630
|
+
// (kind:'answer')——回答仅 followup 注回子代理,不注入 discussion 时间轴。
|
|
2631
|
+
const state = persistence.state.get(run.instanceId);
|
|
2632
|
+
expect(state?.status).toBe('completed');
|
|
2633
|
+
expect(state?.executedSteps.find(step => step.stepId === 'step-ask')?.status).toBe('completed');
|
|
2634
|
+
expect(state?.pendingInteractions).toEqual([]);
|
|
2635
|
+
const answers = state?.userMessages?.filter(message => message.kind === 'answer');
|
|
2636
|
+
expect(answers).toEqual([]);
|
|
2637
|
+
// 回答经 followup 注回子代理(source user,内容 'A'——子代理会话内可见)。
|
|
2638
|
+
const followup = subagents.followups.find(item => item.childId === 'child-1');
|
|
2639
|
+
expect(followup?.content).toEqual([{ type: 'text', text: 'A' }]);
|
|
2640
|
+
});
|
|
2641
|
+
|
|
2642
|
+
it('supports the replyQuestion compat path when the card channel is unavailable', async () => {
|
|
2643
|
+
const { ctx, engine, persistence, llm, agents, subagents } = await harness();
|
|
2644
|
+
await putDefinition(persistence, 'wfl-ask', askYaml);
|
|
2645
|
+
llm.providers.push({ id: 'p', name: 'Provider' });
|
|
2646
|
+
llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
|
|
2647
|
+
// prompt Step(step-ask)走可续接子代理:注册真实 childId 并回填最终输出。
|
|
2648
|
+
const child = new FakeAgent('child-1');
|
|
2649
|
+
child.session.append(assistantMessageEvent([{ type: 'text', text: 'work done' }]));
|
|
2650
|
+
agents.register(child);
|
|
2651
|
+
|
|
2652
|
+
const responses = [
|
|
2653
|
+
'{"completionType":"ask","passThroughVars":{"projectName":"sandore"},"askQuestion":{"questions":[{"id":"q1","question":"Which?","options":[{"label":"A"},{"label":"B"}]}]}}',
|
|
2654
|
+
'{"completionType":"completed","passThroughVars":{}}',
|
|
2655
|
+
];
|
|
2656
|
+
let callIndex = 0;
|
|
2657
|
+
llm.streamHandler = () => textChunks(responses[callIndex++ % responses.length] ?? '');
|
|
2658
|
+
|
|
2659
|
+
const question = waitForQuestion(ctx);
|
|
2660
|
+
const run = await engine.startInstance({ workflowId: 'wfl-ask', originalInput: 'do it', cwd: '/tmp' });
|
|
2661
|
+
const fired = await question;
|
|
2662
|
+
const requestId = (fired.question as { requestId: string }).requestId;
|
|
2663
|
+
|
|
2664
|
+
// 兼容路径回复:resolve ask 的 await 并恢复执行。
|
|
2665
|
+
await engine.replyQuestion(run.instanceId, requestId, { answers: [{ id: 'q1', selected: ['B'] }] });
|
|
2666
|
+
await waitForEnd(ctx);
|
|
2667
|
+
|
|
2668
|
+
const state = persistence.state.get(run.instanceId);
|
|
2669
|
+
expect(state?.status).toBe('completed');
|
|
2670
|
+
// 回答后 pending 已清(只持久化 pending)。Round 26:回答不写入 userMessages。
|
|
2671
|
+
expect(state?.pendingInteractions).toEqual([]);
|
|
2672
|
+
const answers = state?.userMessages?.filter(message => message.kind === 'answer');
|
|
2673
|
+
expect(answers).toEqual([]);
|
|
2674
|
+
// 回答经 followup 注回子代理(内容 'B')。
|
|
2675
|
+
const followup = subagents.followups.find(item => item.childId === 'child-1');
|
|
2676
|
+
expect(followup?.content).toEqual([{ type: 'text', text: 'B' }]);
|
|
2677
|
+
});
|
|
2678
|
+
|
|
2679
|
+
it('resumes the remaining steps and re-runs the gate after a mid-stage ask', async () => {
|
|
2680
|
+
const { ctx, engine, persistence, llm, shell, agents, sessions, userQuestions } = await harness();
|
|
2681
|
+
await putDefinition(persistence, 'wfl-ask-mid', askMidYaml);
|
|
2682
|
+
llm.providers.push({ id: 'p', name: 'Provider' });
|
|
2683
|
+
llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
|
|
2684
|
+
shell.runHandler = async spec => successResult(spec, 'done');
|
|
2685
|
+
// Round 26(场景 B):复用宿主会话 → ask 上报宿主。
|
|
2686
|
+
const existingHost = new FakeAgent('host-1');
|
|
2687
|
+
agents.register(existingHost);
|
|
2688
|
+
sessions.create('host-1');
|
|
2689
|
+
// prompt Step(step-ask)走可续接子代理:注册真实 childId 并回填最终输出。
|
|
2690
|
+
const child = new FakeAgent('child-1');
|
|
2691
|
+
child.session.append(assistantMessageEvent([{ type: 'text', text: 'work done' }]));
|
|
2692
|
+
agents.register(child);
|
|
2693
|
+
|
|
2694
|
+
// assess ①(ask JSON)→ 回答注回 → 重新评估 ②(completed JSON)→ Phase B 重新
|
|
2695
|
+
// fork + 重新 assess ③(completed JSON)→ 门禁(primary 纯代码产物校验,零 AI)。
|
|
2696
|
+
// 偏离 2:Phase B 重新 assess,故 completed JSON 需供给两次;step-ask 无 outputTo
|
|
2697
|
+
// → primary 校验直接通过,无门禁 AI 调用。
|
|
2698
|
+
const responses = [
|
|
2699
|
+
'{"completionType":"ask","passThroughVars":{"projectName":"sandore"},"askQuestion":{"questions":[{"id":"q1","question":"Which?","options":[{"label":"A"},{"label":"B"}]}]}}',
|
|
2700
|
+
'{"completionType":"completed","passThroughVars":{}}',
|
|
2701
|
+
'{"completionType":"completed","passThroughVars":{}}',
|
|
2702
|
+
];
|
|
2703
|
+
let callIndex = 0;
|
|
2704
|
+
llm.streamHandler = () => textChunks(responses[callIndex++ % responses.length] ?? '');
|
|
2705
|
+
|
|
2706
|
+
userQuestions.resolveNext = async () => ({ answers: [{ id: 'q1', selected: ['A'] }] });
|
|
2707
|
+
|
|
2708
|
+
const ended = waitForEnd(ctx);
|
|
2709
|
+
const run = await engine.startInstance({ workflowId: 'wfl-ask-mid', originalInput: 'do it', cwd: '/tmp', hostSessionId: 'host-1' });
|
|
2710
|
+
await ended;
|
|
2711
|
+
|
|
2712
|
+
const state = persistence.state.get(run.instanceId);
|
|
2713
|
+
expect(state?.status).toBe('completed');
|
|
2714
|
+
// 剩余 Step 不丢失:step-ask 与 step-after 均执行。
|
|
2715
|
+
expect(state?.executedSteps.map(step => step.stepId)).toEqual(['step-ask', 'step-after']);
|
|
2716
|
+
// ask 回复后重跑门禁:step-ask 的 finalizeMeta 记录 gateResult。
|
|
2717
|
+
const askStep = state?.executedSteps.find(step => step.stepId === 'step-ask');
|
|
2718
|
+
expect(askStep?.status).toBe('completed');
|
|
2719
|
+
expect(askStep?.finalizeMeta?.gateResult?.gatesPassed).toBe(true);
|
|
2720
|
+
});
|
|
2721
|
+
|
|
2722
|
+
it('emits sandore/step-phase on finalize phase transitions (ask-detect → finalizing → routing → gating/completed)', async () => {
|
|
2723
|
+
const { ctx, engine, persistence, llm, shell, agents, userQuestions, sessions } = await harness();
|
|
2724
|
+
await putDefinition(persistence, 'wfl-phase-flow', phaseFlowYaml);
|
|
2725
|
+
llm.providers.push({ id: 'p', name: 'Provider' });
|
|
2726
|
+
llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
|
|
2727
|
+
shell.runHandler = async spec => successResult(spec, 'gate done');
|
|
2728
|
+
// Round 26(场景 B):复用宿主会话 → ask 上报宿主。
|
|
2729
|
+
const existingHost = new FakeAgent('host-1');
|
|
2730
|
+
agents.register(existingHost);
|
|
2731
|
+
sessions.create('host-1');
|
|
2732
|
+
// prompt Step(step-ask)走可续接子代理:注册真实 childId 并回填最终输出。
|
|
2733
|
+
const child = new FakeAgent('child-1');
|
|
2734
|
+
child.session.append(assistantMessageEvent([{ type: 'text', text: 'work done' }]));
|
|
2735
|
+
agents.register(child);
|
|
2736
|
+
|
|
2737
|
+
// LLM 调用序:① step-ask Phase A assess(ask JSON)→ 回答注回 → 重新评估 ②
|
|
2738
|
+
// (completed JSON)→ Phase B 收尾 ③(compressedOutput 合并 JSON,走降级——child-1
|
|
2739
|
+
// 无事件源)→ routing → completed;④ step-gate 为 shell 步骤(Round 20:跳过
|
|
2740
|
+
// Phase A/B 收尾评估,零 llm 调用)→ Phase C primary 纯代码门禁(outputTo 缺失
|
|
2741
|
+
// 文件,fs 空 → 不通过)→ gating → Step failed → 实例 failed。
|
|
2742
|
+
// 按首条消息文本分发:分支/循环专用模板(shouldExecute/shouldBreak)按其字段名;
|
|
2743
|
+
// 其余为收尾调用——Phase A(buildAssessPrompt 精简后)含「输出是否是在向用户提问」,
|
|
2744
|
+
// Phase B(buildPhaseBFinalizePrompt)含 compressedOutput——按调用序:第 1 次 Phase A
|
|
2745
|
+
// 调用(step-ask)返回 ask JSON 触发 ask-detect;后续 Phase A 返回无 ask JSON;
|
|
2746
|
+
// Phase B 返回统一收尾 JSON(passThroughVars + compressedOutput,对 parseFinalizeResult
|
|
2747
|
+
// 有效)。
|
|
2748
|
+
let phaseACall = 0;
|
|
2749
|
+
llm.streamHandler = (options) => {
|
|
2750
|
+
const text = (options.messages[0]?.content[0] as TextBlock | undefined)?.text ?? '';
|
|
2751
|
+
if (text.includes('shouldBreak')) {
|
|
2752
|
+
return textChunks('{"shouldBreak": false}');
|
|
2753
|
+
}
|
|
2754
|
+
if (text.includes('输出是否是在向用户提问')) {
|
|
2755
|
+
// Phase A ask 判定(精简:仅 hasAsk/askQuestion)。
|
|
2756
|
+
phaseACall += 1;
|
|
2757
|
+
return phaseACall === 1
|
|
2758
|
+
? textChunks('{"hasAsk":true,"askQuestion":{"questions":[{"id":"q1","question":"Which?","options":[{"label":"A"},{"label":"B"}]}]}}')
|
|
2759
|
+
: textChunks('{"hasAsk":false,"askQuestion":{"questions":[]}}');
|
|
2760
|
+
}
|
|
2761
|
+
if (text.includes('compressedOutput')) {
|
|
2762
|
+
// Phase B 收尾(透传变量 + 输出压缩)。
|
|
2763
|
+
return textChunks('{"hasAsk":false,"passThroughVars":{},"compressedOutput":"摘要"}');
|
|
2764
|
+
}
|
|
2765
|
+
// 理论不可达(收尾/分支均含特征字段);兜底返回完成 JSON。
|
|
2766
|
+
return textChunks('{"hasAsk":false,"passThroughVars":{}}');
|
|
2767
|
+
};
|
|
2768
|
+
|
|
2769
|
+
userQuestions.resolveNext = async () => ({ answers: [{ id: 'q1', selected: ['A'] }] });
|
|
2770
|
+
|
|
2771
|
+
const records = recordSandoreEvents(ctx);
|
|
2772
|
+
const ended = waitForEnd(ctx);
|
|
2773
|
+
const run = await engine.startInstance({ workflowId: 'wfl-phase-flow', originalInput: 'do it', cwd: '/tmp', hostSessionId: 'host-1' });
|
|
2774
|
+
await ended;
|
|
2775
|
+
|
|
2776
|
+
// 收尾阶段流转事件:step-ask ask-detect → finalizing → routing → completed(ask 回复
|
|
2777
|
+
// 后续跑;Phase D routing 在门禁通过后、completed 前落盘);step-gate finalizing →
|
|
2778
|
+
// gating(primary 门禁不通过,**不执行 Phase D**)。载荷 phase/name 正确,
|
|
2779
|
+
// childId 为真实子代理(prompt)或合成占位(shell)。
|
|
2780
|
+
const phaseRecords = records.filter(record => record.name === 'sandore/step-phase');
|
|
2781
|
+
expect(phaseRecords.map(record => ({
|
|
2782
|
+
stepId: record.args[1] as string,
|
|
2783
|
+
childId: record.args[2] as string,
|
|
2784
|
+
phase: record.args[3] as string,
|
|
2785
|
+
name: record.args[4] as string,
|
|
2786
|
+
}))).toEqual([
|
|
2787
|
+
{ stepId: 'step-ask', childId: 'child-1', phase: 'ask-detect', name: 'step-ask' },
|
|
2788
|
+
// Round 17:onForkCreated 替代空快照——shell 降级无 fork 子代理,仅 1 个 finalizing(done)
|
|
2789
|
+
{ stepId: 'step-ask', childId: 'child-1', phase: 'finalizing', name: 'step-ask' },
|
|
2790
|
+
{ stepId: 'step-ask', childId: 'child-1', phase: 'routing', name: 'step-ask' },
|
|
2791
|
+
{ stepId: 'step-ask', childId: 'child-1', phase: 'completed', name: 'step-ask' },
|
|
2792
|
+
// Round 20:step-gate 为 shell 步骤,跳过 Phase A/B → 无 finalizing 事件;
|
|
2793
|
+
// Phase C primary 门禁直接失败(2 个 gating:running 快照 + 终态)。
|
|
2794
|
+
{ stepId: 'step-gate', childId: expect.stringMatching(UUID_RE), phase: 'gating', name: 'step-gate' },
|
|
2795
|
+
{ stepId: 'step-gate', childId: expect.stringMatching(UUID_RE), phase: 'gating', name: 'step-gate' },
|
|
2796
|
+
]);
|
|
2797
|
+
|
|
2798
|
+
// Round 25(去污染):宿主会话不再承载 sandore/* 事件(Cordis 半边断言已覆盖
|
|
2799
|
+
// L2758-2775 records;此处反转断言宿主会话保持干净——复用场景宿主会话 id = host-1)。
|
|
2800
|
+
expect(sessions.get('host-1')?.events.some(event => event.type === 'sandore/step-phase')).toBe(false);
|
|
2801
|
+
|
|
2802
|
+
// 阶段状态落盘对齐事件:step-ask completed(finalizeMeta completed)、
|
|
2803
|
+
// step-gate failed(finalizeMeta gating + gateResult 未过)。
|
|
2804
|
+
const state = persistence.state.get(run.instanceId);
|
|
2805
|
+
expect(state?.status).toBe('failed');
|
|
2806
|
+
const askStep = state?.executedSteps.find(step => step.stepId === 'step-ask');
|
|
2807
|
+
expect(askStep?.status).toBe('completed');
|
|
2808
|
+
expect(askStep?.finalizeMeta?.currentPhase).toBe('completed');
|
|
2809
|
+
const gateStep = state?.executedSteps.find(step => step.stepId === 'step-gate');
|
|
2810
|
+
expect(gateStep?.status).toBe('failed');
|
|
2811
|
+
expect(gateStep?.finalizeMeta?.currentPhase).toBe('gating');
|
|
2812
|
+
expect(gateStep?.finalizeMeta?.gateResult?.gatesPassed).toBe(false);
|
|
2813
|
+
});
|
|
2814
|
+
|
|
2815
|
+
// ── Round 7:收尾阶段中间态同步集成测试 ──────────────────────────────────
|
|
2816
|
+
|
|
2817
|
+
it('emitStepPhase 发出时 active.state 已含最新 finalizeMeta(Round 7 中间态同步)', async () => {
|
|
2818
|
+
const { ctx, engine, persistence, llm, shell, agents, sessions, userQuestions } = await harness();
|
|
2819
|
+
await putDefinition(persistence, 'wfl-phase-flow', phaseFlowYaml);
|
|
2820
|
+
llm.providers.push({ id: 'p', name: 'Provider' });
|
|
2821
|
+
llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
|
|
2822
|
+
shell.runHandler = async spec => successResult(spec, 'gate done');
|
|
2823
|
+
// Round 26(场景 B):复用宿主会话 → ask 上报宿主。
|
|
2824
|
+
const existingHost = new FakeAgent('host-1');
|
|
2825
|
+
agents.register(existingHost);
|
|
2826
|
+
sessions.create('host-1');
|
|
2827
|
+
const child = new FakeAgent('child-1');
|
|
2828
|
+
child.session.append(assistantMessageEvent([{ type: 'text', text: 'work done' }]));
|
|
2829
|
+
agents.register(child);
|
|
2830
|
+
|
|
2831
|
+
// LLM 调用序:与 wfl-phase-flow 测试一致。
|
|
2832
|
+
let finalizeCall = 0;
|
|
2833
|
+
llm.streamHandler = (options) => {
|
|
2834
|
+
const text = (options.messages[0]?.content[0] as TextBlock | undefined)?.text ?? '';
|
|
2835
|
+
if (text.includes('shouldBreak')) {
|
|
2836
|
+
return textChunks('{"shouldBreak": false}');
|
|
2837
|
+
}
|
|
2838
|
+
if (text.includes('compressedOutput')) {
|
|
2839
|
+
finalizeCall += 1;
|
|
2840
|
+
return finalizeCall === 1
|
|
2841
|
+
? textChunks('{"hasAsk":true,"passThroughVars":{},"askQuestion":{"questions":[{"id":"q1","question":"Which?","options":[{"label":"A"},{"label":"B"}]}]},"compressedOutput":"摘要"}')
|
|
2842
|
+
: textChunks('{"hasAsk":false,"passThroughVars":{},"compressedOutput":"摘要"}');
|
|
2843
|
+
}
|
|
2844
|
+
return textChunks('{"hasAsk":false,"passThroughVars":{}}');
|
|
2845
|
+
};
|
|
2846
|
+
userQuestions.resolveNext = async () => ({ answers: [{ id: 'q1', selected: ['A'] }] });
|
|
2847
|
+
|
|
2848
|
+
// 关键断言:在 sandore/step-phase 事件监听器中,检查 engine.getInstanceState 返回的
|
|
2849
|
+
// active.state 里对应 step 的 finalizeMeta.currentPhase 已等于事件 phase。
|
|
2850
|
+
const midstateSnapshots: Array<{ stepId: string; phase: string; finalizeMetaPhase: string | undefined; hasStep: boolean; stepsCount: number }> = [];
|
|
2851
|
+
ctx.on('sandore/step-phase', (info, stepId, childId, phase) => {
|
|
2852
|
+
const state = engine.getInstanceState((info as { taskId: string }).taskId);
|
|
2853
|
+
const step = state?.executedSteps.find(s => s.stepId === stepId);
|
|
2854
|
+
midstateSnapshots.push({
|
|
2855
|
+
stepId,
|
|
2856
|
+
phase,
|
|
2857
|
+
finalizeMetaPhase: step?.finalizeMeta?.currentPhase,
|
|
2858
|
+
hasStep: step !== undefined,
|
|
2859
|
+
stepsCount: state?.executedSteps.length ?? 0,
|
|
2860
|
+
});
|
|
2861
|
+
});
|
|
2862
|
+
|
|
2863
|
+
const ended = waitForEnd(ctx);
|
|
2864
|
+
const run = await engine.startInstance({ workflowId: 'wfl-phase-flow', originalInput: 'do it', cwd: '/tmp', hostSessionId: 'host-1' });
|
|
2865
|
+
await ended;
|
|
2866
|
+
|
|
2867
|
+
// 验证每个 step-phase 事件发出时,active.state 里的 finalizeMeta.currentPhase 已是最新。
|
|
2868
|
+
// ask-detect 事件是 Phase A 发出的(在 commitFinalize 之前),不含 finalizeMeta,跳过。
|
|
2869
|
+
for (const snap of midstateSnapshots) {
|
|
2870
|
+
if (snap.phase === 'ask-detect') continue;
|
|
2871
|
+
expect({ stepId: snap.stepId, phase: snap.phase, hasStep: snap.hasStep, stepsCount: snap.stepsCount, finalizeMetaPhase: snap.finalizeMetaPhase })
|
|
2872
|
+
.toEqual(expect.objectContaining({ finalizeMetaPhase: snap.phase }));
|
|
2873
|
+
}
|
|
2874
|
+
|
|
2875
|
+
// 完整事件流存在。
|
|
2876
|
+
const phases = midstateSnapshots.map(s => s.phase);
|
|
2877
|
+
expect(phases).toEqual(expect.arrayContaining(['finalizing', 'gating', 'completed']));
|
|
2878
|
+
});
|
|
2879
|
+
|
|
2880
|
+
it('rejectQuestion marks the ask Step failed + instance failed (no reject bubbling to runAndCatch, 缺陷 26)', async () => {
|
|
2881
|
+
const { ctx, engine, persistence, llm, agents } = await harness();
|
|
2882
|
+
await putDefinition(persistence, 'wfl-ask', askYaml);
|
|
2883
|
+
llm.providers.push({ id: 'p', name: 'Provider' });
|
|
2884
|
+
llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
|
|
2885
|
+
// prompt Step(step-ask)走可续接子代理:注册真实 childId 并回填最终输出。
|
|
2886
|
+
const child = new FakeAgent('child-1');
|
|
2887
|
+
child.session.append(assistantMessageEvent([{ type: 'text', text: 'work done' }]));
|
|
2888
|
+
agents.register(child);
|
|
2889
|
+
// 不设 resolveNext / 不推 answers → FakeUserQuestions.ask 挂起,等待 rejectQuestion。
|
|
2890
|
+
const responses = [
|
|
2891
|
+
'{"completionType":"ask","passThroughVars":{"projectName":"sandore"},"askQuestion":{"questions":[{"id":"q1","question":"Which?","options":[{"label":"A"},{"label":"B"}]}]}}',
|
|
2892
|
+
];
|
|
2893
|
+
let callIndex = 0;
|
|
2894
|
+
llm.streamHandler = () => textChunks(responses[callIndex++ % responses.length] ?? '');
|
|
2895
|
+
|
|
2896
|
+
const records = recordSandoreEvents(ctx);
|
|
2897
|
+
const question = waitForQuestion(ctx);
|
|
2898
|
+
const run = await engine.startInstance({ workflowId: 'wfl-ask', originalInput: 'do it', cwd: '/tmp' });
|
|
2899
|
+
const fired = await question;
|
|
2900
|
+
const requestId = (fired.question as { requestId: string }).requestId;
|
|
2901
|
+
|
|
2902
|
+
// 拒绝问答:rejectQuestion resolve 占位解挂 waiter(非 reject),引擎检测后标 Step failed。
|
|
2903
|
+
await engine.rejectQuestion(run.instanceId, requestId);
|
|
2904
|
+
await waitForEnd(ctx);
|
|
2905
|
+
|
|
2906
|
+
const state = persistence.state.get(run.instanceId);
|
|
2907
|
+
// 实例落 failed(经 handleRejectedQuestion 显式代码,非异常冒泡 runAndCatch)。
|
|
2908
|
+
expect(state?.status).toBe('failed');
|
|
2909
|
+
// ask Step 正确置 failed(非滞留 interacting)。
|
|
2910
|
+
const askStep = state?.executedSteps.find(step => step.stepId === 'step-ask');
|
|
2911
|
+
expect(askStep?.status).toBe('failed');
|
|
2912
|
+
expect(askStep?.error).toBe('Question rejected by user');
|
|
2913
|
+
// 双发 sandore/step-failed(StageExecutor 未对 interacting Step 触发,引擎显式补发)。
|
|
2914
|
+
const stepFailed = records.find(record => record.name === 'sandore/step-failed');
|
|
2915
|
+
expect(stepFailed?.args[1]).toBe('step-ask');
|
|
2916
|
+
// 双发 sandore/end(failed)。
|
|
2917
|
+
const ended = records.find(record => record.name === 'sandore/end');
|
|
2918
|
+
expect((ended?.args[1] as { status: string }).status).toBe('failed');
|
|
2919
|
+
// pending 条目已清(终态条目不进快照)。
|
|
2920
|
+
expect(state?.pendingInteractions).toEqual([]);
|
|
2921
|
+
});
|
|
2922
|
+
|
|
2923
|
+
it('rejectQuestion emits sandore/question_rejected for UI refresh (缺陷 26)', async () => {
|
|
2924
|
+
const { ctx, engine, persistence, llm, agents, sessions } = await harness();
|
|
2925
|
+
await putDefinition(persistence, 'wfl-ask', askYaml);
|
|
2926
|
+
llm.providers.push({ id: 'p', name: 'Provider' });
|
|
2927
|
+
llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
|
|
2928
|
+
// Round 26(场景 B):复用宿主会话 → ask 上报宿主。
|
|
2929
|
+
const existingHost = new FakeAgent('host-1');
|
|
2930
|
+
agents.register(existingHost);
|
|
2931
|
+
sessions.create('host-1');
|
|
2932
|
+
// prompt Step(step-ask)走可续接子代理:注册真实 childId 并回填最终输出。
|
|
2933
|
+
const child = new FakeAgent('child-1');
|
|
2934
|
+
child.session.append(assistantMessageEvent([{ type: 'text', text: 'work done' }]));
|
|
2935
|
+
agents.register(child);
|
|
2936
|
+
const responses = [
|
|
2937
|
+
'{"completionType":"ask","passThroughVars":{"projectName":"sandore"},"askQuestion":{"questions":[{"id":"q1","question":"Which?","options":[{"label":"A"},{"label":"B"}]}]}}',
|
|
2938
|
+
];
|
|
2939
|
+
let callIndex = 0;
|
|
2940
|
+
llm.streamHandler = () => textChunks(responses[callIndex++ % responses.length] ?? '');
|
|
2941
|
+
|
|
2942
|
+
const rejectedFired: unknown[] = [];
|
|
2943
|
+
ctx.on('sandore/question_rejected', (info, questionEntry) => rejectedFired.push({ info, questionEntry }));
|
|
2944
|
+
const question = waitForQuestion(ctx);
|
|
2945
|
+
const run = await engine.startInstance({ workflowId: 'wfl-ask', originalInput: 'do it', cwd: '/tmp', hostSessionId: 'host-1' });
|
|
2946
|
+
const fired = await question;
|
|
2947
|
+
const requestId = (fired.question as { requestId: string }).requestId;
|
|
2948
|
+
|
|
2949
|
+
await engine.rejectQuestion(run.instanceId, requestId);
|
|
2950
|
+
await waitForEnd(ctx);
|
|
2951
|
+
|
|
2952
|
+
expect(rejectedFired).toHaveLength(1);
|
|
2953
|
+
const entry = (rejectedFired[0] as { questionEntry: { kind: string; status: string; requestId: string } }).questionEntry;
|
|
2954
|
+
expect(entry.kind).toBe('question');
|
|
2955
|
+
expect(entry.status).toBe('rejected');
|
|
2956
|
+
expect(entry.requestId).toBe(requestId);
|
|
2957
|
+
});
|
|
2958
|
+
|
|
2959
|
+
it('caps finalize ask rounds at MAX_FINALIZE_ASK_ROUNDS (3) and completes with the last output (status 流转)', async () => {
|
|
2960
|
+
const { ctx, engine, persistence, llm, agents, sessions, userQuestions } = await harness();
|
|
2961
|
+
await putDefinition(persistence, 'wfl-ask', askYaml);
|
|
2962
|
+
llm.providers.push({ id: 'p', name: 'Provider' });
|
|
2963
|
+
llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
|
|
2964
|
+
// Round 26(场景 B):复用宿主会话 → ask 上报宿主。
|
|
2965
|
+
const existingHost = new FakeAgent('host-1');
|
|
2966
|
+
agents.register(existingHost);
|
|
2967
|
+
sessions.create('host-1');
|
|
2968
|
+
const child = new FakeAgent('child-1');
|
|
2969
|
+
child.session.append(assistantMessageEvent([{ type: 'text', text: 'work done' }]));
|
|
2970
|
+
agents.register(child);
|
|
2971
|
+
// 恒返回 hasAsk=true + askQuestion(评估永不判定完成)→ 引擎 ask 闭环反复触发。
|
|
2972
|
+
llm.streamHandler = () => textChunks(
|
|
2973
|
+
'{"hasAsk":true,"passThroughVars":{},"askQuestion":{"questions":[{"id":"q1","question":"Which?","options":[{"label":"A"},{"label":"B"}]}]}}',
|
|
2974
|
+
);
|
|
2975
|
+
userQuestions.resolveNext = async () => ({ answers: [{ id: 'q1', selected: ['A'] }] });
|
|
2976
|
+
|
|
2977
|
+
const ended = waitForEnd(ctx);
|
|
2978
|
+
const run = await engine.startInstance({ workflowId: 'wfl-ask', originalInput: 'do it', cwd: '/tmp', hostSessionId: 'host-1' });
|
|
2979
|
+
await ended;
|
|
2980
|
+
|
|
2981
|
+
// 轮次上限(4.7):最多 3 轮 ask(首次 + 2 次续问),之后 runPhaseA 把最后 output
|
|
2982
|
+
// 视为完成(不再续问),实例收敛 completed。
|
|
2983
|
+
expect(userQuestions.calls).toHaveLength(3);
|
|
2984
|
+
const state = persistence.state.get(run.instanceId);
|
|
2985
|
+
expect(state?.status).toBe('completed');
|
|
2986
|
+
const askStep = state?.executedSteps.find(step => step.stepId === 'step-ask');
|
|
2987
|
+
expect(askStep?.status).toBe('completed');
|
|
2988
|
+
});
|
|
2989
|
+
});
|
|
2990
|
+
|
|
2991
|
+
describe('SandoreEngineRuntime 双通道 ask(4.4:[[SANDORE_ASK]] 机制已删)', () => {
|
|
2992
|
+
it('treats a [[SANDORE_ASK]] marker block as ordinary output (no delegation, no followup)', async () => {
|
|
2993
|
+
const { ctx, engine, persistence, llm, agents, subagents, userQuestions } = await harness();
|
|
2994
|
+
await putDefinition(persistence, 'wfl-delegated', delegatedAskYaml);
|
|
2995
|
+
llm.providers.push({ id: 'p', name: 'Provider' });
|
|
2996
|
+
llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
|
|
2997
|
+
|
|
2998
|
+
const child = new FakeAgent('child-1');
|
|
2999
|
+
child.session.append(assistantMessageEvent([
|
|
3000
|
+
{ type: 'text', text: '[[SANDORE_ASK]]{"questions":[{"id":"q1","question":"Which?"}]}[[/SANDORE_ASK]]' },
|
|
3001
|
+
]));
|
|
3002
|
+
agents.register(child);
|
|
3003
|
+
|
|
3004
|
+
const ended = waitForEnd(ctx);
|
|
3005
|
+
const run = await engine.startInstance({ workflowId: 'wfl-delegated', originalInput: 'do it', cwd: '/tmp' });
|
|
3006
|
+
await ended;
|
|
3007
|
+
|
|
3008
|
+
// 标记块机制已删:不转接、不 ask、不 followup——输出原样透传(子代理提问走
|
|
3009
|
+
// child-scope ask_user_question 工具,见 askFromSubagent 测试)。
|
|
3010
|
+
expect(userQuestions.calls).toHaveLength(0);
|
|
3011
|
+
expect(subagents.followups).toHaveLength(0);
|
|
3012
|
+
const state = persistence.state.get(run.instanceId);
|
|
3013
|
+
expect(state?.status).toBe('completed');
|
|
3014
|
+
expect(state?.executedSteps.find(step => step.stepId === 'step-agent')?.output).toEqual([
|
|
3015
|
+
{ type: 'text', text: '[[SANDORE_ASK]]{"questions":[{"id":"q1","question":"Which?"}]}[[/SANDORE_ASK]]' },
|
|
3016
|
+
]);
|
|
3017
|
+
});
|
|
3018
|
+
|
|
3019
|
+
it('does not delegate an invalid marker block (treated as ordinary output)', async () => {
|
|
3020
|
+
const { ctx, engine, persistence, llm, agents, subagents, userQuestions } = await harness();
|
|
3021
|
+
await putDefinition(persistence, 'wfl-delegated', delegatedAskYaml);
|
|
3022
|
+
llm.providers.push({ id: 'p', name: 'Provider' });
|
|
3023
|
+
llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
|
|
3024
|
+
|
|
3025
|
+
const child = new FakeAgent('child-1');
|
|
3026
|
+
child.session.append(assistantMessageEvent([
|
|
3027
|
+
{ type: 'text', text: '[[SANDORE_ASK]]not json[[/SANDORE_ASK]]' },
|
|
3028
|
+
]));
|
|
3029
|
+
agents.register(child);
|
|
3030
|
+
|
|
3031
|
+
const ended = waitForEnd(ctx);
|
|
3032
|
+
const run = await engine.startInstance({ workflowId: 'wfl-delegated', originalInput: 'do it', cwd: '/tmp' });
|
|
3033
|
+
await ended;
|
|
3034
|
+
|
|
3035
|
+
expect(userQuestions.calls).toHaveLength(0);
|
|
3036
|
+
expect(subagents.followups).toHaveLength(0);
|
|
3037
|
+
const state = persistence.state.get(run.instanceId);
|
|
3038
|
+
expect(state?.status).toBe('completed');
|
|
3039
|
+
expect(state?.executedSteps.find(step => step.stepId === 'step-agent')?.output).toEqual([
|
|
3040
|
+
{ type: 'text', text: '[[SANDORE_ASK]]not json[[/SANDORE_ASK]]' },
|
|
3041
|
+
]);
|
|
3042
|
+
});
|
|
3043
|
+
});
|
|
3044
|
+
|
|
3045
|
+
describe('SandoreEngineRuntime askFromSubagent(双通道 ask 通道 1)', () => {
|
|
3046
|
+
it('routes a subagent question to the engine-level ask (hostAgent identity) and returns the answer', async () => {
|
|
3047
|
+
const { ctx, engine, persistence, llm, agents, sessions, userQuestions } = await harness();
|
|
3048
|
+
await putDefinition(persistence, 'wfl-ask', askYaml);
|
|
3049
|
+
llm.providers.push({ id: 'p', name: 'Provider' });
|
|
3050
|
+
llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
|
|
3051
|
+
// Round 26(场景 B):复用宿主会话 → ask 上报宿主(askFromSubagent 同受 reportToHost 门控)。
|
|
3052
|
+
const existingHost = new FakeAgent('host-1');
|
|
3053
|
+
agents.register(existingHost);
|
|
3054
|
+
sessions.create('host-1');
|
|
3055
|
+
const child = new FakeAgent('child-1');
|
|
3056
|
+
child.session.append(assistantMessageEvent([{ type: 'text', text: 'work done' }]));
|
|
3057
|
+
agents.register(child);
|
|
3058
|
+
llm.streamHandler = () => textChunks('{"hasAsk":false,"passThroughVars":{}}');
|
|
3059
|
+
|
|
3060
|
+
// 等待实例跑完(Step 段已写入 executedSteps,sessionMeta.childId = child-1)。
|
|
3061
|
+
const ended = waitForEnd(ctx);
|
|
3062
|
+
const run = await engine.startInstance({ workflowId: 'wfl-ask', originalInput: 'do it', cwd: '/tmp', hostSessionId: 'host-1' });
|
|
3063
|
+
await ended;
|
|
3064
|
+
// 真实 DSH:子代理 session.header.parentSession = 宿主主代理会话 id(childSessionMeta);
|
|
3065
|
+
// mock 手动补写(hostAgent.id = hostSessionId = host-1)。
|
|
3066
|
+
child.session.header = { parentSession: 'host-1' };
|
|
3067
|
+
|
|
3068
|
+
const firedQuestions: unknown[] = [];
|
|
3069
|
+
ctx.on('sandore/question', (info, question) => firedQuestions.push({ info, question }));
|
|
3070
|
+
userQuestions.resolveNext = async request => {
|
|
3071
|
+
expect(request.questions).toEqual([{ id: 'q1', question: 'Which?' }]);
|
|
3072
|
+
return { answers: [{ id: 'q1', selected: ['A'] }] };
|
|
3073
|
+
};
|
|
3074
|
+
const answer = await engine.askFromSubagent(child, [{ id: 'q1', question: 'Which?' }]);
|
|
3075
|
+
|
|
3076
|
+
expect(answer).toEqual({ answers: [{ id: 'q1', selected: ['A'] }] });
|
|
3077
|
+
// 以宿主主代理(id = hostSessionId)身份发起(非子代理自身——避免 DELEGATED_CALLER)。
|
|
3078
|
+
expect(userQuestions.calls[userQuestions.calls.length - 1]?.agent?.id).toBe('host-1');
|
|
3079
|
+
// 提问登记期间发射 sandore/question(pending 条目;回答后已终态化,仅 pending 持久化)。
|
|
3080
|
+
expect(firedQuestions).toHaveLength(1);
|
|
3081
|
+
const firedEntry = firedQuestions[0] as { question: { kind: string; payload: { questions: Array<{ id: string }> } } };
|
|
3082
|
+
expect(firedEntry.question.kind).toBe('question');
|
|
3083
|
+
expect(firedEntry.question.payload.questions[0]?.id).toBe('q1');
|
|
3084
|
+
});
|
|
3085
|
+
|
|
3086
|
+
it('throws STEP_EXECUTION when the question is rejected (缺陷 26 显式落态)', async () => {
|
|
3087
|
+
const { ctx, engine, persistence, llm, agents, userQuestions } = await harness();
|
|
3088
|
+
await putDefinition(persistence, 'wfl-ask', askYaml);
|
|
3089
|
+
llm.providers.push({ id: 'p', name: 'Provider' });
|
|
3090
|
+
llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
|
|
3091
|
+
const child = new FakeAgent('child-1');
|
|
3092
|
+
child.session.append(assistantMessageEvent([{ type: 'text', text: 'work done' }]));
|
|
3093
|
+
agents.register(child);
|
|
3094
|
+
llm.streamHandler = () => textChunks('{"hasAsk":false,"passThroughVars":{}}');
|
|
3095
|
+
|
|
3096
|
+
const ended = waitForEnd(ctx);
|
|
3097
|
+
const run = await engine.startInstance({ workflowId: 'wfl-ask', originalInput: 'do it', cwd: '/tmp' });
|
|
3098
|
+
await ended;
|
|
3099
|
+
child.session.header = { parentSession: run.instanceId };
|
|
3100
|
+
|
|
3101
|
+
const asking = engine.askFromSubagent(child, [{ id: 'q1', question: 'Which?' }]);
|
|
3102
|
+
// 等 pending 条目登记后拒绝(resolve 占位 → askFromSubagent 抛 STEP_EXECUTION)。
|
|
3103
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
3104
|
+
const state = persistence.state.get(run.instanceId);
|
|
3105
|
+
const entry = state?.pendingInteractions.find(item => item.kind === 'question');
|
|
3106
|
+
expect(entry).toBeDefined();
|
|
3107
|
+
await engine.rejectQuestion(run.instanceId, (entry as { requestId: string }).requestId);
|
|
3108
|
+
|
|
3109
|
+
await expect(asking).rejects.toMatchObject({ code: 'STEP_EXECUTION' });
|
|
3110
|
+
});
|
|
3111
|
+
|
|
3112
|
+
it('throws INVALID_OPERATION for unknown parent session or unknown child', async () => {
|
|
3113
|
+
const { ctx, engine, persistence, llm, agents } = await harness();
|
|
3114
|
+
await putDefinition(persistence, 'wfl-ask', askYaml);
|
|
3115
|
+
llm.providers.push({ id: 'p', name: 'Provider' });
|
|
3116
|
+
llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
|
|
3117
|
+
const child = new FakeAgent('child-1');
|
|
3118
|
+
child.session.append(assistantMessageEvent([{ type: 'text', text: 'work done' }]));
|
|
3119
|
+
agents.register(child);
|
|
3120
|
+
llm.streamHandler = () => textChunks('{"hasAsk":false,"passThroughVars":{}}');
|
|
3121
|
+
|
|
3122
|
+
const ended = waitForEnd(ctx);
|
|
3123
|
+
const run = await engine.startInstance({ workflowId: 'wfl-ask', originalInput: 'do it', cwd: '/tmp' });
|
|
3124
|
+
await ended;
|
|
3125
|
+
|
|
3126
|
+
// 无 parent session header → INVALID_OPERATION。
|
|
3127
|
+
await expect(engine.askFromSubagent(
|
|
3128
|
+
child,
|
|
3129
|
+
[{ id: 'q1', question: 'Q?' }],
|
|
3130
|
+
)).rejects.toMatchObject({ code: 'INVALID_OPERATION' });
|
|
3131
|
+
// parent session 命中但 child 不匹配任何 Step 段 → INVALID_OPERATION。
|
|
3132
|
+
child.session.header = { parentSession: run.instanceId };
|
|
3133
|
+
const unknownChild = new FakeAgent('no-such-child');
|
|
3134
|
+
unknownChild.session.header = { parentSession: run.instanceId };
|
|
3135
|
+
await expect(engine.askFromSubagent(
|
|
3136
|
+
unknownChild,
|
|
3137
|
+
[{ id: 'q1', question: 'Q?' }],
|
|
3138
|
+
)).rejects.toMatchObject({ code: 'INVALID_OPERATION' });
|
|
3139
|
+
});
|
|
3140
|
+
});
|
|
3141
|
+
|
|
3142
|
+
describe('SandoreEngineRuntime registerContinuableSetup gate(4.3:仅当前非终态 sandore 实例的子代理)', () => {
|
|
3143
|
+
it('registers a contribution and installs the child-scope ask tool only for non-terminal sandore instances', async () => {
|
|
3144
|
+
const { ctx, engine, subagents, persistence, llm, agents } = await harness();
|
|
3145
|
+
await putDefinition(persistence, 'wfl-prompt-only', promptOnlyYaml);
|
|
3146
|
+
provideLlmDirectory(llm);
|
|
3147
|
+
// prompt Step 挂起:child status='running' 不自动 settle → 实例保持 running(非终态)。
|
|
3148
|
+
const child = new FakeAgent('child-1');
|
|
3149
|
+
child.status = 'running';
|
|
3150
|
+
agents.register(child);
|
|
3151
|
+
|
|
3152
|
+
const stepStarted = waitForStepStart(ctx);
|
|
3153
|
+
const run = await engine.startInstance({ workflowId: 'wfl-prompt-only', originalInput: 'do it', cwd: '/tmp' });
|
|
3154
|
+
await stepStarted;
|
|
3155
|
+
|
|
3156
|
+
// 引擎启动时注册了 1 个贡献(Service.init → registerSandoreAskToolSetup)。
|
|
3157
|
+
expect(subagents.continuableSetups).toHaveLength(1);
|
|
3158
|
+
const contribution = subagents.continuableSetups[0]!;
|
|
3159
|
+
|
|
3160
|
+
// gate 未命中(parent 非任何 sandore 实例的宿主主代理):不注册工具。
|
|
3161
|
+
const toolsA = { register: vi.fn(() => () => {}) };
|
|
3162
|
+
const disposeA = contribution({
|
|
3163
|
+
tools: toolsA,
|
|
3164
|
+
agent: { session: { header: { parentSession: 'some-other-session' } } },
|
|
3165
|
+
} as unknown as Context);
|
|
3166
|
+
expect(toolsA.register).not.toHaveBeenCalled();
|
|
3167
|
+
disposeA();
|
|
3168
|
+
|
|
3169
|
+
// gate 命中(parent = 当前非终态 sandore 实例的宿主主代理会话 = instanceId):
|
|
3170
|
+
// 注册 child-scope ask_user_question。
|
|
3171
|
+
const toolsB = { register: vi.fn(() => () => {}) };
|
|
3172
|
+
const disposeB = contribution({
|
|
3173
|
+
tools: toolsB,
|
|
3174
|
+
agent: { session: { header: { parentSession: run.instanceId } } },
|
|
3175
|
+
} as unknown as Context);
|
|
3176
|
+
expect(toolsB.register).toHaveBeenCalledTimes(1);
|
|
3177
|
+
expect(toolsB.register).toHaveBeenCalledWith(expect.objectContaining({ name: 'ask_user_question' }));
|
|
3178
|
+
disposeB();
|
|
3179
|
+
|
|
3180
|
+
// 实例终态(completed)后:同一宿主主代理的后续子代理不再注入(避免误注入)。
|
|
3181
|
+
child.session.append(assistantMessageEvent([{ type: 'text', text: 'done' }]));
|
|
3182
|
+
subagents.settleChild('child-1', 'completed');
|
|
3183
|
+
await waitForEnd(ctx);
|
|
3184
|
+
const toolsC = { register: vi.fn(() => () => {}) };
|
|
3185
|
+
const disposeC = contribution({
|
|
3186
|
+
tools: toolsC,
|
|
3187
|
+
agent: { session: { header: { parentSession: run.instanceId } } },
|
|
3188
|
+
} as unknown as Context);
|
|
3189
|
+
expect(toolsC.register).not.toHaveBeenCalled();
|
|
3190
|
+
disposeC();
|
|
3191
|
+
});
|
|
3192
|
+
});
|
|
3193
|
+
|
|
3194
|
+
/** 单 Stage 单 agent Step(用于重试续跑集成测试:首跑无输出 failed,续跑回填输出 completed)。 */
|
|
3195
|
+
const agentFailYaml = `id: wfl-agent-fail
|
|
3196
|
+
name: agent-fail
|
|
3197
|
+
version: "1.0.0"
|
|
3198
|
+
stages:
|
|
3199
|
+
- id: stg-1
|
|
3200
|
+
name: 单agent
|
|
3201
|
+
steps:
|
|
3202
|
+
- id: step-agent
|
|
3203
|
+
steps:
|
|
3204
|
+
- id: step-agent
|
|
3205
|
+
command:
|
|
3206
|
+
type: agent
|
|
3207
|
+
value:
|
|
3208
|
+
promptText: "do work"
|
|
3209
|
+
`;
|
|
3210
|
+
|
|
3211
|
+
/** 单 Stage 三 Step:shell(outputTo)→ agent(中间失败可重试)→ shell(剩余 Step)。 */
|
|
3212
|
+
const multiStepRetryYaml = `id: wfl-multi-retry
|
|
3213
|
+
name: multi-retry
|
|
3214
|
+
version: "1.0.0"
|
|
3215
|
+
stages:
|
|
3216
|
+
- id: stg-1
|
|
3217
|
+
name: 重试续跑
|
|
3218
|
+
steps:
|
|
3219
|
+
- id: step-a
|
|
3220
|
+
- id: step-b
|
|
3221
|
+
- id: step-c
|
|
3222
|
+
steps:
|
|
3223
|
+
- id: step-a
|
|
3224
|
+
command:
|
|
3225
|
+
type: shell
|
|
3226
|
+
value:
|
|
3227
|
+
command: "echo hello"
|
|
3228
|
+
outputTo:
|
|
3229
|
+
- id: opt-a
|
|
3230
|
+
path: "out-a.txt"
|
|
3231
|
+
- id: step-b
|
|
3232
|
+
command:
|
|
3233
|
+
type: agent
|
|
3234
|
+
value:
|
|
3235
|
+
promptText: "do work"
|
|
3236
|
+
outputTo:
|
|
3237
|
+
- id: opt-b
|
|
3238
|
+
path: "out-b.txt"
|
|
3239
|
+
- id: step-c
|
|
3240
|
+
command:
|
|
3241
|
+
type: shell
|
|
3242
|
+
value:
|
|
3243
|
+
command: "echo done"
|
|
3244
|
+
`;
|
|
3245
|
+
|
|
3246
|
+
describe('SandoreEngineRuntime recovery', () => {
|
|
3247
|
+
it('throws INVALID_OPERATION when recovering a terminal instance', async () => {
|
|
3248
|
+
const { engine, persistence } = await harness();
|
|
3249
|
+
await persistence.state.put('inst-done', completedSnapshot('inst-done'));
|
|
3250
|
+
|
|
3251
|
+
await expect(engine.recoverInstance('inst-done')).rejects.toMatchObject({ code: 'INVALID_OPERATION' });
|
|
3252
|
+
});
|
|
3253
|
+
|
|
3254
|
+
it('retries a failed agent step with continue mode and resumes to completed', async () => {
|
|
3255
|
+
const { ctx, engine, persistence, llm, agents, subagents, sessions } = await harness();
|
|
3256
|
+
await putDefinition(persistence, 'wfl-agent-fail', agentFailYaml);
|
|
3257
|
+
llm.providers.push({ id: 'p', name: 'Provider' });
|
|
3258
|
+
llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
|
|
3259
|
+
|
|
3260
|
+
// 子代理首次无输出 → Step failed(真实 childId 已记录)。
|
|
3261
|
+
const child = new FakeAgent('child-1');
|
|
3262
|
+
agents.register(child);
|
|
3263
|
+
|
|
3264
|
+
const ended = waitForEnd(ctx);
|
|
3265
|
+
const run = await engine.startInstance({ workflowId: 'wfl-agent-fail', originalInput: 'do it', cwd: '/tmp' });
|
|
3266
|
+
await ended;
|
|
3267
|
+
expect(persistence.state.get(run.instanceId)?.status).toBe('failed');
|
|
3268
|
+
|
|
3269
|
+
// 续跑前给子代理回填最终输出。
|
|
3270
|
+
child.session.append(assistantMessageEvent([{ type: 'text', text: 'retry output' }]));
|
|
3271
|
+
|
|
3272
|
+
const retried: Array<{ stepId: string; mode: string; name: string; loopIteration?: number[] }> = [];
|
|
3273
|
+
ctx.on('sandore/step-retried', (_info, stepId, mode, name, loopIteration) => retried.push({ stepId, mode, name, ...(loopIteration === undefined ? {} : { loopIteration }) }));
|
|
3274
|
+
|
|
3275
|
+
await engine.retryStep(run.instanceId, 'step-agent', 'continue', 'please retry');
|
|
3276
|
+
|
|
3277
|
+
const state = persistence.state.get(run.instanceId);
|
|
3278
|
+
expect(state?.status).toBe('completed');
|
|
3279
|
+
// 段模型:重试追加新段(attemptIndex 1),断言取最后活跃段。
|
|
3280
|
+
const step = lastSegmentOf(state?.executedSteps ?? [], 'step-agent');
|
|
3281
|
+
expect(step?.output).toEqual([{ type: 'text', text: 'retry output' }]);
|
|
3282
|
+
expect(step?.sessionMeta?.childId).toBe('child-1');
|
|
3283
|
+
expect(step?.retryHistory?.[0]?.mode).toBe('continue');
|
|
3284
|
+
expect(step?.retryHistory?.[0]?.result).toBe('completed');
|
|
3285
|
+
expect(subagents.followups.some(item => item.childId === 'child-1')).toBe(true);
|
|
3286
|
+
|
|
3287
|
+
// sandore/step-retried 单发:Cordis 事件断言(Round 25 去污染:宿主会话不再承载)。
|
|
3288
|
+
expect(retried).toEqual([{ stepId: 'step-agent', mode: 'continue', name: 'step-agent' }]);
|
|
3289
|
+
});
|
|
3290
|
+
|
|
3291
|
+
it('retries a failed middle agent step and runs all remaining steps to completed', async () => {
|
|
3292
|
+
const { ctx, engine, persistence, llm, shell, agents } = await harness();
|
|
3293
|
+
await putDefinition(persistence, 'wfl-multi-retry', multiStepRetryYaml);
|
|
3294
|
+
shell.runHandler = async spec => successResult(spec, 'ok');
|
|
3295
|
+
llm.providers.push({ id: 'p', name: 'Provider' });
|
|
3296
|
+
llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
|
|
3297
|
+
|
|
3298
|
+
const child = new FakeAgent('child-1');
|
|
3299
|
+
agents.register(child);
|
|
3300
|
+
|
|
3301
|
+
const ended = waitForEnd(ctx);
|
|
3302
|
+
const run = await engine.startInstance({ workflowId: 'wfl-multi-retry', originalInput: 'do it', cwd: '/tmp' });
|
|
3303
|
+
await ended;
|
|
3304
|
+
|
|
3305
|
+
// 首跑:step-a 完成、step-b(agent 无输出)失败、step-c 未执行。
|
|
3306
|
+
let state = persistence.state.get(run.instanceId);
|
|
3307
|
+
expect(state?.status).toBe('failed');
|
|
3308
|
+
expect(state?.executedSteps.map(step => step.stepId)).toEqual(['step-a', 'step-b']);
|
|
3309
|
+
expect(state?.executedSteps[1]?.status).toBe('failed');
|
|
3310
|
+
|
|
3311
|
+
// 续跑前给子代理回填最终输出。
|
|
3312
|
+
child.session.append(assistantMessageEvent([{ type: 'text', text: 'retry output' }]));
|
|
3313
|
+
await engine.retryStep(run.instanceId, 'step-b', 'continue', 'please retry');
|
|
3314
|
+
|
|
3315
|
+
state = persistence.state.get(run.instanceId);
|
|
3316
|
+
expect(state?.status).toBe('completed');
|
|
3317
|
+
// 段模型:step-b 重试追加新段(attemptIndex 1),旧 failed 段保留定格快照。
|
|
3318
|
+
// 断言聚焦新段状态与输出(不假设 stepIds 顺序,旧 failed 段的存在单独验证)。
|
|
3319
|
+
const stepIds = state?.executedSteps.map(step => step.stepId) ?? [];
|
|
3320
|
+
expect(new Set(stepIds)).toEqual(new Set(['step-a', 'step-b', 'step-c']));
|
|
3321
|
+
expect(stepIds.filter(id => id === 'step-b').length).toBe(2);
|
|
3322
|
+
const retriedB = lastSegmentOf(state?.executedSteps ?? [], 'step-b');
|
|
3323
|
+
expect(retriedB?.status).toBe('completed');
|
|
3324
|
+
expect(retriedB?.output).toEqual([{ type: 'text', text: 'retry output' }]);
|
|
3325
|
+
expect(retriedB?.retryHistory?.[0]?.mode).toBe('continue');
|
|
3326
|
+
expect(retriedB?.retryHistory?.[0]?.result).toBe('completed');
|
|
3327
|
+
const failedB = state?.executedSteps.find(step => step.stepId === 'step-b' && step.status === 'failed');
|
|
3328
|
+
expect(failedB).toBeDefined();
|
|
3329
|
+
});
|
|
3330
|
+
|
|
3331
|
+
it('preserves outputTo artifacts in the stage contract after retrying the producing step', async () => {
|
|
3332
|
+
const { ctx, engine, persistence, llm, shell, agents } = await harness();
|
|
3333
|
+
await putDefinition(persistence, 'wfl-multi-retry', multiStepRetryYaml);
|
|
3334
|
+
shell.runHandler = async spec => successResult(spec, 'ok');
|
|
3335
|
+
llm.providers.push({ id: 'p', name: 'Provider' });
|
|
3336
|
+
llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
|
|
3337
|
+
|
|
3338
|
+
const child = new FakeAgent('child-1');
|
|
3339
|
+
agents.register(child);
|
|
3340
|
+
|
|
3341
|
+
const ended = waitForEnd(ctx);
|
|
3342
|
+
const run = await engine.startInstance({ workflowId: 'wfl-multi-retry', originalInput: 'do it', cwd: '/tmp' });
|
|
3343
|
+
await ended;
|
|
3344
|
+
expect(persistence.state.get(run.instanceId)?.status).toBe('failed');
|
|
3345
|
+
|
|
3346
|
+
child.session.append(assistantMessageEvent([{ type: 'text', text: 'retry output' }]));
|
|
3347
|
+
await engine.retryStep(run.instanceId, 'step-b', 'continue', 'please retry');
|
|
3348
|
+
|
|
3349
|
+
const state = persistence.state.get(run.instanceId);
|
|
3350
|
+
expect(state?.status).toBe('completed');
|
|
3351
|
+
// 重试 Step 的 outputTo 产物保留在 StageContract.artifacts(含已完成的 step-a)。
|
|
3352
|
+
const contract = state?.stageContracts['stg-1'];
|
|
3353
|
+
expect(contract?.status).toBe('completed');
|
|
3354
|
+
const artifacts = contract?.artifacts ?? [];
|
|
3355
|
+
expect(artifacts.map(item => item.outputToId)).toEqual(['opt-a', 'opt-b']);
|
|
3356
|
+
expect(artifacts.find(item => item.outputToId === 'opt-b')?.stepId).toBe('step-b');
|
|
3357
|
+
});
|
|
3358
|
+
|
|
3359
|
+
it('retries a failed agent step with fork mode to a new childId and completes', async () => {
|
|
3360
|
+
const { ctx, engine, persistence, llm, agents, subagents, sessions } = await harness();
|
|
3361
|
+
await putDefinition(persistence, 'wfl-agent-fail', agentFailYaml);
|
|
3362
|
+
llm.providers.push({ id: 'p', name: 'Provider' });
|
|
3363
|
+
llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
|
|
3364
|
+
|
|
3365
|
+
const child = new FakeAgent('child-1');
|
|
3366
|
+
agents.register(child);
|
|
3367
|
+
|
|
3368
|
+
const ended = waitForEnd(ctx);
|
|
3369
|
+
const run = await engine.startInstance({ workflowId: 'wfl-agent-fail', originalInput: 'do it', cwd: '/tmp' });
|
|
3370
|
+
await ended;
|
|
3371
|
+
expect(persistence.state.get(run.instanceId)?.status).toBe('failed');
|
|
3372
|
+
|
|
3373
|
+
// fork 源:child-1 在 sessions 表登记,其 assistant 输出作为 fork 的 seed 回放。
|
|
3374
|
+
const childSession = sessions.create('child-1');
|
|
3375
|
+
childSession.append('assistant/message', assistantMessageEvent([{ type: 'text', text: 'fork output' }]).data);
|
|
3376
|
+
|
|
3377
|
+
await engine.retryStep(run.instanceId, 'step-agent', 'fork', 'fork it');
|
|
3378
|
+
|
|
3379
|
+
const state = persistence.state.get(run.instanceId);
|
|
3380
|
+
expect(state?.status).toBe('completed');
|
|
3381
|
+
// 段模型:fork 新段(attemptIndex 1)承载新 childId/output/retryHistory。
|
|
3382
|
+
const step = lastSegmentOf(state?.executedSteps ?? [], 'step-agent');
|
|
3383
|
+
expect(step?.output).toEqual([{ type: 'text', text: 'fork output' }]);
|
|
3384
|
+
// 新 childId(合成 session id)续跑,parentSessionId 记旧值。
|
|
3385
|
+
expect(step?.sessionMeta?.childId).toMatch(UUID_RE);
|
|
3386
|
+
expect(step?.sessionMeta?.childId).not.toBe('child-1');
|
|
3387
|
+
expect(step?.parentSessionId).toBe('child-1');
|
|
3388
|
+
expect(step?.retryHistory?.[0]?.mode).toBe('fork');
|
|
3389
|
+
expect(step?.retryHistory?.[0]?.result).toBe('completed');
|
|
3390
|
+
expect(subagents.followups.some(item => item.childId === step?.sessionMeta?.childId)).toBe(true);
|
|
3391
|
+
});
|
|
3392
|
+
|
|
3393
|
+
it('retries a failed agent step with revert mode to a new childId and completes', async () => {
|
|
3394
|
+
const { ctx, engine, persistence, llm, agents, subagents, sessions } = await harness();
|
|
3395
|
+
await putDefinition(persistence, 'wfl-agent-fail', agentFailYaml);
|
|
3396
|
+
llm.providers.push({ id: 'p', name: 'Provider' });
|
|
3397
|
+
llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
|
|
3398
|
+
|
|
3399
|
+
const child = new FakeAgent('child-1');
|
|
3400
|
+
agents.register(child);
|
|
3401
|
+
|
|
3402
|
+
const ended = waitForEnd(ctx);
|
|
3403
|
+
const run = await engine.startInstance({ workflowId: 'wfl-agent-fail', originalInput: 'do it', cwd: '/tmp' });
|
|
3404
|
+
await ended;
|
|
3405
|
+
expect(persistence.state.get(run.instanceId)?.status).toBe('failed');
|
|
3406
|
+
|
|
3407
|
+
// revert 源:child-1 含闭合 turn(turn/start → assistant → turn/end)供安全边界计算。
|
|
3408
|
+
const childSession = sessions.create('child-1');
|
|
3409
|
+
childSession.append('turn/start', { turn: 0 });
|
|
3410
|
+
childSession.append('assistant/message', assistantMessageEvent([{ type: 'text', text: 'revert output' }]).data);
|
|
3411
|
+
childSession.append('turn/end', { turn: 0, reason: { kind: 'completed' } });
|
|
3412
|
+
|
|
3413
|
+
await engine.retryStep(run.instanceId, 'step-agent', 'revert', 'rollback');
|
|
3414
|
+
|
|
3415
|
+
const state = persistence.state.get(run.instanceId);
|
|
3416
|
+
expect(state?.status).toBe('completed');
|
|
3417
|
+
const step = lastSegmentOf(state?.executedSteps ?? [], 'step-agent');
|
|
3418
|
+
expect(step?.output).toEqual([{ type: 'text', text: 'revert output' }]);
|
|
3419
|
+
expect(step?.sessionMeta?.childId).toMatch(UUID_RE);
|
|
3420
|
+
expect(step?.parentSessionId).toBe('child-1');
|
|
3421
|
+
expect(step?.retryHistory?.[0]?.mode).toBe('revert');
|
|
3422
|
+
expect(step?.retryHistory?.[0]?.result).toBe('completed');
|
|
3423
|
+
expect(subagents.followups.some(item => item.childId === step?.sessionMeta?.childId)).toBe(true);
|
|
3424
|
+
});
|
|
3425
|
+
|
|
3426
|
+
it('retries a failed agent step with revert mode to a user-selected earlier turn boundary', async () => {
|
|
3427
|
+
const { ctx, engine, persistence, llm, agents, subagents, sessions } = await harness();
|
|
3428
|
+
await putDefinition(persistence, 'wfl-agent-fail', agentFailYaml);
|
|
3429
|
+
llm.providers.push({ id: 'p', name: 'Provider' });
|
|
3430
|
+
llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
|
|
3431
|
+
|
|
3432
|
+
const child = new FakeAgent('child-1');
|
|
3433
|
+
agents.register(child);
|
|
3434
|
+
|
|
3435
|
+
const ended = waitForEnd(ctx);
|
|
3436
|
+
const run = await engine.startInstance({ workflowId: 'wfl-agent-fail', originalInput: 'do it', cwd: '/tmp' });
|
|
3437
|
+
await ended;
|
|
3438
|
+
expect(persistence.state.get(run.instanceId)?.status).toBe('failed');
|
|
3439
|
+
|
|
3440
|
+
// revert 源:两个闭合 turn(seq 2 与 5)+ 一个未闭合尾部 turn(seq 6 turn/start,无 turn/end)。
|
|
3441
|
+
// 用户传 revertBoundary=2 选更早 turn(截断到 seq 2,丢弃 seq 3-6 的失败内容)。
|
|
3442
|
+
const childSession = sessions.create('child-1');
|
|
3443
|
+
childSession.append('turn/start', { turn: 0 });
|
|
3444
|
+
childSession.append('assistant/message', assistantMessageEvent([{ type: 'text', text: 'first answer' }]).data);
|
|
3445
|
+
childSession.append('turn/end', { turn: 0, reason: { kind: 'completed' } });
|
|
3446
|
+
childSession.append('turn/start', { turn: 1 });
|
|
3447
|
+
childSession.append('assistant/message', assistantMessageEvent([{ type: 'text', text: 'failed attempt' }]).data);
|
|
3448
|
+
childSession.append('turn/end', { turn: 1, reason: { kind: 'completed' } });
|
|
3449
|
+
childSession.append('turn/start', { turn: 2 });
|
|
3450
|
+
childSession.append('assistant/message', assistantMessageEvent([{ type: 'text', text: 'unmatched tail' }]).data);
|
|
3451
|
+
|
|
3452
|
+
await engine.retryStep(run.instanceId, 'step-agent', 'revert', 'rollback', 2);
|
|
3453
|
+
|
|
3454
|
+
const state = persistence.state.get(run.instanceId);
|
|
3455
|
+
expect(state?.status).toBe('completed');
|
|
3456
|
+
const step = lastSegmentOf(state?.executedSteps ?? [], 'step-agent');
|
|
3457
|
+
// seed 截断到 boundary=2(仅含 turn 0 的 first answer),后续 followup 续跑输出该文本。
|
|
3458
|
+
expect(step?.output).toEqual([{ type: 'text', text: 'first answer' }]);
|
|
3459
|
+
expect(step?.retryHistory?.[0]?.mode).toBe('revert');
|
|
3460
|
+
expect(step?.retryHistory?.[0]?.result).toBe('completed');
|
|
3461
|
+
expect(step?.parentSessionId).toBe('child-1');
|
|
3462
|
+
expect(subagents.followups.some(item => item.childId === step?.sessionMeta?.childId)).toBe(true);
|
|
3463
|
+
});
|
|
3464
|
+
|
|
3465
|
+
it('lists the available closed-turn revert boundaries for a failed agent step', async () => {
|
|
3466
|
+
const { ctx: _ctx, engine, persistence, llm, agents, sessions } = await harness();
|
|
3467
|
+
void _ctx;
|
|
3468
|
+
await putDefinition(persistence, 'wfl-agent-fail', agentFailYaml);
|
|
3469
|
+
llm.providers.push({ id: 'p', name: 'Provider' });
|
|
3470
|
+
llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
|
|
3471
|
+
|
|
3472
|
+
const child = new FakeAgent('child-1');
|
|
3473
|
+
agents.register(child);
|
|
3474
|
+
|
|
3475
|
+
// 不实际跑实例:直接放一个 running→failed 的 step 快照 + child-1 会话事件。
|
|
3476
|
+
const now = new Date().toISOString();
|
|
3477
|
+
const state: WorkflowTaskState = {
|
|
3478
|
+
taskId: 'inst-list',
|
|
3479
|
+
workflowId: 'wfl-agent-fail',
|
|
3480
|
+
cwd: '/tmp',
|
|
3481
|
+
status: 'failed',
|
|
3482
|
+
originalInput: 'do it',
|
|
3483
|
+
currentStageId: 'stg-1',
|
|
3484
|
+
executedSteps: [{
|
|
3485
|
+
id: 'exec-1',
|
|
3486
|
+
stepId: 'step-agent',
|
|
3487
|
+
stageId: 'stg-1',
|
|
3488
|
+
name: 'step-agent',
|
|
3489
|
+
status: 'failed',
|
|
3490
|
+
command: { type: 'agent', value: { promptText: 'do' } },
|
|
3491
|
+
sessionMeta: { childId: 'child-1', synthetic: false, command: 'agent' },
|
|
3492
|
+
visitCount: 1,
|
|
3493
|
+
error: 'boom',
|
|
3494
|
+
}],
|
|
3495
|
+
stageInfos: [{ id: 'stg-1', name: 'stage', status: 'failed' }],
|
|
3496
|
+
pendingInteractions: [],
|
|
3497
|
+
pendingQueue: [],
|
|
3498
|
+
visitCounts: {},
|
|
3499
|
+
passThroughVars: {},
|
|
3500
|
+
activeLoopIterationStack: [],
|
|
3501
|
+
activeLoopNodeId: null,
|
|
3502
|
+
activeLoopStack: [],
|
|
3503
|
+
pausedChildIds: [],
|
|
3504
|
+
stageContracts: {},
|
|
3505
|
+
createdAt: now,
|
|
3506
|
+
updatedAt: now,
|
|
3507
|
+
};
|
|
3508
|
+
void persistence.state.put('inst-list', state);
|
|
3509
|
+
|
|
3510
|
+
const childSession = sessions.create('child-1');
|
|
3511
|
+
childSession.append('turn/start', { turn: 0 });
|
|
3512
|
+
childSession.append('assistant/message', assistantMessageEvent([{ type: 'text', text: 'first' }]).data);
|
|
3513
|
+
childSession.append('turn/end', { turn: 0, reason: { kind: 'completed' } });
|
|
3514
|
+
childSession.append('turn/start', { turn: 1 });
|
|
3515
|
+
childSession.append('assistant/message', assistantMessageEvent([{ type: 'text', text: 'second' }]).data);
|
|
3516
|
+
childSession.append('turn/end', { turn: 1, reason: { kind: 'completed' } });
|
|
3517
|
+
childSession.append('turn/start', { turn: 2 });
|
|
3518
|
+
childSession.append('assistant/message', assistantMessageEvent([{ type: 'text', text: 'unmatched' }]).data);
|
|
3519
|
+
|
|
3520
|
+
const boundaries = engine.listRevertBoundaries('inst-list', 'step-agent');
|
|
3521
|
+
// 仅两个闭合 turn 候选(seq 2、5);未闭合尾部 turn 2(turn/start seq 6)不计入。
|
|
3522
|
+
expect(boundaries).toEqual([
|
|
3523
|
+
{ seq: 2, turn: 0, preview: 'first' },
|
|
3524
|
+
{ seq: 5, turn: 1, preview: 'second' },
|
|
3525
|
+
]);
|
|
3526
|
+
});
|
|
3527
|
+
|
|
3528
|
+
it('auto-reverts fork to the last closed turn/end when the source session has an unclosed tail', async () => {
|
|
3529
|
+
const { ctx, engine, persistence, llm, agents, subagents, sessions } = await harness();
|
|
3530
|
+
await putDefinition(persistence, 'wfl-agent-fail', agentFailYaml);
|
|
3531
|
+
llm.providers.push({ id: 'p', name: 'Provider' });
|
|
3532
|
+
llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
|
|
3533
|
+
|
|
3534
|
+
const child = new FakeAgent('child-1');
|
|
3535
|
+
agents.register(child);
|
|
3536
|
+
|
|
3537
|
+
const ended = waitForEnd(ctx);
|
|
3538
|
+
const run = await engine.startInstance({ workflowId: 'wfl-agent-fail', originalInput: 'do it', cwd: '/tmp' });
|
|
3539
|
+
await ended;
|
|
3540
|
+
expect(persistence.state.get(run.instanceId)?.status).toBe('failed');
|
|
3541
|
+
|
|
3542
|
+
// fork 源:turn 0 闭合(seq 0-2),turn 1 未闭合(turn/start seq 3 + assistant seq 4,无 turn/end)。
|
|
3543
|
+
// FakeSessionStore.fork 忠实模拟 DSH:fork(undefined) 会抛 OPEN_TURN(末事件 4 落在未闭合 turn 1)。
|
|
3544
|
+
// recovery-manager fork 防御必须先算 boundary=2(最后一个闭合 turn/end)再 fork,避开 OPEN_TURN。
|
|
3545
|
+
const childSession = sessions.create('child-1');
|
|
3546
|
+
childSession.append('turn/start', { turn: 0 });
|
|
3547
|
+
childSession.append('assistant/message', assistantMessageEvent([{ type: 'text', text: 'fork output' }]).data);
|
|
3548
|
+
childSession.append('turn/end', { turn: 0, reason: { kind: 'completed' } });
|
|
3549
|
+
childSession.append('turn/start', { turn: 1 });
|
|
3550
|
+
childSession.append('assistant/message', assistantMessageEvent([{ type: 'text', text: 'unmatched tail' }]).data);
|
|
3551
|
+
|
|
3552
|
+
await engine.retryStep(run.instanceId, 'step-agent', 'fork', 'fork it');
|
|
3553
|
+
|
|
3554
|
+
const state = persistence.state.get(run.instanceId);
|
|
3555
|
+
// 防御生效:未抛 OPEN_TURN,实例完成(新 agent 续跑输出 fork output)。
|
|
3556
|
+
expect(state?.status).toBe('completed');
|
|
3557
|
+
const step = lastSegmentOf(state?.executedSteps ?? [], 'step-agent');
|
|
3558
|
+
expect(step?.output).toEqual([{ type: 'text', text: 'fork output' }]);
|
|
3559
|
+
expect(step?.sessionMeta?.childId).toMatch(UUID_RE);
|
|
3560
|
+
expect(step?.parentSessionId).toBe('child-1');
|
|
3561
|
+
expect(step?.retryHistory?.[0]?.mode).toBe('fork');
|
|
3562
|
+
expect(step?.retryHistory?.[0]?.result).toBe('completed');
|
|
3563
|
+
expect(subagents.followups.some(item => item.childId === step?.sessionMeta?.childId)).toBe(true);
|
|
3564
|
+
});
|
|
3565
|
+
|
|
3566
|
+
it('truly re-runs an empty-executedSteps non-terminal snapshot to completed (no fake recovery)', async () => {
|
|
3567
|
+
const { ctx, engine, persistence, sessions } = await harness();
|
|
3568
|
+
await putDefinition(persistence, 'wfl-shell-only', shellOnlyYaml);
|
|
3569
|
+
|
|
3570
|
+
await persistence.state.put('inst-running', runningSnapshot('inst-running', 'wfl-shell-only'));
|
|
3571
|
+
sessions.create('inst-running');
|
|
3572
|
+
|
|
3573
|
+
const ended = waitForEnd(ctx);
|
|
3574
|
+
const result = await engine.recoverInstance('inst-running');
|
|
3575
|
+
expect(result).toEqual({ instanceId: 'inst-running', status: 'running' });
|
|
3576
|
+
await ended;
|
|
3577
|
+
|
|
3578
|
+
const state = persistence.state.get('inst-running');
|
|
3579
|
+
expect(state?.status).toBe('completed');
|
|
3580
|
+
expect(state?.executedSteps).toHaveLength(1);
|
|
3581
|
+
expect(state?.executedSteps[0]?.stepId).toBe('step-shell');
|
|
3582
|
+
expect(state?.executedSteps[0]?.status).toBe('completed');
|
|
3583
|
+
});
|
|
3584
|
+
|
|
3585
|
+
it('resumes the host agent before following up the child subagent during recovery', async () => {
|
|
3586
|
+
const { ctx, engine, persistence, sessions, agents, subagents } = await harness();
|
|
3587
|
+
await putDefinition(persistence, 'wfl-agent-fail', agentFailYaml);
|
|
3588
|
+
|
|
3589
|
+
await persistence.state.put('inst-run', stepSnapshot('inst-run', 'wfl-agent-fail', agentStepSnapshot('running'), 'running'));
|
|
3590
|
+
sessions.create('inst-run');
|
|
3591
|
+
// 子代理 child-1 已注册并携带输出(followup 后 resumeSubagent 收集其输出)。
|
|
3592
|
+
const child = new FakeAgent('child-1');
|
|
3593
|
+
child.session.append(assistantMessageEvent([{ type: 'text', text: 'recovered output' }]));
|
|
3594
|
+
agents.register(child);
|
|
3595
|
+
|
|
3596
|
+
const ended = waitForEnd(ctx);
|
|
3597
|
+
const result = await engine.recoverInstance('inst-run');
|
|
3598
|
+
expect(result.status).toBe('running');
|
|
3599
|
+
await ended;
|
|
3600
|
+
|
|
3601
|
+
// 宿主主代理经 agents.resume 冷恢复,且作为 followup 的 parent(先宿主后子代理)。
|
|
3602
|
+
expect(agents.setupCount).toBeGreaterThanOrEqual(1);
|
|
3603
|
+
expect(subagents.followups).toHaveLength(1);
|
|
3604
|
+
expect(subagents.followups[0]?.childId).toBe('child-1');
|
|
3605
|
+
expect(subagents.followups[0]?.parent.id).toBe('inst-run');
|
|
3606
|
+
|
|
3607
|
+
const state = persistence.state.get('inst-run');
|
|
3608
|
+
expect(state?.status).toBe('completed');
|
|
3609
|
+
expect(state?.executedSteps[0]?.output).toEqual([{ type: 'text', text: 'recovered output' }]);
|
|
3610
|
+
});
|
|
3611
|
+
|
|
3612
|
+
it('rebuilds the paused registry from pausedChildIds and stays paused without re-running', async () => {
|
|
3613
|
+
const { engine, persistence, sessions, subagents } = await harness();
|
|
3614
|
+
await putDefinition(persistence, 'wfl-agent-fail', agentFailYaml);
|
|
3615
|
+
|
|
3616
|
+
await persistence.state.put('inst-paused', pausedSnapshot('inst-paused'));
|
|
3617
|
+
sessions.create('inst-paused');
|
|
3618
|
+
|
|
3619
|
+
const result = await engine.recoverInstance('inst-paused');
|
|
3620
|
+
expect(result.status).toBe('paused');
|
|
3621
|
+
// 不自动 followup(等待手动 resume)。
|
|
3622
|
+
expect(subagents.followups).toHaveLength(0);
|
|
3623
|
+
|
|
3624
|
+
// 登记表已从 pausedChildIds 重建:resumeStep 可反查并 followup 原 childId。
|
|
3625
|
+
await engine.resumeStep('inst-paused', 'step-agent', 'continue now');
|
|
3626
|
+
expect(subagents.followups).toHaveLength(1);
|
|
3627
|
+
expect(subagents.followups[0]?.childId).toBe('child-1');
|
|
3628
|
+
});
|
|
3629
|
+
|
|
3630
|
+
it('marks an unrecoverable synthetic (shell) running step as failed during recovery', async () => {
|
|
3631
|
+
const { engine, persistence, sessions } = await harness();
|
|
3632
|
+
await putDefinition(persistence, 'wfl-shell-only', shellOnlyYaml);
|
|
3633
|
+
|
|
3634
|
+
await persistence.state.put('inst-shell', stepSnapshot('inst-shell', 'wfl-shell-only', shellStepSnapshot('running'), 'running'));
|
|
3635
|
+
sessions.create('inst-shell');
|
|
3636
|
+
|
|
3637
|
+
const result = await engine.recoverInstance('inst-shell');
|
|
3638
|
+
expect(result.status).toBe('failed');
|
|
3639
|
+
|
|
3640
|
+
const state = persistence.state.get('inst-shell');
|
|
3641
|
+
expect(state?.status).toBe('failed');
|
|
3642
|
+
expect(state?.executedSteps[0]?.status).toBe('failed');
|
|
3643
|
+
expect(state?.executedSteps[0]?.error).toContain('not recoverable');
|
|
3644
|
+
});
|
|
3645
|
+
|
|
3646
|
+
it('recoverOrphanedInstances scans the state table, skips terminal, and recovers non-terminal', async () => {
|
|
3647
|
+
const { engine, persistence, sessions, agents } = await harness();
|
|
3648
|
+
await putDefinition(persistence, 'wfl-agent-fail', agentFailYaml);
|
|
3649
|
+
|
|
3650
|
+
// 终态实例(跳过)。
|
|
3651
|
+
await persistence.state.put('inst-done', completedSnapshot('inst-done'));
|
|
3652
|
+
await persistence.state.put('inst-failed', { ...completedSnapshot('inst-failed'), status: 'failed' });
|
|
3653
|
+
await persistence.state.put('inst-aborted', { ...completedSnapshot('inst-aborted'), status: 'aborted' });
|
|
3654
|
+
|
|
3655
|
+
// 非终态实例(恢复):预建宿主 Session 供 agents.resume 后读回。
|
|
3656
|
+
await persistence.state.put('inst-paused', pausedSnapshot('inst-paused'));
|
|
3657
|
+
sessions.create('inst-paused');
|
|
3658
|
+
|
|
3659
|
+
const result = await (engine as SandoreEngineRuntime).recoverOrphanedInstances();
|
|
3660
|
+
expect(result.recovered).toEqual(['inst-paused']);
|
|
3661
|
+
expect(result.errors).toHaveLength(0);
|
|
3662
|
+
// 宿主主代理经 agents.resume 冷恢复(setup 重跑)。
|
|
3663
|
+
expect(agents.setupCount).toBeGreaterThanOrEqual(1);
|
|
3664
|
+
});
|
|
3665
|
+
|
|
3666
|
+
it('recoverOrphanedInstances aggregates a single failing instance without blocking others', async () => {
|
|
3667
|
+
const { engine, persistence, sessions } = await harness();
|
|
3668
|
+
await putDefinition(persistence, 'wfl-agent-fail', agentFailYaml);
|
|
3669
|
+
|
|
3670
|
+
// 一个可恢复(定义存在 + 宿主 Session),一个定义缺失(失败进 errors)。
|
|
3671
|
+
await persistence.state.put('inst-ok', pausedSnapshot('inst-ok'));
|
|
3672
|
+
sessions.create('inst-ok');
|
|
3673
|
+
await persistence.state.put('inst-bad', pausedSnapshot('inst-bad', 'wfl-missing'));
|
|
3674
|
+
|
|
3675
|
+
const result = await (engine as SandoreEngineRuntime).recoverOrphanedInstances();
|
|
3676
|
+
expect(result.recovered).toEqual(['inst-ok']);
|
|
3677
|
+
expect(result.errors.map(item => item.instanceId)).toEqual(['inst-bad']);
|
|
3678
|
+
expect(result.errors[0]?.error).toContain('not found');
|
|
3679
|
+
});
|
|
3680
|
+
|
|
3681
|
+
it('aggregates host resume failure into errors without blocking', async () => {
|
|
3682
|
+
const { engine, persistence, sessions, agents } = await harness();
|
|
3683
|
+
await putDefinition(persistence, 'wfl-agent-fail', agentFailYaml);
|
|
3684
|
+
|
|
3685
|
+
await persistence.state.put('inst-run', pausedSnapshot('inst-run'));
|
|
3686
|
+
sessions.create('inst-run');
|
|
3687
|
+
agents.setupError = new Error('resume boom');
|
|
3688
|
+
|
|
3689
|
+
const result = await (engine as SandoreEngineRuntime).recoverOrphanedInstances();
|
|
3690
|
+
expect(result.recovered).toEqual([]);
|
|
3691
|
+
expect(result.errors.map(item => item.instanceId)).toEqual(['inst-run']);
|
|
3692
|
+
expect(result.errors[0]?.error).toContain('resume boom');
|
|
3693
|
+
});
|
|
3694
|
+
|
|
3695
|
+
it('recovers orphaned instances automatically at Service.init (startup scan)', async () => {
|
|
3696
|
+
const ctx = new Context();
|
|
3697
|
+
const storageDomain = new MemoryStorageDomain();
|
|
3698
|
+
ctx.provide('storageDomain', storageDomain as unknown as DomainFacility);
|
|
3699
|
+
await ctx.plugin(SandorePersistence);
|
|
3700
|
+
const persistence = ctx.sandorePersistence;
|
|
3701
|
+
await putDefinition(persistence, 'wfl-agent-fail', agentFailYaml);
|
|
3702
|
+
await persistence.state.put('inst-init', pausedSnapshot('inst-init'));
|
|
3703
|
+
|
|
3704
|
+
const sessions = new FakeSessionStore();
|
|
3705
|
+
sessions.create('inst-init');
|
|
3706
|
+
const agents = new FakeAgentRegistry(sessions);
|
|
3707
|
+
ctx.provide('sessions', sessions as unknown as SessionStore);
|
|
3708
|
+
ctx.provide('sessionPersistence', {});
|
|
3709
|
+
ctx.provide('agents', agents);
|
|
3710
|
+
ctx.provide('subagents', new FakeSubagents(ctx, (id) => agents.get(id)));
|
|
3711
|
+
ctx.provide('llm', new FakeLlm() as unknown as LlmRuntime);
|
|
3712
|
+
ctx.provide('shell', new FakeShell() as unknown as ShellExecutor);
|
|
3713
|
+
ctx.provide('subprocess', new FakeSubprocess());
|
|
3714
|
+
ctx.provide('userQuestions', new FakeUserQuestions());
|
|
3715
|
+
ctx.provide('sessionTitle', new FakeSessionTitle());
|
|
3716
|
+
ctx.provide('fs', {});
|
|
3717
|
+
|
|
3718
|
+
await ctx.plugin(SandoreEngineRuntime, {});
|
|
3719
|
+
|
|
3720
|
+
// 启动扫描自动冷恢复宿主主代理(agents.resume setup 重跑)+ 实例进内存。
|
|
3721
|
+
expect(agents.setupCount).toBeGreaterThanOrEqual(1);
|
|
3722
|
+
expect(ctx.sandoreEngine.getState('inst-init').status).toBe('paused');
|
|
3723
|
+
});
|
|
3724
|
+
|
|
3725
|
+
it('clears stale pendingInteractions from the snapshot during recovery (defect 9 B / 27)', async () => {
|
|
3726
|
+
const { engine, persistence, sessions } = await harness();
|
|
3727
|
+
await putDefinition(persistence, 'wfl-agent-fail', agentFailYaml);
|
|
3728
|
+
|
|
3729
|
+
await persistence.state.put('inst-stale', stalePendingSnapshot('inst-stale'));
|
|
3730
|
+
sessions.create('inst-stale');
|
|
3731
|
+
|
|
3732
|
+
const result = await engine.recoverInstance('inst-stale');
|
|
3733
|
+
// paused-only 实例不重跑;恢复时 clearAll 清空快照 stale pendingInteractions。
|
|
3734
|
+
expect(result.status).toBe('paused');
|
|
3735
|
+
const state = persistence.state.get('inst-stale');
|
|
3736
|
+
expect(state?.pendingInteractions).toEqual([]);
|
|
3737
|
+
// 回复 stale requestId 抛 INVALID_OPERATION(InteractionManager Maps 已清空,无 ghost 条目)。
|
|
3738
|
+
await expect(engine.replyQuestion('inst-stale', 'req-stale', { answers: [{ id: 'q1', selected: ['A'] }] }))
|
|
3739
|
+
.rejects.toMatchObject({ code: 'INVALID_OPERATION' });
|
|
3740
|
+
});
|
|
3741
|
+
|
|
3742
|
+
it('resumes a between-steps crash without re-running the completed step (defect 9 G)', async () => {
|
|
3743
|
+
const { ctx, engine, persistence, sessions, shell } = await harness();
|
|
3744
|
+
await putDefinition(persistence, 'wfl-linear-shell', linearShellYaml);
|
|
3745
|
+
shell.runHandler = async spec => successResult(spec, spec.command);
|
|
3746
|
+
|
|
3747
|
+
await persistence.state.put('inst-between', betweenStepsSnapshot('inst-between'));
|
|
3748
|
+
sessions.create('inst-between');
|
|
3749
|
+
|
|
3750
|
+
const ended = waitForEnd(ctx);
|
|
3751
|
+
const result = await engine.recoverInstance('inst-between');
|
|
3752
|
+
expect(result.status).toBe('running');
|
|
3753
|
+
await ended;
|
|
3754
|
+
|
|
3755
|
+
const state = persistence.state.get('inst-between');
|
|
3756
|
+
expect(state?.status).toBe('completed');
|
|
3757
|
+
// step-a 不重跑:仅 'exec-a' 一段(无新增 step-a 段 → 非恰好一次语义成立)。
|
|
3758
|
+
const stepA = state?.executedSteps.filter(s => s.stepId === 'step-a') ?? [];
|
|
3759
|
+
expect(stepA).toHaveLength(1);
|
|
3760
|
+
expect(stepA[0]?.id).toBe('exec-a');
|
|
3761
|
+
expect(stepA[0]?.status).toBe('completed');
|
|
3762
|
+
// step-b 从 step-a 反推 DAG next 续跑一次完成。
|
|
3763
|
+
const stepB = state?.executedSteps.filter(s => s.stepId === 'step-b') ?? [];
|
|
3764
|
+
expect(stepB).toHaveLength(1);
|
|
3765
|
+
expect(stepB[0]?.status).toBe('completed');
|
|
3766
|
+
});
|
|
3767
|
+
|
|
3768
|
+
it('marks the instance failed and clears memory when recover() followup throws (defect 11)', async () => {
|
|
3769
|
+
const { ctx, engine, persistence, sessions, subagents } = await harness();
|
|
3770
|
+
await putDefinition(persistence, 'wfl-agent-fail', agentFailYaml);
|
|
3771
|
+
|
|
3772
|
+
await persistence.state.put('inst-run', stepSnapshot('inst-run', 'wfl-agent-fail', agentStepSnapshot('running'), 'running'));
|
|
3773
|
+
sessions.create('inst-run');
|
|
3774
|
+
// 注入 followup 故障:recover() 冷恢复 followup 抛错(缺陷 11 真实可抛场景收敛点)。
|
|
3775
|
+
subagents.followupError = new Error('cold resume boom');
|
|
3776
|
+
|
|
3777
|
+
const ended = waitForEnd(ctx);
|
|
3778
|
+
const result = await engine.recoverInstance('inst-run');
|
|
3779
|
+
// catch 落 failed 终态(对齐 runAndCatch 语义)。
|
|
3780
|
+
expect(result.status).toBe('failed');
|
|
3781
|
+
await ended;
|
|
3782
|
+
|
|
3783
|
+
const state = persistence.state.get('inst-run');
|
|
3784
|
+
expect(state?.status).toBe('failed');
|
|
3785
|
+
// 实例已从内存清除:二次 recoverInstance 读快照(failed 终态)抛 INVALID_OPERATION,
|
|
3786
|
+
// 不卡 running(修复前 followup 抛错传播出 recoverInstance → 实例残留内存 running +
|
|
3787
|
+
// 二次 recoverInstance no-op 永久卡 running)。
|
|
3788
|
+
await expect(engine.recoverInstance('inst-run')).rejects.toMatchObject({ code: 'INVALID_OPERATION' });
|
|
3789
|
+
});
|
|
3790
|
+
|
|
3791
|
+
it('preserves extracted pass-through vars across crash recovery so the next step resolves them (defect 15)', async () => {
|
|
3792
|
+
const { ctx, engine, persistence, sessions, shell } = await harness();
|
|
3793
|
+
await putDefinition(persistence, 'wfl-recover-ptv', recoverPtvYaml);
|
|
3794
|
+
// shell 回显命令本身,便于断言经 replacePassThroughVars 替换后的实际命令。
|
|
3795
|
+
shell.runHandler = async spec => successResult(spec, spec.command);
|
|
3796
|
+
|
|
3797
|
+
// 快照:running、无活跃段、无已完成 Step,但 passThroughVars 已含提取值
|
|
3798
|
+
// (模拟前置 Stage 已提取 projectName=sandore 后崩溃,恢复后续跑本 Stage)。
|
|
3799
|
+
// 当前执行段模型:persistState 全量持久化含 passThroughVars → recoverInstance
|
|
3800
|
+
// 全量读回 → StageExecutor.initState 播种提取值(合并顺序修复后 input 优先)→
|
|
3801
|
+
// step-use 的 shell 命令拿到提取值,传递链不断。
|
|
3802
|
+
await persistence.state.put('inst-ptv', extractedPtvSnapshot('inst-ptv'));
|
|
3803
|
+
sessions.create('inst-ptv');
|
|
3804
|
+
|
|
3805
|
+
const ended = waitForEnd(ctx);
|
|
3806
|
+
const result = await engine.recoverInstance('inst-ptv');
|
|
3807
|
+
expect(result.status).toBe('running');
|
|
3808
|
+
await ended;
|
|
3809
|
+
|
|
3810
|
+
const state = persistence.state.get('inst-ptv');
|
|
3811
|
+
expect(state?.status).toBe('completed');
|
|
3812
|
+
// 崩溃恢复保留:step-use 的 shell 命令拿到提取值 'sandore' 而非保留字面量 ${projectName}。
|
|
3813
|
+
expect(shell.specs.map(spec => spec.command)).toEqual(['echo sandore']);
|
|
3814
|
+
// 提取值原样保留在 state.passThroughVars(传递链不断)。
|
|
3815
|
+
expect(state?.passThroughVars['projectName']).toEqual({ value: 'sandore', description: '项目名' });
|
|
3816
|
+
});
|
|
3817
|
+
|
|
3818
|
+
it('follows up all running subagent steps during multi-running crash recovery (defect 10 multi-path)', async () => {
|
|
3819
|
+
// 并发崩溃恢复:两个 running agent Step 同时存在快照中 → recover() 全部 followup。
|
|
3820
|
+
// 对齐基准恢复 E 步(遍历全部 running/interacting Step 逐一入队)。
|
|
3821
|
+
const multiAgentYaml = `id: wfl-multi-agent
|
|
3822
|
+
name: multi-agent
|
|
3823
|
+
version: "1.0.0"
|
|
3824
|
+
stages:
|
|
3825
|
+
- id: stg-1
|
|
3826
|
+
name: 并行agent
|
|
3827
|
+
steps:
|
|
3828
|
+
- id: step-router
|
|
3829
|
+
branches:
|
|
3830
|
+
- next: step-a
|
|
3831
|
+
- next: step-b
|
|
3832
|
+
- id: step-a
|
|
3833
|
+
branches:
|
|
3834
|
+
- next: step-end
|
|
3835
|
+
- id: step-b
|
|
3836
|
+
branches:
|
|
3837
|
+
- next: step-end
|
|
3838
|
+
- id: step-end
|
|
3839
|
+
steps:
|
|
3840
|
+
- id: step-router
|
|
3841
|
+
- id: step-a
|
|
3842
|
+
command:
|
|
3843
|
+
type: agent
|
|
3844
|
+
value:
|
|
3845
|
+
promptText: "do A"
|
|
3846
|
+
- id: step-b
|
|
3847
|
+
command:
|
|
3848
|
+
type: agent
|
|
3849
|
+
value:
|
|
3850
|
+
promptText: "do B"
|
|
3851
|
+
- id: step-end
|
|
3852
|
+
command:
|
|
3853
|
+
type: shell
|
|
3854
|
+
value:
|
|
3855
|
+
command: "echo end"
|
|
3856
|
+
`;
|
|
3857
|
+
const { ctx, engine, persistence, sessions, subagents, agents, llm } = await harness();
|
|
3858
|
+
await putDefinition(persistence, 'wfl-multi-agent', multiAgentYaml);
|
|
3859
|
+
provideLlmDirectory(llm);
|
|
3860
|
+
|
|
3861
|
+
// 快照:两个 running agent Step(step-a→child-1, step-b→child-2)同时存在。
|
|
3862
|
+
const createdAt = new Date().toISOString();
|
|
3863
|
+
const multiRunningSnapshot: WorkflowTaskState = {
|
|
3864
|
+
taskId: 'inst-multi-run',
|
|
3865
|
+
workflowId: 'wfl-multi-agent',
|
|
3866
|
+
cwd: '/tmp',
|
|
3867
|
+
status: 'running',
|
|
3868
|
+
originalInput: 'do parallel work',
|
|
3869
|
+
currentStageId: 'stg-1',
|
|
3870
|
+
executedSteps: [
|
|
3871
|
+
{
|
|
3872
|
+
id: 'exec-router',
|
|
3873
|
+
stepId: 'step-router',
|
|
3874
|
+
stageId: 'stg-1',
|
|
3875
|
+
name: 'step-router',
|
|
3876
|
+
status: 'completed',
|
|
3877
|
+
visitCount: 1,
|
|
3878
|
+
createdAt,
|
|
3879
|
+
attemptIndex: 0,
|
|
3880
|
+
loopIteration: [],
|
|
3881
|
+
},
|
|
3882
|
+
{
|
|
3883
|
+
id: 'exec-a',
|
|
3884
|
+
stepId: 'step-a',
|
|
3885
|
+
stageId: 'stg-1',
|
|
3886
|
+
name: 'step-a',
|
|
3887
|
+
status: 'running',
|
|
3888
|
+
command: { type: 'agent', value: { promptText: 'do A' } },
|
|
3889
|
+
sessionMeta: { childId: 'child-1', synthetic: false, command: 'agent' },
|
|
3890
|
+
visitCount: 1,
|
|
3891
|
+
createdAt,
|
|
3892
|
+
attemptIndex: 0,
|
|
3893
|
+
},
|
|
3894
|
+
{
|
|
3895
|
+
id: 'exec-b',
|
|
3896
|
+
stepId: 'step-b',
|
|
3897
|
+
stageId: 'stg-1',
|
|
3898
|
+
name: 'step-b',
|
|
3899
|
+
status: 'running',
|
|
3900
|
+
command: { type: 'agent', value: { promptText: 'do B' } },
|
|
3901
|
+
sessionMeta: { childId: 'child-2', synthetic: false, command: 'agent' },
|
|
3902
|
+
visitCount: 1,
|
|
3903
|
+
createdAt,
|
|
3904
|
+
attemptIndex: 0,
|
|
3905
|
+
},
|
|
3906
|
+
],
|
|
3907
|
+
stageInfos: [{ id: 'stg-1', name: '并行agent', status: 'running' }],
|
|
3908
|
+
pendingInteractions: [],
|
|
3909
|
+
pendingQueue: ['step-end'],
|
|
3910
|
+
visitCounts: { 'step-router': 1, 'step-a': 1, 'step-b': 1 },
|
|
3911
|
+
passThroughVars: {},
|
|
3912
|
+
activeLoopIterationStack: [],
|
|
3913
|
+
activeLoopNodeId: null,
|
|
3914
|
+
activeLoopStack: [],
|
|
3915
|
+
pausedChildIds: [],
|
|
3916
|
+
stageContracts: {},
|
|
3917
|
+
createdAt,
|
|
3918
|
+
updatedAt: createdAt,
|
|
3919
|
+
};
|
|
3920
|
+
await persistence.state.put('inst-multi-run', multiRunningSnapshot);
|
|
3921
|
+
sessions.create('inst-multi-run');
|
|
3922
|
+
|
|
3923
|
+
// 注册两个子代理(冷恢复 followup 后 whenIdle + finalAssistantOutput)。
|
|
3924
|
+
const child1 = new FakeAgent('child-1');
|
|
3925
|
+
child1.session.append(assistantMessageEvent([{ type: 'text', text: 'recovered A' }]));
|
|
3926
|
+
const child2 = new FakeAgent('child-2');
|
|
3927
|
+
child2.session.append(assistantMessageEvent([{ type: 'text', text: 'recovered B' }]));
|
|
3928
|
+
agents.register(child1);
|
|
3929
|
+
agents.register(child2);
|
|
3930
|
+
|
|
3931
|
+
const ended = waitForEnd(ctx);
|
|
3932
|
+
const result = await engine.recoverInstance('inst-multi-run');
|
|
3933
|
+
expect(result.status).toBe('running');
|
|
3934
|
+
await ended;
|
|
3935
|
+
|
|
3936
|
+
// 缺陷 10 多路恢复:两个 running agent Step 全部被 followup(不只是第一个)。
|
|
3937
|
+
const followups = subagents.followups.filter(f => f.parent.id === 'inst-multi-run' || f.childId === 'child-1' || f.childId === 'child-2');
|
|
3938
|
+
expect(followups.length).toBeGreaterThanOrEqual(2);
|
|
3939
|
+
const followedChildIds = new Set(followups.map(f => f.childId));
|
|
3940
|
+
expect(followedChildIds.has('child-1')).toBe(true);
|
|
3941
|
+
expect(followedChildIds.has('child-2')).toBe(true);
|
|
3942
|
+
|
|
3943
|
+
// 恢复后实例完成(两个 agent 续跑 + step-end 执行)。
|
|
3944
|
+
const state = persistence.state.get('inst-multi-run');
|
|
3945
|
+
expect(state?.status).toBe('completed');
|
|
3946
|
+
});
|
|
3947
|
+
});
|
|
3948
|
+
|
|
3949
|
+
/** 单 Stage 单 prompt Step(用于 sendStepMessage 测试:prompt 为真实可续接子代理)。 */
|
|
3950
|
+
const promptOnlyYaml = `id: wfl-prompt-only
|
|
3951
|
+
name: prompt-only
|
|
3952
|
+
version: "1.0.0"
|
|
3953
|
+
stages:
|
|
3954
|
+
- id: stg-1
|
|
3955
|
+
name: 提示
|
|
3956
|
+
steps:
|
|
3957
|
+
- id: step-prompt
|
|
3958
|
+
steps:
|
|
3959
|
+
- id: step-prompt
|
|
3960
|
+
command:
|
|
3961
|
+
type: prompt
|
|
3962
|
+
value:
|
|
3963
|
+
promptText: "do it"
|
|
3964
|
+
`;
|
|
3965
|
+
|
|
3966
|
+
describe('SandoreEngineRuntime sendStepMessage (缺陷 31: @mention 讨论消息封装端点)', () => {
|
|
3967
|
+
it('forwards the message to subagents.followup + persists userMessages kind:message + dual-emits sandore/step-message', async () => {
|
|
3968
|
+
const { ctx, engine, persistence, llm, agents, subagents, sessions } = await harness();
|
|
3969
|
+
await putDefinition(persistence, 'wfl-agent-fail', agentFailYaml);
|
|
3970
|
+
provideLlmDirectory(llm);
|
|
3971
|
+
|
|
3972
|
+
// 注册挂起 agent:status='running' → 不自动 settle(collectSubagentOutput 挂起等待),
|
|
3973
|
+
// 保持 step-agent running 不进入终态,供 sendStepMessage 测试。
|
|
3974
|
+
const child = new FakeAgent('child-1');
|
|
3975
|
+
child.status = 'running';
|
|
3976
|
+
agents.register(child);
|
|
3977
|
+
|
|
3978
|
+
const messageEvents: Array<{ info: unknown; stepId: string; childId: string; message: string; name: string }> = [];
|
|
3979
|
+
ctx.on('sandore/step-message', (info, stepId, childId, message, name) =>
|
|
3980
|
+
messageEvents.push({ info, stepId, childId, message, name }),
|
|
3981
|
+
);
|
|
3982
|
+
|
|
3983
|
+
const stepStarted = waitForStepStart(ctx);
|
|
3984
|
+
const run = await engine.startInstance({ workflowId: 'wfl-agent-fail', originalInput: 'do it', cwd: '/tmp' });
|
|
3985
|
+
await stepStarted;
|
|
3986
|
+
// 等待 onChildStarted 把 sessionMeta 切为 synthetic=false + childId='child-1'。
|
|
3987
|
+
await vi.waitFor(() => {
|
|
3988
|
+
const step = persistence.state.get(run.instanceId)?.executedSteps.find(s => s.stepId === 'step-agent');
|
|
3989
|
+
expect(step?.sessionMeta?.synthetic).toBe(false);
|
|
3990
|
+
expect(step?.sessionMeta?.childId).toBe('child-1');
|
|
3991
|
+
});
|
|
3992
|
+
|
|
3993
|
+
await engine.sendStepMessage(run.instanceId, 'step-agent', '请确认参数');
|
|
3994
|
+
|
|
3995
|
+
// followup 收到消息:childId 指向运行中子代理,content 为用户消息文本。
|
|
3996
|
+
expect(subagents.followups).toHaveLength(1);
|
|
3997
|
+
expect(subagents.followups[0]?.childId).toBe('child-1');
|
|
3998
|
+
expect(subagents.followups[0]?.content).toEqual([{ type: 'text', text: '请确认参数' }]);
|
|
3999
|
+
expect(subagents.followups[0]?.options.source).toMatchObject({ kind: 'user' });
|
|
4000
|
+
|
|
4001
|
+
// userMessages 落盘 kind:'message'(非 'input',避免污染 originalInput 语义)。
|
|
4002
|
+
const state = persistence.state.get(run.instanceId);
|
|
4003
|
+
const messages = state?.userMessages?.filter(m => m.kind === 'message') ?? [];
|
|
4004
|
+
expect(messages).toHaveLength(1);
|
|
4005
|
+
expect(messages[0]?.target).toBe('step-agent');
|
|
4006
|
+
expect(messages[0]?.content).toBe('请确认参数');
|
|
4007
|
+
|
|
4008
|
+
// 单发 sandore/step-message:Cordis emit(listener 触发;Round 25 去污染:宿主会话不再承载)。
|
|
4009
|
+
expect(messageEvents).toHaveLength(1);
|
|
4010
|
+
expect(messageEvents[0]?.stepId).toBe('step-agent');
|
|
4011
|
+
expect(messageEvents[0]?.childId).toBe('child-1');
|
|
4012
|
+
expect(messageEvents[0]?.message).toBe('请确认参数');
|
|
4013
|
+
expect(messageEvents[0]?.name).toBe('step-agent');
|
|
4014
|
+
|
|
4015
|
+
// 清理:让挂起的 Step 正常完成(append assistant 输出 + settle completed),令 run 收敛。
|
|
4016
|
+
child.session.append(assistantMessageEvent([{ type: 'text', text: 'done' }]));
|
|
4017
|
+
subagents.settleChild('child-1', 'completed');
|
|
4018
|
+
await waitForEnd(ctx);
|
|
4019
|
+
});
|
|
4020
|
+
|
|
4021
|
+
it('forwards a message to a running prompt subagent (决策 D3: prompt 为真实子代理, 缺陷 31)', async () => {
|
|
4022
|
+
const { ctx, engine, persistence, llm, agents, subagents, sessions } = await harness();
|
|
4023
|
+
await putDefinition(persistence, 'wfl-prompt-only', promptOnlyYaml);
|
|
4024
|
+
provideLlmDirectory(llm);
|
|
4025
|
+
// prompt Step 挂起:child status='running' 不自动 settle(collectSubagentOutput 等待),
|
|
4026
|
+
// 保持 step-prompt running,供 sendStepMessage 测试。
|
|
4027
|
+
const child = new FakeAgent('child-1');
|
|
4028
|
+
child.status = 'running';
|
|
4029
|
+
agents.register(child);
|
|
4030
|
+
|
|
4031
|
+
const messageEvents: Array<{ info: unknown; stepId: string; childId: string; message: string; name: string }> = [];
|
|
4032
|
+
ctx.on('sandore/step-message', (info, stepId, childId, message, name) =>
|
|
4033
|
+
messageEvents.push({ info, stepId, childId, message, name }),
|
|
4034
|
+
);
|
|
4035
|
+
|
|
4036
|
+
const stepStarted = waitForStepStart(ctx);
|
|
4037
|
+
const run = await engine.startInstance({ workflowId: 'wfl-prompt-only', originalInput: 'do it', cwd: '/tmp' });
|
|
4038
|
+
await stepStarted;
|
|
4039
|
+
// 等待 onChildStarted 把 sessionMeta 切为 synthetic=false + childId='child-1'。
|
|
4040
|
+
await vi.waitFor(() => {
|
|
4041
|
+
const step = persistence.state.get(run.instanceId)?.executedSteps.find(s => s.stepId === 'step-prompt');
|
|
4042
|
+
expect(step?.sessionMeta?.synthetic).toBe(false);
|
|
4043
|
+
expect(step?.sessionMeta?.childId).toBe('child-1');
|
|
4044
|
+
});
|
|
4045
|
+
|
|
4046
|
+
await engine.sendStepMessage(run.instanceId, 'step-prompt', 'hi');
|
|
4047
|
+
|
|
4048
|
+
// prompt 为真实子代理:followup 收到消息(childId 指向运行中子代理)+ userMessages
|
|
4049
|
+
// kind:message 落盘 + 双发 sandore/step-message。
|
|
4050
|
+
expect(subagents.followups).toHaveLength(1);
|
|
4051
|
+
expect(subagents.followups[0]?.childId).toBe('child-1');
|
|
4052
|
+
expect(subagents.followups[0]?.content).toEqual([{ type: 'text', text: 'hi' }]);
|
|
4053
|
+
const state = persistence.state.get(run.instanceId);
|
|
4054
|
+
const messages = state?.userMessages?.filter(m => m.kind === 'message') ?? [];
|
|
4055
|
+
expect(messages).toHaveLength(1);
|
|
4056
|
+
expect(messages[0]?.target).toBe('step-prompt');
|
|
4057
|
+
expect(messages[0]?.content).toBe('hi');
|
|
4058
|
+
expect(messageEvents).toHaveLength(1);
|
|
4059
|
+
|
|
4060
|
+
// 清理:append 输出 + settle completed,令 run 收敛。
|
|
4061
|
+
child.session.append(assistantMessageEvent([{ type: 'text', text: 'done' }]));
|
|
4062
|
+
subagents.settleChild('child-1', 'completed');
|
|
4063
|
+
await waitForEnd(ctx);
|
|
4064
|
+
});
|
|
4065
|
+
|
|
4066
|
+
it('rejects a terminal instance with INVALID_OPERATION (缺陷 31)', async () => {
|
|
4067
|
+
const { ctx, engine, persistence, llm, shell, agents } = await harness();
|
|
4068
|
+
await putDefinition(persistence, 'wfl-linear', linearYaml);
|
|
4069
|
+
shell.runHandler = async spec => successResult(spec, 'hello');
|
|
4070
|
+
provideLlmDirectory(llm);
|
|
4071
|
+
llm.streamHandler = () => textChunks('summary');
|
|
4072
|
+
// prompt Step 走可续接子代理:注册真实 childId 并回填最终输出。
|
|
4073
|
+
const promptChild = new FakeAgent('child-1');
|
|
4074
|
+
promptChild.session.append(assistantMessageEvent([{ type: 'text', text: 'summary' }]));
|
|
4075
|
+
agents.register(promptChild);
|
|
4076
|
+
|
|
4077
|
+
const ended = waitForEnd(ctx);
|
|
4078
|
+
const run = await engine.startInstance({ workflowId: 'wfl-linear', originalInput: 'do it', cwd: '/tmp' });
|
|
4079
|
+
await ended;
|
|
4080
|
+
expect(persistence.state.get(run.instanceId)?.status).toBe('completed');
|
|
4081
|
+
|
|
4082
|
+
await expect(engine.sendStepMessage(run.instanceId, 'step-abc', 'hi')).rejects.toMatchObject({ code: 'INVALID_OPERATION' });
|
|
4083
|
+
});
|
|
4084
|
+
|
|
4085
|
+
it('throws NODE_NOT_FOUND for an unknown instance (缺陷 31)', async () => {
|
|
4086
|
+
const { engine } = await harness();
|
|
4087
|
+
await expect(engine.sendStepMessage('inst-none', 'step-agent', 'hi')).rejects.toMatchObject({ code: 'NODE_NOT_FOUND' });
|
|
4088
|
+
});
|
|
4089
|
+
|
|
4090
|
+
it('throws INVALID_OPERATION for an unknown stepId (缺陷 31)', async () => {
|
|
4091
|
+
const { ctx, engine, persistence, llm, agents, subagents } = await harness();
|
|
4092
|
+
await putDefinition(persistence, 'wfl-agent-fail', agentFailYaml);
|
|
4093
|
+
provideLlmDirectory(llm);
|
|
4094
|
+
|
|
4095
|
+
const child = new FakeAgent('child-1');
|
|
4096
|
+
child.status = 'running';
|
|
4097
|
+
agents.register(child);
|
|
4098
|
+
|
|
4099
|
+
const stepStarted = waitForStepStart(ctx);
|
|
4100
|
+
const run = await engine.startInstance({ workflowId: 'wfl-agent-fail', originalInput: 'do it', cwd: '/tmp' });
|
|
4101
|
+
await stepStarted;
|
|
4102
|
+
await vi.waitFor(() => {
|
|
4103
|
+
const step = persistence.state.get(run.instanceId)?.executedSteps.find(s => s.stepId === 'step-agent');
|
|
4104
|
+
expect(step?.sessionMeta?.childId).toBe('child-1');
|
|
4105
|
+
});
|
|
4106
|
+
|
|
4107
|
+
await expect(engine.sendStepMessage(run.instanceId, 'step-missing', 'hi')).rejects.toMatchObject({ code: 'INVALID_OPERATION' });
|
|
4108
|
+
|
|
4109
|
+
// 清理:让挂起的 Step 正常完成(append 输出 + settle completed),令 run 收敛。
|
|
4110
|
+
child.session.append(assistantMessageEvent([{ type: 'text', text: 'done' }]));
|
|
4111
|
+
subagents.settleChild('child-1', 'completed');
|
|
4112
|
+
await waitForEnd(ctx);
|
|
4113
|
+
});
|
|
4114
|
+
});
|
|
4115
|
+
|
|
4116
|
+
/** 配置 agent Step 可解析的 provider/model 目录(resolveProviderModel L3 兜底)。 */
|
|
4117
|
+
function provideLlmDirectory(llm: FakeLlm): void {
|
|
4118
|
+
llm.providers.push({ id: 'p', name: 'Provider' });
|
|
4119
|
+
llm.models.set('p', [{ provider: 'p', id: 'm', name: 'Model' }]);
|
|
4120
|
+
}
|
|
4121
|
+
|
|
4122
|
+
describe('complex workflow e2e', () => {
|
|
4123
|
+
/**
|
|
4124
|
+
* 复杂工作流 e2e:代码审查与修复流程,覆盖 prompt 命令 + shell 命令 + 条件分支
|
|
4125
|
+
* (shouldExecute)+ 并行分支(无 condition 多 next)+ 循环(loop + breakCondition
|
|
4126
|
+
* shouldBreak + maxIterations)+ 收尾门禁(gates primary/secondary)+ 跨 Stage。
|
|
4127
|
+
*
|
|
4128
|
+
* mock llm.streamHandler 按首条消息文本分发:含 'shouldExecute' → 批量条件分支 JSON
|
|
4129
|
+
* (分支 id 数组);含 'shouldBreak' → 循环跳出 JSON(首次 false 继续、二次 true 跳出);含
|
|
4130
|
+
* 'hasAsk' → 收尾 assess JSON(提取 projectName=sandore);其余 → secondary 门禁
|
|
4131
|
+
* JSON(primary 为纯代码产物校验、无 AI 提示词)。prompt Step(step-finalize)自身
|
|
4132
|
+
* 输出来自可续接子代理会话(决策 D3)。mock shell.runHandler 回显命令对应 stdout。
|
|
4133
|
+
*/
|
|
4134
|
+
it('runs the review-fix flow across two stages with branches, parallel, loop, and gates to completed', async () => {
|
|
4135
|
+
const { ctx, engine, persistence, llm, shell, agents, subagents } = await harness();
|
|
4136
|
+
await putDefinition(persistence, 'wfl-complex', complexReviewFixYaml);
|
|
4137
|
+
provideLlmDirectory(llm);
|
|
4138
|
+
// shell.runHandler:按命令回显对应 stdout(便于产物/事件断言)。
|
|
4139
|
+
const stdoutByCommand: Record<string, string> = {
|
|
4140
|
+
'echo scan': 'scan-output',
|
|
4141
|
+
'echo fix': 'fix-output',
|
|
4142
|
+
'echo report': 'report-output',
|
|
4143
|
+
'echo merge': 'merge-output',
|
|
4144
|
+
'echo iter': 'iter-output',
|
|
4145
|
+
'echo stage1-done': 'stage1-done',
|
|
4146
|
+
};
|
|
4147
|
+
shell.runHandler = async spec => successResult(spec, stdoutByCommand[spec.command] ?? 'ok');
|
|
4148
|
+
// step-finalize 为 prompt Step,走可续接子代理(决策 D3):注册真实 childId 并回填输出。
|
|
4149
|
+
const promptChild = new FakeAgent('child-1');
|
|
4150
|
+
promptChild.session.append(assistantMessageEvent([{ type: 'text', text: 'final-analysis' }]));
|
|
4151
|
+
agents.register(promptChild);
|
|
4152
|
+
// Phase C secondary 门禁经 startContinuable 建宿主可续接子代理(child-2,与 step
|
|
4153
|
+
// 执行子代理同机制):预注册 FakeAgent 并回填门禁审查输出(gateStatus done)——
|
|
4154
|
+
// 门禁 AI 走子代理 agent loop,不消费 llm.stream。
|
|
4155
|
+
const gateChild = new FakeAgent('child-2');
|
|
4156
|
+
gateChild.session.append(assistantMessageEvent([{ type: 'text', text: '{"gateStatus":"done"}' }]));
|
|
4157
|
+
agents.register(gateChild);
|
|
4158
|
+
|
|
4159
|
+
// 条件分支/循环跳出/收尾各走专用模板(其「## 返回格式」节含对应字段名),
|
|
4160
|
+
// mock 据首条消息文本含哪个字段名分发对应 JSON。循环跳出首次 false、二次 true
|
|
4161
|
+
// (演示 continue 与 break 两条 loopMeta 路由条目均回写)。prompt Step 自身输出
|
|
4162
|
+
// 来自子代理会话(不再消费 llm.stream)。primary 门禁为纯代码产物校验(零 AI),
|
|
4163
|
+
// 无对应 AI 提示词——secondary 门禁经 startContinuable 子代理 settle 审查
|
|
4164
|
+
// (gateStatus done),亦不消费 llm.stream;Phase B 收尾走降级 llm.stream
|
|
4165
|
+
// (compressedOutput 合并 JSON)。
|
|
4166
|
+
let breakCall = 0;
|
|
4167
|
+
llm.streamHandler = (options) => {
|
|
4168
|
+
const text = (options.messages[0]?.content[0] as TextBlock | undefined)?.text ?? '';
|
|
4169
|
+
if (text.includes('shouldExecute')) {
|
|
4170
|
+
// Round 20:批量模板——返回满足条件的分支 id 数组(branchId = branch.next)。
|
|
4171
|
+
return textChunks('{"shouldExecute": ["step-fix"]}');
|
|
4172
|
+
}
|
|
4173
|
+
if (text.includes('shouldBreak')) {
|
|
4174
|
+
breakCall += 1;
|
|
4175
|
+
return textChunks(breakCall === 1 ? '{"shouldBreak": false}' : '{"shouldBreak": true}');
|
|
4176
|
+
}
|
|
4177
|
+
if (text.includes('compressedOutput')) {
|
|
4178
|
+
// Phase B 收尾(降级 llm.stream):透传变量提取 + 压缩摘要合并 JSON。
|
|
4179
|
+
return textChunks('{"passThroughVars":{"projectName":"sandore"},"compressedOutput":"finalize 摘要"}');
|
|
4180
|
+
}
|
|
4181
|
+
if (text.includes('hasAsk')) {
|
|
4182
|
+
return textChunks('{"hasAsk":false,"passThroughVars":{"projectName":"sandore"}}');
|
|
4183
|
+
}
|
|
4184
|
+
// 理论不可达(收尾真 fork 后无其他 llm.stream 消费方);兜底返回门禁通过。
|
|
4185
|
+
return textChunks('{"gateStatus":"done"}');
|
|
4186
|
+
};
|
|
4187
|
+
|
|
4188
|
+
const records = recordSandoreEvents(ctx);
|
|
4189
|
+
const ended = waitForEnd(ctx);
|
|
4190
|
+
const run = await engine.startInstance({ workflowId: 'wfl-complex', originalInput: 'do it', cwd: '/tmp' });
|
|
4191
|
+
await ended;
|
|
4192
|
+
|
|
4193
|
+
const state = persistence.state.get(run.instanceId);
|
|
4194
|
+
expect(state?.status).toBe('completed');
|
|
4195
|
+
|
|
4196
|
+
const stepIds = state?.executedSteps.map(step => step.stepId) ?? [];
|
|
4197
|
+
|
|
4198
|
+
// ── 顺序:Stage 1 首步 step-scan → 路由门 step-route(条件评估现场保留)。
|
|
4199
|
+
expect(stepIds[0]).toBe('step-scan');
|
|
4200
|
+
expect(stepIds[1]).toBe('step-route');
|
|
4201
|
+
const routeEntry = state?.executedSteps.find(step => step.stepId === 'step-route');
|
|
4202
|
+
expect(routeEntry?.branchMeta?.status).toBe('completed');
|
|
4203
|
+
expect(routeEntry?.branchMeta?.result).toBe('{"shouldExecute": ["step-fix"]}');
|
|
4204
|
+
|
|
4205
|
+
// ── 并行分支(无 condition)+ 条件分支(shouldExecute=true):step-fix 与 step-report
|
|
4206
|
+
// 均执行(并发,Promise.all);两者顺序不假定(mock 同步但并发调度),以 Set 断言
|
|
4207
|
+
// 且确认二者均落在 step-route 之后、step-merge 之前。
|
|
4208
|
+
const parallelPair = new Set(stepIds.slice(2, 4));
|
|
4209
|
+
expect(parallelPair).toEqual(new Set(['step-fix', 'step-report']));
|
|
4210
|
+
expect(stepIds[4]).toBe('step-merge');
|
|
4211
|
+
// 汇合节点仅执行一次(nextBatch dedupe)。
|
|
4212
|
+
expect(stepIds.filter(id => id === 'step-merge')).toHaveLength(1);
|
|
4213
|
+
|
|
4214
|
+
// ── 循环(loop + breakCondition shouldBreak + maxIterations):循环门 step-loop 出现
|
|
4215
|
+
// 两次(continue 透传 loopMeta + break 透传 loopMeta,缺陷 23);循环体 step-iter
|
|
4216
|
+
// 仅执行一次(首趟 continue 后跑一次,第二趟 break 直接跳出未再跑 body)。
|
|
4217
|
+
expect(stepIds[5]).toBe('step-loop');
|
|
4218
|
+
expect(stepIds[6]).toBe('step-iter');
|
|
4219
|
+
expect(stepIds[7]).toBe('step-loop');
|
|
4220
|
+
const loopEntries = state?.executedSteps.filter(step => step.stepId === 'step-loop') ?? [];
|
|
4221
|
+
expect(loopEntries).toHaveLength(2);
|
|
4222
|
+
// continue 与 break 两条路由条目均回写 loopMeta(completed) 且携带 shouldBreak 评估现场。
|
|
4223
|
+
expect(loopEntries.every(entry => entry.loopMeta?.status === 'completed')).toBe(true);
|
|
4224
|
+
expect(loopEntries[0]?.loopMeta?.result).toContain('shouldBreak');
|
|
4225
|
+
expect(loopEntries[1]?.loopMeta?.result).toContain('shouldBreak');
|
|
4226
|
+
// continue=false 现场(首趟)、break=true 现场(次趟)。
|
|
4227
|
+
expect(loopEntries[0]?.loopMeta?.result).toBe('{"shouldBreak": false}');
|
|
4228
|
+
expect(loopEntries[1]?.loopMeta?.result).toBe('{"shouldBreak": true}');
|
|
4229
|
+
const iterEntries = state?.executedSteps.filter(step => step.stepId === 'step-iter') ?? [];
|
|
4230
|
+
expect(iterEntries).toHaveLength(1);
|
|
4231
|
+
|
|
4232
|
+
// ── 跨 Stage:Stage 1 收尾 step-exit → Stage 2 step-finalize。
|
|
4233
|
+
expect(stepIds[8]).toBe('step-exit');
|
|
4234
|
+
expect(stepIds[9]).toBe('step-finalize');
|
|
4235
|
+
// 两个 Stage 均完成,契约落盘(跨 Stage 推进由 tryAdvanceStage 承接)。
|
|
4236
|
+
expect(state?.stageInfos.map(info => info.status)).toEqual(['completed', 'completed']);
|
|
4237
|
+
expect(Object.keys(state?.stageContracts ?? {})).toHaveLength(2);
|
|
4238
|
+
|
|
4239
|
+
// ── 收尾门禁(gates primary/secondary):step-finalize 的 finalizeMeta 记录
|
|
4240
|
+
// gateResult。primary 为纯代码产物校验(无 outputTo → 通过,零 AI),
|
|
4241
|
+
// secondary 为 AI 门禁审查(显式 enabled:true)→ 两者均通过 → gatesPassed=true。
|
|
4242
|
+
const finalizeStep = state?.executedSteps.find(step => step.stepId === 'step-finalize');
|
|
4243
|
+
expect(finalizeStep?.status).toBe('completed');
|
|
4244
|
+
expect(finalizeStep?.finalizeMeta?.currentPhase).toBe('completed');
|
|
4245
|
+
expect(finalizeStep?.finalizeMeta?.gateResult?.gatesPassed).toBe(true);
|
|
4246
|
+
expect(finalizeStep?.finalizeMeta?.gateResult?.primaryPassed).toBe(true);
|
|
4247
|
+
expect(finalizeStep?.finalizeMeta?.gateResult?.secondaryPassed).toBe(true);
|
|
4248
|
+
|
|
4249
|
+
// Phase C secondary 经 startContinuable 建宿主可续接子代理(与 step 执行子代理同
|
|
4250
|
+
// 机制:agent loop + 工具 + preset 系统提示词;prompt 携带门禁审查要求);
|
|
4251
|
+
// primary 纯代码校验不建子代理。宿主主代理也经 agents.create 创建(startInstance)
|
|
4252
|
+
// → agents.create 记录仅含宿主主代理(门禁不再走 agents.create)。
|
|
4253
|
+
const gateStart = subagents.continuableStarts[subagents.continuableStarts.length - 1];
|
|
4254
|
+
expect(gateStart).toBeDefined();
|
|
4255
|
+
expect(gateStart?.provider).toBe('fork');
|
|
4256
|
+
expect(gateStart?.request.parent.id).toBe(run.instanceId);
|
|
4257
|
+
const gatePrompt = (gateStart?.request.prompt[0] as { type: string; text: string } | undefined)?.text ?? '';
|
|
4258
|
+
expect(gatePrompt).toContain('gateStatus');
|
|
4259
|
+
// gateSessionId = startContinuable 返回的真实 childId(UI 可打开;settle 侧由 DSH
|
|
4260
|
+
// dispose,mock 中仍留在 registry)。
|
|
4261
|
+
const gateSessionId = finalizeStep?.finalizeMeta?.gateResult?.gateSessionId ?? '';
|
|
4262
|
+
expect(gateSessionId).toBe('child-2');
|
|
4263
|
+
expect(agents.get(gateSessionId)).toBeDefined();
|
|
4264
|
+
expect(agents.get(gateSessionId)?.session.events.some(event =>
|
|
4265
|
+
event.type === 'assistant/message'
|
|
4266
|
+
&& event.data.message.content.some(block => block.type === 'text' && block.text.includes('gateStatus')),
|
|
4267
|
+
)).toBe(true);
|
|
4268
|
+
|
|
4269
|
+
// ── 产物收集:step-finalize prompt 自身输出为 'final-analysis'(子代理会话输出)。
|
|
4270
|
+
expect(finalizeStep?.output).toEqual([{ type: 'text', text: 'final-analysis' }]);
|
|
4271
|
+
|
|
4272
|
+
// ── 透传变量提取:assess 提取 projectName=sandore 并保留 Step 定义中的 description。
|
|
4273
|
+
expect(state?.passThroughVars['projectName']).toEqual({ value: 'sandore', description: '项目名' });
|
|
4274
|
+
|
|
4275
|
+
// ── Stage 1 契约产物:step-scan 声明 outputTo(opt-scan),契约 artifacts 含对应条目。
|
|
4276
|
+
const reviewContract = state?.stageContracts['stg-review'];
|
|
4277
|
+
expect(reviewContract?.status).toBe('completed');
|
|
4278
|
+
expect(reviewContract?.artifacts.find(item => item.outputToId === 'opt-scan')?.stepId).toBe('step-scan');
|
|
4279
|
+
|
|
4280
|
+
// ── shell 命令覆盖:6 个 shell Step 各跑一次(step-finalize 为 prompt,不跑 shell)。
|
|
4281
|
+
// 并行分支(step-report)在条件分支(step-fix)之前纳入 nextNodeIds(resolveNextNodes
|
|
4282
|
+
// 先处理无 condition 的并行分支再评估条件分支),故 shell 调用序为 report → fix。
|
|
4283
|
+
expect(shell.specs.map(spec => spec.command)).toEqual([
|
|
4284
|
+
'echo scan',
|
|
4285
|
+
'echo report',
|
|
4286
|
+
'echo fix',
|
|
4287
|
+
'echo merge',
|
|
4288
|
+
'echo iter',
|
|
4289
|
+
'echo stage1-done',
|
|
4290
|
+
]);
|
|
4291
|
+
|
|
4292
|
+
// ── llm 调用覆盖:条件分支 1 + 循环跳出 2 + Phase A assess 1 + Phase B 重新
|
|
4293
|
+
// assess 1(偏离 2:不复用 Phase A 结果)= 5 次(收尾真 fork 后 Phase B 降级
|
|
4294
|
+
// 走 llm.stream——child-1 无事件源;primary 纯代码校验零 AI;secondary 走宿主
|
|
4295
|
+
// 子代理驱动轮不消费 llm.stream;prompt Step 输出来自子代理会话)。
|
|
4296
|
+
expect(llm.calls).toHaveLength(5);
|
|
4297
|
+
|
|
4298
|
+
// ── 事件序:start → 两 Stage 各 stage-start/step-*/stage-completed → end。
|
|
4299
|
+
expect(records[0]?.name).toBe('sandore/start');
|
|
4300
|
+
expect(records[records.length - 1]?.name).toBe('sandore/end');
|
|
4301
|
+
const stageStarts = records.filter(r => r.name === 'sandore/stage-start').map(r => r.args[1]);
|
|
4302
|
+
expect(stageStarts).toEqual(['stg-review', 'stg-gate']);
|
|
4303
|
+
const stageCompleted = records.filter(r => r.name === 'sandore/stage-completed').map(r => r.args[1]);
|
|
4304
|
+
expect(stageCompleted).toEqual(['stg-review', 'stg-gate']);
|
|
4305
|
+
// 终态 end 为 completed。
|
|
4306
|
+
expect((records[records.length - 1]?.args[1] as { status: string }).status).toBe('completed');
|
|
4307
|
+
});
|
|
4308
|
+
});
|
|
4309
|
+
|
|
4310
|
+
/** 单 prompt Step(未解析透传变量)——Phase B 真 fork 的引擎级集成。 */
|
|
4311
|
+
const forkBPhaseYaml = `id: wfl-fork-b
|
|
4312
|
+
name: fork-b
|
|
4313
|
+
version: "1.0.0"
|
|
4314
|
+
stages:
|
|
4315
|
+
- id: stg-1
|
|
4316
|
+
name: 收尾真fork
|
|
4317
|
+
steps:
|
|
4318
|
+
- id: step-p
|
|
4319
|
+
steps:
|
|
4320
|
+
- id: step-p
|
|
4321
|
+
command:
|
|
4322
|
+
type: prompt
|
|
4323
|
+
value:
|
|
4324
|
+
promptText: "do it"
|
|
4325
|
+
passThroughVars:
|
|
4326
|
+
- name: projectName
|
|
4327
|
+
value: "\${projectName}"
|
|
4328
|
+
description: "项目名"
|
|
4329
|
+
`;
|
|
4330
|
+
|
|
4331
|
+
describe('SandoreEngineRuntime 收尾真 fork(Phase B agents.create 重建宿主子代理)', () => {
|
|
4332
|
+
it('Phase B 真 fork:agents.create 收到 seed=完整事件 + meta.parentSession=宿主主代理 + origin=subagent;透传变量在 Phase B 回写', async () => {
|
|
4333
|
+
const { ctx, engine, persistence, llm, shell, sessions, agents } = await harness();
|
|
4334
|
+
await putDefinition(persistence, 'wfl-fork-b', forkBPhaseYaml);
|
|
4335
|
+
provideLlmDirectory(llm);
|
|
4336
|
+
shell.runHandler = async spec => successResult(spec, 'done');
|
|
4337
|
+
// prompt Step(step-p)走可续接子代理:注册真实 childId 并回填最终输出。
|
|
4338
|
+
const child = new FakeAgent('child-1');
|
|
4339
|
+
child.session.append(assistantMessageEvent([{ type: 'text', text: 'work output' }]));
|
|
4340
|
+
agents.register(child);
|
|
4341
|
+
// 源子代理完整事件(live store 仍可读)→ Phase B 真 fork 的 seed(闭合 turn:4 条事件)。
|
|
4342
|
+
const childSession = sessions.create('child-1');
|
|
4343
|
+
childSession.append('turn/start', { turn: 0 });
|
|
4344
|
+
childSession.append('user/message', { id: 'u1', content: [{ type: 'text', text: 'do it' }] });
|
|
4345
|
+
childSession.append('assistant/message', assistantMessageEvent([{ type: 'text', text: 'work output' }]).data);
|
|
4346
|
+
childSession.append('turn/end', { turn: 0, reason: { kind: 'completed' } });
|
|
4347
|
+
// Phase B 真 fork 的评估回复(一个 LLM 调用返回合并 JSON:passThroughVars + compressedOutput)。
|
|
4348
|
+
agents.createFollowupHandler = () => [{ type: 'text', text: '{"passThroughVars":{"projectName":"sandore"},"compressedOutput":"step 压缩摘要"}' }];
|
|
4349
|
+
|
|
4350
|
+
// Phase A 评估临时会话 1 次(completed JSON);Phase B 真 fork 走子代理不消费 llm.stream;
|
|
4351
|
+
// compressStepOutput 已移除(压缩摘要由 Phase B JSON 的 compressedOutput 承担)→ 无额外调用。
|
|
4352
|
+
llm.streamHandler = () => textChunks('{"hasAsk":false,"passThroughVars":{}}');
|
|
4353
|
+
|
|
4354
|
+
const ended = waitForEnd(ctx);
|
|
4355
|
+
const run = await engine.startInstance({ workflowId: 'wfl-fork-b', originalInput: 'do it', cwd: '/tmp' });
|
|
4356
|
+
await ended;
|
|
4357
|
+
|
|
4358
|
+
const state = persistence.state.get(run.instanceId);
|
|
4359
|
+
expect(state?.status).toBe('completed');
|
|
4360
|
+
|
|
4361
|
+
// Phase B 真 fork:agents.create 收到 seed = 完整事件 + meta(parentSession=宿主主代理、
|
|
4362
|
+
// origin='subagent'、seedLength=4、cwd=/tmp)。create 记录序 = [宿主主代理, fork 子代理]。
|
|
4363
|
+
const forkMeta = agents.createMetas[agents.createMetas.length - 1];
|
|
4364
|
+
expect(agents.createSeeds[agents.createSeeds.length - 1]).toEqual(childSession.events);
|
|
4365
|
+
expect(forkMeta).toMatchObject({ cwd: '/tmp', parentSession: run.instanceId, seedLength: 4, origin: 'subagent' });
|
|
4366
|
+
// 驱动一轮:followup 收到收尾处理提示词(含 compressedOutput 格式)、whenIdle 被 await。
|
|
4367
|
+
const forkedAgent = agents.createdAgents[agents.createdAgents.length - 1]!;
|
|
4368
|
+
expect(forkedAgent.followups).toHaveLength(1);
|
|
4369
|
+
const promptText = (forkedAgent.followups[0]?.content[0] as { type: string; text: string } | undefined)?.text ?? '';
|
|
4370
|
+
// 2026-08-25:fork 版不注入参考信息节(上下文在会话里);降级才注入。
|
|
4371
|
+
expect(promptText).not.toContain('<ReferenceInfo>');
|
|
4372
|
+
expect(promptText).toContain('compressedOutput');
|
|
4373
|
+
expect(forkedAgent.whenIdleCount).toBe(1);
|
|
4374
|
+
|
|
4375
|
+
// 透传变量在 Phase B 回写(保留 description)。
|
|
4376
|
+
expect(state?.passThroughVars['projectName']).toEqual({ value: 'sandore', description: '项目名' });
|
|
4377
|
+
// finalizeMeta.assessResult.forkSessionId = fork 子代理 id(finalize-{stepId}-{random}),评估完 dispose。
|
|
4378
|
+
// Round 17:顶层 assessSessionId 已删除,sessionId 从 assessResult.forkSessionId 内读
|
|
4379
|
+
const step = state?.executedSteps.find(s => s.stepId === 'step-p');
|
|
4380
|
+
expect(step?.finalizeMeta?.assessResult?.forkSessionId).toMatch(UUID_RE);
|
|
4381
|
+
expect(agents.get(step?.finalizeMeta?.assessResult?.forkSessionId ?? '')).toBeUndefined();
|
|
4382
|
+
// 收尾阶段流转落盘:completed 终态(Phase B finalizing → Phase D routing → completed)。
|
|
4383
|
+
expect(step?.finalizeMeta?.currentPhase).toBe('completed');
|
|
4384
|
+
// llm.stream 覆盖:仅 Phase A 临时会话 assess 1 次。**Phase B 真 fork 子代理不消费
|
|
4385
|
+
// llm.stream**(模型调用走 followup 驱动轮);compressStepOutput 已删除(Phase B JSON
|
|
4386
|
+
// 的 compressedOutput 即压缩摘要)→ 无 compress 调用。
|
|
4387
|
+
expect(llm.calls).toHaveLength(1);
|
|
4388
|
+
});
|
|
4389
|
+
});
|