Skip to content

Commit 6de9eaa

Browse files
authored
Merge pull request #666 from Cai-Tang-www/feat/feishu-approval-strict-fsm
fix(runtime): 修复 resume_verify_closure 首轮 verify 启动失败并完善状态机闭环
2 parents b88fc2f + 4c20ebb commit 6de9eaa

17 files changed

Lines changed: 927 additions & 42 deletions

internal/config/feishu_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,9 @@ func TestFeishuConfigValidateRejectsInvalidNumericRanges(t *testing.T) {
236236
}
237237

238238
func TestFeishuConfigValidateRejectsMissingRequiredFieldsIndividually(t *testing.T) {
239+
t.Setenv(FeishuAppSecretEnvVar, "")
240+
t.Setenv(FeishuSigningSecretEnvVar, "")
241+
239242
base := FeishuConfig{
240243
Enabled: true,
241244
AppID: "app",
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
package runtime
2+
3+
import (
4+
"context"
5+
"errors"
6+
"testing"
7+
"time"
8+
9+
providertypes "neo-code/internal/provider/types"
10+
"neo-code/internal/runtime/controlplane"
11+
"neo-code/internal/security"
12+
agentsession "neo-code/internal/session"
13+
"neo-code/internal/tools"
14+
)
15+
16+
func TestAskUserLifecycleTransitionsBackToExecute(t *testing.T) {
17+
t.Parallel()
18+
19+
registry := tools.NewRegistry()
20+
registry.Register(&stubTool{
21+
name: tools.ToolNameAskUser,
22+
executeFn: func(_ context.Context, input tools.ToolCallInput) (tools.ToolResult, error) {
23+
input.AskUserEventEmitter("user_question_requested", map[string]any{
24+
"request_id": "ask-lifecycle-1",
25+
"question_id": "q1",
26+
"title": "Need choice",
27+
"kind": "single_choice",
28+
})
29+
input.AskUserEventEmitter("user_question_answered", map[string]any{
30+
"request_id": "ask-lifecycle-1",
31+
"question_id": "q1",
32+
"status": "answered",
33+
"values": []any{"yes"},
34+
})
35+
return tools.ToolResult{Name: tools.ToolNameAskUser, Content: "answered"}, nil
36+
},
37+
})
38+
manager, err := tools.NewManager(registry, newAllowPermissionEngine(t), nil)
39+
if err != nil {
40+
t.Fatalf("new manager: %v", err)
41+
}
42+
43+
service := NewWithFactory(
44+
newRuntimeConfigManager(t),
45+
manager,
46+
newMemoryStore(),
47+
&scriptedProviderFactory{provider: &scriptedProvider{}},
48+
nil,
49+
)
50+
service.events = make(chan RuntimeEvent, 64)
51+
52+
state := newRunState("run-ask-lifecycle", agentsession.New("ask-lifecycle"))
53+
state.planningEnabled = true
54+
state.session.AgentMode = agentsession.AgentModePlan
55+
if err := service.setBaseRunState(context.Background(), &state, controlplane.RunStatePlan); err != nil {
56+
t.Fatalf("set base state: %v", err)
57+
}
58+
if err := service.setBaseRunState(context.Background(), &state, controlplane.RunStateExecute); err != nil {
59+
t.Fatalf("set base state: %v", err)
60+
}
61+
62+
_, execErr := service.executeToolCallWithPermission(context.Background(), permissionExecutionInput{
63+
RunID: "run-ask-lifecycle",
64+
SessionID: state.session.ID,
65+
State: &state,
66+
ToolTimeout: time.Second,
67+
Call: providertypes.ToolCall{
68+
ID: "call-ask-lifecycle",
69+
Name: tools.ToolNameAskUser,
70+
Arguments: `{"question_id":"q1","title":"Need choice","kind":"single_choice"}`,
71+
},
72+
})
73+
if execErr != nil {
74+
t.Fatalf("executeToolCallWithPermission() error = %v", execErr)
75+
}
76+
if state.lifecycle != controlplane.RunStateExecute {
77+
t.Fatalf("lifecycle = %q, want execute", state.lifecycle)
78+
}
79+
if state.pendingUserQuestion != nil {
80+
t.Fatalf("pending user question should be cleared, got %#v", state.pendingUserQuestion)
81+
}
82+
83+
events := collectRuntimeEvents(service.Events())
84+
if !hasPhaseTransition(events, "execute", "waiting_user_question") {
85+
t.Fatalf("missing execute -> waiting_user_question transition, events=%+v", events)
86+
}
87+
if !hasPhaseTransition(events, "waiting_user_question", "execute") {
88+
t.Fatalf("missing waiting_user_question -> execute transition, events=%+v", events)
89+
}
90+
}
91+
92+
func TestAskUserLifecycleCleanupOnInterruptedQuestion(t *testing.T) {
93+
t.Parallel()
94+
95+
registry := tools.NewRegistry()
96+
registry.Register(&stubTool{
97+
name: tools.ToolNameAskUser,
98+
executeFn: func(_ context.Context, input tools.ToolCallInput) (tools.ToolResult, error) {
99+
input.AskUserEventEmitter("user_question_requested", map[string]any{
100+
"request_id": "ask-lifecycle-interrupted",
101+
"question_id": "q2",
102+
"title": "Need text",
103+
"kind": "text",
104+
})
105+
return tools.NewErrorResult(tools.ToolNameAskUser, "interrupted", "", nil), errors.New("ask interrupted")
106+
},
107+
})
108+
manager, err := tools.NewManager(registry, newAllowPermissionEngine(t), nil)
109+
if err != nil {
110+
t.Fatalf("new manager: %v", err)
111+
}
112+
113+
service := NewWithFactory(
114+
newRuntimeConfigManager(t),
115+
manager,
116+
newMemoryStore(),
117+
&scriptedProviderFactory{provider: &scriptedProvider{}},
118+
nil,
119+
)
120+
service.events = make(chan RuntimeEvent, 64)
121+
122+
state := newRunState("run-ask-cleanup", agentsession.New("ask-cleanup"))
123+
state.planningEnabled = true
124+
state.session.AgentMode = agentsession.AgentModePlan
125+
if err := service.setBaseRunState(context.Background(), &state, controlplane.RunStatePlan); err != nil {
126+
t.Fatalf("set base state: %v", err)
127+
}
128+
if err := service.setBaseRunState(context.Background(), &state, controlplane.RunStateExecute); err != nil {
129+
t.Fatalf("set base state: %v", err)
130+
}
131+
132+
_, execErr := service.executeToolCallWithPermission(context.Background(), permissionExecutionInput{
133+
RunID: "run-ask-cleanup",
134+
SessionID: state.session.ID,
135+
State: &state,
136+
ToolTimeout: time.Second,
137+
Call: providertypes.ToolCall{
138+
ID: "call-ask-cleanup",
139+
Name: tools.ToolNameAskUser,
140+
Arguments: `{"question_id":"q2","title":"Need text","kind":"text"}`,
141+
},
142+
})
143+
if execErr == nil {
144+
t.Fatalf("expected ask_user interrupted error")
145+
}
146+
if state.lifecycle != controlplane.RunStateExecute {
147+
t.Fatalf("lifecycle = %q, want execute", state.lifecycle)
148+
}
149+
if state.pendingUserQuestion != nil {
150+
t.Fatalf("pending user question should be cleared after cleanup, got %#v", state.pendingUserQuestion)
151+
}
152+
}
153+
154+
func hasPhaseTransition(events []RuntimeEvent, from string, to string) bool {
155+
for _, event := range events {
156+
if event.Type != EventPhaseChanged {
157+
continue
158+
}
159+
payload, ok := event.Payload.(PhaseChangedPayload)
160+
if !ok {
161+
continue
162+
}
163+
if payload.From == from && payload.To == to {
164+
return true
165+
}
166+
}
167+
return false
168+
}
169+
170+
func newAllowPermissionEngine(t *testing.T) security.PermissionEngine {
171+
t.Helper()
172+
engine, err := security.NewStaticGateway(security.DecisionAllow, nil)
173+
if err != nil {
174+
t.Fatalf("new static gateway: %v", err)
175+
}
176+
return engine
177+
}

0 commit comments

Comments
 (0)