Skip to content

Commit 2c883e9

Browse files
committed
Run daemon tests and wire scenarios under assignments
1 parent 197d396 commit 2c883e9

30 files changed

Lines changed: 562 additions & 598 deletions

‎apps/daemon/internal/agent/codex/preparation_router_test.go‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,10 +89,12 @@ func TestPreparationRouterRetainsActualNativeChild(t *testing.T) {
8989
if err != nil {
9090
t.Fatal(err)
9191
}
92+
env.Assignment = proto.AssignmentRef{SessionID: session, AssignmentID: "assignment", Epoch: 1}
9293
if err = r.Handle(t.Context(), env); err != nil {
9394
t.Fatal(err)
9495
}
9596
}
97+
send(proto.TypeAssignmentBind, "bind", proto.AssignmentBindPayload{EnvironmentID: environment})
9698
await := func(state string) proto.PreparationStatusPayload {
9799
t.Helper()
98100
timer := time.NewTimer(4 * time.Second)

‎apps/daemon/internal/cli/claude_sdk_live_linux_test.go‎

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent"
1616
"github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/dispatch"
1717
"github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto"
18+
"github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest"
1819
"github.com/google/uuid"
1920
)
2021

@@ -68,6 +69,7 @@ func TestLiveRegisteredClaudeSDK(t *testing.T) {
6869
Cancelled bool `json:"cancelled"`
6970
}
7071
nonce := "registered-function-" + uuid.NewString()
72+
ref := proto.AssignmentRef{SessionID: prototest.SessionID, AssignmentID: "registered-acceptance", Epoch: 1}
7173
run := func(index int, prompt, resume string, callFunction, cancelOnText bool) execution {
7274
t.Helper()
7375
reg := agent.NewRegistry()
@@ -87,21 +89,24 @@ func TestLiveRegisteredClaudeSDK(t *testing.T) {
8789
ctx, cancel := context.WithTimeout(t.Context(), 120*time.Second)
8890
defer cancel()
8991
id := uuid.NewString()
90-
request := proto.PromptRequestPayload{RunID: id, AgentKind: "claude_sdk", Input: proto.TextInput(prompt), AgentStateKey: "registered-acceptance", AgentSessionID: resume, StrictResume: true, ReleaseOnCompletion: true, ObserveMessages: true, ObserveToolObservations: true, DisableExecutionEnvironment: true, DisableSubagents: true, ExecutionControls: &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"}, Model: "MiniMax-M3"}
92+
request := proto.PromptRequestPayload{AgentKind: "claude_sdk", AgentStateKey: prototest.StateKey, AgentSessionID: resume, RequireExistingNativeSession: resume != "", StrictResume: true, ReleaseOnCompletion: true, ObserveMessages: true, ObserveToolObservations: true, DisableExecutionEnvironment: true, DisableSubagents: true, ExecutionControls: &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"}, Model: "MiniMax-M3"}
9193
if callFunction {
9294
request.FunctionTools = []proto.FunctionTool{{Name: "lookup", Description: "Return a verification value.", Parameters: json.RawMessage(`{"type":"object","properties":{"id":{"type":"string"}},"required":["id"],"additionalProperties":false}`)}}
9395
}
94-
handle := func(kind string, payload any) {
96+
send := func(kind, envID string, payload any) {
9597
t.Helper()
96-
env, err := proto.NewEnvelope(kind, id, payload)
98+
env, err := proto.NewEnvelope(kind, envID, payload)
9799
if err != nil {
98100
t.Fatal(err)
99101
}
102+
env.Assignment = ref
100103
if err := router.Handle(ctx, env); err != nil {
101104
t.Fatal("registered router request failed", err)
102105
}
103106
}
104-
handle(proto.TypePromptRequest, request)
107+
handle := func(kind string, payload any) { t.Helper(); send(kind, id, payload) }
108+
send(proto.TypeAssignmentBind, "bind", proto.AssignmentBindPayload{})
109+
send(proto.TypeExecutionPrepare, "prepare", proto.ExecutionPreparePayload{SessionID: ref.SessionID, Configuration: request})
105110
proof := execution{}
106111
defer func() {
107112
data, _ := json.MarshalIndent(proof, "", " ")
@@ -117,6 +122,23 @@ func TestLiveRegisteredClaudeSDK(t *testing.T) {
117122
case <-ctx.Done():
118123
t.Fatal("registered execution timed out", ctx.Err())
119124
}
125+
if event.Type == proto.TypeAssignmentStatus {
126+
continue
127+
}
128+
if event.Type == proto.TypePreparationStatus {
129+
var status proto.PreparationStatusPayload
130+
if err := event.DecodePayload(&status); err != nil {
131+
t.Fatal(err)
132+
}
133+
switch status.State {
134+
case "ready":
135+
send(proto.TypeExecutionStart, "prepare", proto.ExecutionStartPayload{Handle: status.Handle, ExecutorID: status.ExecutorID, RunID: id, Input: proto.TextInput(prompt)})
136+
case "started":
137+
default:
138+
t.Fatal("registered preparation failed", status)
139+
}
140+
continue
141+
}
120142
if event.ID != id {
121143
t.Fatal("event identity changed")
122144
}

‎apps/daemon/internal/cli/connect_cleanup_test.go‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,11 +147,16 @@ func testDisconnectedPumpCleanup(t *testing.T, suspend bool) {
147147
t.Fatal("initial connection missing")
148148
}
149149
defer peer.Close()
150+
ref := proto.AssignmentRef{SessionID: "cleanup", AssignmentID: "assignment", Epoch: 1}
151+
if got := sendAssignment(t, peer, proto.TypeAssignmentBind, ref, proto.AssignmentBindPayload{}); got.State != proto.AssignmentBound {
152+
t.Fatalf("bind = %+v", got)
153+
}
150154
env, err := proto.NewEnvelope(proto.TypeExecutionPrepare, "prepare", proto.ExecutionPreparePayload{SessionID: "cleanup",
151155
Configuration: proto.PromptRequestPayload{AgentKind: "cleanup", AgentStateKey: "agents-api-cleanup", StrictResume: true, DisableExecutionEnvironment: true}})
152156
if err != nil {
153157
t.Fatal(err)
154158
}
159+
env.Assignment = ref
155160
if err := peer.WriteJSON(env); err != nil {
156161
t.Fatal(err)
157162
}
@@ -197,3 +202,26 @@ func testDisconnectedPumpCleanup(t *testing.T, suspend bool) {
197202
t.Fatalf("factory=%d close=%d", factories.Load(), owner.closes.Load())
198203
}
199204
}
205+
206+
func sendAssignment(t *testing.T, peer *websocket.Conn, kind string, ref proto.AssignmentRef, payload any) proto.AssignmentStatusPayload {
207+
t.Helper()
208+
env, err := proto.NewEnvelope(kind, kind, payload)
209+
if err != nil {
210+
t.Fatal(err)
211+
}
212+
env.Assignment = ref
213+
if err := peer.WriteJSON(env); err != nil {
214+
t.Fatal(err)
215+
}
216+
_ = peer.SetReadDeadline(time.Now().Add(3 * time.Second))
217+
for {
218+
var reply proto.Envelope
219+
if err := peer.ReadJSON(&reply); err != nil {
220+
t.Fatal(err)
221+
}
222+
var status proto.AssignmentStatusPayload
223+
if reply.Type == proto.TypeAssignmentStatus && reply.ID == kind && reply.Assignment == ref && reply.DecodePayload(&status) == nil {
224+
return status
225+
}
226+
}
227+
}

‎apps/daemon/internal/cli/connect_suspend_test.go‎

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ func TestPlannedReconnectRequiresAuthenticatedMatchingResume(t *testing.T) {
6161
t.Fatal("initial connection missing")
6262
}
6363
defer first.Close()
64+
if got := sendAssignment(t, first, proto.TypeAssignmentBind, lifecycleRef, proto.AssignmentBindPayload{EnvironmentID: "env"}); got.State != proto.AssignmentBound {
65+
t.Fatalf("bind = %+v", got)
66+
}
6467
request := proto.EnvironmentSuspendPayload{EnvironmentID: "env", SuspendID: "planned"}
6568
sendLifecycleFrame(t, first, proto.TypeEnvironmentQuiesce, request)
6669
result := readLifecycleResult(t, first, proto.TypeEnvironmentQuiesced)
@@ -105,6 +108,15 @@ func TestPlannedReconnectRequiresAuthenticatedMatchingResume(t *testing.T) {
105108
if scenario == "wrong_operation" {
106109
request.SuspendID = "stale"
107110
}
111+
if scenario == "resume" {
112+
// Core may release the Session before it resumes; the reply
113+
// arrives on the new connection.
114+
released := lifecycleRef
115+
released.Epoch++
116+
if got := sendAssignment(t, second, proto.TypeAssignmentRelease, released, proto.AssignmentReleasePayload{}); got.State != proto.AssignmentReleased {
117+
t.Fatalf("release during suspension = %+v", got)
118+
}
119+
}
108120
sendLifecycleFrame(t, second, proto.TypeEnvironmentResume, request)
109121
if scenario == "resume" || scenario == "rollback" || scenario == "reconnect" {
110122
result = readLifecycleResult(t, second, proto.TypeEnvironmentResumed)
@@ -153,16 +165,21 @@ func TestPlannedReconnectRequiresAuthenticatedMatchingResume(t *testing.T) {
153165
}
154166
}
155167

168+
// lifecycleRef is the assignment of the Session whose Environment suspends.
169+
var lifecycleRef = proto.AssignmentRef{SessionID: "session", AssignmentID: "assignment", Epoch: 1}
170+
156171
func sendLifecycleFrame(t *testing.T, peer *websocket.Conn, kind string, payload proto.EnvironmentSuspendPayload) {
157172
t.Helper()
158173
env, err := proto.NewEnvelope(kind, "operation", payload)
159174
if err != nil {
160175
t.Fatal(err)
161176
}
177+
env.Assignment = lifecycleRef
162178
if err := peer.WriteJSON(env); err != nil {
163179
t.Fatal(err)
164180
}
165181
}
182+
166183
func readLifecycleResult(t *testing.T, peer *websocket.Conn, kind string) proto.EnvironmentSuspendResultPayload {
167184
t.Helper()
168185
_ = peer.SetReadDeadline(time.Now().Add(3 * time.Second))
@@ -238,10 +255,11 @@ func TestSuspensionReconnectBeforeConfirmation(t *testing.T) {
238255
t.Run(scenario, func(t *testing.T) {
239256
t.Setenv("OAC_RUNTIME_WORKSPACE", "")
240257
var attempts atomic.Int32
241-
peers := make(chan *websocket.Conn, 2)
258+
peers := make(chan *websocket.Conn, 3)
242259
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
243260
attempt := attempts.Add(1)
244-
if scenario == "revoked" && attempt > 1 {
261+
// The first connection binds and quiesces the Router.
262+
if scenario == "revoked" && attempt > 2 {
245263
http.Error(w, "revoked", http.StatusUnauthorized)
246264
return
247265
}
@@ -254,15 +272,30 @@ func TestSuspensionReconnectBeforeConfirmation(t *testing.T) {
254272
dial := func(ctx context.Context) (*transport.Conn, error) {
255273
return transport.Dial(ctx, transport.DialOptions{WSURL: "ws" + strings.TrimPrefix(server.URL, "http"), DeviceID: "device", Credential: "credential", DaemonVersion: proto.Version})
256274
}
257-
state, err := newSuspendedRouter(nil, agent.NewRegistry())
275+
conn, err := dial(t.Context())
276+
if err != nil {
277+
t.Fatal(err)
278+
}
279+
setup := <-peers
280+
defer setup.Close()
281+
state, err := newSuspendedRouter(conn, agent.NewRegistry())
258282
if err != nil {
259283
t.Fatal(err)
260284
}
261285
defer state.shutdown()
286+
bind, err := proto.NewEnvelope(proto.TypeAssignmentBind, "bind", proto.AssignmentBindPayload{EnvironmentID: "env"})
287+
if err != nil {
288+
t.Fatal(err)
289+
}
290+
bind.Assignment = lifecycleRef
291+
if err := state.router.Handle(t.Context(), bind); err != nil {
292+
t.Fatal(err)
293+
}
262294
request := proto.EnvironmentSuspendPayload{EnvironmentID: "env", SuspendID: "pending-confirmation"}
263-
if err := state.router.Quiesce(t.Context(), request); err != nil {
295+
if err := state.router.Quiesce(t.Context(), lifecycleRef, request); err != nil {
264296
t.Fatal(err)
265297
}
298+
_ = conn.Close()
266299
control := &suspendControl{path: filepath.Join(t.TempDir(), "control.json"), identity: suspendIdentity{EnvironmentID: "env"}, signal: make(chan os.Signal, 1)}
267300
if err := control.Arm(request); err != nil {
268301
t.Fatal(err)
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
package dispatch_test
2+
3+
import (
4+
"context"
5+
"sync/atomic"
6+
"testing"
7+
"time"
8+
9+
"github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent"
10+
"github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/dispatch"
11+
"github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto"
12+
)
13+
14+
// frameFor returns the last frame of kind correlated with id.
15+
func frameFor(sender *recSender, kind, id string) (proto.Envelope, bool) {
16+
frames := sender.snapshot()
17+
for i := len(frames) - 1; i >= 0; i-- {
18+
if frames[i].Type == kind && frames[i].ID == id {
19+
return frames[i], true
20+
}
21+
}
22+
return proto.Envelope{}, false
23+
}
24+
25+
func waitAssignmentStatus(t *testing.T, sender *recSender, id string) proto.AssignmentStatusPayload {
26+
t.Helper()
27+
waitFor(t, func() bool { return hasFrame(sender, proto.TypeAssignmentStatus, id) }, "assignment_status "+id)
28+
frame, _ := frameFor(sender, proto.TypeAssignmentStatus, id)
29+
var status proto.AssignmentStatusPayload
30+
if err := frame.DecodePayload(&status); err != nil {
31+
t.Fatal(err)
32+
}
33+
return status
34+
}
35+
36+
// release sends assignment_release under the session's assignment at epoch.
37+
func release(t *testing.T, r *dispatch.Router, session, id string, epoch uint64, removeHome bool) {
38+
t.Helper()
39+
env := scoped(t, session, proto.TypeAssignmentRelease, id, proto.AssignmentReleasePayload{RemoveHome: removeHome})
40+
env.Assignment.Epoch = epoch
41+
if err := r.Handle(t.Context(), env); err != nil {
42+
t.Fatal(err)
43+
}
44+
}
45+
46+
// observedExecutor runs closed before it closes.
47+
type observedExecutor struct {
48+
*reusableExecutor
49+
closed func()
50+
}
51+
52+
func (e *observedExecutor) Close(ctx context.Context) error {
53+
e.closed()
54+
return e.reusableExecutor.Close(ctx)
55+
}
56+
57+
func TestAssignmentRejectsStaleAndForeignFrames(t *testing.T) {
58+
h := newHarness(t)
59+
defer h.router.Shutdown(context.Background())
60+
assign(t, h.router, "s", "")
61+
release(t, h.router, "s", "release", 2, false)
62+
if got := waitAssignmentStatus(t, h.sender, "release"); got.State != proto.AssignmentReleased || got.ErrorCode != "" {
63+
t.Fatalf("release = %+v", got)
64+
}
65+
foreign := ref("s")
66+
foreign.AssignmentID = "foreign"
67+
prepare := proto.ExecutionPreparePayload{SessionID: "s", Configuration: proto.PromptRequestPayload{AgentKind: "fake_alpha", AgentStateKey: stateKey("s"), StrictResume: true, DisableExecutionEnvironment: true}}
68+
for id, test := range map[string]struct {
69+
ref proto.AssignmentRef
70+
code string
71+
}{
72+
"stale": {ref("s"), proto.AssignmentStale},
73+
"foreign": {foreign, proto.AssignmentConflict},
74+
} {
75+
env := scoped(t, "s", proto.TypeExecutionPrepare, id, prepare)
76+
env.Assignment = test.ref
77+
if err := h.router.Handle(t.Context(), env); err == nil {
78+
t.Fatalf("%s preparation admitted", id)
79+
}
80+
if got := waitPreparationStatus(t, h.sender, id, "rejected", ""); got.ErrorCode != test.code {
81+
t.Fatalf("%s preparation = %+v", id, got)
82+
}
83+
bind := scoped(t, "s", proto.TypeAssignmentBind, id+"-bind", proto.AssignmentBindPayload{})
84+
bind.Assignment = test.ref
85+
if err := h.router.Handle(t.Context(), bind); err != nil {
86+
t.Fatal(err)
87+
}
88+
if got := waitAssignmentStatus(t, h.sender, id+"-bind"); got.State != proto.AssignmentFailed || got.ErrorCode != test.code {
89+
t.Fatalf("%s bind = %+v", id, got)
90+
}
91+
}
92+
}
93+
94+
func TestAssignmentReleaseWaitsForRacingPreparation(t *testing.T) {
95+
var calls atomic.Int32
96+
var sender *recSender
97+
var replyBeforeClose atomic.Bool
98+
owner := &observedExecutor{reusableExecutor: &reusableExecutor{}, closed: func() {
99+
replyBeforeClose.Store(hasFrame(sender, proto.TypeAssignmentStatus, "release"))
100+
}}
101+
entered, cancelled, unblock := make(chan struct{}), make(chan struct{}), make(chan struct{})
102+
r, sender := poolRouter(t, func(ctx context.Context, _ proto.PromptRequestPayload) (agent.Executor, error) {
103+
calls.Add(1)
104+
close(entered)
105+
<-ctx.Done()
106+
close(cancelled)
107+
<-unblock
108+
return owner, nil
109+
})
110+
defer close(unblock)
111+
assign(t, r, preparationSessionID, "")
112+
if err := r.Handle(t.Context(), scoped(t, preparationSessionID, proto.TypeExecutionPrepare, "prepare", executorRequest())); err != nil {
113+
t.Fatal(err)
114+
}
115+
<-entered
116+
release(t, r, preparationSessionID, "release", 2, false)
117+
select {
118+
case <-cancelled:
119+
case <-time.After(3 * time.Second):
120+
t.Fatal("release did not cancel the preparation")
121+
}
122+
if hasFrame(sender, proto.TypeAssignmentStatus, "release") {
123+
t.Fatal("release replied before the Executor closed")
124+
}
125+
unblock <- struct{}{}
126+
if got := waitAssignmentStatus(t, sender, "release"); got.State != proto.AssignmentReleased || got.ErrorCode != "" {
127+
t.Fatalf("release = %+v", got)
128+
}
129+
if owner.closes.Load() != 1 || replyBeforeClose.Load() {
130+
t.Fatalf("Executor closes = %d, replied before close = %t", owner.closes.Load(), replyBeforeClose.Load())
131+
}
132+
for _, frame := range sender.snapshot() {
133+
var status proto.PreparationStatusPayload
134+
if frame.Type == proto.TypePreparationStatus && frame.ID == "prepare" && frame.DecodePayload(&status) == nil && status.State == "ready" {
135+
t.Fatal("released preparation became ready")
136+
}
137+
}
138+
late := scoped(t, preparationSessionID, proto.TypeExecutionPrepare, "late", executorRequest())
139+
late.Assignment.Epoch = 2
140+
if err := r.Handle(t.Context(), late); err == nil {
141+
t.Fatal("released assignment admitted a preparation")
142+
}
143+
if got := waitPreparationStatus(t, sender, "late", "rejected", ""); got.ErrorCode != proto.AssignmentStale || calls.Load() != 1 {
144+
t.Fatalf("late preparation = %+v, factory calls = %d", got, calls.Load())
145+
}
146+
}
147+
148+
func TestReleaseWithoutHomeRemovalKeepsAssignment(t *testing.T) {
149+
h := newHarness(t)
150+
defer h.router.Shutdown(context.Background())
151+
assign(t, h.router, "s", "")
152+
release(t, h.router, "s", "remove", 2, true)
153+
if got := waitAssignmentStatus(t, h.sender, "remove"); got.State != proto.AssignmentFailed || got.ErrorCode != proto.UnsupportedOperation {
154+
t.Fatalf("release = %+v", got)
155+
}
156+
startRun(t, h.router, h.sender, "fake_alpha", "s")
157+
}
158+
159+
func TestUnknownEnvelopeGetsCorrelatedProtocolError(t *testing.T) {
160+
h := newHarness(t)
161+
defer h.router.Shutdown(context.Background())
162+
if err := h.router.Handle(t.Context(), scoped(t, "s", "future_operation", "request", nil)); err != nil {
163+
t.Fatal(err)
164+
}
165+
frame, ok := frameFor(h.sender, proto.TypeProtocolError, "request")
166+
var got proto.ProtocolErrorPayload
167+
if !ok || frame.DecodePayload(&got) != nil || got != (proto.ProtocolErrorPayload{Type: "future_operation", ErrorCode: proto.UnsupportedOperation}) || frame.Assignment != ref("s") {
168+
t.Fatalf("protocol_error = %+v %+v", frame, got)
169+
}
170+
}

0 commit comments

Comments
 (0)